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'; 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: [ 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", 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', ]); }); 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 }; }, })), 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 = { 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(); }); 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}`); 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'); } 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); +} 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/**'], diff --git a/src/eslint-cli.ts b/src/tools/eslint/cli.ts similarity index 58% rename from src/eslint-cli.ts rename to src/tools/eslint/cli.ts index ef77662..8d95f5f 100644 --- a/src/eslint-cli.ts +++ b/src/tools/eslint/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { runStandaloneDevtoolsCommand } from './internal/runStandaloneDevtoolsCommand.js'; +import { runStandaloneDevtoolsCommand } from '../../internal/runStandaloneDevtoolsCommand.js'; const result = await runStandaloneDevtoolsCommand('lint', process.argv.slice(2)); process.exit(result.exitCode); diff --git a/src/eslint.ts b/src/tools/eslint/index.ts similarity index 93% rename from src/eslint.ts rename to src/tools/eslint/index.ts index f8c98be..b23f793 100644 --- a/src/eslint.ts +++ b/src/tools/eslint/index.ts @@ -52,12 +52,8 @@ export function createConfig(options: DevtoolsConfigOptions): ReturnType ({ ...config, files: normalizedOptions.files, @@ -66,7 +62,6 @@ export function createConfig(options: DevtoolsConfigOptions): ReturnType { + 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'); + }); +}); 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}`); + } +} 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 }; + } +} 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" + ] +} 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 +} 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[]; 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 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." 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[];