From 9e97efb754add240c66939b9bcf3ca9080ba666c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:36:54 +0200 Subject: [PATCH 01/34] Implement managed devtools file synchronization --- src/tools/shared/managedFiles.ts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/tools/shared/managedFiles.ts diff --git a/src/tools/shared/managedFiles.ts b/src/tools/shared/managedFiles.ts new file mode 100644 index 0000000..2ee76a8 --- /dev/null +++ b/src/tools/shared/managedFiles.ts @@ -0,0 +1,85 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +export type ManagedFileState = 'current' | 'missing' | 'outdated'; +export type ManagedFileAction = 'created' | 'unchanged' | 'updated' | 'would-create' | 'would-update'; + +export interface ManagedFileDefinition { + readonly targetPath: string; + readonly sourceUrl: URL; +} + +export interface ManagedFileStatus { + readonly targetPath: string; + readonly state: ManagedFileState; +} + +export interface ManagedFileSyncResult { + readonly targetPath: string; + readonly action: ManagedFileAction; +} + +export function inspectManagedFiles( + targetRoot: string, + files: readonly ManagedFileDefinition[], +): readonly ManagedFileStatus[] { + assertDirectory(targetRoot); + + return files.map((file) => { + const targetPath = resolve(targetRoot, file.targetPath); + if (!existsSync(targetPath)) { + return { targetPath: file.targetPath, state: 'missing' }; + } + + const current = readFileSync(targetPath, 'utf8'); + const canonical = readFileSync(file.sourceUrl, 'utf8'); + return { + targetPath: file.targetPath, + state: current === canonical ? 'current' : 'outdated', + }; + }); +} + +export function syncManagedFiles( + targetRoot: string, + files: readonly ManagedFileDefinition[], + options: { readonly dryRun?: boolean } = {}, +): readonly ManagedFileSyncResult[] { + const statuses = inspectManagedFiles(targetRoot, files); + + return statuses.map((status, index) => { + const definition = files[index]; + if (definition === undefined) { + throw new Error(`Missing managed file definition for ${status.targetPath}.`); + } + + if (status.state === 'current') { + return { targetPath: status.targetPath, action: 'unchanged' }; + } + + if (options.dryRun === true) { + return { + targetPath: status.targetPath, + action: status.state === 'missing' ? 'would-create' : 'would-update', + }; + } + + const targetPath = resolve(targetRoot, definition.targetPath); + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, readFileSync(definition.sourceUrl, 'utf8'), 'utf8'); + + return { + targetPath: status.targetPath, + action: status.state === 'missing' ? 'created' : 'updated', + }; + }); +} + +function assertDirectory(targetRoot: string): void { + if (!existsSync(targetRoot)) { + throw new Error(`Target path does not exist: ${targetRoot}`); + } + if (!statSync(targetRoot).isDirectory()) { + throw new Error(`Target path is not a directory: ${targetRoot}`); + } +} From 27c56b13c0105c9863f2669932219c2315b90fc6 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:37:33 +0200 Subject: [PATCH 02/34] Add canonical CI workflow --- src/tools/workflows/files/ci.yml | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/tools/workflows/files/ci.yml diff --git a/src/tools/workflows/files/ci.yml b/src/tools/workflows/files/ci.yml new file mode 100644 index 0000000..8df3352 --- /dev/null +++ b/src/tools/workflows/files/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Validate Ankhorage repository + run: bunx @ankhorage/ankh doctor validate . + + - name: Run build + run: | + if node -e "const p=require('./package.json'); process.exit(p.scripts?.build ? 0 : 1)"; then + bun run build + else + echo "No build script found; skipping." + fi + + - name: Run lint + run: | + if node -e "const p=require('./package.json'); process.exit(p.scripts?.lint ? 0 : 1)"; then + bun run lint + else + echo "No lint script found; skipping." + fi + + - name: Run format check + run: | + if node -e "const p=require('./package.json'); process.exit(p.scripts?.['format:check'] ? 0 : 1)"; then + bun run format:check + else + echo "No format:check script found; skipping." + fi + + - name: Run Knip + run: | + if node -e "const p=require('./package.json'); process.exit(p.scripts?.knip ? 0 : 1)"; then + bun run knip + else + echo "No knip script found; skipping." + fi + + - name: Run tests + run: | + if node -e "const p=require('./package.json'); process.exit(p.scripts?.test ? 0 : 1)"; then + bun run test + else + echo "No test script found; skipping." + fi + + - name: Run typecheck + run: | + if node -e "const p=require('./package.json'); process.exit(p.scripts?.typecheck ? 0 : 1)"; then + bun run typecheck + else + echo "No typecheck script found; skipping." + fi + + - name: Check changesets + if: github.event_name == 'pull_request' + run: | + if [ -f ./scripts/check-changeset-status.sh ]; then + bash ./scripts/check-changeset-status.sh + else + echo "No changeset status script found; skipping." + fi From 086812a2ffb49074c38a0908951754901fbc7acc Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:38:04 +0200 Subject: [PATCH 03/34] Add canonical release workflow --- src/tools/workflows/files/release.yml | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/tools/workflows/files/release.yml diff --git a/src/tools/workflows/files/release.yml b/src/tools/workflows/files/release.yml new file mode 100644 index 0000000..71e5870 --- /dev/null +++ b/src/tools/workflows/files/release.yml @@ -0,0 +1,65 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + + - name: Setup Node for npm publishing + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Update npm + run: npm install -g npm@latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build package + run: | + if node -e "const p=require('./package.json'); process.exit(p.scripts?.build ? 0 : 1)"; then + bun run build + else + echo "No build script found; skipping." + fi + + - name: Create release pull request or publish to npm + if: hashFiles('.changeset/config.json') != '' + uses: changesets/action@v1 + with: + version: bun run version-packages + publish: bunx changeset publish + commit: Version Packages + title: Version Packages + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Skip release + if: hashFiles('.changeset/config.json') == '' + run: echo "No Changesets config found; skipping release." From 97f92e4fa39862d7513bce28b697c25f705cb955 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:38:19 +0200 Subject: [PATCH 04/34] Add canonical VS Code settings --- src/tools/vscode/files/settings.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/tools/vscode/files/settings.json diff --git a/src/tools/vscode/files/settings.json b/src/tools/vscode/files/settings.json new file mode 100644 index 0000000..f70dab9 --- /dev/null +++ b/src/tools/vscode/files/settings.json @@ -0,0 +1,15 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "editor.tabSize": 2, + "editor.detectIndentation": false +} From 2b2ab3820e64adac0a0a25db8c99ed0c34b196f1 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:38:28 +0200 Subject: [PATCH 05/34] Add canonical VS Code extension recommendations --- src/tools/vscode/files/extensions.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/tools/vscode/files/extensions.json diff --git a/src/tools/vscode/files/extensions.json b/src/tools/vscode/files/extensions.json new file mode 100644 index 0000000..4a81931 --- /dev/null +++ b/src/tools/vscode/files/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "oven.bun-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "redhat.vscode-yaml", + "github.vscode-github-actions" + ] +} From 60e92689062fc723433550c4da9a5132fab7a773 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:38:37 +0200 Subject: [PATCH 06/34] Expose managed workflow definitions --- src/tools/workflows/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/tools/workflows/index.ts diff --git a/src/tools/workflows/index.ts b/src/tools/workflows/index.ts new file mode 100644 index 0000000..56caa66 --- /dev/null +++ b/src/tools/workflows/index.ts @@ -0,0 +1,12 @@ +import type { ManagedFileDefinition } from '../shared/managedFiles.js'; + +export const workflowFiles = [ + { + targetPath: '.github/workflows/ci.yml', + sourceUrl: new URL('./files/ci.yml', import.meta.url), + }, + { + targetPath: '.github/workflows/release.yml', + sourceUrl: new URL('./files/release.yml', import.meta.url), + }, +] as const satisfies readonly ManagedFileDefinition[]; From 6a58b6afc0175e0921910b7cdfc12986d40f31b0 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:38:48 +0200 Subject: [PATCH 07/34] Expose managed VS Code definitions --- src/tools/vscode/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/tools/vscode/index.ts diff --git a/src/tools/vscode/index.ts b/src/tools/vscode/index.ts new file mode 100644 index 0000000..34b679c --- /dev/null +++ b/src/tools/vscode/index.ts @@ -0,0 +1,12 @@ +import type { ManagedFileDefinition } from '../shared/managedFiles.js'; + +export const vscodeFiles = [ + { + targetPath: '.vscode/settings.json', + sourceUrl: new URL('./files/settings.json', import.meta.url), + }, + { + targetPath: '.vscode/extensions.json', + sourceUrl: new URL('./files/extensions.json', import.meta.url), + }, +] as const satisfies readonly ManagedFileDefinition[]; From 4b822efc801eb0fa5ad0f3347d2da0c5e6797b1c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:39:14 +0200 Subject: [PATCH 08/34] Add managed files command runner --- src/tools/shared/runManagedFilesCommand.ts | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/tools/shared/runManagedFilesCommand.ts diff --git a/src/tools/shared/runManagedFilesCommand.ts b/src/tools/shared/runManagedFilesCommand.ts new file mode 100644 index 0000000..257aad8 --- /dev/null +++ b/src/tools/shared/runManagedFilesCommand.ts @@ -0,0 +1,46 @@ +import { resolve } from 'node:path'; + +import type { ManagedFileDefinition } from './managedFiles.js'; +import { inspectManagedFiles, syncManagedFiles } from './managedFiles.js'; + +export interface ManagedFilesCommandContext { + readonly cwd: string; + writeStdout(text: string): void; + writeStderr(text: string): void; +} + +export async function runManagedFilesCommand( + mode: 'status' | 'sync', + files: readonly ManagedFileDefinition[], + argv: readonly string[], + context: ManagedFilesCommandContext, +): Promise<{ readonly exitCode: number }> { + const dryRun = argv.includes('--dry-run'); + const positional = argv.filter((argument) => argument !== '--dry-run'); + const targetRoot = resolve(context.cwd, positional[0] ?? '.'); + + if (positional.length > 1 || (mode === 'status' && dryRun)) { + context.writeStderr('Usage error: provide at most one target path; --dry-run is sync-only.\n'); + return { exitCode: 2 }; + } + + try { + if (mode === 'status') { + const statuses = inspectManagedFiles(targetRoot, files); + for (const status of statuses) { + const marker = status.state === 'current' ? '✓' : status.state === 'missing' ? '+' : '✗'; + context.writeStdout(`${marker} ${status.targetPath} ${status.state}\n`); + } + return { exitCode: statuses.every((status) => status.state === 'current') ? 0 : 1 }; + } + + const results = syncManagedFiles(targetRoot, files, { dryRun }); + for (const result of results) { + context.writeStdout(`${result.action} ${result.targetPath}\n`); + } + return { exitCode: 0 }; + } catch (error) { + context.writeStderr(`${error instanceof Error ? error.message : String(error)}\n`); + return { exitCode: 1 }; + } +} From e23e8fbda5f7e2ac4f6061b97f87eea512ef717b Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:41:03 +0200 Subject: [PATCH 09/34] Add devtools sync and status commands --- src/internal/devtoolsCommands.ts | 103 +++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/src/internal/devtoolsCommands.ts b/src/internal/devtoolsCommands.ts index 5308716..1db6051 100644 --- a/src/internal/devtoolsCommands.ts +++ b/src/internal/devtoolsCommands.ts @@ -1,15 +1,37 @@ export type DevtoolsToolName = 'format' | 'knip' | 'lint'; +export type ManagedConcern = 'all' | 'vscode' | 'workflows'; -export interface DevtoolsCommandDefinition { - path: readonly [DevtoolsToolName]; - capability: 'devtools.format' | 'devtools.knip' | 'devtools.lint'; - summary: string; - packageName: 'eslint' | 'knip' | 'prettier'; - binName: 'eslint' | 'knip' | 'prettier'; +export interface BinaryDevtoolsCommandDefinition { + readonly kind: 'binary'; + readonly path: readonly [DevtoolsToolName]; + readonly capability: 'devtools.format' | 'devtools.knip' | 'devtools.lint'; + readonly summary: string; + readonly packageName: 'eslint' | 'knip' | 'prettier'; + readonly binName: 'eslint' | 'knip' | 'prettier'; } +export interface ManagedDevtoolsCommandDefinition { + readonly kind: 'managed-files'; + readonly path: readonly ['sync' | 'status'] | readonly ['workflows' | 'vscode', 'sync' | 'status']; + readonly capability: + | 'devtools.sync' + | 'devtools.status' + | 'devtools.workflows.sync' + | 'devtools.workflows.status' + | 'devtools.vscode.sync' + | 'devtools.vscode.status'; + readonly summary: string; + readonly concern: ManagedConcern; + readonly mode: 'sync' | 'status'; +} + +export type DevtoolsCommandDefinition = + | BinaryDevtoolsCommandDefinition + | ManagedDevtoolsCommandDefinition; + const DEVTOOLS_COMMANDS = [ { + kind: 'binary', path: ['lint'], capability: 'devtools.lint', summary: 'Run the shared ESLint toolchain.', @@ -17,6 +39,7 @@ const DEVTOOLS_COMMANDS = [ binName: 'eslint', }, { + kind: 'binary', path: ['format'], capability: 'devtools.format', summary: 'Run the shared Prettier toolchain.', @@ -24,12 +47,61 @@ const DEVTOOLS_COMMANDS = [ binName: 'prettier', }, { + kind: 'binary', path: ['knip'], capability: 'devtools.knip', summary: 'Run the shared Knip toolchain.', packageName: 'knip', binName: 'knip', }, + { + kind: 'managed-files', + path: ['sync'], + capability: 'devtools.sync', + summary: 'Synchronize all managed repository development files.', + concern: 'all', + mode: 'sync', + }, + { + kind: 'managed-files', + path: ['status'], + capability: 'devtools.status', + summary: 'Report drift in all managed repository development files.', + concern: 'all', + mode: 'status', + }, + { + kind: 'managed-files', + path: ['workflows', 'sync'], + capability: 'devtools.workflows.sync', + summary: 'Synchronize managed GitHub Actions workflows.', + concern: 'workflows', + mode: 'sync', + }, + { + kind: 'managed-files', + path: ['workflows', 'status'], + capability: 'devtools.workflows.status', + summary: 'Report drift in managed GitHub Actions workflows.', + concern: 'workflows', + mode: 'status', + }, + { + kind: 'managed-files', + path: ['vscode', 'sync'], + capability: 'devtools.vscode.sync', + summary: 'Synchronize managed VS Code workspace files.', + concern: 'vscode', + mode: 'sync', + }, + { + kind: 'managed-files', + path: ['vscode', 'status'], + capability: 'devtools.vscode.status', + summary: 'Report drift in managed VS Code workspace files.', + concern: 'vscode', + mode: 'status', + }, ] as const satisfies readonly DevtoolsCommandDefinition[]; export function getDevtoolsCommands(): readonly DevtoolsCommandDefinition[] { @@ -39,15 +111,20 @@ export function getDevtoolsCommands(): readonly DevtoolsCommandDefinition[] { export function findDevtoolsCommandByPath( path: readonly string[], ): DevtoolsCommandDefinition | null { - if (path.length !== 1) { - return null; - } - - return DEVTOOLS_COMMANDS.find((command) => command.path[0] === path[0]) ?? null; + return ( + DEVTOOLS_COMMANDS.find( + (command) => + command.path.length === path.length && + command.path.every((segment, index) => segment === path[index]), + ) ?? null + ); } -export function getDevtoolsCommand(toolName: DevtoolsToolName): DevtoolsCommandDefinition { - const command = DEVTOOLS_COMMANDS.find((candidate) => candidate.path[0] === toolName); +export function getDevtoolsCommand(toolName: DevtoolsToolName): BinaryDevtoolsCommandDefinition { + const command = DEVTOOLS_COMMANDS.find( + (candidate): candidate is BinaryDevtoolsCommandDefinition => + candidate.kind === 'binary' && candidate.path[0] === toolName, + ); if (command === undefined) { throw new Error(`Unknown devtools command: ${toolName}`); From 93bcf8176b75e489a7cf304dc63f7a074161ba74 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:41:23 +0200 Subject: [PATCH 10/34] Dispatch managed devtools commands --- src/internal/runManagedDevtoolsCommand.ts | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/internal/runManagedDevtoolsCommand.ts diff --git a/src/internal/runManagedDevtoolsCommand.ts b/src/internal/runManagedDevtoolsCommand.ts new file mode 100644 index 0000000..a773c50 --- /dev/null +++ b/src/internal/runManagedDevtoolsCommand.ts @@ -0,0 +1,25 @@ +import type { ManagedDevtoolsCommandDefinition } from './devtoolsCommands.js'; +import { runManagedFilesCommand } from '../tools/shared/runManagedFilesCommand.js'; +import { vscodeFiles } from '../tools/vscode/index.js'; +import { workflowFiles } from '../tools/workflows/index.js'; + +export interface ManagedDevtoolsCommandContext { + readonly cwd: string; + writeStdout(text: string): void; + writeStderr(text: string): void; +} + +export function runManagedDevtoolsCommand( + command: ManagedDevtoolsCommandDefinition, + argv: readonly string[], + context: ManagedDevtoolsCommandContext, +): Promise<{ readonly exitCode: number }> { + const files = + command.concern === 'workflows' + ? workflowFiles + : command.concern === 'vscode' + ? vscodeFiles + : [...workflowFiles, ...vscodeFiles]; + + return runManagedFilesCommand(command.mode, files, argv, context); +} From 7381b15877b1382c9c46bea5cb48f662f5ddfff4 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:42:05 +0200 Subject: [PATCH 11/34] Route managed devtools provider commands --- src/cli/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index b61d7bf..1379df3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,6 +4,7 @@ import type { AnkhRuntimeCommandProvider } from '@ankhorage/ankh'; import { getDevtoolsCommands } from '../internal/devtoolsCommands.js'; import { runDevtoolsCommand } from '../internal/runDevtoolsCommand.js'; +import { runManagedDevtoolsCommand } from '../internal/runManagedDevtoolsCommand.js'; const packageVersion = readPackageVersion(); const commands = getDevtoolsCommands(); @@ -21,7 +22,10 @@ const provider = { handlers: commands.map((command) => ({ path: [...command.path], handler: async (request) => { - const result = await runDevtoolsCommand(command, request.argv); + const result = + command.kind === 'binary' + ? await runDevtoolsCommand(command, request.argv, { cwd: request.context.cwd }) + : await runManagedDevtoolsCommand(command, request.argv, request.context); return { exitCode: result.exitCode }; }, })), From 9604483593166adaa279e24ed581c0c1084ed464 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:43:34 +0200 Subject: [PATCH 12/34] Restrict binary devtools runner types --- src/internal/runDevtoolsCommand.ts | 69 ++++++++---------------------- 1 file changed, 17 insertions(+), 52 deletions(-) diff --git a/src/internal/runDevtoolsCommand.ts b/src/internal/runDevtoolsCommand.ts index 99328c2..fab79d2 100644 --- a/src/internal/runDevtoolsCommand.ts +++ b/src/internal/runDevtoolsCommand.ts @@ -4,11 +4,14 @@ import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, extname, join, resolve } from 'node:path'; -import type { DevtoolsCommandDefinition, DevtoolsToolName } from './devtoolsCommands.js'; +import type { + BinaryDevtoolsCommandDefinition, + DevtoolsToolName, +} from './devtoolsCommands.js'; const require = createRequire(import.meta.url); -export type { DevtoolsCommandDefinition, DevtoolsToolName }; +export type { BinaryDevtoolsCommandDefinition, DevtoolsToolName }; export interface DevtoolsRunResult { exitCode: number; @@ -47,7 +50,7 @@ type SpawnProcess = ( interface DevtoolsRunnerDependencies { readonly logError: (message: string) => void; readonly resolveExecutionTarget: ( - command: DevtoolsCommandDefinition, + command: BinaryDevtoolsCommandDefinition, ) => Promise; readonly spawnProcess: SpawnProcess; } @@ -61,7 +64,7 @@ const defaultRunnerDependencies: DevtoolsRunnerDependencies = { }; export async function runDevtoolsCommand( - command: DevtoolsCommandDefinition, + command: BinaryDevtoolsCommandDefinition, argv: readonly string[], options?: DevtoolsRunnerOptions, ): Promise { @@ -69,7 +72,7 @@ export async function runDevtoolsCommand( } export async function runDevtoolsCommandWithDependencies( - command: DevtoolsCommandDefinition, + command: BinaryDevtoolsCommandDefinition, argv: readonly string[], options: DevtoolsRunnerOptions | undefined, dependencies: DevtoolsRunnerDependencies, @@ -80,7 +83,6 @@ export async function runDevtoolsCommandWithDependencies( executionTarget = await dependencies.resolveExecutionTarget(command); } catch (error) { dependencies.logError(`Failed to resolve ${command.binName}: ${getErrorMessage(error)}`); - return { exitCode: 1 }; } @@ -89,12 +91,8 @@ export async function runDevtoolsCommandWithDependencies( return await new Promise((resolveResult) => { let settled = false; - const settle = (result: DevtoolsRunResult) => { - if (settled) { - return; - } - + if (settled) return; settled = true; resolveResult(result); }; @@ -102,12 +100,7 @@ export async function runDevtoolsCommandWithDependencies( const child = dependencies.spawnProcess( executionTarget.command, [...executionTarget.args, ...argv], - { - cwd, - env, - shell: executionTarget.shell, - stdio: 'inherit', - }, + { cwd, env, shell: executionTarget.shell, stdio: 'inherit' }, ); child.on('error', (error) => { @@ -121,80 +114,52 @@ export async function runDevtoolsCommandWithDependencies( settle({ exitCode: 1 }); return; } - settle({ exitCode: code ?? 1 }); }); }); } async function resolveExecutionTarget( - command: DevtoolsCommandDefinition, + command: BinaryDevtoolsCommandDefinition, ): Promise { const binPath = await readPackageBinPath(command.packageName, command.binName); if (await shouldExecuteWithNode(binPath)) { - return { - command: process.execPath, - args: [binPath], - shell: false, - }; + return { command: process.execPath, args: [binPath], shell: false }; } - - return { - command: binPath, - args: [], - shell: process.platform === 'win32', - }; + return { command: binPath, args: [], shell: process.platform === 'win32' }; } function findPackageJsonPath(packageName: string): string { let currentDirectory = dirname(require.resolve(packageName)); - while (currentDirectory !== dirname(currentDirectory)) { const packageJsonPath = join(currentDirectory, 'package.json'); - - if (existsSync(packageJsonPath)) { - return packageJsonPath; - } - + if (existsSync(packageJsonPath)) return packageJsonPath; currentDirectory = dirname(currentDirectory); } - throw new Error(`Could not find package metadata for ${packageName}.`); } async function readPackageBinPath(packageName: string, binName: string): Promise { const packageJsonPath = findPackageJsonPath(packageName); const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as unknown; - if (!isRecord(packageJson)) { throw new Error(`Package metadata for ${packageName} is not an object.`); } const { bin } = packageJson; let relativeBinPath: string | undefined; - - if (typeof bin === 'string') { - relativeBinPath = bin; - } else if (isRecord(bin)) { - const namedBin = bin[binName]; - if (typeof namedBin === 'string') { - relativeBinPath = namedBin; - } - } + if (typeof bin === 'string') relativeBinPath = bin; + else if (isRecord(bin) && typeof bin[binName] === 'string') relativeBinPath = bin[binName]; if (relativeBinPath === undefined) { throw new Error(`Package ${packageName} does not expose a ${binName} binary.`); } - return resolve(dirname(packageJsonPath), relativeBinPath); } async function shouldExecuteWithNode(binPath: string): Promise { const extension = extname(binPath).toLowerCase(); - if (extension === '.cjs' || extension === '.js' || extension === '.mjs') { - return true; - } - + if (extension === '.cjs' || extension === '.js' || extension === '.mjs') return true; const firstLine = (await readFile(binPath, 'utf8')).split('\n', 1)[0] ?? ''; return firstLine.startsWith('#!') && firstLine.includes('node'); } From 5b40372cb4f994e43325a6bba37fa0951677f336 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:45:02 +0200 Subject: [PATCH 13/34] Publish managed devtools commands and assets --- package.json | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index e2dbb2d..b680429 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ankhorage/devtools", "version": "1.1.2", - "description": "Shared Lint and Format Configuration for Ankhorage", + "description": "Shared development tooling and repository standards for Ankhorage", "license": "MIT", "homepage": "https://github.com/ankhorage/devtools#readme", "bugs": { @@ -21,7 +21,13 @@ "capabilities": [ "devtools.lint", "devtools.format", - "devtools.knip" + "devtools.knip", + "devtools.sync", + "devtools.status", + "devtools.workflows.sync", + "devtools.workflows.status", + "devtools.vscode.sync", + "devtools.vscode.status" ] }, "keywords": [ @@ -29,15 +35,14 @@ "eslint", "prettier", "knip", - "linting", - "formatting", - "static-analysis", + "github-actions", + "vscode", "developer-tools" ], "bin": { - "ankhorage-eslint": "./dist/eslint-cli.js", - "ankhorage-knip": "./dist/knip-cli.js", - "ankhorage-prettier": "./dist/prettier-cli.js" + "ankhorage-eslint": "./dist/tools/eslint/cli.js", + "ankhorage-knip": "./dist/tools/knip/cli.js", + "ankhorage-prettier": "./dist/tools/prettier/cli.js" }, "exports": { "./cli": { @@ -45,15 +50,15 @@ "import": "./dist/cli/index.js" }, "./eslint": { - "types": "./dist/eslint.d.ts", - "import": "./dist/eslint.js" + "types": "./dist/tools/eslint/index.d.ts", + "import": "./dist/tools/eslint/index.js" }, "./knip": { - "types": "./dist/knip.d.ts", - "import": "./dist/knip.js" + "types": "./dist/tools/knip/index.d.ts", + "import": "./dist/tools/knip/index.js" }, "./prettier": { - "require": "./dist/prettier.cjs" + "require": "./dist/tools/prettier/index.cjs" } }, "files": [ @@ -63,7 +68,7 @@ "examples" ], "scripts": { - "build": "rm -rf dist tsconfig.tsbuildinfo && tsc && cp src/prettier.cjs dist/prettier.cjs", + "build": "rm -rf dist tsconfig.tsbuildinfo && tsc && mkdir -p dist/tools/workflows dist/tools/vscode dist/tools/prettier && cp -R src/tools/workflows/files dist/tools/workflows/files && cp -R src/tools/vscode/files dist/tools/vscode/files && cp src/tools/prettier/index.cjs dist/tools/prettier/index.cjs", "typecheck": "bun x tsc --noEmit -p tsconfig.test.json", "doctor": "ankhorage-doctor validate .", "knip": "knip", From 1ab3cff65723829325caa7e73ecaec2633a36fb4 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:45:46 +0200 Subject: [PATCH 14/34] Move ESLint tooling under src tools --- src/tools/eslint/index.ts | 112 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/tools/eslint/index.ts diff --git a/src/tools/eslint/index.ts b/src/tools/eslint/index.ts new file mode 100644 index 0000000..b23f793 --- /dev/null +++ b/src/tools/eslint/index.ts @@ -0,0 +1,112 @@ +import js from '@eslint/js'; +import prettierConfig from 'eslint-config-prettier'; +import importPlugin from 'eslint-plugin-import'; +import prettierPlugin from 'eslint-plugin-prettier'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import unusedImports from 'eslint-plugin-unused-imports'; +import tseslint from 'typescript-eslint'; + +import type { DevtoolsConfigOptions, FlatConfigItem } from './types.js'; + +export const defaultIgnores = [ + '**/ios/**', + '**/android/**', + '**/dist/**', + '**/build/**', + '**/.expo/**', + '**/.next/**', + '**/node_modules/**', + '**/*.d.ts', + '**/templates/**', + '**/files/**', +] as const; + +export const defaultRestrictedImports = [ + { + name: 'react-native-reanimated-dnd', + message: + "Forbidden in Ankhorage packages. Use '@ankhorage/react-native-reanimated-dnd-web' directly.", + }, +] as const; + +export function createConfig(options: DevtoolsConfigOptions): ReturnType { + const normalizedOptions = { + allowDefaultProject: [], + additionalIgnores: [], + restrictedImports: [], + overrides: [], + includePrettier: true, + ...options, + }; + + const combinedRestrictedImports = [ + ...defaultRestrictedImports, + ...normalizedOptions.restrictedImports, + ]; + + const plugins = { + import: importPlugin, + prettier: prettierPlugin, + 'simple-import-sort': simpleImportSort, + 'unused-imports': unusedImports, + }; + + return tseslint.config( + { ignores: [...defaultIgnores, ...normalizedOptions.additionalIgnores] }, + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked.map((config) => ({ + ...config, + files: normalizedOptions.files, + })), + ...tseslint.configs.stylisticTypeChecked.map((config) => ({ + ...config, + files: normalizedOptions.files, + })), + { + files: normalizedOptions.files, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: normalizedOptions.project, + tsconfigRootDir: normalizedOptions.tsconfigRootDir, + allowDefaultProject: normalizedOptions.allowDefaultProject, + }, + }, + plugins, + rules: { + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/prefer-readonly': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + '@typescript-eslint/prefer-as-const': 'error', + '@typescript-eslint/no-unnecessary-type-arguments': 'error', + '@typescript-eslint/no-unnecessary-condition': 'error', + '@typescript-eslint/no-unnecessary-type-constraint': 'error', + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + '@typescript-eslint/prefer-nullish-coalescing': 'error', + 'no-restricted-imports': ['error', { paths: combinedRestrictedImports }], + 'prefer-destructuring': 'off', + '@typescript-eslint/prefer-destructuring': 'error', + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'error', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + 'import/order': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'error', + 'prettier/prettier': 'error', + 'no-console': 'off', + }, + }, + ...normalizedOptions.overrides, + ...(normalizedOptions.includePrettier ? [prettierConfig as FlatConfigItem] : []), + ); +} From 6f835595e8d6ab1b0e064404d3a15eced6c1fd0b Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:45:58 +0200 Subject: [PATCH 15/34] Move ESLint types under src tools --- src/tools/eslint/types.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/tools/eslint/types.ts diff --git a/src/tools/eslint/types.ts b/src/tools/eslint/types.ts new file mode 100644 index 0000000..bb95186 --- /dev/null +++ b/src/tools/eslint/types.ts @@ -0,0 +1,20 @@ +import type tseslint from 'typescript-eslint'; + +type FlatConfig = ReturnType; +export type FlatConfigItem = FlatConfig[number]; + +interface RestrictedImport { + name: string; + message: string; +} + +export interface DevtoolsConfigOptions { + tsconfigRootDir: string; + project: string[]; + files: string[]; + allowDefaultProject?: string[]; + additionalIgnores?: string[]; + restrictedImports?: RestrictedImport[]; + overrides?: FlatConfigItem[]; + includePrettier?: boolean; +} From 035d1560e2dd13d6613e8b01593b334879573d0d Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:46:07 +0200 Subject: [PATCH 16/34] Move ESLint CLI under src tools --- src/tools/eslint/cli.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/tools/eslint/cli.ts diff --git a/src/tools/eslint/cli.ts b/src/tools/eslint/cli.ts new file mode 100644 index 0000000..8d95f5f --- /dev/null +++ b/src/tools/eslint/cli.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { runStandaloneDevtoolsCommand } from '../../internal/runStandaloneDevtoolsCommand.js'; + +const result = await runStandaloneDevtoolsCommand('lint', process.argv.slice(2)); +process.exit(result.exitCode); From 1e51091efc0e26b5c495c657f8409f72c8ef8728 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:46:35 +0200 Subject: [PATCH 17/34] Move Knip tooling under src tools --- src/tools/knip/index.ts | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/tools/knip/index.ts diff --git a/src/tools/knip/index.ts b/src/tools/knip/index.ts new file mode 100644 index 0000000..b1ee540 --- /dev/null +++ b/src/tools/knip/index.ts @@ -0,0 +1,60 @@ +import type { KnipConfig } from 'knip'; + +export interface DevtoolsKnipWorkspaceConfigOptions { + entry?: string[]; + project?: string[]; + ignore?: string[]; + ignoreBinaries?: string[]; + ignoreDependencies?: (string | RegExp)[]; + ignoreFiles?: string[]; +} + +export interface DevtoolsKnipConfigOptions extends DevtoolsKnipWorkspaceConfigOptions { + workspaces?: Record; +} + +export interface DevtoolsKnipMonorepoConfigOptions { + root?: DevtoolsKnipWorkspaceConfigOptions; + workspaceDefaults?: DevtoolsKnipWorkspaceConfigOptions; + workspaceGlobs?: string[]; + workspaces?: Record; +} + +const DEFAULT_MONOREPO_WORKSPACE_GLOBS = ['packages/*', 'apps/*'] as const; + +export function createKnipConfig(options: DevtoolsKnipConfigOptions = {}): KnipConfig { + return { + ...(options.entry === undefined ? {} : { entry: options.entry }), + ...(options.project === undefined ? {} : { project: options.project }), + ...(options.ignore === undefined ? {} : { ignore: options.ignore }), + ...(options.ignoreBinaries === undefined ? {} : { ignoreBinaries: options.ignoreBinaries }), + ...(options.ignoreDependencies === undefined + ? {} + : { ignoreDependencies: options.ignoreDependencies }), + ...(options.ignoreFiles === undefined ? {} : { ignoreFiles: options.ignoreFiles }), + ...(options.workspaces === undefined ? {} : { workspaces: options.workspaces }), + } satisfies KnipConfig; +} + +export function createKnipMonorepoConfig( + options: DevtoolsKnipMonorepoConfigOptions = {}, +): KnipConfig { + const explicitWorkspaces = options.workspaces ?? {}; + const workspaceGlobs = options.workspaceGlobs ?? [...DEFAULT_MONOREPO_WORKSPACE_GLOBS]; + const workspaces: Record = { + '.': options.root ?? {}, + }; + + for (const workspaceGlob of workspaceGlobs) { + workspaces[workspaceGlob] = { + ...(options.workspaceDefaults ?? {}), + ...(explicitWorkspaces[workspaceGlob] ?? {}), + }; + } + + for (const [workspaceGlob, workspaceConfig] of Object.entries(explicitWorkspaces)) { + workspaces[workspaceGlob] = workspaceConfig; + } + + return createKnipConfig({ workspaces }); +} From bf56e3ac35c15817e4cc27d0477add81961585d6 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:46:44 +0200 Subject: [PATCH 18/34] Move Knip CLI under src tools --- src/tools/knip/cli.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/tools/knip/cli.ts diff --git a/src/tools/knip/cli.ts b/src/tools/knip/cli.ts new file mode 100644 index 0000000..e3eb283 --- /dev/null +++ b/src/tools/knip/cli.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { runStandaloneDevtoolsCommand } from '../../internal/runStandaloneDevtoolsCommand.js'; + +const result = await runStandaloneDevtoolsCommand('knip', process.argv.slice(2)); +process.exit(result.exitCode); From c106b17ee98f9fafde86d647fb5cbcd938f8487a Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:46:53 +0200 Subject: [PATCH 19/34] Move Prettier CLI under src tools --- src/tools/prettier/cli.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/tools/prettier/cli.ts diff --git a/src/tools/prettier/cli.ts b/src/tools/prettier/cli.ts new file mode 100644 index 0000000..7f23336 --- /dev/null +++ b/src/tools/prettier/cli.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { runStandaloneDevtoolsCommand } from '../../internal/runStandaloneDevtoolsCommand.js'; + +const result = await runStandaloneDevtoolsCommand('format', process.argv.slice(2)); +process.exit(result.exitCode); From 0e658f0156266f70f1de14beef1cf66ce4ce6595 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:47:03 +0200 Subject: [PATCH 20/34] Move Prettier config under src tools --- src/tools/prettier/index.cjs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/tools/prettier/index.cjs diff --git a/src/tools/prettier/index.cjs b/src/tools/prettier/index.cjs new file mode 100644 index 0000000..558a1c3 --- /dev/null +++ b/src/tools/prettier/index.cjs @@ -0,0 +1,9 @@ +/** @type {import('prettier').Config} */ +module.exports = { + semi: true, + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + tabWidth: 2, + arrowParens: 'always', +}; From e125aae7819ad625104910c7b6f572835abde047 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:47:14 +0200 Subject: [PATCH 21/34] Use relocated ESLint tooling --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 76c967a..44d8317 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,4 @@ -import { createConfig } from './dist/eslint.js'; +import { createConfig } from './dist/tools/eslint/index.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; From 2e6ba5a1c80f3c3fe51051e116e3fd218165cc50 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:47:24 +0200 Subject: [PATCH 22/34] Use relocated Knip tooling --- knip.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.config.ts b/knip.config.ts index 4d4df9f..a821621 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -1,4 +1,4 @@ -import { createKnipConfig } from './src/knip.js'; +import { createKnipConfig } from './src/tools/knip/index.js'; export default createKnipConfig({ ignoreFiles: [ From 808fad3835ee0a7a8013b4f4a97641934e17cbaa Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:47:34 +0200 Subject: [PATCH 23/34] Remove relocated ESLint source --- src/eslint.ts | 131 -------------------------------------------------- 1 file changed, 131 deletions(-) delete mode 100644 src/eslint.ts diff --git a/src/eslint.ts b/src/eslint.ts deleted file mode 100644 index f8c98be..0000000 --- a/src/eslint.ts +++ /dev/null @@ -1,131 +0,0 @@ -import js from '@eslint/js'; -import prettierConfig from 'eslint-config-prettier'; -import importPlugin from 'eslint-plugin-import'; -import prettierPlugin from 'eslint-plugin-prettier'; -import simpleImportSort from 'eslint-plugin-simple-import-sort'; -import unusedImports from 'eslint-plugin-unused-imports'; -import tseslint from 'typescript-eslint'; - -import type { DevtoolsConfigOptions, FlatConfigItem } from './types.js'; - -export const defaultIgnores = [ - '**/ios/**', - '**/android/**', - '**/dist/**', - '**/build/**', - '**/.expo/**', - '**/.next/**', - '**/node_modules/**', - '**/*.d.ts', - '**/templates/**', - '**/files/**', -] as const; - -export const defaultRestrictedImports = [ - { - name: 'react-native-reanimated-dnd', - message: - "Forbidden in Ankhorage packages. Use '@ankhorage/react-native-reanimated-dnd-web' directly.", - }, -] as const; - -export function createConfig(options: DevtoolsConfigOptions): ReturnType { - const normalizedOptions = { - allowDefaultProject: [], - additionalIgnores: [], - restrictedImports: [], - overrides: [], - includePrettier: true, - ...options, - }; - - const combinedRestrictedImports = [ - ...defaultRestrictedImports, - ...normalizedOptions.restrictedImports, - ]; - - const plugins = { - import: importPlugin, - prettier: prettierPlugin, - 'simple-import-sort': simpleImportSort, - 'unused-imports': unusedImports, - }; - - return tseslint.config( - { - ignores: [...defaultIgnores, ...normalizedOptions.additionalIgnores], - }, - - js.configs.recommended, - - ...tseslint.configs.recommendedTypeChecked.map((config) => ({ - ...config, - files: normalizedOptions.files, - })), - ...tseslint.configs.stylisticTypeChecked.map((config) => ({ - ...config, - files: normalizedOptions.files, - })), - - { - files: normalizedOptions.files, - languageOptions: { - parser: tseslint.parser, - parserOptions: { - project: normalizedOptions.project, - tsconfigRootDir: normalizedOptions.tsconfigRootDir, - allowDefaultProject: normalizedOptions.allowDefaultProject, - }, - }, - plugins, - rules: { - '@typescript-eslint/no-non-null-assertion': 'error', - '@typescript-eslint/prefer-readonly': 'error', - '@typescript-eslint/prefer-optional-chain': 'error', - '@typescript-eslint/prefer-as-const': 'error', - '@typescript-eslint/no-unnecessary-type-arguments': 'error', - '@typescript-eslint/no-unnecessary-condition': 'error', - '@typescript-eslint/no-unnecessary-type-constraint': 'error', - '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], - '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], - '@typescript-eslint/prefer-nullish-coalescing': 'error', - - 'no-restricted-imports': [ - 'error', - { - paths: combinedRestrictedImports, - }, - ], - - 'prefer-destructuring': 'off', - '@typescript-eslint/prefer-destructuring': 'error', - - 'simple-import-sort/imports': 'error', - 'simple-import-sort/exports': 'error', - - 'unused-imports/no-unused-imports': 'error', - 'unused-imports/no-unused-vars': [ - 'error', - { - vars: 'all', - varsIgnorePattern: '^_', - args: 'after-used', - argsIgnorePattern: '^_', - }, - ], - - 'import/order': 'off', - '@typescript-eslint/no-unused-vars': 'off', - - '@typescript-eslint/no-explicit-any': 'error', - - 'prettier/prettier': 'error', - - 'no-console': 'off', - }, - }, - - ...normalizedOptions.overrides, - ...(normalizedOptions.includePrettier ? [prettierConfig as FlatConfigItem] : []), - ); -} From 52d53ec948b9d3d2c65b7add07d1964ddcc1f51e Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:47:40 +0200 Subject: [PATCH 24/34] Remove relocated ESLint types --- src/types.ts | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/types.ts diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index bb95186..0000000 --- a/src/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type tseslint from 'typescript-eslint'; - -type FlatConfig = ReturnType; -export type FlatConfigItem = FlatConfig[number]; - -interface RestrictedImport { - name: string; - message: string; -} - -export interface DevtoolsConfigOptions { - tsconfigRootDir: string; - project: string[]; - files: string[]; - allowDefaultProject?: string[]; - additionalIgnores?: string[]; - restrictedImports?: RestrictedImport[]; - overrides?: FlatConfigItem[]; - includePrettier?: boolean; -} From f5f0b1dfcc3e14b47e536d266b104e79558de0b8 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:47:48 +0200 Subject: [PATCH 25/34] Remove relocated Knip source --- src/knip.ts | 60 ----------------------------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 src/knip.ts diff --git a/src/knip.ts b/src/knip.ts deleted file mode 100644 index b1ee540..0000000 --- a/src/knip.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { KnipConfig } from 'knip'; - -export interface DevtoolsKnipWorkspaceConfigOptions { - entry?: string[]; - project?: string[]; - ignore?: string[]; - ignoreBinaries?: string[]; - ignoreDependencies?: (string | RegExp)[]; - ignoreFiles?: string[]; -} - -export interface DevtoolsKnipConfigOptions extends DevtoolsKnipWorkspaceConfigOptions { - workspaces?: Record; -} - -export interface DevtoolsKnipMonorepoConfigOptions { - root?: DevtoolsKnipWorkspaceConfigOptions; - workspaceDefaults?: DevtoolsKnipWorkspaceConfigOptions; - workspaceGlobs?: string[]; - workspaces?: Record; -} - -const DEFAULT_MONOREPO_WORKSPACE_GLOBS = ['packages/*', 'apps/*'] as const; - -export function createKnipConfig(options: DevtoolsKnipConfigOptions = {}): KnipConfig { - return { - ...(options.entry === undefined ? {} : { entry: options.entry }), - ...(options.project === undefined ? {} : { project: options.project }), - ...(options.ignore === undefined ? {} : { ignore: options.ignore }), - ...(options.ignoreBinaries === undefined ? {} : { ignoreBinaries: options.ignoreBinaries }), - ...(options.ignoreDependencies === undefined - ? {} - : { ignoreDependencies: options.ignoreDependencies }), - ...(options.ignoreFiles === undefined ? {} : { ignoreFiles: options.ignoreFiles }), - ...(options.workspaces === undefined ? {} : { workspaces: options.workspaces }), - } satisfies KnipConfig; -} - -export function createKnipMonorepoConfig( - options: DevtoolsKnipMonorepoConfigOptions = {}, -): KnipConfig { - const explicitWorkspaces = options.workspaces ?? {}; - const workspaceGlobs = options.workspaceGlobs ?? [...DEFAULT_MONOREPO_WORKSPACE_GLOBS]; - const workspaces: Record = { - '.': options.root ?? {}, - }; - - for (const workspaceGlob of workspaceGlobs) { - workspaces[workspaceGlob] = { - ...(options.workspaceDefaults ?? {}), - ...(explicitWorkspaces[workspaceGlob] ?? {}), - }; - } - - for (const [workspaceGlob, workspaceConfig] of Object.entries(explicitWorkspaces)) { - workspaces[workspaceGlob] = workspaceConfig; - } - - return createKnipConfig({ workspaces }); -} From 2769bf7d4b46986245bafa493e48d6762afcd0a9 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:47:56 +0200 Subject: [PATCH 26/34] Remove relocated ESLint CLI --- src/eslint-cli.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/eslint-cli.ts diff --git a/src/eslint-cli.ts b/src/eslint-cli.ts deleted file mode 100644 index ef77662..0000000 --- a/src/eslint-cli.ts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node - -import { runStandaloneDevtoolsCommand } from './internal/runStandaloneDevtoolsCommand.js'; - -const result = await runStandaloneDevtoolsCommand('lint', process.argv.slice(2)); -process.exit(result.exitCode); From e6779facce4e29db3946a7af9d8ec09171798eb1 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:48:03 +0200 Subject: [PATCH 27/34] Remove relocated Knip CLI --- src/knip-cli.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/knip-cli.ts diff --git a/src/knip-cli.ts b/src/knip-cli.ts deleted file mode 100644 index d713c38..0000000 --- a/src/knip-cli.ts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node - -import { runStandaloneDevtoolsCommand } from './internal/runStandaloneDevtoolsCommand.js'; - -const result = await runStandaloneDevtoolsCommand('knip', process.argv.slice(2)); -process.exit(result.exitCode); From 2ff8dd27c19a10a9f022b1a25aa7342bcbbc0456 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:48:11 +0200 Subject: [PATCH 28/34] Remove relocated Prettier CLI --- src/prettier-cli.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/prettier-cli.ts diff --git a/src/prettier-cli.ts b/src/prettier-cli.ts deleted file mode 100644 index 094ffd7..0000000 --- a/src/prettier-cli.ts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node - -import { runStandaloneDevtoolsCommand } from './internal/runStandaloneDevtoolsCommand.js'; - -const result = await runStandaloneDevtoolsCommand('format', process.argv.slice(2)); -process.exit(result.exitCode); From cc1a6a2bd6432daf962392c7ff8146642f54fc16 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:48:18 +0200 Subject: [PATCH 29/34] Remove relocated Prettier config --- src/prettier.cjs | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/prettier.cjs diff --git a/src/prettier.cjs b/src/prettier.cjs deleted file mode 100644 index 558a1c3..0000000 --- a/src/prettier.cjs +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('prettier').Config} */ -module.exports = { - semi: true, - singleQuote: true, - trailingComma: 'all', - printWidth: 100, - tabWidth: 2, - arrowParens: 'always', -}; From ca94b6a6b09549970a014e7adc4e4b69aa87a9e1 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:48:38 +0200 Subject: [PATCH 30/34] Update ESLint tests for tools structure --- src/eslint.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eslint.test.ts b/src/eslint.test.ts index 6a07e3c..e6771e8 100644 --- a/src/eslint.test.ts +++ b/src/eslint.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { createConfig, defaultRestrictedImports } from './eslint.js'; +import { createConfig, defaultRestrictedImports } from './tools/eslint/index.js'; describe('createConfig sanity check', () => { const baseOptions = { From eec0744d04310ccc8b9ae1909e792c89c7f68393 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:48:56 +0200 Subject: [PATCH 31/34] Update Knip tests for tools structure --- src/knip.test.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/knip.test.ts b/src/knip.test.ts index ecea2b1..fb80adf 100644 --- a/src/knip.test.ts +++ b/src/knip.test.ts @@ -1,25 +1,23 @@ import { describe, expect, it } from 'bun:test'; -import { createKnipConfig } from './knip.js'; +import { createKnipConfig } from './tools/knip/index.js'; describe('createKnipConfig', () => { it('returns an empty config by default so Knip can use zero-config discovery', () => { - const config = createKnipConfig(); - - expect(config).toEqual({}); + expect(createKnipConfig()).toEqual({}); }); it('uses explicit repo-specific config when provided', () => { - const config = createKnipConfig({ - entry: ['scripts/release.ts'], - project: ['scripts/**/*.ts'], - ignore: ['fixtures/**'], - ignoreBinaries: ['eslint', 'prettier'], - ignoreDependencies: ['optional-package'], - ignoreFiles: ['examples/package/prettier.config.cjs'], - }); - - expect(config).toEqual({ + expect( + createKnipConfig({ + entry: ['scripts/release.ts'], + project: ['scripts/**/*.ts'], + ignore: ['fixtures/**'], + ignoreBinaries: ['eslint', 'prettier'], + ignoreDependencies: ['optional-package'], + ignoreFiles: ['examples/package/prettier.config.cjs'], + }), + ).toEqual({ entry: ['scripts/release.ts'], project: ['scripts/**/*.ts'], ignore: ['fixtures/**'], From abb6bab7f6d0c38e75462b5b2b188a299d8e168b Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:49:26 +0200 Subject: [PATCH 32/34] Cover expanded devtools provider surface --- src/ankh.provider.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ankh.provider.test.ts b/src/ankh.provider.test.ts index 191288c..31dbad8 100644 --- a/src/ankh.provider.test.ts +++ b/src/ankh.provider.test.ts @@ -4,7 +4,7 @@ import provider from './cli/index.js'; import { getDevtoolsCommands } from './internal/devtoolsCommands.js'; describe('devtools package provider', () => { - it('exposes only the shipped devtools commands and capabilities', () => { + it('exposes the canonical devtools command surface', () => { const commands = getDevtoolsCommands(); expect(provider.id).toBe('@ankhorage/devtools'); @@ -21,6 +21,12 @@ describe('devtools package provider', () => { 'lint', 'format', 'knip', + 'sync', + 'status', + 'workflows sync', + 'workflows status', + 'vscode sync', + 'vscode status', ]); }); From 8ad97e10732b471f34c27031a0dca7f3605bb7e8 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:49:46 +0200 Subject: [PATCH 33/34] Cover nested devtools commands --- src/internal/devtoolsCommands.test.ts | 50 ++++++++++++--------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/src/internal/devtoolsCommands.test.ts b/src/internal/devtoolsCommands.test.ts index d72515e..dd73019 100644 --- a/src/internal/devtoolsCommands.test.ts +++ b/src/internal/devtoolsCommands.test.ts @@ -7,37 +7,33 @@ import { } from './devtoolsCommands.js'; describe('devtools command table', () => { - it('defines the exact provider-backed command surface', () => { - const commands = getDevtoolsCommands(); - - expect(commands).toEqual([ - { - path: ['lint'], - capability: 'devtools.lint', - summary: 'Run the shared ESLint toolchain.', - packageName: 'eslint', - binName: 'eslint', - }, - { - path: ['format'], - capability: 'devtools.format', - summary: 'Run the shared Prettier toolchain.', - packageName: 'prettier', - binName: 'prettier', - }, - { - path: ['knip'], - capability: 'devtools.knip', - summary: 'Run the shared Knip toolchain.', - packageName: 'knip', - binName: 'knip', - }, + it('defines the canonical provider-backed command paths', () => { + expect(getDevtoolsCommands().map((command) => command.path.join(' '))).toEqual([ + 'lint', + 'format', + 'knip', + 'sync', + 'status', + 'workflows sync', + 'workflows status', + 'vscode sync', + 'vscode status', ]); }); - it('returns commands by path and rejects unknown commands', () => { + it('returns binary commands by tool name', () => { expect(getDevtoolsCommand('lint').path).toEqual(['lint']); - expect(findDevtoolsCommandByPath(['format'])?.capability).toBe('devtools.format'); + expect(getDevtoolsCommand('format').binName).toBe('prettier'); + expect(getDevtoolsCommand('knip').packageName).toBe('knip'); + }); + + it('resolves nested managed commands and rejects unknown paths', () => { + expect(findDevtoolsCommandByPath(['workflows', 'sync'])?.capability).toBe( + 'devtools.workflows.sync', + ); + expect(findDevtoolsCommandByPath(['vscode', 'status'])?.capability).toBe( + 'devtools.vscode.status', + ); expect(findDevtoolsCommandByPath(['unknown'])).toBeNull(); expect(findDevtoolsCommandByPath(['lint', 'extra'])).toBeNull(); }); From 985bc81439607840018403c6aea6e34b06f26871 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:50:13 +0200 Subject: [PATCH 34/34] Test managed file synchronization --- src/tools/shared/managedFiles.test.ts | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/tools/shared/managedFiles.test.ts diff --git a/src/tools/shared/managedFiles.test.ts b/src/tools/shared/managedFiles.test.ts new file mode 100644 index 0000000..24c3055 --- /dev/null +++ b/src/tools/shared/managedFiles.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'bun:test'; + +import { inspectManagedFiles, syncManagedFiles } from './managedFiles.js'; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function fixtureRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'devtools-managed-')); + tempRoots.push(root); + return root; +} + +describe('managed files', () => { + it('creates missing files, updates drift, and is idempotent', () => { + const root = fixtureRoot(); + const source = join(root, 'canonical.txt'); + writeFileSync(source, 'canonical\n'); + const definitions = [{ targetPath: '.config/example.txt', sourceUrl: new URL(`file://${source}`) }]; + + expect(inspectManagedFiles(root, definitions)[0]?.state).toBe('missing'); + expect(syncManagedFiles(root, definitions)[0]?.action).toBe('created'); + expect(syncManagedFiles(root, definitions)[0]?.action).toBe('unchanged'); + + writeFileSync(join(root, '.config/example.txt'), 'drift\n'); + expect(inspectManagedFiles(root, definitions)[0]?.state).toBe('outdated'); + expect(syncManagedFiles(root, definitions)[0]?.action).toBe('updated'); + expect(readFileSync(join(root, '.config/example.txt'), 'utf8')).toBe('canonical\n'); + }); + + it('reports dry-run changes without writing', () => { + const root = fixtureRoot(); + const source = join(root, 'canonical.txt'); + writeFileSync(source, 'canonical\n'); + const definitions = [{ targetPath: '.config/example.txt', sourceUrl: new URL(`file://${source}`) }]; + + expect(syncManagedFiles(root, definitions, { dryRun: true })[0]?.action).toBe( + 'would-create', + ); + expect(inspectManagedFiles(root, definitions)[0]?.state).toBe('missing'); + }); +});