diff --git a/.changeset/create-versions-trail.md b/.changeset/create-versions-trail.md new file mode 100644 index 000000000..7fe47770c --- /dev/null +++ b/.changeset/create-versions-trail.md @@ -0,0 +1,5 @@ +--- +"@ontrails/trails": minor +--- + +Add the public `create.versions` trail (`trails create versions`). Scaffold dependency version derivation graduates from `scripts/sync-scaffold-versions.ts` into the `create` surface: check mode verifies `apps/trails/src/scaffold-versions.generated.ts` is current, write mode regenerates it, and the root script remains as a thin compatibility wrapper. diff --git a/apps/trails/src/__tests__/create-versions-trail.test.ts b/apps/trails/src/__tests__/create-versions-trail.test.ts new file mode 100644 index 000000000..4f09bd8a7 --- /dev/null +++ b/apps/trails/src/__tests__/create-versions-trail.test.ts @@ -0,0 +1,195 @@ +import { deriveCliCommands } from '@ontrails/cli'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { app } from '../app.js'; +import { + diagnoseOntrailsPackagePin, + syncScaffoldVersions, +} from '../scaffold-version-sync.js'; +import { createVersionsTrail } from '../trails/create-versions.js'; + +const roots: string[] = []; + +const fixturePackageJson = { + catalog: { + commander: '^14.0.0', + zod: '^4.0.0', + }, + devDependencies: { + '@types/bun': '^1.0.0', + lefthook: '^2.0.0', + oxfmt: '0.1.0', + oxlint: '1.0.0', + typescript: '^5.0.0', + ultracite: '7.0.0', + }, + name: 'fixture-root', +}; + +const expectedGeneratedContent = [ + '// GENERATED FILE — do not edit by hand. Run `bun run scaffold-versions:sync` to regenerate.', + '', + 'export const scaffoldDependencyVersions = {', + " bunTypes: '^1.0.0',", + " commander: '^14.0.0',", + " lefthook: '^2.0.0',", + " oxfmt: '0.1.0',", + " oxlint: '1.0.0',", + " typescript: '^5.0.0',", + " ultracite: '7.0.0',", + " zod: '^4.0.0',", + '} as const;', + '', +].join('\n'); + +const makeTempRoot = ( + packageJson: Record = fixturePackageJson +): string => { + const root = mkdtempSync(join(tmpdir(), 'trails-create-versions-')); + roots.push(root); + writeFileSync( + join(root, 'package.json'), + `${JSON.stringify(packageJson, null, 2)}\n` + ); + mkdirSync(join(root, 'apps/trails/src'), { recursive: true }); + return root; +}; + +const blaze = async (input: { check: boolean; rootDir: string }) => + await createVersionsTrail.blaze(input, { + cwd: input.rootDir, + env: { TRAILS_ENV: 'test' }, + } as never); + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe('diagnoseOntrailsPackagePin', () => { + test('accepts exact generated @ontrails package pins', () => { + expect( + diagnoseOntrailsPackagePin({ + ontrailsPackageRange: '1.0.0-beta.18', + trailsPackageVersion: '1.0.0-beta.18', + }) + ).toBeUndefined(); + }); + + test('rejects caret prerelease ranges for generated @ontrails packages', () => { + expect( + diagnoseOntrailsPackagePin({ + ontrailsPackageRange: '^1.0.0-beta.18', + trailsPackageVersion: '1.0.0-beta.18', + }) + ).toContain('must be exact pins'); + }); + + test('rejects plain version drift for generated @ontrails packages', () => { + expect( + diagnoseOntrailsPackagePin({ + ontrailsPackageRange: '1.0.0-beta.17', + trailsPackageVersion: '1.0.0-beta.18', + }) + ).toContain('must be exact pins'); + }); + + test('requires both scaffold version exports', () => { + expect(diagnoseOntrailsPackagePin({})).toContain( + 'must export `ontrailsPackageRange` and `trailsPackageVersion`' + ); + }); +}); + +describe('create.versions trail', () => { + test('projects as a nested CLI command', () => { + const commands = deriveCliCommands(app); + if (commands.isErr()) { + throw commands.error; + } + + const paths = commands.value.map((command) => command.path.join(' ')); + expect(paths).toContain('create versions'); + }); + + test('writes the generated file from root package.json versions', async () => { + const root = makeTempRoot(); + + const result = await blaze({ check: false, rootDir: root }); + + expect(result.isOk()).toBe(true); + if (result.isErr()) { + throw result.error; + } + expect(result.value).toEqual({ + generatedPath: join( + root, + 'apps/trails/src/scaffold-versions.generated.ts' + ), + mode: 'write', + written: true, + }); + expect( + readFileSync( + join(root, 'apps/trails/src/scaffold-versions.generated.ts'), + 'utf8' + ) + ).toBe(expectedGeneratedContent); + }); + + test('check mode passes when the generated file is current', async () => { + const root = makeTempRoot(); + await syncScaffoldVersions({ check: false, rootDir: root }); + + const result = await blaze({ check: true, rootDir: root }); + + expect(result.isOk()).toBe(true); + if (result.isErr()) { + throw result.error; + } + expect(result.value).toEqual({ + generatedPath: join( + root, + 'apps/trails/src/scaffold-versions.generated.ts' + ), + mode: 'check', + written: false, + }); + }); + + test('check mode reports drift when the generated file is missing', async () => { + const root = makeTempRoot(); + + const result = await blaze({ check: true, rootDir: root }); + + expect(result.isErr()).toBe(true); + if (result.isOk()) { + throw new Error('expected check mode to fail without a generated file'); + } + expect(result.error.message).toContain('scaffold-versions:sync'); + }); + + test('reports missing devDependency entries from root package.json', async () => { + const { lefthook: _omitted, ...devDependencies } = + fixturePackageJson.devDependencies; + const root = makeTempRoot({ ...fixturePackageJson, devDependencies }); + + const result = await blaze({ check: false, rootDir: root }); + + expect(result.isErr()).toBe(true); + if (result.isOk()) { + throw new Error('expected missing lefthook entry to fail'); + } + expect(result.error.message).toContain('missing "lefthook"'); + }); +}); diff --git a/apps/trails/src/app.ts b/apps/trails/src/app.ts index ce60409c3..8ca06607c 100644 --- a/apps/trails/src/app.ts +++ b/apps/trails/src/app.ts @@ -22,6 +22,7 @@ import * as completionsComplete from './trails/completions-complete.js'; import * as create from './trails/create.js'; import * as createAdapter from './trails/create-adapter.js'; import * as createScaffold from './trails/create-scaffold.js'; +import * as createVersions from './trails/create-versions.js'; import * as deprecate from './trails/deprecate.js'; import * as devClean from './trails/dev-clean.js'; import * as devReset from './trails/dev-reset.js'; @@ -70,6 +71,7 @@ export const operatorApp = topo( create, createAdapter, createScaffold, + createVersions, addSurface, addVerify, addTrail, diff --git a/apps/trails/src/scaffold-version-sync.ts b/apps/trails/src/scaffold-version-sync.ts new file mode 100644 index 000000000..283108b02 --- /dev/null +++ b/apps/trails/src/scaffold-version-sync.ts @@ -0,0 +1,183 @@ +/** + * Scaffold dependency version derivation for the `create.versions` trail. + * + * Generates or validates `apps/trails/src/scaffold-versions.generated.ts` + * from the root `package.json` catalog and devDependencies, and validates + * that generated `@ontrails/*` package pins track the `@ontrails/trails` + * version exactly. + */ + +import { resolve } from 'node:path'; + +import { + ontrailsPackageRange as appOntrailsPackageRange, + trailsPackageVersion as appTrailsPackageVersion, +} from './versions.js'; + +interface RootPackageJson { + readonly catalog?: Record; + readonly devDependencies?: Record; +} + +export interface OntrailsPackagePinState { + readonly ontrailsPackageRange?: string; + readonly trailsPackageVersion?: string; +} + +export interface SyncScaffoldVersionsResult { + readonly generatedPath: string; + readonly mode: 'check' | 'write'; + readonly written: boolean; +} + +export const diagnoseOntrailsPackagePin = ({ + ontrailsPackageRange, + trailsPackageVersion, +}: OntrailsPackagePinState): string | undefined => { + if ( + typeof ontrailsPackageRange !== 'string' || + typeof trailsPackageVersion !== 'string' + ) { + return ( + 'create.versions: apps/trails/src/versions.ts must export ' + + '`ontrailsPackageRange` and `trailsPackageVersion`.' + ); + } + if (ontrailsPackageRange !== trailsPackageVersion) { + return ( + 'create.versions: scaffolded @ontrails/* packages must be exact ' + + `pins for @ontrails/trails (${trailsPackageVersion}); got ` + + `${ontrailsPackageRange}.` + ); + } + return undefined; +}; + +const requireValue = ( + value: string | undefined, + label: string, + source: string, + rootPackageJsonPath: string +): string => { + if (typeof value !== 'string' || value.length === 0) { + throw new Error( + `create.versions: missing "${label}" under ${source} in ${rootPackageJsonPath}` + ); + } + return value; +}; + +const loadScaffoldVersions = async ( + rootPackageJsonPath: string +): Promise & Readonly>> => { + const rootPkg = (await Bun.file( + rootPackageJsonPath + ).json()) as RootPackageJson; + const catalog = rootPkg.catalog ?? {}; + const devDeps = rootPkg.devDependencies ?? {}; + + return { + bunTypes: requireValue( + devDeps['@types/bun'], + '@types/bun', + 'devDependencies', + rootPackageJsonPath + ), + commander: requireValue( + catalog['commander'], + 'commander', + 'catalog', + rootPackageJsonPath + ), + lefthook: requireValue( + devDeps['lefthook'], + 'lefthook', + 'devDependencies', + rootPackageJsonPath + ), + oxfmt: requireValue( + devDeps['oxfmt'], + 'oxfmt', + 'devDependencies', + rootPackageJsonPath + ), + oxlint: requireValue( + devDeps['oxlint'], + 'oxlint', + 'devDependencies', + rootPackageJsonPath + ), + typescript: requireValue( + devDeps['typescript'], + 'typescript', + 'devDependencies', + rootPackageJsonPath + ), + ultracite: requireValue( + devDeps['ultracite'], + 'ultracite', + 'devDependencies', + rootPackageJsonPath + ), + zod: requireValue(catalog['zod'], 'zod', 'catalog', rootPackageJsonPath), + }; +}; + +const renderGeneratedFile = ( + versions: Record & Readonly> +): string => { + const keys = Object.keys(versions).toSorted(); + const lines = keys.map((key: string) => ` ${key}: '${versions[key]}',`); + return [ + '// GENERATED FILE — do not edit by hand. Run `bun run scaffold-versions:sync` to regenerate.', + '', + 'export const scaffoldDependencyVersions = {', + ...lines, + '} as const;', + '', + ].join('\n'); +}; + +export const syncScaffoldVersions = async (options: { + check: boolean; + rootDir: string; +}): Promise => { + const rootPackageJsonPath = resolve(options.rootDir, 'package.json'); + const generatedPath = resolve( + options.rootDir, + 'apps/trails/src/scaffold-versions.generated.ts' + ); + const versions = await loadScaffoldVersions(rootPackageJsonPath); + const expected = renderGeneratedFile(versions); + + if (options.check) { + const generatedFile = Bun.file(generatedPath); + const existing = (await generatedFile.exists()) + ? await generatedFile.text() + : undefined; + if (existing !== expected) { + throw new Error( + `create.versions: ${generatedPath} is out of date.\n` + + 'Run `bun run scaffold-versions:sync` to regenerate.' + ); + } + } else { + await Bun.write(generatedPath, expected); + } + + // Normally quiet because versions.ts derives both exports from one source; + // this trips only if future/manual drift breaks that invariant. + const diagnostic = diagnoseOntrailsPackagePin({ + ontrailsPackageRange: appOntrailsPackageRange, + trailsPackageVersion: appTrailsPackageVersion, + }); + if (diagnostic !== undefined) { + throw new Error(diagnostic); + } + + return { + generatedPath, + mode: options.check ? 'check' : 'write', + written: !options.check, + }; +}; diff --git a/apps/trails/src/scaffold-versions.generated.ts b/apps/trails/src/scaffold-versions.generated.ts index 3d0aaebda..b9af9fa88 100644 --- a/apps/trails/src/scaffold-versions.generated.ts +++ b/apps/trails/src/scaffold-versions.generated.ts @@ -1,4 +1,4 @@ -// GENERATED FILE — do not edit by hand. Run `bun scripts/sync-scaffold-versions.ts` to regenerate. +// GENERATED FILE — do not edit by hand. Run `bun run scaffold-versions:sync` to regenerate. export const scaffoldDependencyVersions = { bunTypes: '^1.3.11', diff --git a/apps/trails/src/trails/create-versions.ts b/apps/trails/src/trails/create-versions.ts new file mode 100644 index 000000000..54b998a0e --- /dev/null +++ b/apps/trails/src/trails/create-versions.ts @@ -0,0 +1,62 @@ +/** + * `create.versions` trail -- Sync generated scaffold dependency versions. + * + * Derives `apps/trails/src/scaffold-versions.generated.ts` from the root + * `package.json` catalog and devDependencies. Graduated from + * `scripts/sync-scaffold-versions.ts`. + */ + +import { Result, trail, ValidationError } from '@ontrails/core'; +import { z } from 'zod'; + +import { syncScaffoldVersions } from '../scaffold-version-sync.js'; +import { resolveTrailRootDir } from './root-dir.js'; + +const createVersionsInputSchema = z.object({ + check: z + .boolean() + .default(false) + .describe('Verify the generated file is current instead of writing'), + rootDir: z.string().optional().describe('Workspace root directory'), +}); + +const createVersionsOutputSchema = z.object({ + generatedPath: z.string(), + mode: z.enum(['check', 'write']), + written: z.boolean(), +}); + +export const createVersionsTrail = trail('create.versions', { + blaze: async (input, ctx) => { + const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd); + if (rootDirResult.isErr()) { + return rootDirResult; + } + + try { + return Result.ok( + await syncScaffoldVersions({ + check: input.check, + rootDir: rootDirResult.value, + }) + ); + } catch (error) { + return Result.err( + new ValidationError( + error instanceof Error ? error.message : String(error) + ) + ); + } + }, + description: 'Sync generated scaffold dependency versions', + examples: [ + { + input: { check: true }, + name: 'Verify generated scaffold versions are current', + }, + ], + input: createVersionsInputSchema, + intent: 'write', + output: createVersionsOutputSchema, + permit: { scopes: ['project:write'] }, +}); diff --git a/scripts/__tests__/sync-scaffold-versions.test.ts b/scripts/__tests__/sync-scaffold-versions.test.ts deleted file mode 100644 index 077456237..000000000 --- a/scripts/__tests__/sync-scaffold-versions.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { diagnoseOntrailsPackagePin } from '../sync-scaffold-versions.ts'; - -describe('sync-scaffold-versions', () => { - test('accepts exact generated @ontrails package pins', () => { - expect( - diagnoseOntrailsPackagePin({ - ontrailsPackageRange: '1.0.0-beta.18', - trailsPackageVersion: '1.0.0-beta.18', - }) - ).toBeUndefined(); - }); - - test('rejects caret prerelease ranges for generated @ontrails packages', () => { - expect( - diagnoseOntrailsPackagePin({ - ontrailsPackageRange: '^1.0.0-beta.18', - trailsPackageVersion: '1.0.0-beta.18', - }) - ).toContain('must be exact pins'); - }); - - test('rejects plain version drift for generated @ontrails packages', () => { - expect( - diagnoseOntrailsPackagePin({ - ontrailsPackageRange: '1.0.0-beta.17', - trailsPackageVersion: '1.0.0-beta.18', - }) - ).toContain('must be exact pins'); - }); - - test('requires both scaffold version exports', () => { - expect(diagnoseOntrailsPackagePin({})).toContain( - 'must export `ontrailsPackageRange` and `trailsPackageVersion`' - ); - }); -}); diff --git a/scripts/sync-scaffold-versions.ts b/scripts/sync-scaffold-versions.ts index be3a3f671..aafa3a7f9 100644 --- a/scripts/sync-scaffold-versions.ts +++ b/scripts/sync-scaffold-versions.ts @@ -1,9 +1,8 @@ #!/usr/bin/env bun /** - * Generates or validates `apps/trails/src/scaffold-versions.generated.ts` - * from the root `package.json` catalog and devDependencies, and validates that - * generated `@ontrails/*` package pins track the `@ontrails/trails` version - * exactly. + * Compatibility wrapper. The scaffold-version derivation graduated into the + * `create.versions` trail (`trails create versions`); this script only + * forwards to the trails CLI. * * Usage: * bun scripts/sync-scaffold-versions.ts # write generated file @@ -11,154 +10,24 @@ */ import { dirname, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, '..'); -const rootPackageJsonPath = resolve(repoRoot, 'package.json'); -const generatedFilePath = resolve( - repoRoot, - 'apps/trails/src/scaffold-versions.generated.ts' -); -const scaffoldVersionsModulePath = resolve( - repoRoot, - 'apps/trails/src/versions.ts' -); - -interface RootPackageJson { - readonly catalog?: Record; - readonly devDependencies?: Record; -} - -export interface OntrailsPackagePinState { - readonly ontrailsPackageRange?: string; - readonly trailsPackageVersion?: string; -} - -type ScaffoldVersionsModule = OntrailsPackagePinState; - -export const diagnoseOntrailsPackagePin = ({ - ontrailsPackageRange, - trailsPackageVersion, -}: OntrailsPackagePinState): string | undefined => { - if ( - typeof ontrailsPackageRange !== 'string' || - typeof trailsPackageVersion !== 'string' - ) { - return ( - 'sync-scaffold-versions: apps/trails/src/versions.ts must export ' + - '`ontrailsPackageRange` and `trailsPackageVersion`.' - ); - } - if (ontrailsPackageRange !== trailsPackageVersion) { - return ( - 'sync-scaffold-versions: scaffolded @ontrails/* packages must be exact ' + - `pins for @ontrails/trails (${trailsPackageVersion}); got ` + - `${ontrailsPackageRange}.` - ); - } - return undefined; -}; - -const requireValue = ( - value: string | undefined, - label: string, - source: string -): string => { - if (typeof value !== 'string' || value.length === 0) { - throw new Error( - `sync-scaffold-versions: missing "${label}" under ${source} in ${rootPackageJsonPath}` - ); - } - return value; -}; - -const loadScaffoldVersions = async (): Promise< - Record & Readonly> -> => { - const rootPkg = (await Bun.file( - rootPackageJsonPath - ).json()) as RootPackageJson; - const catalog = rootPkg.catalog ?? {}; - const devDeps = rootPkg.devDependencies ?? {}; - - return { - bunTypes: requireValue( - devDeps['@types/bun'], - '@types/bun', - 'devDependencies' - ), - commander: requireValue(catalog['commander'], 'commander', 'catalog'), - lefthook: requireValue(devDeps['lefthook'], 'lefthook', 'devDependencies'), - oxfmt: requireValue(devDeps['oxfmt'], 'oxfmt', 'devDependencies'), - oxlint: requireValue(devDeps['oxlint'], 'oxlint', 'devDependencies'), - typescript: requireValue( - devDeps['typescript'], - 'typescript', - 'devDependencies' - ), - ultracite: requireValue( - devDeps['ultracite'], - 'ultracite', - 'devDependencies' - ), - zod: requireValue(catalog['zod'], 'zod', 'catalog'), - }; -}; - -const renderGeneratedFile = ( - versions: Record & Readonly> -): string => { - const keys = Object.keys(versions).toSorted(); - const lines = keys.map((key: string) => ` ${key}: '${versions[key]}',`); - return [ - '// GENERATED FILE — do not edit by hand. Run `bun scripts/sync-scaffold-versions.ts` to regenerate.', - '', - 'export const scaffoldDependencyVersions = {', - ...lines, - '} as const;', - '', - ].join('\n'); -}; - -const check = async (expected: string): Promise => { - const existing = await Bun.file(generatedFilePath).text(); - if (existing === expected) { - return; - } - console.error( - `sync-scaffold-versions: ${generatedFilePath} is out of date.\n` + - 'Run `bun scripts/sync-scaffold-versions.ts` to regenerate.' - ); - process.exit(1); -}; - -const checkOntrailsPackagePin = async (): Promise => { - const versionsModule = (await import( - pathToFileURL(scaffoldVersionsModulePath).href - )) as ScaffoldVersionsModule; - // Normally quiet because versions.ts derives both exports from one source; - // this trips only if future/manual drift breaks that invariant. - const diagnostic = diagnoseOntrailsPackagePin(versionsModule); - if (diagnostic !== undefined) { - console.error(diagnostic); - process.exit(1); - } -}; - -const run = async (): Promise => { - const versions = await loadScaffoldVersions(); - const expected = renderGeneratedFile(versions); - if (process.argv.includes('--check')) { - await check(expected); - await checkOntrailsPackagePin(); - return; - } - await Bun.write(generatedFilePath, expected); - await checkOntrailsPackagePin(); - console.log(`Wrote ${generatedFilePath}`); -}; - -if (import.meta.main) { - await run(); -} +const passthrough = process.argv.includes('--check') ? ['--check'] : []; + +const proc = Bun.spawnSync({ + cmd: [ + 'bun', + 'apps/trails/bin/trails.ts', + 'create', + 'versions', + ...passthrough, + '--permit', + '{"id":"scaffold-versions-sync","scopes":["project:write"]}', + ], + cwd: repoRoot, + stdio: ['inherit', 'inherit', 'inherit'], +}); + +process.exit(proc.exitCode ?? 1);