Archon/scripts/generate-bundled-defaults.ts
Rasmus Widing 86e4c8d605
fix(bundled-defaults): auto-generate import list, emit inline strings (#1263)
* fix(bundled-defaults): auto-generate import list, emit inline strings

Root-cause fix for bundle drift (15 commands + 7 workflows previously
missing from binary distributions) and a prerequisite for packaging
@archon/workflows as a Node-loadable SDK.

The hand-maintained `bundled-defaults.ts` import list is replaced by
`scripts/generate-bundled-defaults.ts`, which walks
`.archon/{commands,workflows}/defaults/` and emits a generated source
file with inline string literals. `bundled-defaults.ts` becomes a thin
facade that re-exports the generated records and keeps the
`isBinaryBuild()` helper.

Inline strings (via JSON.stringify) replace Bun's
`import X from '...' with { type: 'text' }` attributes. The binary build
still embeds the data at compile time, but the module now loads under
Node too — removing SDK blocker #2.

- Generator: `scripts/generate-bundled-defaults.ts` (+ `--check` mode for CI)
- `package.json`: `generate:bundled`, `check:bundled`; wired into `validate`
- `build-binaries.sh`: regenerates defaults before compile
- Test: `bundle completeness` now derives expected set from on-disk files
- All 56 defaults (36 commands + 20 workflows) now in the bundle

* fix(bundled-defaults): address PR review feedback

Review: https://github.com/coleam00/Archon/pull/1263#issuecomment-4262719090

Generator:
- Guard against .yaml/.yml name collisions (previously silent overwrite)
- Add early access() check with actionable error when run from wrong cwd
- Type top-level catch as unknown; print only message for Error instances
- Drop redundant /* eslint-disable */ emission (global ignore covers it)
- Fix misleading CI-mechanism claim in header comment
- Collapse dead `if (!ext) continue` guard into a single typed pass

Scripts get real type-checking + linting:
- New scripts/tsconfig.json extending root config
- type-check now includes scripts/ via `tsc --noEmit -p scripts/tsconfig.json`
- Drop `scripts/**` from eslint ignores; add to projectService file scope

Tests:
- Inline listNames helper (Rule of Three)
- Drop redundant toBeDefined/typeof assertions; the Record<string, string>
  type plus length > 50 already cover them
- Add content-fidelity round-trip assertion (defense against generator
  content bugs, not just key-set drift)

Facade comment: drop dead reference to .claude/rules/dx-quirks.md.

CI: wire `bun run check:bundled` into .github/workflows/test.yml so the
header's CI-verification claim is truthful.

Docs: CLAUDE.md step count four→five; add contributor bullet about
`bun run generate:bundled` in the Defaults section and CONTRIBUTING.md.

* chore(e2e): bump Codex model to gpt-5.2

gpt-5.1-codex-mini is deprecated and unavailable on ChatGPT-account Codex
auth. Plain gpt-5.2 works. Verified end-to-end:

- e2e-codex-smoke: structured output returns {category:'math'}
- e2e-mixed-providers: claude+codex both return expected tokens
2026-04-16 21:27:51 +02:00

172 lines
5.6 KiB
TypeScript

#!/usr/bin/env bun
/**
* Regenerates packages/workflows/src/defaults/bundled-defaults.generated.ts from
* the on-disk defaults in .archon/commands/defaults/ and .archon/workflows/defaults/.
*
* Emits inline string literals (via JSON.stringify) rather than Bun's
* `import X from '...' with { type: 'text' }` attributes so the module loads
* in Node too. This fixes two problems at once:
* - bundle drift (hand-maintained import list in bundled-defaults.ts)
* - SDK blocker #2 (type: 'text' import attributes are Bun-specific)
*
* Determinism: filenames are sorted before emission so `bun run check:bundled`
* (which regenerates into memory and compares to the committed file) catches
* unregenerated changes. Wired into `bun run validate` and CI.
*
* Usage:
* bun run scripts/generate-bundled-defaults.ts # write
* bun run scripts/generate-bundled-defaults.ts --check # verify (exit 2 if stale)
*
* Exit codes:
* 0 file generated (and unchanged, if --check)
* 1 unexpected error (missing dir, unreadable source, invalid filename, etc.)
* 2 --check was passed and the file would change
*/
import { access, readFile, readdir, writeFile } from 'fs/promises';
import { join, resolve } from 'path';
const REPO_ROOT = resolve(import.meta.dir, '..');
const COMMANDS_DIR = join(REPO_ROOT, '.archon/commands/defaults');
const WORKFLOWS_DIR = join(REPO_ROOT, '.archon/workflows/defaults');
const OUTPUT_PATH = join(
REPO_ROOT,
'packages/workflows/src/defaults/bundled-defaults.generated.ts'
);
const CHECK_ONLY = process.argv.includes('--check');
interface BundledFile {
name: string;
content: string;
}
async function ensureDir(dir: string, label: string): Promise<void> {
try {
await access(dir);
} catch {
throw new Error(
`${label} directory not found: ${dir}\n` +
`Run this script from the repo root (cwd was ${process.cwd()}), ` +
'or verify the .archon/ tree exists.'
);
}
}
async function collectFiles(dir: string, extensions: readonly string[]): Promise<BundledFile[]> {
const entries = await readdir(dir);
const matched = entries
.map(entry => {
const ext = extensions.find(e => entry.endsWith(e));
return ext ? { entry, ext } : undefined;
})
.filter((m): m is { entry: string; ext: string } => m !== undefined)
.sort((a, b) => a.entry.localeCompare(b.entry));
const files: BundledFile[] = [];
const seen = new Set<string>();
for (const { entry, ext } of matched) {
const name = entry.slice(0, -ext.length);
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
throw new Error(
`Bundled default has invalid filename "${entry}" in ${dir}. ` +
'Names must be kebab-case (lowercase letters, digits, hyphens).'
);
}
if (seen.has(name)) {
throw new Error(
`Bundled default name collision: "${name}" appears with multiple extensions in ${dir}. ` +
'Keep a single file per name (remove either the .yaml or .yml variant).'
);
}
seen.add(name);
const content = await readFile(join(dir, entry), 'utf-8');
if (!content.trim()) {
throw new Error(`Bundled default "${entry}" in ${dir} is empty.`);
}
files.push({ name, content });
}
return files;
}
function renderRecord(comment: string, exportName: string, files: BundledFile[]): string {
const entries = files
.map(f => ` ${JSON.stringify(f.name)}: ${JSON.stringify(f.content)},`)
.join('\n');
return [
`// ${comment} (${files.length} total)`,
`export const ${exportName}: Record<string, string> = {`,
entries,
'};',
].join('\n');
}
function renderFile(commands: BundledFile[], workflows: BundledFile[]): string {
const header = [
'/**',
' * AUTO-GENERATED — DO NOT EDIT.',
' *',
' * Regenerate with: bun run generate:bundled',
' * Verify up-to-date: bun run check:bundled',
' *',
' * Source of truth:',
' * .archon/commands/defaults/*.md',
' * .archon/workflows/defaults/*.{yaml,yml}',
' *',
' * Contents are inlined as plain string literals (JSON-escaped) so this',
' * module loads in both Bun and Node. Previous versions used',
" * `import X from '...' with { type: 'text' }` which is Bun-specific.",
' */',
'',
].join('\n');
return [
header,
renderRecord('Bundled default commands', 'BUNDLED_COMMANDS', commands),
'',
renderRecord('Bundled default workflows', 'BUNDLED_WORKFLOWS', workflows),
'',
].join('\n');
}
async function main(): Promise<void> {
await Promise.all([
ensureDir(COMMANDS_DIR, 'Commands defaults'),
ensureDir(WORKFLOWS_DIR, 'Workflows defaults'),
]);
const [commands, workflows] = await Promise.all([
collectFiles(COMMANDS_DIR, ['.md']),
collectFiles(WORKFLOWS_DIR, ['.yaml', '.yml']),
]);
const contents = renderFile(commands, workflows);
if (CHECK_ONLY) {
let existing = '';
try {
existing = await readFile(OUTPUT_PATH, 'utf-8');
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') throw err;
}
if (existing !== contents) {
console.error('bundled-defaults.generated.ts is stale.\n' + 'Run: bun run generate:bundled');
process.exit(2);
}
console.log(
`bundled-defaults.generated.ts is up to date (${commands.length} commands, ${workflows.length} workflows).`
);
return;
}
await writeFile(OUTPUT_PATH, contents, 'utf-8');
console.log(
`Wrote ${OUTPUT_PATH}\n ${commands.length} commands, ${workflows.length} workflows.`
);
}
main().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.error(msg);
process.exit(1);
});