diff --git a/.changeset/centralize-repository-devtools.md b/.changeset/centralize-repository-devtools.md new file mode 100644 index 0000000..fd687bd --- /dev/null +++ b/.changeset/centralize-repository-devtools.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/devtools': minor +--- + +Add canonical GitHub Actions and VS Code synchronization commands, move tool implementations under `src/tools`, and expose the complete `ankh devtools` provider surface. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 756a8ac..05eb663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run doctor validation - run: bun run doctor + - name: Validate Ankhorage repository + run: bunx @ankhorage/ankh doctor validate . - name: Run build run: | @@ -81,8 +81,8 @@ jobs: - name: Check changesets if: github.event_name == 'pull_request' run: | - if [ -f ./scripts/check-changeset-status.sh ]; then - bash ./scripts/check-changeset-status.sh + if node -e "const p=require('./package.json'); process.exit(p.scripts?.['changeset:status'] ? 0 : 1)"; then + bun run changeset:status else - echo "No changeset status script found; skipping." + echo "No changeset:status script found; skipping." fi diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4a81931 --- /dev/null +++ b/.vscode/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/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8ee7303 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": false, + "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/README.md b/README.md index 2a8d4b6..2fee4a9 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,29 @@ # devtools -Shared ESLint, Prettier, and Knip configuration for modern TypeScript projects. +Shared development tools and repository standards for Ankhorage TypeScript projects. -## What you get +## What it owns -- Consistent linting across repos -- Zero-config Prettier setup -- Shared Knip static-analysis defaults -- Bundled tool binaries for ESLint, Prettier, and Knip -- Ankh provider commands for lint, format, and Knip -- Strict TypeScript rules without compromise -- One source of truth for tooling +`@ankhorage/devtools` is the single source of truth for these equal, separate concerns: -## Features +```text +src/ +├── cli/ +└── tools/ + ├── eslint/ + ├── prettier/ + ├── knip/ + ├── workflows/ + └── vscode/ +``` + +- `eslint`: shared flat ESLint configuration and the bundled ESLint runner +- `prettier`: shared Prettier configuration and the bundled Prettier runner +- `knip`: shared Knip configuration and the bundled Knip runner +- `workflows`: canonical `.github/workflows/ci.yml` and `release.yml` +- `vscode`: canonical `.vscode/settings.json` and `extensions.json` -- Flat ESLint config (latest standard) -- Preconfigured plugin ecosystem -- Prettier integration -- Shared Knip config factories -- `ankhorage-eslint`, `ankhorage-prettier`, and `ankhorage-knip` binaries -- `ankh devtools lint`, `ankh devtools format`, and `ankh devtools knip` -- Monorepo-friendly +There is no generic template layer around these concerns. Each tool owns its canonical files and behavior. ## Installation @@ -28,23 +31,43 @@ Shared ESLint, Prettier, and Knip configuration for modern TypeScript projects. bun add -D @ankhorage/devtools ``` -The package owns the ESLint, Prettier, and Knip toolchain. Consuming repos should not install `eslint`, `prettier`, or `knip` directly unless they intentionally need a different version from the shared Ankhorage toolchain. +The package owns the ESLint, Prettier, and Knip versions used by consuming repositories. Do not install those tools directly unless a repository intentionally opts out of the shared Ankhorage toolchain. + +## Ankh provider -It also participates in Ankh package discovery with: +The package is discovered under the `devtools` category and exposes these capabilities: -- category: `devtools` -- capabilities: - - `devtools.lint` - - `devtools.format` - - `devtools.knip` +- `devtools.lint` +- `devtools.format` +- `devtools.knip` +- `devtools.sync` +- `devtools.status` +- `devtools.workflows.sync` +- `devtools.workflows.status` +- `devtools.vscode.sync` +- `devtools.vscode.status` -`@ankhorage/devtools` owns primitive lint/format/knip tooling. Local emulator, app, and workstation workflows belong in `@ankhorage/dev`. +The canonical command prefix is always: + +```bash +ankh devtools ... +``` + +## Tool commands + +```bash +ankh devtools lint -- --max-warnings=0 . +ankh devtools format -- --check . +ankh devtools knip -- --production +``` -## Usage +These commands delegate to the same bundled tools as the package binaries: -### Scripts +- `ankh devtools lint` → `ankhorage-eslint` +- `ankh devtools format` → `ankhorage-prettier` +- `ankh devtools knip` → `ankhorage-knip` -Use the devtools-owned binaries in package scripts: +Recommended package scripts: ```json { @@ -58,165 +81,169 @@ Use the devtools-owned binaries in package scripts: } ``` -### Ankh commands +## Repository synchronization -When discovered by `@ankhorage/ankh`, the package exposes: +### Synchronize all managed files ```bash -ankh devtools lint -- --max-warnings=0 . -ankh devtools format -- --check . -ankh devtools knip -- --production +ankh devtools sync . ``` -These provider-backed commands delegate to the same underlying tools as the standalone binaries: +The target path is optional and defaults to the current working directory: + +```bash +ankh devtools sync +``` -- `ankh devtools lint` -> `ankhorage-eslint` -- `ankh devtools format` -> `ankhorage-prettier` -- `ankh devtools knip` -> `ankhorage-knip` +### Report drift without changing files -### ESLint +```bash +ankh devtools status . +``` -Create an `eslint.config.js` file: +`status` exits with code `1` when any managed file is missing or outdated. It exits with code `0` when all managed files are current. -```js -import { createConfig } from '@ankhorage/devtools/eslint'; +Example output: -export default createConfig({ - files: ['src/**/*.{ts,tsx}'], - project: ['./tsconfig.json'], - tsconfigRootDir: import.meta.dirname, -}); +```text +✓ .github/workflows/ci.yml +✗ .github/workflows/release.yml outdated ++ .vscode/settings.json missing +✓ .vscode/extensions.json ``` -### Prettier +### Synchronize one concern -Create a Prettier config file: +```bash +ankh devtools workflows sync . +ankh devtools vscode sync . +``` -```js -export { default } from '@ankhorage/devtools/prettier'; +### Report one concern + +```bash +ankh devtools workflows status . +ankh devtools vscode status . ``` -CommonJS repos can use: +### Preview synchronization -```js -module.exports = require('@ankhorage/devtools/prettier'); +```bash +ankh devtools sync . --dry-run +ankh devtools workflows sync . --dry-run +ankh devtools vscode sync . --dry-run ``` -### Knip +A dry run reports `would create` and `would update` actions without writing any files. -Create a repo-local `knip.config.ts` file: +## Synchronization guarantees -```ts -import { createKnipConfig } from '@ankhorage/devtools/knip'; +Synchronization is deterministic and idempotent: -export default createKnipConfig(); -``` +- missing managed files are created +- outdated managed files are replaced with the canonical package version +- current managed files are left untouched +- unrelated files are never modified +- unknown files in `.github/workflows` and `.vscode` are never deleted +- repeating `sync` after a successful run produces only `unchanged` results +- invalid target paths and write failures return a non-zero exit code -Repos can add narrow repo-specific patterns when needed: +The canonical files are packaged with `@ankhorage/devtools`; synchronization does not fetch mutable files from GitHub at runtime. -```ts -import { createKnipConfig } from '@ankhorage/devtools/knip'; +## Managed GitHub Actions workflows -export default createKnipConfig({ - entry: ['scripts/release.ts'], - project: ['scripts/**/*.ts'], - ignore: ['fixtures/**'], - ignoreBinaries: ['custom-tool'], - ignoreFiles: ['examples/fixture.ts'], -}); +`workflows` owns exactly: + +```text +.github/workflows/ci.yml +.github/workflows/release.yml ``` -For workspaces-based monorepos, use the monorepo preset: +The CI workflow: -```ts -import { createKnipMonorepoConfig } from '@ankhorage/devtools/knip'; +- checks out full history +- installs the pinned Bun version +- installs dependencies with `bun install --frozen-lockfile` +- runs `bunx @ankhorage/ankh doctor validate .` +- conditionally runs build, lint, format check, Knip, tests, and typecheck when scripts exist +- conditionally runs `changeset:status` for pull requests when the script exists -export default createKnipMonorepoConfig({ - root: { - ignoreFiles: ['.prettierrc.js', 'eslint.config.js'], - }, -}); -``` +The release workflow: -By default, the monorepo preset configures Knip workspaces for the root package, `packages/*`, and `apps/*`. Repos can override those defaults or add extra workspace globs: +- checks out full history +- configures Bun and Node for npm publishing +- installs dependencies with the frozen lockfile +- conditionally builds +- runs the Changesets release PR/publish flow +- protects release execution with a concurrency group +- skips cleanly when no Changesets configuration exists -```ts -import { createKnipMonorepoConfig } from '@ankhorage/devtools/knip'; - -export default createKnipMonorepoConfig({ - workspaceGlobs: ['packages/*', 'apps/*', 'examples/*'], - workspaceDefaults: { - ignoreFiles: ['fixtures/**'], - }, - workspaces: { - '.': { - ignoreFiles: ['.prettierrc.js', 'eslint.config.js'], - }, - 'apps/editor': { - ignoreFiles: ['babel.config.js'], - ignoreDependencies: ['babel-preset-expo'], - }, - }, -}); +## Managed VS Code configuration + +`vscode` owns exactly: + +```text +.vscode/settings.json +.vscode/extensions.json ``` -The shared config intentionally keeps defaults narrow so Knip can still report real unused files, exports, dependencies, and binaries. Prefer explicit `entry`, `project`, `ignoreBinaries`, `ignoreDependencies`, or `ignoreFiles` over broad ignores. +The shared settings use the workspace TypeScript SDK, enable the workspace-SDK prompt, configure intentional ESLint save actions, and enforce basic whitespace/newline consistency. -### CI +The extension recommendations are limited to the standard Ankhorage workflow: -Run the scripts in CI after dependencies are installed: +- Bun +- ESLint +- Prettier +- YAML +- GitHub Actions -```yaml -- name: Run lint - run: bun run lint +`launch.json` is intentionally not managed globally. Libraries, CLIs, Expo packages, services, and integration repositories require different debug configurations. -- name: Run format check - run: bun run format:check +## ESLint -- name: Run Knip - run: bun run knip -``` +Create `eslint.config.mjs`: -For workflows that support optional scripts, use the same guard style as the other devtools checks: +```js +import { createConfig } from '@ankhorage/devtools/eslint'; -```yaml -- 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 +export default createConfig({ + files: ['src/**/*.{ts,tsx}'], + project: ['./tsconfig.json'], + tsconfigRootDir: import.meta.dirname, +}); ``` -## Use Cases +## Prettier -- Monorepos with shared standards -- Teams that want strict, predictable linting -- Projects avoiding duplicated config -- Repos that need consistent unused-file, unused-export, and dependency checks +ES modules: -## Why this exists +```js +export { default } from '@ankhorage/devtools/prettier'; +``` -Maintaining ESLint, Prettier, and Knip configs across multiple repositories leads to: +CommonJS: -- duplication -- inconsistency -- drift over time +```js +module.exports = require('@ankhorage/devtools/prettier'); +``` + +## Knip -This package centralizes tooling so all projects stay aligned. +```ts +import { createKnipConfig } from '@ankhorage/devtools/knip'; -## Scope +export default createKnipConfig(); +``` -Includes: +Monorepos can use `createKnipMonorepoConfig` and add narrow repository-specific entries, projects, ignores, binaries, dependencies, or workspace overrides. -- ESLint configuration and binary wrapper -- Prettier configuration and binary wrapper -- Knip configuration and binary wrapper -- Ankh provider descriptors and handlers for lint, format, and Knip +## Adding another managed concern -Excludes: +A new concern should: -- runtime code -- build tooling -- local dev workflows +1. live in its own sibling directory under `src/tools` +2. define only the files and behavior it owns +3. expose deterministic status and synchronization through the shared managed-file engine +4. add provider commands under `ankh devtools` +5. include source-tree, built-package, dry-run, status, and idempotence coverage +6. document overwrite and exit-code behavior 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..a0d964b 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 tools 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,15 @@ "eslint", "prettier", "knip", - "linting", - "formatting", - "static-analysis", + "github-actions", + "vscode", + "repository-sync", "developer-tools" ], "bin": { - "ankhorage-eslint": "./dist/eslint-cli.js", - "ankhorage-knip": "./dist/knip-cli.js", - "ankhorage-prettier": "./dist/prettier-cli.js" + "ankhorage-eslint": "./dist/cli/bin/eslint.js", + "ankhorage-knip": "./dist/cli/bin/knip.js", + "ankhorage-prettier": "./dist/cli/bin/prettier.js" }, "exports": { "./cli": { @@ -45,15 +51,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 +69,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/prettier dist/tools/workflows dist/tools/vscode && cp src/tools/prettier/index.cjs dist/tools/prettier/index.cjs && cp -R src/tools/workflows/files dist/tools/workflows/files && cp -R src/tools/vscode/files dist/tools/vscode/files", "typecheck": "bun x tsc --noEmit -p tsconfig.test.json", "doctor": "ankhorage-doctor validate .", "knip": "knip", diff --git a/prettier.config.cjs b/prettier.config.cjs index fb97126..b3f54f1 100644 --- a/prettier.config.cjs +++ b/prettier.config.cjs @@ -1,2 +1,2 @@ /** @type {import('prettier').Config} */ -module.exports = require('./src/prettier.cjs'); +module.exports = require('./src/tools/prettier/index.cjs'); diff --git a/src/ankh.provider.test.ts b/src/ankh.provider.test.ts index 191288c..3a5eb14 100644 --- a/src/ankh.provider.test.ts +++ b/src/ankh.provider.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'bun:test'; +import { getDevtoolsCommands } from './cli/commands.js'; 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 complete canonical devtools command surface', () => { const commands = getDevtoolsCommands(); expect(provider.id).toBe('@ankhorage/devtools'); @@ -21,12 +21,17 @@ describe('devtools package provider', () => { 'lint', 'format', 'knip', + 'sync', + 'status', + 'workflows sync', + 'workflows status', + 'vscode sync', + 'vscode status', ]); }); it('binds exactly one handler for every command descriptor', () => { - const { handlers } = provider; - const handlerPaths = handlers.map((handler) => handler.path.join(' ')).sort(); + const handlerPaths = provider.handlers.map((handler) => handler.path.join(' ')).sort(); const commandPaths = provider.commands.map((command) => command.path.join(' ')).sort(); expect(handlerPaths).toEqual(commandPaths); diff --git a/src/cli/bin/eslint.ts b/src/cli/bin/eslint.ts new file mode 100644 index 0000000..8107b81 --- /dev/null +++ b/src/cli/bin/eslint.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { runStandaloneTool } from '../runStandaloneTool.js'; + +const result = await runStandaloneTool('lint', process.argv.slice(2)); +process.exit(result.exitCode); diff --git a/src/cli/bin/knip.ts b/src/cli/bin/knip.ts new file mode 100644 index 0000000..2d6fd1e --- /dev/null +++ b/src/cli/bin/knip.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { runStandaloneTool } from '../runStandaloneTool.js'; + +const result = await runStandaloneTool('knip', process.argv.slice(2)); +process.exit(result.exitCode); diff --git a/src/cli/bin/prettier.ts b/src/cli/bin/prettier.ts new file mode 100644 index 0000000..f8003a8 --- /dev/null +++ b/src/cli/bin/prettier.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { runStandaloneTool } from '../runStandaloneTool.js'; + +const result = await runStandaloneTool('format', process.argv.slice(2)); +process.exit(result.exitCode); diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts new file mode 100644 index 0000000..e6427e3 --- /dev/null +++ b/src/cli/commands.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test'; + +import { + findDevtoolsCommandByPath, + getDevtoolsCommands, + getDevtoolsToolCommand, +} from './commands.js'; + +describe('devtools command table', () => { + it('defines the exact provider-backed command surface', () => { + expect(getDevtoolsCommands().map((command) => command.path.join(' '))).toEqual([ + 'lint', + 'format', + 'knip', + 'sync', + 'status', + 'workflows sync', + 'workflows status', + 'vscode sync', + 'vscode status', + ]); + }); + + it('resolves single- and multi-segment command paths', () => { + expect(getDevtoolsToolCommand('lint').capability).toBe('devtools.lint'); + expect(findDevtoolsCommandByPath(['workflows', 'sync'])?.capability).toBe( + 'devtools.workflows.sync', + ); + expect(findDevtoolsCommandByPath(['vscode', 'status'])?.capability).toBe( + 'devtools.vscode.status', + ); + expect(findDevtoolsCommandByPath(['dev', 'sync'])).toBeNull(); + expect(findDevtoolsCommandByPath(['workflows'])).toBeNull(); + }); +}); diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 0000000..7740473 --- /dev/null +++ b/src/cli/commands.ts @@ -0,0 +1,143 @@ +export type DevtoolsToolName = 'format' | 'knip' | 'lint'; +type DevtoolsManagedScope = 'all' | 'vscode' | 'workflows'; +type DevtoolsManagedOperation = 'status' | 'sync'; + +type DevtoolsCapability = + | 'devtools.format' + | 'devtools.knip' + | 'devtools.lint' + | 'devtools.status' + | 'devtools.sync' + | 'devtools.vscode.status' + | 'devtools.vscode.sync' + | 'devtools.workflows.status' + | 'devtools.workflows.sync'; + +interface DevtoolsCommandBase { + readonly path: readonly [string, ...string[]]; + readonly capability: DevtoolsCapability; + readonly summary: string; +} + +export interface DevtoolsExternalCommandDefinition extends DevtoolsCommandBase { + readonly kind: 'external'; + readonly toolName: DevtoolsToolName; + readonly packageName: 'eslint' | 'knip' | 'prettier'; + readonly binName: 'eslint' | 'knip' | 'prettier'; +} + +export interface DevtoolsRepositoryCommandDefinition extends DevtoolsCommandBase { + readonly kind: 'repository'; + readonly scope: DevtoolsManagedScope; + readonly operation: DevtoolsManagedOperation; +} + +export type DevtoolsCommandDefinition = + | DevtoolsExternalCommandDefinition + | DevtoolsRepositoryCommandDefinition; + +const DEVTOOLS_COMMANDS = [ + { + kind: 'external', + toolName: 'lint', + path: ['lint'], + capability: 'devtools.lint', + summary: 'Run the shared ESLint toolchain.', + packageName: 'eslint', + binName: 'eslint', + }, + { + kind: 'external', + toolName: 'format', + path: ['format'], + capability: 'devtools.format', + summary: 'Run the shared Prettier toolchain.', + packageName: 'prettier', + binName: 'prettier', + }, + { + kind: 'external', + toolName: 'knip', + path: ['knip'], + capability: 'devtools.knip', + summary: 'Run the shared Knip toolchain.', + packageName: 'knip', + binName: 'knip', + }, + { + kind: 'repository', + path: ['sync'], + capability: 'devtools.sync', + summary: 'Synchronize all centrally managed repository files.', + scope: 'all', + operation: 'sync', + }, + { + kind: 'repository', + path: ['status'], + capability: 'devtools.status', + summary: 'Report drift for all centrally managed repository files.', + scope: 'all', + operation: 'status', + }, + { + kind: 'repository', + path: ['workflows', 'sync'], + capability: 'devtools.workflows.sync', + summary: 'Synchronize the canonical GitHub Actions workflows.', + scope: 'workflows', + operation: 'sync', + }, + { + kind: 'repository', + path: ['workflows', 'status'], + capability: 'devtools.workflows.status', + summary: 'Report drift for the canonical GitHub Actions workflows.', + scope: 'workflows', + operation: 'status', + }, + { + kind: 'repository', + path: ['vscode', 'sync'], + capability: 'devtools.vscode.sync', + summary: 'Synchronize the canonical VS Code workspace configuration.', + scope: 'vscode', + operation: 'sync', + }, + { + kind: 'repository', + path: ['vscode', 'status'], + capability: 'devtools.vscode.status', + summary: 'Report drift for the canonical VS Code workspace configuration.', + scope: 'vscode', + operation: 'status', + }, +] as const satisfies readonly DevtoolsCommandDefinition[]; + +export function getDevtoolsCommands(): readonly DevtoolsCommandDefinition[] { + return DEVTOOLS_COMMANDS; +} + +export function findDevtoolsCommandByPath( + path: readonly string[], +): DevtoolsCommandDefinition | null { + return ( + DEVTOOLS_COMMANDS.find( + (command) => + command.path.length === path.length && + command.path.every((segment, index) => segment === path[index]), + ) ?? null + ); +} + +export function getDevtoolsToolCommand( + toolName: DevtoolsToolName, +): DevtoolsExternalCommandDefinition { + for (const command of DEVTOOLS_COMMANDS) { + if (command.kind === 'external' && command.toolName === toolName) { + return command; + } + } + + throw new Error(`Unknown devtools tool command: ${toolName}`); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index b61d7bf..1846245 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs'; import type { AnkhRuntimeCommandProvider } from '@ankhorage/ankh'; -import { getDevtoolsCommands } from '../internal/devtoolsCommands.js'; -import { runDevtoolsCommand } from '../internal/runDevtoolsCommand.js'; +import { getDevtoolsCommands } from './commands.js'; +import { runProviderCommand } from './runProviderCommand.js'; const packageVersion = readPackageVersion(); const commands = getDevtoolsCommands(); @@ -21,8 +21,7 @@ const provider = { handlers: commands.map((command) => ({ path: [...command.path], handler: async (request) => { - const result = await runDevtoolsCommand(command, request.argv); - return { exitCode: result.exitCode }; + return await runProviderCommand(command, request.argv, request.context); }, })), } satisfies AnkhRuntimeCommandProvider; diff --git a/src/cli/runExternalTool.test.ts b/src/cli/runExternalTool.test.ts new file mode 100644 index 0000000..4534c6b --- /dev/null +++ b/src/cli/runExternalTool.test.ts @@ -0,0 +1,69 @@ +import { EventEmitter } from 'node:events'; + +import { describe, expect, it } from 'bun:test'; + +import { getDevtoolsToolCommand } from './commands.js'; +import { runExternalToolWithDependencies } from './runExternalTool.js'; + +type FakeChildProcess = EventEmitter & { + on(event: 'error', listener: (error: Error) => void): FakeChildProcess; + on( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): FakeChildProcess; +}; + +describe('runExternalTool', () => { + it('preserves argv, cwd, env, and returns the child exit code', async () => { + const spawnCalls: unknown[] = []; + const result = await runExternalToolWithDependencies( + getDevtoolsToolCommand('lint'), + ['--max-warnings=0', 'src'], + { cwd: '/tmp/devtools-fixture', env: { FOO: 'bar' } }, + { + logError: () => undefined, + resolveExecutionTarget: () => + Promise.resolve({ command: process.execPath, args: ['/tmp/eslint.js'], shell: false }), + spawnProcess: (command, args, options) => { + spawnCalls.push({ command, args, options }); + const child = new EventEmitter() as FakeChildProcess; + queueMicrotask(() => child.emit('exit', 7, null)); + return child; + }, + }, + ); + + expect(result).toEqual({ exitCode: 7 }); + expect(spawnCalls).toEqual([ + { + command: process.execPath, + args: ['/tmp/eslint.js', '--max-warnings=0', 'src'], + options: { + cwd: '/tmp/devtools-fixture', + env: { FOO: 'bar' }, + shell: false, + stdio: 'inherit', + }, + }, + ]); + }); + + it('fails cleanly when command resolution fails', async () => { + const messages: string[] = []; + const result = await runExternalToolWithDependencies( + getDevtoolsToolCommand('lint'), + ['.'], + undefined, + { + logError: (message) => messages.push(message), + resolveExecutionTarget: () => Promise.reject(new Error('missing eslint binary')), + spawnProcess: () => { + throw new Error('spawn should not run'); + }, + }, + ); + + expect(result).toEqual({ exitCode: 1 }); + expect(messages).toEqual(['Failed to resolve eslint: missing eslint binary']); + }); +}); diff --git a/src/internal/runDevtoolsCommand.ts b/src/cli/runExternalTool.ts similarity index 86% rename from src/internal/runDevtoolsCommand.ts rename to src/cli/runExternalTool.ts index 99328c2..2378702 100644 --- a/src/internal/runDevtoolsCommand.ts +++ b/src/cli/runExternalTool.ts @@ -4,25 +4,23 @@ 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 { DevtoolsExternalCommandDefinition } from './commands.js'; const require = createRequire(import.meta.url); -export type { DevtoolsCommandDefinition, DevtoolsToolName }; - export interface DevtoolsRunResult { - exitCode: number; + readonly exitCode: number; } interface DevtoolsRunnerOptions { - cwd?: string; - env?: NodeJS.ProcessEnv; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; } interface ResolvedExecutionTarget { - command: string; - args: readonly string[]; - shell: boolean; + readonly command: string; + readonly args: readonly string[]; + readonly shell: boolean; } interface SpawnedProcess { @@ -37,17 +35,17 @@ type SpawnProcess = ( command: string, args: readonly string[], options: { - cwd: string; - env: NodeJS.ProcessEnv; - shell: boolean; - stdio: 'inherit'; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly shell: boolean; + readonly stdio: 'inherit'; }, ) => SpawnedProcess; interface DevtoolsRunnerDependencies { readonly logError: (message: string) => void; readonly resolveExecutionTarget: ( - command: DevtoolsCommandDefinition, + command: DevtoolsExternalCommandDefinition, ) => Promise; readonly spawnProcess: SpawnProcess; } @@ -60,16 +58,16 @@ const defaultRunnerDependencies: DevtoolsRunnerDependencies = { spawnProcess: (command, args, options) => spawn(command, args, options), }; -export async function runDevtoolsCommand( - command: DevtoolsCommandDefinition, +export async function runExternalTool( + command: DevtoolsExternalCommandDefinition, argv: readonly string[], options?: DevtoolsRunnerOptions, ): Promise { - return runDevtoolsCommandWithDependencies(command, argv, options, defaultRunnerDependencies); + return await runExternalToolWithDependencies(command, argv, options, defaultRunnerDependencies); } -export async function runDevtoolsCommandWithDependencies( - command: DevtoolsCommandDefinition, +export async function runExternalToolWithDependencies( + command: DevtoolsExternalCommandDefinition, argv: readonly string[], options: DevtoolsRunnerOptions | undefined, dependencies: DevtoolsRunnerDependencies, @@ -80,7 +78,6 @@ export async function runDevtoolsCommandWithDependencies( executionTarget = await dependencies.resolveExecutionTarget(command); } catch (error) { dependencies.logError(`Failed to resolve ${command.binName}: ${getErrorMessage(error)}`); - return { exitCode: 1 }; } @@ -128,7 +125,7 @@ export async function runDevtoolsCommandWithDependencies( } async function resolveExecutionTarget( - command: DevtoolsCommandDefinition, + command: DevtoolsExternalCommandDefinition, ): Promise { const binPath = await readPackageBinPath(command.packageName, command.binName); if (await shouldExecuteWithNode(binPath)) { diff --git a/src/cli/runProviderCommand.ts b/src/cli/runProviderCommand.ts new file mode 100644 index 0000000..13c03a2 --- /dev/null +++ b/src/cli/runProviderCommand.ts @@ -0,0 +1,25 @@ +import type { DevtoolsCommandDefinition } from './commands.js'; +import { runExternalTool } from './runExternalTool.js'; +import { + type DevtoolsRepositoryCommandContext, + runRepositoryCommand, +} from './runRepositoryCommand.js'; + +export interface DevtoolsProviderCommandContext extends DevtoolsRepositoryCommandContext { + readonly env: Readonly>; +} + +export async function runProviderCommand( + command: DevtoolsCommandDefinition, + argv: readonly string[], + context: DevtoolsProviderCommandContext, +): Promise<{ readonly exitCode: number }> { + if (command.kind === 'external') { + return await runExternalTool(command, argv, { + cwd: context.cwd, + env: { ...context.env }, + }); + } + + return await runRepositoryCommand(command, argv, context); +} diff --git a/src/cli/runRepositoryCommand.test.ts b/src/cli/runRepositoryCommand.test.ts new file mode 100644 index 0000000..66f856d --- /dev/null +++ b/src/cli/runRepositoryCommand.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'bun:test'; + +import { findDevtoolsCommandByPath } from './commands.js'; +import { parseRepositoryArguments, runRepositoryCommand } from './runRepositoryCommand.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +describe('repository commands', () => { + it('syncs only the selected concern and status reports drift without writing', async () => { + const target = await mkdtemp('/tmp/devtools-repository-command-'); + temporaryDirectories.push(target); + const stdout: string[] = []; + const stderr: string[] = []; + const context = { + cwd: target, + writeStdout: (text: string) => stdout.push(text), + writeStderr: (text: string) => stderr.push(text), + }; + const workflowsSync = findDevtoolsCommandByPath(['workflows', 'sync']); + const allStatus = findDevtoolsCommandByPath(['status']); + if (workflowsSync?.kind !== 'repository' || allStatus?.kind !== 'repository') { + throw new Error('Expected repository commands.'); + } + + expect((await runRepositoryCommand(workflowsSync, [], context)).exitCode).toBe(0); + expect(await readFile(join(target, '.github/workflows/ci.yml'), 'utf8')).toContain( + 'bunx @ankhorage/ankh doctor validate .', + ); + expect(await Bun.file(join(target, '.vscode/settings.json')).exists()).toBe(false); + + stdout.length = 0; + expect((await runRepositoryCommand(allStatus, [], context)).exitCode).toBe(1); + expect(stdout.join('')).toContain('.vscode/settings.json missing'); + expect(stderr).toEqual([]); + }); + + it('supports dry-run and validates arguments', async () => { + const target = await mkdtemp('/tmp/devtools-repository-dry-run-'); + temporaryDirectories.push(target); + const stdout: string[] = []; + const sync = findDevtoolsCommandByPath(['sync']); + if (sync?.kind !== 'repository') { + throw new Error('Expected sync command.'); + } + + expect( + ( + await runRepositoryCommand(sync, ['--dry-run'], { + cwd: target, + writeStdout: (text) => stdout.push(text), + writeStderr: () => undefined, + }) + ).exitCode, + ).toBe(0); + expect(stdout.join('')).toContain('would create'); + expect(await Bun.file(join(target, '.github/workflows/ci.yml')).exists()).toBe(false); + expect(() => parseRepositoryArguments(['--dry-run'], false)).toThrow( + '--dry-run is only valid for sync commands.', + ); + expect(() => parseRepositoryArguments(['one', 'two'], true)).toThrow( + 'Only one target path may be provided.', + ); + }); +}); diff --git a/src/cli/runRepositoryCommand.ts b/src/cli/runRepositoryCommand.ts new file mode 100644 index 0000000..8fdf3b8 --- /dev/null +++ b/src/cli/runRepositoryCommand.ts @@ -0,0 +1,166 @@ +import { + inspectManagedFiles, + type ManagedFileDefinition, + type ManagedFileStatus, + type ManagedFileSyncResult, + resolveManagedTargetDirectory, + syncManagedFiles, +} from '../tools/shared/managedFiles.js'; +import { vscodeManagedFiles } from '../tools/vscode/index.js'; +import { workflowManagedFiles } from '../tools/workflows/index.js'; +import type { DevtoolsRepositoryCommandDefinition } from './commands.js'; + +export interface DevtoolsRepositoryCommandContext { + readonly cwd: string; + writeStdout(text: string): void; + writeStderr(text: string): void; +} + +export interface DevtoolsRepositoryCommandResult { + readonly exitCode: number; +} + +interface ParsedRepositoryArguments { + readonly dryRun: boolean; + readonly targetPath: string | undefined; +} + +export async function runRepositoryCommand( + command: DevtoolsRepositoryCommandDefinition, + argv: readonly string[], + context: DevtoolsRepositoryCommandContext, +): Promise { + let parsedArguments: ParsedRepositoryArguments; + + try { + parsedArguments = parseRepositoryArguments(argv, command.operation === 'sync'); + } catch (error) { + context.writeStderr(`${getErrorMessage(error)}\n`); + return { exitCode: 1 }; + } + + try { + const targetDirectory = await resolveManagedTargetDirectory( + context.cwd, + parsedArguments.targetPath, + ); + const definitions = getManagedFiles(command.scope); + + if (command.operation === 'status') { + const statuses = await inspectManagedFiles(targetDirectory, definitions); + writeStatusOutput(statuses, context); + return { + exitCode: statuses.some((status) => status.state !== 'current') ? 1 : 0, + }; + } + + const results = await syncManagedFiles(targetDirectory, definitions, { + dryRun: parsedArguments.dryRun, + }); + writeSyncOutput(results, context); + return { exitCode: 0 }; + } catch (error) { + context.writeStderr(`${getErrorMessage(error)}\n`); + return { exitCode: 1 }; + } +} + +export function parseRepositoryArguments( + argv: readonly string[], + allowDryRun: boolean, +): ParsedRepositoryArguments { + let dryRun = false; + let targetPath: string | undefined; + + for (const argument of argv) { + if (argument === '--dry-run') { + if (!allowDryRun) { + throw new Error('--dry-run is only valid for sync commands.'); + } + dryRun = true; + continue; + } + + if (argument.startsWith('-')) { + throw new Error(`Unknown option: ${argument}`); + } + + if (targetPath !== undefined) { + throw new Error('Only one target path may be provided.'); + } + + targetPath = argument; + } + + return { dryRun, targetPath }; +} + +function getManagedFiles( + scope: DevtoolsRepositoryCommandDefinition['scope'], +): readonly ManagedFileDefinition[] { + if (scope === 'workflows') { + return workflowManagedFiles; + } + + if (scope === 'vscode') { + return vscodeManagedFiles; + } + + return [...workflowManagedFiles, ...vscodeManagedFiles]; +} + +function writeStatusOutput( + statuses: readonly ManagedFileStatus[], + context: DevtoolsRepositoryCommandContext, +): void { + for (const status of statuses) { + if (status.state === 'current') { + context.writeStdout(`✓ ${status.relativePath}\n`); + } else if (status.state === 'missing') { + context.writeStdout(`+ ${status.relativePath} missing\n`); + } else { + context.writeStdout(`✗ ${status.relativePath} outdated\n`); + } + } +} + +function writeSyncOutput( + results: readonly ManagedFileSyncResult[], + context: DevtoolsRepositoryCommandContext, +): void { + for (const result of results) { + const prefix = getActionPrefix(result.action); + context.writeStdout(`${prefix} ${result.relativePath} ${formatAction(result.action)}\n`); + } +} + +function getActionPrefix(action: ManagedFileSyncResult['action']): string { + if (action === 'unchanged') { + return '✓'; + } + + if (action === 'created' || action === 'would-create') { + return '+'; + } + + return '↻'; +} + +function formatAction(action: ManagedFileSyncResult['action']): string { + switch (action) { + case 'created': + return 'created'; + case 'updated': + return 'updated'; + case 'unchanged': + return 'unchanged'; + case 'would-create': + return 'would create'; + case 'would-update': + return 'would update'; + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/cli/runStandaloneTool.ts b/src/cli/runStandaloneTool.ts new file mode 100644 index 0000000..6f87b93 --- /dev/null +++ b/src/cli/runStandaloneTool.ts @@ -0,0 +1,9 @@ +import { type DevtoolsToolName, getDevtoolsToolCommand } from './commands.js'; +import { type DevtoolsRunResult, runExternalTool } from './runExternalTool.js'; + +export async function runStandaloneTool( + toolName: DevtoolsToolName, + argv: readonly string[], +): Promise { + return await runExternalTool(getDevtoolsToolCommand(toolName), argv); +} 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); 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 deleted file mode 100644 index d72515e..0000000 --- a/src/internal/devtoolsCommands.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'bun:test'; - -import { - findDevtoolsCommandByPath, - getDevtoolsCommand, - getDevtoolsCommands, -} 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('returns commands by path and rejects unknown commands', () => { - expect(getDevtoolsCommand('lint').path).toEqual(['lint']); - expect(findDevtoolsCommandByPath(['format'])?.capability).toBe('devtools.format'); - expect(findDevtoolsCommandByPath(['unknown'])).toBeNull(); - expect(findDevtoolsCommandByPath(['lint', 'extra'])).toBeNull(); - }); -}); diff --git a/src/internal/devtoolsCommands.ts b/src/internal/devtoolsCommands.ts deleted file mode 100644 index 5308716..0000000 --- a/src/internal/devtoolsCommands.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type DevtoolsToolName = 'format' | 'knip' | 'lint'; - -export interface DevtoolsCommandDefinition { - path: readonly [DevtoolsToolName]; - capability: 'devtools.format' | 'devtools.knip' | 'devtools.lint'; - summary: string; - packageName: 'eslint' | 'knip' | 'prettier'; - binName: 'eslint' | 'knip' | 'prettier'; -} - -const DEVTOOLS_COMMANDS = [ - { - 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', - }, -] as const satisfies readonly DevtoolsCommandDefinition[]; - -export function getDevtoolsCommands(): readonly DevtoolsCommandDefinition[] { - return DEVTOOLS_COMMANDS; -} - -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; -} - -export function getDevtoolsCommand(toolName: DevtoolsToolName): DevtoolsCommandDefinition { - const command = DEVTOOLS_COMMANDS.find((candidate) => candidate.path[0] === toolName); - - if (command === undefined) { - throw new Error(`Unknown devtools command: ${toolName}`); - } - - return command; -} diff --git a/src/internal/readmeDocs.test.ts b/src/internal/readmeDocs.test.ts index 0ba0992..da21b0d 100644 --- a/src/internal/readmeDocs.test.ts +++ b/src/internal/readmeDocs.test.ts @@ -3,26 +3,26 @@ import { describe, expect, it } from 'bun:test'; import { getReadmeDocumentationErrors } from './readmeDocs.js'; describe('README documentation validation', () => { - it('accepts a README that documents the shipped command surface', () => { + it('accepts a README that documents the complete command surface', () => { const readme = [ 'ankh devtools lint', 'ankh devtools format', 'ankh devtools knip', - 'ankhorage-eslint', - 'ankhorage-prettier', - 'ankhorage-knip', - 'devtools', - 'devtools.lint', - 'devtools.format', - 'devtools.knip', + 'ankh devtools sync', + 'ankh devtools status', + 'ankh devtools workflows sync', + 'ankh devtools vscode sync', + 'devtools.workflows.sync', + 'devtools.vscode.sync', + '--dry-run', ].join('\n'); expect(getReadmeDocumentationErrors(readme)).toEqual([]); }); - it('reports missing command or capability references', () => { + it('reports missing command references', () => { expect(getReadmeDocumentationErrors('ankh devtools lint')).toContain( - 'README.md is missing required documentation snippet: devtools.knip', + 'README.md is missing required documentation snippet: ankh devtools sync', ); }); }); diff --git a/src/internal/readmeDocs.ts b/src/internal/readmeDocs.ts index e7c3e86..2d420b9 100644 --- a/src/internal/readmeDocs.ts +++ b/src/internal/readmeDocs.ts @@ -2,13 +2,13 @@ const REQUIRED_README_SNIPPETS = [ 'ankh devtools lint', 'ankh devtools format', 'ankh devtools knip', - 'ankhorage-eslint', - 'ankhorage-prettier', - 'ankhorage-knip', - 'devtools', - 'devtools.lint', - 'devtools.format', - 'devtools.knip', + 'ankh devtools sync', + 'ankh devtools status', + 'ankh devtools workflows sync', + 'ankh devtools vscode sync', + 'devtools.workflows.sync', + 'devtools.vscode.sync', + '--dry-run', ] as const; export function getReadmeDocumentationErrors(readmeContents: string): string[] { diff --git a/src/internal/runDevtoolsCommand.test.ts b/src/internal/runDevtoolsCommand.test.ts deleted file mode 100644 index 325b732..0000000 --- a/src/internal/runDevtoolsCommand.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { EventEmitter } from 'node:events'; - -import { describe, expect, it } from 'bun:test'; - -import { getDevtoolsCommand } from './devtoolsCommands.js'; -import { runDevtoolsCommandWithDependencies } from './runDevtoolsCommand.js'; - -type FakeChildProcess = EventEmitter & { - on(event: 'error', listener: (error: Error) => void): FakeChildProcess; - on( - event: 'exit', - listener: (code: number | null, signal: NodeJS.Signals | null) => void, - ): FakeChildProcess; -}; - -describe('runDevtoolsCommand', () => { - it('preserves argv, cwd, env, and returns the child exit code', async () => { - const command = getDevtoolsCommand('lint'); - const messages: string[] = []; - const spawnCalls: { - command: string; - args: readonly string[]; - options: { - cwd: string; - env: NodeJS.ProcessEnv; - shell: boolean; - stdio: 'inherit'; - }; - }[] = []; - - const result = await runDevtoolsCommandWithDependencies( - command, - ['--max-warnings=0', 'src'], - { - cwd: '/tmp/devtools-fixture', - env: { ...process.env, FOO: 'bar' }, - }, - { - logError: (message) => { - messages.push(message); - }, - resolveExecutionTarget: () => - Promise.resolve({ - command: process.execPath, - args: ['/tmp/eslint.js'], - shell: false, - }), - spawnProcess: (spawnCommand, args, options) => { - spawnCalls.push({ - command: spawnCommand, - args, - options: { - cwd: options.cwd, - env: options.env, - shell: options.shell, - stdio: options.stdio, - }, - }); - - const child = new EventEmitter() as FakeChildProcess; - - queueMicrotask(() => { - child.emit('exit', 7, null); - }); - - return child; - }, - }, - ); - - expect(result).toEqual({ exitCode: 7 }); - expect(messages).toEqual([]); - expect(spawnCalls).toEqual([ - { - command: process.execPath, - args: ['/tmp/eslint.js', '--max-warnings=0', 'src'], - options: { - cwd: '/tmp/devtools-fixture', - env: { ...process.env, FOO: 'bar' }, - shell: false, - stdio: 'inherit', - }, - }, - ]); - }); - - it('treats spawn errors as exit code 1', async () => { - const messages: string[] = []; - - const result = await runDevtoolsCommandWithDependencies( - getDevtoolsCommand('format'), - ['--check', '.'], - undefined, - { - logError: (message) => { - messages.push(message); - }, - resolveExecutionTarget: () => - Promise.resolve({ - command: '/tmp/prettier', - args: [], - shell: false, - }), - spawnProcess: () => { - const child = new EventEmitter() as FakeChildProcess; - - queueMicrotask(() => { - child.emit('error', new Error('spawn failed')); - }); - - return child; - }, - }, - ); - - expect(result).toEqual({ exitCode: 1 }); - expect(messages).toEqual(['Failed to start prettier: spawn failed']); - }); - - it('treats process signals as non-zero failures with a clear message', async () => { - const messages: string[] = []; - - const result = await runDevtoolsCommandWithDependencies( - getDevtoolsCommand('knip'), - [], - undefined, - { - logError: (message) => { - messages.push(message); - }, - resolveExecutionTarget: () => - Promise.resolve({ - command: '/tmp/knip', - args: [], - shell: false, - }), - spawnProcess: () => { - const child = new EventEmitter() as FakeChildProcess; - - queueMicrotask(() => { - child.emit('exit', null, 'SIGTERM'); - }); - - return child; - }, - }, - ); - - expect(result).toEqual({ exitCode: 1 }); - expect(messages).toEqual(['knip exited with signal SIGTERM.']); - }); - - it('fails cleanly when command resolution fails', async () => { - const messages: string[] = []; - - const result = await runDevtoolsCommandWithDependencies( - getDevtoolsCommand('lint'), - ['.'], - undefined, - { - logError: (message) => { - messages.push(message); - }, - resolveExecutionTarget: () => Promise.reject(new Error('missing eslint binary')), - spawnProcess: () => { - throw new Error('spawn should not run'); - }, - }, - ); - - expect(result).toEqual({ exitCode: 1 }); - expect(messages).toEqual(['Failed to resolve eslint: missing eslint binary']); - }); -}); diff --git a/src/internal/runStandaloneDevtoolsCommand.ts b/src/internal/runStandaloneDevtoolsCommand.ts deleted file mode 100644 index f955ef3..0000000 --- a/src/internal/runStandaloneDevtoolsCommand.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getDevtoolsCommand } from './devtoolsCommands.js'; -import type { DevtoolsToolName } from './runDevtoolsCommand.js'; -import { type DevtoolsRunResult, runDevtoolsCommand } from './runDevtoolsCommand.js'; - -export async function runStandaloneDevtoolsCommand( - toolName: DevtoolsToolName, - argv: readonly string[], -): Promise { - return runDevtoolsCommand(getDevtoolsCommand(toolName), argv); -} 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); 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/package.test.ts b/src/package.test.ts index c3aab25..f1196da 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -1,9 +1,21 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'bun:test'; +const capabilities = [ + 'devtools.lint', + 'devtools.format', + 'devtools.knip', + 'devtools.sync', + 'devtools.status', + 'devtools.workflows.sync', + 'devtools.workflows.status', + 'devtools.vscode.sync', + 'devtools.vscode.status', +]; + describe('package metadata', () => { - it('publishes exact Ankh metadata and required scripts without expanding exports', () => { + it('publishes the canonical provider, binaries, exports, and managed assets', () => { const packageJson = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf8'), ) as Record; @@ -13,62 +25,56 @@ describe('package metadata', () => { expect(packageJson.ankh).toEqual({ category: 'devtools', provider: './dist/cli/index.js', - capabilities: ['devtools.lint', 'devtools.format', 'devtools.knip'], + capabilities, }); - expect(packageJson.bin).toEqual({ - 'ankhorage-eslint': './dist/eslint-cli.js', - 'ankhorage-knip': './dist/knip-cli.js', - 'ankhorage-prettier': './dist/prettier-cli.js', + 'ankhorage-eslint': './dist/cli/bin/eslint.js', + 'ankhorage-knip': './dist/cli/bin/knip.js', + 'ankhorage-prettier': './dist/cli/bin/prettier.js', }); - expect(packageJson.exports).toEqual({ './cli': { types: './dist/cli/index.d.ts', 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', }, }); - const scripts = packageJson.scripts as Record; - expect(Object.keys(scripts).sort()).toEqual( - [ - 'build', - 'changeset', - 'changeset:status', - 'docs', - 'docs:check', - 'doctor', - 'format', - 'format:check', - 'knip', - 'lint', - 'lint:fix', - 'test', - 'typecheck', - 'version-packages', - ].sort(), - ); + const { build } = packageJson.scripts as Record; + expect(build).toContain('src/tools/workflows/files'); + expect(build).toContain('dist/tools/workflows/files'); + expect(build).toContain('src/tools/vscode/files'); + expect(build).toContain('dist/tools/vscode/files'); + expect(build).toContain('src/tools/prettier/index.cjs'); + expect(build).toContain('dist/tools/prettier/index.cjs'); }); - it('documents the provider-backed command surface in the README', () => { - const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8'); + it('ships every canonical managed asset in the source tree', () => { + expect(existsSync(new URL('./tools/workflows/files/ci.yml', import.meta.url))).toBe(true); + expect(existsSync(new URL('./tools/workflows/files/release.yml', import.meta.url))).toBe(true); + expect(existsSync(new URL('./tools/vscode/files/settings.json', import.meta.url))).toBe(true); + expect(existsSync(new URL('./tools/vscode/files/extensions.json', import.meta.url))).toBe(true); + }); - expect(readme).toContain('ankh devtools lint'); - expect(readme).toContain('ankh devtools format'); - expect(readme).toContain('ankh devtools knip'); - expect(readme).toContain('devtools.lint'); - expect(readme).toContain('devtools.format'); - expect(readme).toContain('devtools.knip'); + it('documents only the canonical devtools command surface', () => { + const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8'); + for (const capability of capabilities) { + expect(readme).toContain(capability); + } + expect(readme).toContain('ankh devtools sync'); + expect(readme).toContain('ankh devtools workflows sync'); + expect(readme).toContain('ankh devtools vscode sync'); + expect(readme).not.toContain('ankh dev '); + expect(readme).not.toContain('`@ankhorage/dev`'); }); }); 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); diff --git a/src/eslint.ts b/src/tools/eslint/index.ts similarity index 98% rename from src/eslint.ts rename to src/tools/eslint/index.ts index f8c98be..7929f1e 100644 --- a/src/eslint.ts +++ b/src/tools/eslint/index.ts @@ -55,9 +55,7 @@ export function createConfig(options: DevtoolsConfigOptions): ReturnType ({ ...config, files: normalizedOptions.files, @@ -66,7 +64,6 @@ export function createConfig(options: DevtoolsConfigOptions): ReturnType; +export type FlatConfigItem = FlatConfig[number]; + +interface RestrictedImport { + readonly name: string; + readonly message: string; +} + +export interface DevtoolsConfigOptions { + readonly tsconfigRootDir: string; + readonly project: string[]; + readonly files: string[]; + readonly allowDefaultProject?: string[]; + readonly additionalIgnores?: string[]; + readonly restrictedImports?: RestrictedImport[]; + readonly overrides?: FlatConfigItem[]; + readonly includePrettier?: boolean; +} diff --git a/src/knip.ts b/src/tools/knip/index.ts similarity index 77% rename from src/knip.ts rename to src/tools/knip/index.ts index b1ee540..4baac31 100644 --- a/src/knip.ts +++ b/src/tools/knip/index.ts @@ -1,23 +1,23 @@ import type { KnipConfig } from 'knip'; export interface DevtoolsKnipWorkspaceConfigOptions { - entry?: string[]; - project?: string[]; - ignore?: string[]; - ignoreBinaries?: string[]; - ignoreDependencies?: (string | RegExp)[]; - ignoreFiles?: string[]; + readonly entry?: string[]; + readonly project?: string[]; + readonly ignore?: string[]; + readonly ignoreBinaries?: string[]; + readonly ignoreDependencies?: (string | RegExp)[]; + readonly ignoreFiles?: string[]; } export interface DevtoolsKnipConfigOptions extends DevtoolsKnipWorkspaceConfigOptions { - workspaces?: Record; + readonly workspaces?: Record; } export interface DevtoolsKnipMonorepoConfigOptions { - root?: DevtoolsKnipWorkspaceConfigOptions; - workspaceDefaults?: DevtoolsKnipWorkspaceConfigOptions; - workspaceGlobs?: string[]; - workspaces?: Record; + readonly root?: DevtoolsKnipWorkspaceConfigOptions; + readonly workspaceDefaults?: DevtoolsKnipWorkspaceConfigOptions; + readonly workspaceGlobs?: string[]; + readonly workspaces?: Record; } const DEFAULT_MONOREPO_WORKSPACE_GLOBS = ['packages/*', 'apps/*'] as const; diff --git a/src/prettier.cjs b/src/tools/prettier/index.cjs similarity index 100% rename from src/prettier.cjs rename to src/tools/prettier/index.cjs diff --git a/src/tools/shared/managedFiles.test.ts b/src/tools/shared/managedFiles.test.ts new file mode 100644 index 0000000..42c47de --- /dev/null +++ b/src/tools/shared/managedFiles.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { afterEach, describe, expect, it } from 'bun:test'; + +import { + inspectManagedFiles, + type ManagedFileDefinition, + resolveManagedTargetDirectory, + syncManagedFiles, +} from './managedFiles.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +describe('managed file synchronization', () => { + it('creates, updates, preserves unrelated files, and becomes idempotent', async () => { + const fixture = await createFixture(); + await writeFile(join(fixture.target, 'unrelated.txt'), 'keep me\n'); + + expect(await inspectManagedFiles(fixture.target, fixture.definitions)).toEqual([ + { relativePath: '.managed/example.txt', state: 'missing' }, + ]); + + expect(await syncManagedFiles(fixture.target, fixture.definitions, { dryRun: false })).toEqual([ + { relativePath: '.managed/example.txt', action: 'created' }, + ]); + expect(await readFile(join(fixture.target, '.managed/example.txt'), 'utf8')).toBe( + 'canonical\n', + ); + expect(await readFile(join(fixture.target, 'unrelated.txt'), 'utf8')).toBe('keep me\n'); + + await writeFile(join(fixture.target, '.managed/example.txt'), 'outdated\n'); + expect(await syncManagedFiles(fixture.target, fixture.definitions, { dryRun: false })).toEqual([ + { relativePath: '.managed/example.txt', action: 'updated' }, + ]); + expect(await syncManagedFiles(fixture.target, fixture.definitions, { dryRun: false })).toEqual([ + { relativePath: '.managed/example.txt', action: 'unchanged' }, + ]); + }); + + it('reports dry-run actions without writing files', async () => { + const fixture = await createFixture(); + + expect(await syncManagedFiles(fixture.target, fixture.definitions, { dryRun: true })).toEqual([ + { relativePath: '.managed/example.txt', action: 'would-create' }, + ]); + expect(await inspectManagedFiles(fixture.target, fixture.definitions)).toEqual([ + { relativePath: '.managed/example.txt', state: 'missing' }, + ]); + }); + + it('defaults targets to cwd and rejects invalid targets', async () => { + const fixture = await createFixture(); + expect(await resolveManagedTargetDirectory(fixture.target, undefined)).toBe(fixture.target); + + let thrownError: unknown; + try { + await resolveManagedTargetDirectory(fixture.target, 'does-not-exist'); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toContain('Target directory does not exist'); + }); +}); + +async function createFixture(): Promise<{ + readonly target: string; + readonly definitions: readonly ManagedFileDefinition[]; +}> { + const root = await mkdtemp('/tmp/devtools-managed-files-'); + temporaryDirectories.push(root); + const canonical = join(root, 'canonical.txt'); + const target = join(root, 'target'); + await Bun.write(canonical, 'canonical\n'); + await mkdir(target); + await writeFile(join(target, '.keep'), ''); + + return { + target, + definitions: [ + { + relativePath: '.managed/example.txt', + sourceUrl: pathToFileURL(canonical), + }, + ], + }; +} diff --git a/src/tools/shared/managedFiles.ts b/src/tools/shared/managedFiles.ts new file mode 100644 index 0000000..299a5a8 --- /dev/null +++ b/src/tools/shared/managedFiles.ts @@ -0,0 +1,129 @@ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +export interface ManagedFileDefinition { + readonly relativePath: string; + readonly sourceUrl: URL; +} + +type ManagedFileState = 'current' | 'missing' | 'outdated'; +type ManagedFileSyncAction = 'unchanged' | 'created' | 'updated' | 'would-create' | 'would-update'; + +export interface ManagedFileStatus { + readonly relativePath: string; + readonly state: ManagedFileState; +} + +export interface ManagedFileSyncResult { + readonly relativePath: string; + readonly action: ManagedFileSyncAction; +} + +export async function resolveManagedTargetDirectory( + cwd: string, + requestedPath: string | undefined, +): Promise { + const targetDirectory = resolve(cwd, requestedPath ?? '.'); + + let targetStats; + try { + targetStats = await stat(targetDirectory); + } catch (error) { + throw new Error(`Target directory does not exist: ${targetDirectory}`, { cause: error }); + } + + if (!targetStats.isDirectory()) { + throw new Error(`Target path is not a directory: ${targetDirectory}`); + } + + return targetDirectory; +} + +export async function inspectManagedFiles( + targetDirectory: string, + definitions: readonly ManagedFileDefinition[], +): Promise { + return await Promise.all( + definitions.map(async (definition): Promise => { + const canonicalContents = await readCanonicalContents(definition); + const targetPath = resolve(targetDirectory, definition.relativePath); + + try { + const targetContents = await readFile(targetPath, 'utf8'); + return { + relativePath: definition.relativePath, + state: targetContents === canonicalContents ? 'current' : 'outdated', + }; + } catch (error) { + if (isMissingFileError(error)) { + return { + relativePath: definition.relativePath, + state: 'missing', + }; + } + + throw new Error(`Failed to inspect managed file: ${targetPath}`, { cause: error }); + } + }), + ); +} + +export async function syncManagedFiles( + targetDirectory: string, + definitions: readonly ManagedFileDefinition[], + options: { readonly dryRun: boolean }, +): Promise { + const statuses = await inspectManagedFiles(targetDirectory, definitions); + const definitionsByPath = new Map( + definitions.map((definition) => [definition.relativePath, definition] as const), + ); + const results: ManagedFileSyncResult[] = []; + + for (const status of statuses) { + if (status.state === 'current') { + results.push({ relativePath: status.relativePath, action: 'unchanged' }); + continue; + } + + const definition = definitionsByPath.get(status.relativePath); + if (definition === undefined) { + throw new Error(`Missing managed file definition for ${status.relativePath}.`); + } + + if (options.dryRun) { + results.push({ + relativePath: status.relativePath, + action: status.state === 'missing' ? 'would-create' : 'would-update', + }); + continue; + } + + const targetPath = resolve(targetDirectory, definition.relativePath); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, await readCanonicalContents(definition), 'utf8'); + results.push({ + relativePath: status.relativePath, + action: status.state === 'missing' ? 'created' : 'updated', + }); + } + + return results; +} + +async function readCanonicalContents(definition: ManagedFileDefinition): Promise { + try { + return await readFile(definition.sourceUrl, 'utf8'); + } catch (error) { + throw new Error(`Failed to read canonical managed file: ${definition.relativePath}`, { + cause: error, + }); + } +} + +function isMissingFileError(error: unknown): boolean { + return isNodeError(error) && error.code === 'ENOENT'; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} 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..8ee7303 --- /dev/null +++ b/src/tools/vscode/files/settings.json @@ -0,0 +1,15 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": false, + "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..bd96bef --- /dev/null +++ b/src/tools/vscode/index.ts @@ -0,0 +1,12 @@ +import type { ManagedFileDefinition } from '../shared/managedFiles.js'; + +export const vscodeManagedFiles = [ + { + relativePath: '.vscode/settings.json', + sourceUrl: new URL('./files/settings.json', import.meta.url), + }, + { + relativePath: '.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..05eb663 --- /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 node -e "const p=require('./package.json'); process.exit(p.scripts?.['changeset:status'] ? 0 : 1)"; then + bun run changeset:status + 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..34b5529 --- /dev/null +++ b/src/tools/workflows/index.ts @@ -0,0 +1,12 @@ +import type { ManagedFileDefinition } from '../shared/managedFiles.js'; + +export const workflowManagedFiles = [ + { + relativePath: '.github/workflows/ci.yml', + sourceUrl: new URL('./files/ci.yml', import.meta.url), + }, + { + relativePath: '.github/workflows/release.yml', + sourceUrl: new URL('./files/release.yml', import.meta.url), + }, +] as const satisfies readonly ManagedFileDefinition[]; 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; -}