From 20700b2065c57e6ebf8a61b8890d00473d4877c0 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sat, 27 Jun 2026 19:08:20 -0700 Subject: [PATCH 1/2] Adopt oclif command-plugins: migrate commands, rename plugin to module, preinstall sync Replace the hand-rolled arg dispatcher with the ix-cli-core oclif runner; every command (update, catalog, write, review/matrix/to-plan) is now a BaseCommand subclass, with ix-flow subprocess delegation unchanged and the command surface preserved. Rename spec-module management from 'plugin' to 'module', keeping 'quoin plugin' as a deprecated forwarding alias. Declare @agent-ix/filament-plan-sync in oclif.plugins + dependencies so 'quoin sync' is discovered as a preinstalled core plugin (IT-005). (FR-016, FR-017, FR-018) Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/quoin.js | 16 +- package.json | 22 ++ src/base.ts | 57 +++ src/cli.ts | 489 ++----------------------- src/commands/catalog/index.ts | 2 + src/commands/catalog/list.ts | 34 ++ src/commands/catalog/show.ts | 37 ++ src/commands/catalog/validate.ts | 32 ++ src/commands/matrix.ts | 18 + src/commands/module/ensure-defaults.ts | 25 ++ src/commands/module/index.ts | 2 + src/commands/module/install.ts | 35 ++ src/commands/module/list.ts | 16 + src/commands/module/remove.ts | 21 ++ src/commands/plugin/deprecation.ts | 10 + src/commands/plugin/ensure-defaults.ts | 14 + src/commands/plugin/index.ts | 3 + src/commands/plugin/install.ts | 13 + src/commands/plugin/list.ts | 13 + src/commands/plugin/remove.ts | 13 + src/commands/review.ts | 18 + src/commands/to-plan.ts | 18 + src/commands/update.ts | 49 +++ src/commands/write.ts | 58 +++ src/flow-command.ts | 39 ++ src/index.ts | 5 +- src/version.ts | 32 ++ tests/cli.test.ts | 409 ++++++++++++--------- tests/it-005-sync-discovery.test.ts | 61 +++ tests/scripts.test.ts | 39 +- tests/update.test.ts | 57 ++- vite.config.ts | 24 ++ 32 files changed, 1007 insertions(+), 674 deletions(-) create mode 100644 src/base.ts create mode 100644 src/commands/catalog/index.ts create mode 100644 src/commands/catalog/list.ts create mode 100644 src/commands/catalog/show.ts create mode 100644 src/commands/catalog/validate.ts create mode 100644 src/commands/matrix.ts create mode 100644 src/commands/module/ensure-defaults.ts create mode 100644 src/commands/module/index.ts create mode 100644 src/commands/module/install.ts create mode 100644 src/commands/module/list.ts create mode 100644 src/commands/module/remove.ts create mode 100644 src/commands/plugin/deprecation.ts create mode 100644 src/commands/plugin/ensure-defaults.ts create mode 100644 src/commands/plugin/index.ts create mode 100644 src/commands/plugin/install.ts create mode 100644 src/commands/plugin/list.ts create mode 100644 src/commands/plugin/remove.ts create mode 100644 src/commands/review.ts create mode 100644 src/commands/to-plan.ts create mode 100644 src/commands/update.ts create mode 100644 src/commands/write.ts create mode 100644 src/flow-command.ts create mode 100644 src/version.ts create mode 100644 tests/it-005-sync-discovery.test.ts diff --git a/bin/quoin.js b/bin/quoin.js index bfc13f7..1640bbc 100755 --- a/bin/quoin.js +++ b/bin/quoin.js @@ -1,8 +1,14 @@ #!/usr/bin/env node -import { main } from "../dist/cli.js"; +import { execute } from "@agent-ix/ix-cli-core"; -main(process.argv.slice(2)).catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exitCode = 1; -}); +import { isVersionRequest, packageVersion } from "../dist/cli.js"; + +const argv = process.argv.slice(2); + +// Preserve quoin's bare build-time version output ahead of the oclif runner. +if (isVersionRequest(argv)) { + console.log(packageVersion()); +} else { + await execute({ dir: import.meta.url }); +} diff --git a/package.json b/package.json index 0e5bda8..aa18214 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,26 @@ "bin": { "quoin": "bin/quoin.js" }, + "oclif": { + "bin": "quoin", + "dirname": "quoin", + "commands": "./dist/commands", + "plugins": [ + "@agent-ix/filament-plan-sync" + ], + "topicSeparator": " ", + "topics": { + "catalog": { + "description": "Inspect the active artifact/object catalog (list, show, validate)." + }, + "module": { + "description": "Install and manage user/community spec modules (list, install, remove, ensure-defaults)." + }, + "plugin": { + "description": "[DEPRECATED] Renamed to `module`; retained as a forwarding alias." + } + } + }, "packageManager": "pnpm@10.33.4", "scripts": { "help": "node scripts/help.js", @@ -57,8 +77,10 @@ "test-results:warnings": "./node_modules/.bin/jest-results warnings" }, "dependencies": { + "@agent-ix/filament-plan-sync": "file:../filament-plan-sync", "@agent-ix/ix-cli-core": ">=0.11.0", "@agent-ix/ts-plugin-kit": ">=0.1.3", + "@oclif/core": ">=4.11.4", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/src/base.ts b/src/base.ts new file mode 100644 index 0000000..258a3b4 --- /dev/null +++ b/src/base.ts @@ -0,0 +1,57 @@ +import { BaseCommand, maybeOfferUpdate } from "@agent-ix/ix-cli-core"; + +import { ixHome } from "./catalog.js"; +import { packageVersion } from "./version.js"; + +/** + * Shared base for every quoin command. + * + * Extends ix-cli-core's {@link BaseCommand} (which owns the `--config-root` / + * `--no-project-config` global flags and the capability lifecycle) and adds the + * two cross-cutting concerns the legacy hand-rolled dispatcher used to apply to + * every invocation: + * + * 1. Resolve the quoin home (`--config-root` flag, else `IX_HOME`) and export + * it as `process.env.IX_HOME` so the catalog / module modules — which read + * `ixHome()` — observe the per-invocation override exactly as before. + * 2. Nudge toward a newer published quoin (throttled, interactive-only, silent + * in CI / under `--json` / for `update` itself). Never blocks or throws. + */ +export abstract class QuoinCommand extends BaseCommand { + /** Commands that must not trigger the update nudge (e.g. `update`). */ + protected skipUpdateNudge = false; + + /** Resolved quoin home for this invocation. */ + protected home = ""; + + public override async init(): Promise { + await super.init(); + + const configRoot = + configRootFromArgv(this.argv) ?? process.env.IX_CONFIG_ROOT; + this.home = + typeof configRoot === "string" && configRoot.length > 0 + ? configRoot + : ixHome(); + process.env.IX_HOME = this.home; + + if (!this.skipUpdateNudge && !this.argv.includes("--json")) { + await maybeOfferUpdate({ + packageName: "@agent-ix/quoin", + currentVersion: packageVersion(), + }); + } + } +} + +/** Read the `--config-root ` / `--config-root=` value from argv. */ +function configRootFromArgv(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--config-root") return argv[i + 1]; + if (arg.startsWith("--config-root=")) { + return arg.slice("--config-root=".length); + } + } + return undefined; +} diff --git a/src/cli.ts b/src/cli.ts index cb3818f..924dc5d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,457 +1,40 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - configureRuntimeContext, - maybeOfferUpdate, - runSelfUpdate, -} from "@agent-ix/ix-cli-core"; - -import { findCatalogEntry, ixHome, loadCatalog } from "./catalog.js"; -import { ensureDefaultModules } from "./modules.js"; -import { installPlugin, listPlugins, removePlugin } from "./plugins.js"; -import { specFlowNames, startSpecFlow } from "./flows.js"; -import { - createAuthoringPack, - formatAuthoringPack, - parseTypeList, -} from "./write.js"; - -interface ParsedArgs { - command?: string; - subcommand?: string; - positionals: string[]; - flags: Record; -} - -// quoin is published to the public npm registry (see package.json -// publishConfig.registry). `update` defaults here so it resolves the package -// from public npm regardless of the ambient npm config; override with -// --registry for local-dev snapshots (e.g. http://npm.ix/). -const DEFAULT_UPDATE_REGISTRY = "https://registry.npmjs.org/"; - -const USAGE = `quoin - -Spec workflow and catalog CLI for Agent IX. - -quoin starts spec-domain work. ix-flow resumes, advances, acknowledges gates, -and inspects workflow runs created by quoin. - -Default modules (installed on first use into ~/.ix/filament/modules): - Artifact modules: spec-artifacts-iso, spec-artifacts-app, spec-artifacts-process - Object modules: spec-objects-business, spec-objects-architecture, - spec-objects-enterprise, spec-objects-operational, - spec-objects-security - Workflows: review, matrix, to-plan - -Usage: - quoin write --types - quoin catalog list|validate - quoin catalog show - quoin plugin install - quoin plugin list - quoin plugin remove - quoin review|matrix|to-plan [--target ...] - quoin update [--check] [--registry ] - -Global flags: - --json - --config-root Defaults to ~/.ix - -Examples: - quoin catalog list - quoin catalog show FR - quoin plugin install github:agent-ix/spec-objects-custom - quoin write . --types FR,domain - ix-flow status -`; - -const WRITE_USAGE = `quoin write - -Build an authoring pack for spec files an agent is about to create or edit. - -Pass the target repository and the artifact/object types involved in the work. -quoin resolves those types from the active catalog and returns the local -skeletons, schemas, module roots, and Quire validation command for the repo. - -Usage: - quoin write --types - -Examples: - quoin write . --types FR - quoin write . --types FR,domain,entity - quoin write ../my-service --types fr,DOMAIN --json - -Notes: - - Type lookup is case-insensitive; FR and fr are the same type. - - Types can be artifacts or objects from the default module set or plugins. - - Use the returned skeletons and schemas as the authoring contract. - - Run the returned Quire command after editing spec files. -`; - -const CATALOG_USAGE = `quoin catalog - -Inspect the active artifact/object catalog. - -The catalog is assembled from, in order: - 1. QUOIN_MODULE_PATHS - 2. modules installed under ~/.ix/filament/modules - -The default module set is installed there automatically on first catalog access, -and updated by "quoin plugin install". The same directory is read by quire-rs. - -Default artifact modules: - spec-artifacts-iso FR, NFR, StR, US, IT, TC, AC, CON, Spec - spec-artifacts-app app-level artifacts - spec-artifacts-process plans, reviews, matrices, ADRs, tasks, findings - -Default object modules: - spec-objects-business - spec-objects-architecture - spec-objects-enterprise - spec-objects-operational - spec-objects-security - -Usage: - quoin catalog list - quoin catalog show - quoin catalog validate - -Examples: - quoin catalog show FR - quoin catalog show entity - quoin catalog validate --json -`; - -const PLUGIN_USAGE = `quoin plugin - -Install and manage user/community spec modules. - -Plugins add or override artifact/object modules under ~/.ix/filament/modules. -Use "catalog list" to see the full active catalog, including default modules and -plugins. - -Supported install sources: - path: Use a local directory containing manifest.yaml - github:/ Clone a GitHub repository (manifest at root) - github:/@ Pin to a tag, branch, or sha - github:/// Manifest in a monorepo subdirectory (@ ok) - package: Install a module from an npm package (manifest at root) - package:@ Pin to an npm version - -Usage: - quoin plugin install - quoin plugin list - quoin plugin remove - quoin plugin ensure-defaults - -Examples: - quoin plugin install path:../spec-objects-custom - quoin plugin install github:agent-ix/spec-objects-custom - quoin plugin install github:agent-ix/spec-objects-security//spec_objects_security@v0.1.1 - quoin plugin install package:@agent-ix/spec-objects-security@0.4.0 - quoin plugin list - quoin plugin ensure-defaults -`; - -const UPDATE_USAGE = `quoin update - -Check for and install the latest published quoin. - -quoin is distributed on the public npm registry as @agent-ix/quoin. This command -queries the registry for the latest version, compares it to the running version, -and (unless --check) runs npm install -g to upgrade in place. - -Usage: - quoin update [--check] [--registry ] - -Flags: - --check Report whether an update is available; do not install. - --registry Force an npm registry to query/install from. Defaults - to the public npm registry - (https://registry.npmjs.org/), where @agent-ix/quoin is - published. Pass http://npm.ix/ for local dev snapshots. - -Examples: - quoin update - quoin update --check - quoin update --registry http://npm.ix/ -`; - -const FLOW_USAGE = `quoin workflows - -Start bundled spec review/planning workflows. quoin creates the workflow run in ~/.ix/flows. -Use ix-flow to inspect, resume, advance phases, and acknowledge human gates. - -Available workflow launchers: - review Run a composite spec review - matrix Build or update a requirements test matrix - to-plan Convert accepted requirements into an implementation plan - -Usage: - quoin review|matrix|to-plan [--target ...] - -Examples: - quoin review --target spec/ - quoin matrix --target spec/ - ix-flow status - ix-flow resume -`; - -export async function main(argv: string[]): Promise { - const parsed = parseArgs(argv); - if (parsed.flags.version || parsed.flags.v || parsed.command === "version") { +import { run, type RunnerLoadOptions } from "@agent-ix/ix-cli-core"; + +import { packageVersion } from "./version.js"; + +export { packageVersion, resolveVersion } from "./version.js"; + +// quoin bakes a truthful `git describe` version at build time and prints the +// bare string for `--version` / `-v` / `version` (the oclif default version +// output is decorated with the platform/node triple, which would break the +// historical surface and the build-time drift signal). Intercept those forms +// before handing off to the oclif runner. +const VERSION_REQUESTS = new Set(["--version", "-v", "version"]); + +export function isVersionRequest(argv: readonly string[]): boolean { + return argv.length > 0 && VERSION_REQUESTS.has(argv[0]); +} + +/** + * quoin entry point. + * + * Replaces the legacy hand-rolled argv dispatcher: every command now runs as a + * `BaseCommand` subclass discovered and dispatched by the ix-cli-core oclif + * runner (FR-016). The only pre-dispatch concern retained here is the bare + * version print (above). + * + * @param argv argument vector (without the node/bin prefix) + * @param options oclif config source — a file URL (`import.meta.url`), a + * directory, or a pre-loaded `Config` (used by tests). Defaults to oclif's + * own resolution. + */ +export async function main( + argv: string[], + options?: RunnerLoadOptions, +): Promise { + if (isVersionRequest(argv)) { console.log(packageVersion()); return; } - if (!parsed.command) { - console.log(USAGE); - return; - } - if (parsed.flags.help || parsed.flags.h) { - console.log(helpFor(parsed)); - return; - } - - const home = stringFlag(parsed, "config-root") ?? ixHome(); - process.env.IX_HOME = home; - configureRuntimeContext({ - configRoot: home, - configNamespace: "ix", - projectConfigRoot: `${process.cwd()}/.ix`, - projectConfigEnabled: parsed.flags["no-project-config"] !== true, - }); - - // Nudge toward a newer published quoin (throttled, interactive-only, silent in - // CI / under --json / for the update command itself). Never blocks or throws. - if (parsed.command !== "update" && parsed.flags.json !== true) { - await maybeOfferUpdate({ - packageName: "@agent-ix/quoin", - currentVersion: packageVersion(), - }); - } - - if (parsed.command === "update") return runUpdate(parsed); - if (parsed.command === "catalog") return runCatalog(parsed); - if (parsed.command === "plugin") return runPlugin(parsed); - if (parsed.command === "write") return runWrite(parsed); - if (specFlowNames().includes(parsed.command)) { - return startSpecFlow(parsed.command, { - json: parsed.flags.json === true, - id: stringFlag(parsed, "id"), - target: arrayFlag(parsed, "target"), - }); - } - throw new Error(`unknown command ${parsed.command}\n\n${USAGE}`); -} - -// Baked at build time from `git describe` (see vite.config.ts). A bare semver -// means a clean tagged release; a `--g` suffix means the build is ahead of -// its tag. Empty for dev/test/no-git builds, which fall back to package.json. -declare const __QUOIN_VERSION__: string; - -function readPackageJsonVersion(): string { - const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); - const packageJson = JSON.parse( - readFileSync(join(packageRoot, "package.json"), "utf8"), - ) as { version?: unknown }; - if (typeof packageJson.version !== "string") { - throw new Error("package.json version is missing"); - } - return packageJson.version; -} - -// Prefer the build-time baked version (truthful about drift); fall back to -// package.json when it is absent (dev/test builds, or a no-git build). -export function resolveVersion(baked: string): string { - if (baked) return baked; - return readPackageJsonVersion(); -} - -export function packageVersion(): string { - return resolveVersion(__QUOIN_VERSION__); -} - -function helpFor(parsed: ParsedArgs): string { - if (parsed.command === "update") return UPDATE_USAGE; - if (parsed.command === "write") return WRITE_USAGE; - if (parsed.command === "catalog") return CATALOG_USAGE; - if (parsed.command === "plugin") return PLUGIN_USAGE; - // helpFor is only called after main() has returned early for - // `!parsed.command`, so command is always defined here; the prior - // `parsed.command ?? ""` fallback was an unreachable defensive branch. - if (specFlowNames().includes(parsed.command as string)) return FLOW_USAGE; - return USAGE; -} - -function runWrite(parsed: ParsedArgs): void { - const [repoDir] = parsed.positionals; - if (!repoDir) throw new Error(`write requires \n\n${WRITE_USAGE}`); - ensureDefaultModules(); - const catalog = loadCatalog(); - const pack = createAuthoringPack( - catalog, - repoDir, - parseTypeList(arrayFlag(parsed, "types")), - ); - console.log( - parsed.flags.json - ? JSON.stringify(pack, null, 2) - : formatAuthoringPack(pack), - ); -} - -async function runUpdate(parsed: ParsedArgs): Promise { - await runSelfUpdate({ - packageName: "@agent-ix/quoin", - currentVersion: packageVersion(), - header: "quoin update", - // Default to public npm (where @agent-ix/quoin is published) so update - // works regardless of the ambient npm config. Override for local dev with - // --registry http://npm.ix/. - registry: stringFlag(parsed, "registry") ?? DEFAULT_UPDATE_REGISTRY, - check: parsed.flags.check === true, - }); -} - -function runCatalog(parsed: ParsedArgs): void { - ensureDefaultModules(); - const catalog = loadCatalog(); - switch (parsed.subcommand) { - case "list": - case undefined: - if (parsed.flags.json) { - console.log(JSON.stringify(catalog, null, 2)); - return; - } - for (const module of catalog.modules) { - console.log( - `${module.name}@${module.version ?? "unknown"} ${module.root}`, - ); - } - return; - case "show": { - const [name] = parsed.positionals; - if (!name) throw new Error("catalog show requires "); - const entry = findCatalogEntry(catalog, name); - if (!entry) throw new Error(`catalog type not found: ${name}`); - console.log( - parsed.flags.json - ? JSON.stringify(entry, null, 2) - : `${entry.kind} ${entry.name} from ${entry.moduleName}\n${entry.moduleRoot}`, - ); - return; - } - case "validate": - if (catalog.duplicates.length > 0) { - console.error( - JSON.stringify( - { ok: false, duplicates: catalog.duplicates }, - null, - 2, - ), - ); - process.exitCode = 1; - return; - } - console.log( - parsed.flags.json - ? JSON.stringify( - { ok: true, modules: catalog.modules.length }, - null, - 2, - ) - : `catalog ok (${catalog.modules.length} modules)`, - ); - return; - default: - throw new Error(`unknown catalog command ${parsed.subcommand}`); - } -} - -function runPlugin(parsed: ParsedArgs): void { - switch (parsed.subcommand) { - case "list": - case undefined: - console.log(JSON.stringify({ plugins: listPlugins() }, null, 2)); - return; - case "install": { - const [source] = parsed.positionals; - if (!source) throw new Error("plugin install requires "); - console.log(JSON.stringify(installPlugin(source), null, 2)); - return; - } - case "remove": { - const [name] = parsed.positionals; - if (!name) throw new Error("plugin remove requires "); - removePlugin(name); - console.log(`removed ${name}`); - return; - } - case "ensure-defaults": - // Idempotently materialize the default module set into - // ~/.ix/filament/modules. Exposed as an explicit command so other tools - // (notably `quire validate`) can lazy-install the defaults by shelling - // out here, rather than relying on the side effect of `catalog list`. - ensureDefaultModules(); - console.log( - JSON.stringify( - { ensured: true, plugins: listPlugins().map((p) => p.name) }, - null, - 2, - ), - ); - return; - default: - throw new Error(`unknown plugin command ${parsed.subcommand}`); - } -} - -function parseArgs(argv: string[]): ParsedArgs { - const flags: ParsedArgs["flags"] = {}; - const positionals: string[] = []; - let command: string | undefined; - let subcommand: string | undefined; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg.startsWith("--")) { - const eq = arg.indexOf("="); - const key = arg.slice(2, eq === -1 ? undefined : eq); - const value = - eq !== -1 - ? arg.slice(eq + 1) - : argv[i + 1] && !argv[i + 1].startsWith("-") - ? argv[++i] - : true; - if (key === "target" || key === "types") - flags[key] = [ - ...arrayFlag({ flags } as ParsedArgs, key), - String(value), - ]; - else flags[key] = value; - } else if (arg.startsWith("-") && arg.length > 1) { - flags[arg.slice(1)] = true; - } else if (!command) { - command = arg; - } else if (!subcommand && (command === "catalog" || command === "plugin")) { - subcommand = arg; - } else { - positionals.push(arg); - } - } - return { command, subcommand, positionals, flags }; -} - -function stringFlag(parsed: ParsedArgs, key: string): string | undefined { - const value = parsed.flags[key]; - return typeof value === "string" ? value : undefined; -} - -function arrayFlag(parsed: ParsedArgs, key: string): string[] { - // Only called for `target`/`types`, which parseArgs always stores as arrays - // (or leaves undefined on the first occurrence). The previous - // `typeof value === "string"` case was therefore unreachable. - const value = parsed.flags[key]; - return Array.isArray(value) ? value : []; + await run(argv, options); } diff --git a/src/commands/catalog/index.ts b/src/commands/catalog/index.ts new file mode 100644 index 0000000..d0ed246 --- /dev/null +++ b/src/commands/catalog/index.ts @@ -0,0 +1,2 @@ +// `quoin catalog` with no subcommand behaves like `quoin catalog list`. +export { default } from "./list.js"; diff --git a/src/commands/catalog/list.ts b/src/commands/catalog/list.ts new file mode 100644 index 0000000..1eb0c4b --- /dev/null +++ b/src/commands/catalog/list.ts @@ -0,0 +1,34 @@ +import { Flags } from "@oclif/core"; + +import { QuoinCommand } from "../../base.js"; +import { loadCatalog } from "../../catalog.js"; +import { ensureDefaultModules } from "../../modules.js"; + +export default class CatalogList extends QuoinCommand { + static summary = "List the active artifact/object catalog modules."; + static description = `The catalog is assembled from, in order: + 1. QUOIN_MODULE_PATHS + 2. modules installed under ~/.ix/filament/modules + +The default module set is installed there automatically on first catalog access, +and updated by "quoin module install". The same directory is read by quire-rs.`; + + static examples = ["quoin catalog list", "quoin catalog list --json"]; + + static flags = { + json: Flags.boolean({ description: "Emit the full catalog as JSON." }), + }; + + async run(): Promise { + const { flags } = await this.parse(CatalogList); + ensureDefaultModules(); + const catalog = loadCatalog(); + if (flags.json) { + this.log(JSON.stringify(catalog, null, 2)); + return; + } + for (const module of catalog.modules) { + this.log(`${module.name}@${module.version ?? "unknown"} ${module.root}`); + } + } +} diff --git a/src/commands/catalog/show.ts b/src/commands/catalog/show.ts new file mode 100644 index 0000000..44b0d8a --- /dev/null +++ b/src/commands/catalog/show.ts @@ -0,0 +1,37 @@ +import { Args, Flags } from "@oclif/core"; + +import { QuoinCommand } from "../../base.js"; +import { findCatalogEntry, loadCatalog } from "../../catalog.js"; +import { ensureDefaultModules } from "../../modules.js"; + +export default class CatalogShow extends QuoinCommand { + static summary = "Show a single artifact/object type from the catalog."; + static examples = [ + "quoin catalog show FR", + "quoin catalog show entity", + "quoin catalog show FR --json", + ]; + + static args = { + type: Args.string({ description: "Artifact or object type name." }), + }; + + static flags = { + json: Flags.boolean({ description: "Emit the entry as JSON." }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(CatalogShow); + ensureDefaultModules(); + const catalog = loadCatalog(); + const name = args.type; + if (!name) throw new Error("catalog show requires "); + const entry = findCatalogEntry(catalog, name); + if (!entry) throw new Error(`catalog type not found: ${name}`); + this.log( + flags.json + ? JSON.stringify(entry, null, 2) + : `${entry.kind} ${entry.name} from ${entry.moduleName}\n${entry.moduleRoot}`, + ); + } +} diff --git a/src/commands/catalog/validate.ts b/src/commands/catalog/validate.ts new file mode 100644 index 0000000..4fe8121 --- /dev/null +++ b/src/commands/catalog/validate.ts @@ -0,0 +1,32 @@ +import { Flags } from "@oclif/core"; + +import { QuoinCommand } from "../../base.js"; +import { loadCatalog } from "../../catalog.js"; +import { ensureDefaultModules } from "../../modules.js"; + +export default class CatalogValidate extends QuoinCommand { + static summary = "Validate the active catalog has no duplicate types."; + static examples = ["quoin catalog validate", "quoin catalog validate --json"]; + + static flags = { + json: Flags.boolean({ description: "Emit the result as JSON." }), + }; + + async run(): Promise { + const { flags } = await this.parse(CatalogValidate); + ensureDefaultModules(); + const catalog = loadCatalog(); + if (catalog.duplicates.length > 0) { + this.logToStderr( + JSON.stringify({ ok: false, duplicates: catalog.duplicates }, null, 2), + ); + process.exitCode = 1; + return; + } + this.log( + flags.json + ? JSON.stringify({ ok: true, modules: catalog.modules.length }, null, 2) + : `catalog ok (${catalog.modules.length} modules)`, + ); + } +} diff --git a/src/commands/matrix.ts b/src/commands/matrix.ts new file mode 100644 index 0000000..dc2a982 --- /dev/null +++ b/src/commands/matrix.ts @@ -0,0 +1,18 @@ +import { FlowCommand } from "../flow-command.js"; + +export default class Matrix extends FlowCommand { + static summary = "Build or update a requirements test matrix."; + static description = `Starts the bundled matrix workflow run in ~/.ix/flows. Use ix-flow to inspect, +resume, advance phases, and acknowledge human gates.`; + + static examples = ["quoin matrix --target spec/", "ix-flow status "]; + + static flags = FlowCommand.flowFlags; + + protected readonly flowName = "matrix"; + + async run(): Promise { + const { flags } = await this.parse(Matrix); + await this.launch(flags); + } +} diff --git a/src/commands/module/ensure-defaults.ts b/src/commands/module/ensure-defaults.ts new file mode 100644 index 0000000..3ee82da --- /dev/null +++ b/src/commands/module/ensure-defaults.ts @@ -0,0 +1,25 @@ +import { QuoinCommand } from "../../base.js"; +import { ensureDefaultModules } from "../../modules.js"; +import { listPlugins } from "../../plugins.js"; + +export default class ModuleEnsureDefaults extends QuoinCommand { + static summary = "Idempotently install the default spec module set."; + static description = `Materializes the default module set into ~/.ix/filament/modules. Exposed as an +explicit command so other tools (notably "quire validate") can lazy-install the +defaults by shelling out here, rather than relying on the side effect of +"quoin catalog list".`; + + static examples = ["quoin module ensure-defaults"]; + + async run(): Promise { + await this.parse(ModuleEnsureDefaults); + ensureDefaultModules(); + this.log( + JSON.stringify( + { ensured: true, plugins: listPlugins().map((p) => p.name) }, + null, + 2, + ), + ); + } +} diff --git a/src/commands/module/index.ts b/src/commands/module/index.ts new file mode 100644 index 0000000..3dd589d --- /dev/null +++ b/src/commands/module/index.ts @@ -0,0 +1,2 @@ +// `quoin module` with no subcommand behaves like `quoin module list`. +export { default } from "./list.js"; diff --git a/src/commands/module/install.ts b/src/commands/module/install.ts new file mode 100644 index 0000000..be2e3c5 --- /dev/null +++ b/src/commands/module/install.ts @@ -0,0 +1,35 @@ +import { Args } from "@oclif/core"; + +import { QuoinCommand } from "../../base.js"; +import { installPlugin } from "../../plugins.js"; + +export default class ModuleInstall extends QuoinCommand { + static summary = "Install or update a user/community spec module."; + static description = `Supported install sources: + path: Use a local directory containing manifest.yaml + github:/ Clone a GitHub repository (manifest at root) + github:/@ Pin to a tag, branch, or sha + github:/// Manifest in a monorepo subdirectory (@ ok) + package: Install a module from an npm package (manifest at root) + package:@ Pin to an npm version`; + + static examples = [ + "quoin module install path:../spec-objects-custom", + "quoin module install github:agent-ix/spec-objects-custom", + "quoin module install github:agent-ix/spec-objects-security//spec_objects_security@v0.1.1", + "quoin module install package:@agent-ix/spec-objects-security@0.4.0", + ]; + + static args = { + source: Args.string({ + description: "Install source (path:..., github:..., or package:...).", + }), + }; + + async run(): Promise { + const { args } = await this.parse(ModuleInstall); + const source = args.source; + if (!source) throw new Error("module install requires "); + this.log(JSON.stringify(installPlugin(source), null, 2)); + } +} diff --git a/src/commands/module/list.ts b/src/commands/module/list.ts new file mode 100644 index 0000000..d585d11 --- /dev/null +++ b/src/commands/module/list.ts @@ -0,0 +1,16 @@ +import { QuoinCommand } from "../../base.js"; +import { listPlugins } from "../../plugins.js"; + +export default class ModuleList extends QuoinCommand { + static summary = "List installed spec modules."; + static description = `Spec modules add or override artifact/object types under ~/.ix/filament/modules. +Use "quoin catalog list" to see the full active catalog, including default +modules and any installed modules.`; + + static examples = ["quoin module list"]; + + async run(): Promise { + await this.parse(ModuleList); + this.log(JSON.stringify({ plugins: listPlugins() }, null, 2)); + } +} diff --git a/src/commands/module/remove.ts b/src/commands/module/remove.ts new file mode 100644 index 0000000..beb2fc9 --- /dev/null +++ b/src/commands/module/remove.ts @@ -0,0 +1,21 @@ +import { Args } from "@oclif/core"; + +import { QuoinCommand } from "../../base.js"; +import { removePlugin } from "../../plugins.js"; + +export default class ModuleRemove extends QuoinCommand { + static summary = "Remove an installed spec module."; + static examples = ["quoin module remove spec-objects-custom"]; + + static args = { + name: Args.string({ description: "Installed module name." }), + }; + + async run(): Promise { + const { args } = await this.parse(ModuleRemove); + const name = args.name; + if (!name) throw new Error("module remove requires "); + removePlugin(name); + this.log(`removed ${name}`); + } +} diff --git a/src/commands/plugin/deprecation.ts b/src/commands/plugin/deprecation.ts new file mode 100644 index 0000000..dd5b2f9 --- /dev/null +++ b/src/commands/plugin/deprecation.ts @@ -0,0 +1,10 @@ +/** + * Shared deprecation notice for the legacy `quoin plugin` topic. + * + * `plugin` was renamed to `module` (FR-017) to free the `plugin` namespace for + * the oclif sense of installable command packages. The alias is retained for at + * least one minor cycle; every alias subcommand emits this warning to stderr + * before forwarding to its `module` counterpart. + */ +export const PLUGIN_DEPRECATION_NOTICE = + "warning: `quoin plugin` is deprecated and will be removed; use `quoin module` instead."; diff --git a/src/commands/plugin/ensure-defaults.ts b/src/commands/plugin/ensure-defaults.ts new file mode 100644 index 0000000..d39d721 --- /dev/null +++ b/src/commands/plugin/ensure-defaults.ts @@ -0,0 +1,14 @@ +import ModuleEnsureDefaults from "../module/ensure-defaults.js"; +import { PLUGIN_DEPRECATION_NOTICE } from "./deprecation.js"; + +/** Deprecated alias for `quoin module ensure-defaults`. */ +export default class PluginEnsureDefaults extends ModuleEnsureDefaults { + static override summary = + "[DEPRECATED] Alias for `quoin module ensure-defaults`."; + static override hidden = true; + + override async run(): Promise { + this.logToStderr(PLUGIN_DEPRECATION_NOTICE); + await super.run(); + } +} diff --git a/src/commands/plugin/index.ts b/src/commands/plugin/index.ts new file mode 100644 index 0000000..cbc4739 --- /dev/null +++ b/src/commands/plugin/index.ts @@ -0,0 +1,3 @@ +// Deprecated `quoin plugin` (no subcommand) — warns and behaves like +// `quoin module list`. +export { default } from "./list.js"; diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts new file mode 100644 index 0000000..3b71d45 --- /dev/null +++ b/src/commands/plugin/install.ts @@ -0,0 +1,13 @@ +import ModuleInstall from "../module/install.js"; +import { PLUGIN_DEPRECATION_NOTICE } from "./deprecation.js"; + +/** Deprecated alias for `quoin module install`. */ +export default class PluginInstall extends ModuleInstall { + static override summary = "[DEPRECATED] Alias for `quoin module install`."; + static override hidden = true; + + override async run(): Promise { + this.logToStderr(PLUGIN_DEPRECATION_NOTICE); + await super.run(); + } +} diff --git a/src/commands/plugin/list.ts b/src/commands/plugin/list.ts new file mode 100644 index 0000000..e7223c3 --- /dev/null +++ b/src/commands/plugin/list.ts @@ -0,0 +1,13 @@ +import ModuleList from "../module/list.js"; +import { PLUGIN_DEPRECATION_NOTICE } from "./deprecation.js"; + +/** Deprecated alias for `quoin module list`. */ +export default class PluginList extends ModuleList { + static override summary = "[DEPRECATED] Alias for `quoin module list`."; + static override hidden = true; + + override async run(): Promise { + this.logToStderr(PLUGIN_DEPRECATION_NOTICE); + await super.run(); + } +} diff --git a/src/commands/plugin/remove.ts b/src/commands/plugin/remove.ts new file mode 100644 index 0000000..63decc2 --- /dev/null +++ b/src/commands/plugin/remove.ts @@ -0,0 +1,13 @@ +import ModuleRemove from "../module/remove.js"; +import { PLUGIN_DEPRECATION_NOTICE } from "./deprecation.js"; + +/** Deprecated alias for `quoin module remove`. */ +export default class PluginRemove extends ModuleRemove { + static override summary = "[DEPRECATED] Alias for `quoin module remove`."; + static override hidden = true; + + override async run(): Promise { + this.logToStderr(PLUGIN_DEPRECATION_NOTICE); + await super.run(); + } +} diff --git a/src/commands/review.ts b/src/commands/review.ts new file mode 100644 index 0000000..6ababc5 --- /dev/null +++ b/src/commands/review.ts @@ -0,0 +1,18 @@ +import { FlowCommand } from "../flow-command.js"; + +export default class Review extends FlowCommand { + static summary = "Run a composite spec review workflow."; + static description = `Starts the bundled review workflow run in ~/.ix/flows. Use ix-flow to inspect, +resume, advance phases, and acknowledge human gates.`; + + static examples = ["quoin review --target spec/", "ix-flow status "]; + + static flags = FlowCommand.flowFlags; + + protected readonly flowName = "review"; + + async run(): Promise { + const { flags } = await this.parse(Review); + await this.launch(flags); + } +} diff --git a/src/commands/to-plan.ts b/src/commands/to-plan.ts new file mode 100644 index 0000000..b45398b --- /dev/null +++ b/src/commands/to-plan.ts @@ -0,0 +1,18 @@ +import { FlowCommand } from "../flow-command.js"; + +export default class ToPlan extends FlowCommand { + static summary = "Convert accepted requirements into an implementation plan."; + static description = `Starts the bundled to-plan workflow run in ~/.ix/flows. Use ix-flow to inspect, +resume, advance phases, and acknowledge human gates.`; + + static examples = ["quoin to-plan --target spec/", "ix-flow status "]; + + static flags = FlowCommand.flowFlags; + + protected readonly flowName = "to-plan"; + + async run(): Promise { + const { flags } = await this.parse(ToPlan); + await this.launch(flags); + } +} diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..a760f60 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,49 @@ +import { Flags } from "@oclif/core"; +import { runSelfUpdate } from "@agent-ix/ix-cli-core"; + +import { QuoinCommand } from "../base.js"; +import { packageVersion } from "../version.js"; + +// quoin is published to the public npm registry (see package.json +// publishConfig.registry). `update` defaults here so it resolves the package +// from public npm regardless of the ambient npm config; override with +// --registry for local-dev snapshots (e.g. http://npm.ix/). +const DEFAULT_UPDATE_REGISTRY = "https://registry.npmjs.org/"; + +export default class Update extends QuoinCommand { + static summary = "Check for and install the latest published quoin."; + static description = `quoin is distributed on the public npm registry as @agent-ix/quoin. This command +queries the registry for the latest version, compares it to the running version, +and (unless --check) runs npm install -g to upgrade in place.`; + + static examples = [ + "quoin update", + "quoin update --check", + "quoin update --registry http://npm.ix/", + ]; + + static flags = { + check: Flags.boolean({ + description: "Report whether an update is available; do not install.", + default: false, + }), + registry: Flags.string({ + description: + "Force an npm registry to query/install from. Defaults to the public npm registry (https://registry.npmjs.org/), where @agent-ix/quoin is published. Pass http://npm.ix/ for local dev snapshots.", + }), + }; + + // `update` itself must never trigger the new-version nudge. + protected override skipUpdateNudge = true; + + async run(): Promise { + const { flags } = await this.parse(Update); + await runSelfUpdate({ + packageName: "@agent-ix/quoin", + currentVersion: packageVersion(), + header: "quoin update", + registry: flags.registry ?? DEFAULT_UPDATE_REGISTRY, + check: flags.check === true, + }); + } +} diff --git a/src/commands/write.ts b/src/commands/write.ts new file mode 100644 index 0000000..64883ac --- /dev/null +++ b/src/commands/write.ts @@ -0,0 +1,58 @@ +import { Args, Flags } from "@oclif/core"; + +import { QuoinCommand } from "../base.js"; +import { loadCatalog } from "../catalog.js"; +import { ensureDefaultModules } from "../modules.js"; +import { + createAuthoringPack, + formatAuthoringPack, + parseTypeList, +} from "../write.js"; + +export default class Write extends QuoinCommand { + static summary = + "Build an authoring pack for spec files an agent is about to create or edit."; + static description = `Pass the target repository and the artifact/object types involved in the work. +quoin resolves those types from the active catalog and returns the local +skeletons, schemas, module roots, and Quire validation command for the repo. + +Notes: + - Type lookup is case-insensitive; FR and fr are the same type. + - Types can be artifacts or objects from the default module set or modules. + - Use the returned skeletons and schemas as the authoring contract. + - Run the returned Quire command after editing spec files.`; + + static examples = [ + "quoin write . --types FR", + "quoin write . --types FR,domain,entity", + "quoin write ../my-service --types fr,DOMAIN --json", + ]; + + static args = { + repo_dir: Args.string({ description: "Target repository directory." }), + }; + + static flags = { + types: Flags.string({ + description: "Artifact/object type(s), comma-separated or repeated.", + multiple: true, + }), + json: Flags.boolean({ description: "Emit the authoring pack as JSON." }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(Write); + const repoDir = args.repo_dir; + if (!repoDir) throw new Error("write requires "); + ensureDefaultModules(); + const catalog = loadCatalog(); + const pack = createAuthoringPack( + catalog, + repoDir, + parseTypeList(flags.types), + ); + this.log( + flags.json ? JSON.stringify(pack, null, 2) : formatAuthoringPack(pack), + ); + } +} diff --git a/src/flow-command.ts b/src/flow-command.ts new file mode 100644 index 0000000..fb6fdd5 --- /dev/null +++ b/src/flow-command.ts @@ -0,0 +1,39 @@ +import { Flags } from "@oclif/core"; + +import { QuoinCommand } from "./base.js"; +import { startSpecFlow } from "./flows.js"; + +/** + * Base for the bundled spec-workflow launchers (`review`, `matrix`, `to-plan`). + * + * quoin creates the workflow run in ~/.ix/flows by spawning `ix-flow` (see + * {@link startSpecFlow}); ix-flow then inspects, resumes, advances phases, and + * acknowledges human gates. The subprocess delegation is unchanged from the + * legacy dispatcher — the launcher only translates flags into the ix-flow argv. + */ +export abstract class FlowCommand extends QuoinCommand { + /** Flags shared by every flow launcher; concrete commands reuse this set. */ + static flowFlags = { + target: Flags.string({ + description: "Spec target ref to feed the workflow (repeatable).", + multiple: true, + }), + json: Flags.boolean({ description: "Forward --json to ix-flow." }), + id: Flags.string({ description: "Explicit workflow run id." }), + }; + + /** ix-flow workflow name; equals the command id (review/matrix/to-plan). */ + protected abstract readonly flowName: string; + + protected async launch(flags: { + target?: string[]; + json?: boolean; + id?: string; + }): Promise { + await startSpecFlow(this.flowName, { + json: flags.json === true, + ...(flags.id ? { id: flags.id } : {}), + ...(flags.target ? { target: flags.target } : {}), + }); + } +} diff --git a/src/index.ts b/src/index.ts index 5f1faca..ab6eb55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -export { main } from "./cli.js"; +export { main, isVersionRequest } from "./cli.js"; +export { packageVersion, resolveVersion } from "./version.js"; export { loadCatalog, defaultModuleRoots, @@ -17,3 +18,5 @@ export { formatAuthoringPack, parseTypeList, } from "./write.js"; +export { QuoinCommand } from "./base.js"; +export { FlowCommand } from "./flow-command.js"; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..c0876d7 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Baked at build time from `git describe` (see vite.config.ts). A bare semver +// means a clean tagged release; a `--g` suffix means the build is ahead +// of its tag. Empty for dev/test/no-git builds, which fall back to package.json. +declare const __QUOIN_VERSION__: string; + +function readPackageJsonVersion(): string { + const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const packageJson = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ) as { version?: unknown }; + if (typeof packageJson.version !== "string") { + throw new Error("package.json version is missing"); + } + return packageJson.version; +} + +// Prefer the build-time baked version (truthful about drift); fall back to +// package.json when it is absent (dev/test builds, or a no-git build). +export function resolveVersion(baked: string): string { + if (baked) return baked; + return readPackageJsonVersion(); +} + +export function packageVersion(): string { + return resolveVersion( + typeof __QUOIN_VERSION__ === "string" ? __QUOIN_VERSION__ : "", + ); +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 7821b33..e042946 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,26 +1,74 @@ -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { settings, type Config } from "@oclif/core"; import { stringify as stringifyYaml } from "yaml"; -// The CLI's catalog/write handlers call ensureDefaultModules() internally with -// the committed default-modules.yaml, whose git-subdir sources would hit the +import { loadConfig, run } from "@agent-ix/ix-cli-core"; + +// The catalog/write handlers call ensureDefaultModules() internally with the +// committed default-modules.yaml, whose git-subdir sources would hit the // network. Stub it to a no-op; tests supply the catalog hermetically through -// QUOIN_MODULE_PATHS (read by loadCatalog) instead. +// QUOIN_MODULE_PATHS (read by loadCatalog) instead. The command classes import +// the SAME src/modules module, so this mock applies to them. vi.mock("../src/modules", () => ({ ensureDefaultModules: () => {}, defaultModulesManifest: () => ({ schemaVersion: 1, entries: [] }), - filamentModulesDir: (home: string) => join(home, "filament", "modules"), })); import { main, packageVersion } from "../src/cli"; +import CatalogIndex from "../src/commands/catalog/index"; +import CatalogList from "../src/commands/catalog/list"; +import CatalogShow from "../src/commands/catalog/show"; +import CatalogValidate from "../src/commands/catalog/validate"; +import ModuleIndex from "../src/commands/module/index"; +import ModuleList from "../src/commands/module/list"; +import ModuleInstall from "../src/commands/module/install"; +import ModuleRemove from "../src/commands/module/remove"; +import ModuleEnsureDefaults from "../src/commands/module/ensure-defaults"; +import PluginList from "../src/commands/plugin/list"; +import PluginInstall from "../src/commands/plugin/install"; +import PluginRemove from "../src/commands/plugin/remove"; +import PluginEnsureDefaults from "../src/commands/plugin/ensure-defaults"; +import Write from "../src/commands/write"; +import Review from "../src/commands/review"; +import Matrix from "../src/commands/matrix"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// A single oclif Config rooted at the quoin package; reused so every command is +// instantiated against the real plugin/command graph (FR-016). `Command.run` +// uses the passed class directly, so command bodies execute from src (keeping +// the modules mock effective) while the runner config supplies context. +let config: Config; + +// oclif Command base type narrowed to its static `run`. The command classes are +// concrete subclasses; this captures the shape we invoke in tests. +type RunnableCommand = { + run: (argv: string[], opts: Config) => Promise; +}; + +async function runCmd(Cmd: RunnableCommand, argv: string[]): Promise { + await Cmd.run(argv, config); +} function tmp(prefix: string): string { return mkdtempSync(join(tmpdir(), `quoin-${prefix}-`)); } // ---- console capture helpers ------------------------------------------------- +// oclif's `this.log` routes to console.log and `this.logToStderr` to +// console.error, so the legacy capture helpers still observe command output. function captureLog(): { lines: string[]; restore: () => void } { const lines: string[] = []; @@ -81,12 +129,10 @@ function businessModule(root: string): string { ); } -// Two fixture module roots joined into an QUOIN_MODULE_PATHS value. function modulePaths(...roots: string[]): string { return roots.join(":"); } -// A populated catalog: returns the home dir and sets QUOIN_MODULE_PATHS. function populatedCatalog(): string { const src = tmp("src"); process.env.QUOIN_MODULE_PATHS = modulePaths( @@ -96,7 +142,6 @@ function populatedCatalog(): string { return tmp("home"); } -// A catalog with a duplicate "domain" object type across two modules. function duplicateCatalog(): string { const src = tmp("src"); const extra = makeModule(src, "spec-objects-extra", { @@ -116,6 +161,18 @@ const savedEnv = { }; let savedExitCode: number | undefined; +beforeAll(async () => { + // Under vitest NODE_ENV=test, oclif would auto-transpile and resolve commands + // from src/*.ts (whose ".js" import specifiers don't resolve via raw + // import()). Production loads dist/commands directly; pin that behavior so the + // discovery/dispatch assertions exercise the built command graph. + settings.enableAutoTranspile = false; + if (!existsSync(join(repoRoot, "dist", "commands", "update.js"))) { + execSync("pnpm run build", { cwd: repoRoot, stdio: "inherit" }); + } + config = await loadConfig({ root: repoRoot }); +}); + beforeEach(() => { savedExitCode = process.exitCode; }); @@ -132,88 +189,89 @@ afterEach(() => { process.exitCode = savedExitCode; }); -// ---- main() dispatch --------------------------------------------------------- +// ---- runner / dispatch parity (TC-016, TC-107) ------------------------------- -describe("main dispatch", () => { - test("--version, -v, and the version command all print the package version", async () => { - const c = captureLog(); - try { - await main(["--version"]); - await main(["-v"]); - await main(["version"]); - } finally { - c.restore(); +describe("oclif runner dispatch parity", () => { + test("the runner discovers every migrated command (no legacy dispatcher)", () => { + const ids = new Set(config.commandIDs); + for (const id of [ + "update", + "write", + "review", + "matrix", + "to-plan", + "catalog", + "catalog:list", + "catalog:show", + "catalog:validate", + "module", + "module:list", + "module:install", + "module:remove", + "module:ensure-defaults", + "plugin", + "plugin:list", + "plugin:install", + "plugin:remove", + "plugin:ensure-defaults", + ]) { + expect(ids.has(id)).toBe(true); } - expect(c.lines).toEqual([ - packageVersion(), - packageVersion(), - packageVersion(), - ]); }); - test("no command prints the top-level usage", async () => { - const c = captureLog(); - try { - await main([]); - } finally { - c.restore(); - } - expect(c.lines.join("\n")).toContain("Spec workflow and catalog CLI"); + test("subcommands are space-separated (topicSeparator)", () => { + expect(config.topicSeparator).toBe(" "); }); - test("--help and -h print command help", async () => { - const c = captureLog(); - try { - await main(["plugin", "--help"]); - await main(["write", "-h"]); - await main(["--help"]); // no command -> USAGE - } finally { - c.restore(); - } - expect(c.lines[0]).toContain( - "Install and manage user/community spec modules", - ); - expect(c.lines[1]).toContain("Build an authoring pack"); - expect(c.lines[2]).toContain("Spec workflow and catalog CLI"); + test("the hand-rolled parseArgs dispatcher is gone from cli.ts", () => { + const source = readFileSync(join(repoRoot, "src", "cli.ts"), "utf8"); + expect(source).not.toContain("function parseArgs"); + expect(source).not.toContain("function runCatalog"); + expect(source).not.toContain("function runPlugin"); }); - test("help for a spec-flow command prints the workflow usage", async () => { - const c = captureLog(); - try { - await main(["review", "--help"]); - } finally { - c.restore(); - } - expect(c.lines.join("\n")).toContain("Start bundled spec review/planning"); + test("an unknown command is rejected by the runner", async () => { + await expect(run(["bogus"], config)).rejects.toThrow(); }); - test("--help on an unrecognized command falls back to the top-level usage", async () => { - // command is truthy (so the help branch runs) but matches no known command, - // exercising helpFor's USAGE fallthrough. + test("an unknown subcommand is rejected by the runner", async () => { + await expect(run(["catalog", "bogus"], config)).rejects.toThrow(); + }); +}); + +// ---- version ----------------------------------------------------------------- + +describe("version", () => { + test("--version, -v, and the version command all print the package version", async () => { const c = captureLog(); try { - await main(["bogus", "--help", "--config-root", tmp("home")]); + await main(["--version"]); + await main(["-v"]); + await main(["version"]); } finally { c.restore(); } - expect(c.lines.join("\n")).toContain("Spec workflow and catalog CLI"); + expect(c.lines).toEqual([ + packageVersion(), + packageVersion(), + packageVersion(), + ]); }); - test("throws on an unknown command", async () => { - await expect(main(["bogus", "--config-root", tmp("home")])).rejects.toThrow( - /unknown command bogus/, - ); + test("packageVersion returns a non-empty string", () => { + expect(typeof packageVersion()).toBe("string"); + expect(packageVersion().length).toBeGreaterThan(0); }); }); // ---- catalog ----------------------------------------------------------------- -describe("runCatalog", () => { +describe("catalog", () => { test("list (explicit) prints one line per module", async () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "list", "--config-root", home]); + await runCmd(CatalogList, ["--config-root", home]); } finally { c.restore(); } @@ -223,13 +281,10 @@ describe("runCatalog", () => { }); test("falls back to IX_HOME when --config-root is omitted, and prints 'unknown' for a versionless module", async () => { - // No --config-root -> main uses ixHome() (IX_HOME). A module without a - // version field renders the `?? "unknown"` branch. const src = tmp("src-noversion"); const noVersion = makeModule(src, "spec-objects-noversion", { object_types: [{ name: "thing" }], }); - // Strip the version that makeModule injects. writeFileSync( join(noVersion, "manifest.yaml"), stringifyYaml({ @@ -241,18 +296,18 @@ describe("runCatalog", () => { process.env.IX_HOME = tmp("home"); const c = captureLog(); try { - await main(["catalog", "list"]); + await runCmd(CatalogList, []); } finally { c.restore(); } expect(c.lines.join("\n")).toContain("spec-objects-noversion@unknown"); }); - test("undefined subcommand behaves like list", async () => { + test("the catalog index command behaves like list", async () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "--config-root", home]); + await runCmd(CatalogIndex, ["--config-root", home]); } finally { c.restore(); } @@ -263,7 +318,7 @@ describe("runCatalog", () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "list", "--json", "--config-root", home]); + await runCmd(CatalogList, ["--json", "--config-root", home]); } finally { c.restore(); } @@ -277,7 +332,7 @@ describe("runCatalog", () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "show", "FR", "--config-root", home]); + await runCmd(CatalogShow, ["FR", "--config-root", home]); } finally { c.restore(); } @@ -288,7 +343,7 @@ describe("runCatalog", () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "show", "FR", "--json", "--config-root", home]); + await runCmd(CatalogShow, ["FR", "--json", "--config-root", home]); } finally { c.restore(); } @@ -297,15 +352,15 @@ describe("runCatalog", () => { test("show without a type throws", async () => { const home = populatedCatalog(); - await expect( - main(["catalog", "show", "--config-root", home]), - ).rejects.toThrow("catalog show requires "); + await expect(runCmd(CatalogShow, ["--config-root", home])).rejects.toThrow( + "catalog show requires ", + ); }); test("show of a missing type throws", async () => { const home = populatedCatalog(); await expect( - main(["catalog", "show", "NOPE", "--config-root", home]), + runCmd(CatalogShow, ["NOPE", "--config-root", home]), ).rejects.toThrow("catalog type not found: NOPE"); }); @@ -313,7 +368,7 @@ describe("runCatalog", () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "validate", "--config-root", home]); + await runCmd(CatalogValidate, ["--config-root", home]); } finally { c.restore(); } @@ -325,7 +380,7 @@ describe("runCatalog", () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "validate", "--json", "--config-root", home]); + await runCmd(CatalogValidate, ["--json", "--config-root", home]); } finally { c.restore(); } @@ -336,7 +391,7 @@ describe("runCatalog", () => { const home = duplicateCatalog(); const err = captureError(); try { - await main(["catalog", "validate", "--config-root", home]); + await runCmd(CatalogValidate, ["--config-root", home]); } finally { err.restore(); } @@ -345,24 +400,17 @@ describe("runCatalog", () => { expect(parsed.duplicates[0].name).toBe("domain"); expect(process.exitCode).toBe(1); }); - - test("unknown catalog subcommand throws", async () => { - const home = populatedCatalog(); - await expect( - main(["catalog", "bogus", "--config-root", home]), - ).rejects.toThrow("unknown catalog command bogus"); - }); }); -// ---- plugin ------------------------------------------------------------------ +// ---- module (canonical spec-module command, TC-017) -------------------------- -describe("runPlugin", () => { - test("list (explicit and undefined) prints the plugin registry as JSON", async () => { +describe("module", () => { + test("list and index both print the registry as JSON", async () => { const home = tmp("home"); const c = captureLog(); try { - await main(["plugin", "list", "--config-root", home]); - await main(["plugin", "--config-root", home]); + await runCmd(ModuleList, ["--config-root", home]); + await runCmd(ModuleIndex, ["--config-root", home]); } finally { c.restore(); } @@ -370,12 +418,12 @@ describe("runPlugin", () => { expect(JSON.parse(c.lines[1])).toHaveProperty("plugins"); }); - test("install adds a plugin from a path source", async () => { + test("install adds a module from a path source", async () => { const home = tmp("home"); - const mod = businessModule(tmp("plugin-src")); + const mod = businessModule(tmp("module-src")); const c = captureLog(); try { - await main(["plugin", "install", `path:${mod}`, "--config-root", home]); + await runCmd(ModuleInstall, [`path:${mod}`, "--config-root", home]); } finally { c.restore(); } @@ -385,17 +433,15 @@ describe("runPlugin", () => { test("install without a source throws", async () => { const home = tmp("home"); await expect( - main(["plugin", "install", "--config-root", home]), - ).rejects.toThrow("plugin install requires "); + runCmd(ModuleInstall, ["--config-root", home]), + ).rejects.toThrow("module install requires "); }); test("ensure-defaults runs the installer and reports the registry", async () => { const home = tmp("home"); const c = captureLog(); try { - // ensureDefaultModules is mocked to a no-op, so this exercises the - // command dispatch + output contract without hitting the network. - await main(["plugin", "ensure-defaults", "--config-root", home]); + await runCmd(ModuleEnsureDefaults, ["--config-root", home]); } finally { c.restore(); } @@ -404,15 +450,13 @@ describe("runPlugin", () => { expect(Array.isArray(out.plugins)).toBe(true); }); - test("ensure-defaults reports installed plugin names from a non-empty registry", async () => { + test("ensure-defaults reports installed module names from a non-empty registry", async () => { const home = tmp("home"); - const mod = businessModule(tmp("plugin-src")); + const mod = businessModule(tmp("module-src")); const c = captureLog(); try { - await main(["plugin", "install", `path:${mod}`, "--config-root", home]); - // With a plugin already in the registry, ensure-defaults exercises the - // `listPlugins().map((p) => p.name)` reporting path (cli.ts). - await main(["plugin", "ensure-defaults", "--config-root", home]); + await runCmd(ModuleInstall, [`path:${mod}`, "--config-root", home]); + await runCmd(ModuleEnsureDefaults, ["--config-root", home]); } finally { c.restore(); } @@ -421,15 +465,13 @@ describe("runPlugin", () => { expect(out.plugins).toContain("spec-objects-business"); }); - test("remove deletes a plugin and prints confirmation", async () => { + test("remove deletes a module and prints confirmation", async () => { const home = tmp("home"); - const mod = businessModule(tmp("plugin-src")); + const mod = businessModule(tmp("module-src")); const c = captureLog(); try { - await main(["plugin", "install", `path:${mod}`, "--config-root", home]); - await main([ - "plugin", - "remove", + await runCmd(ModuleInstall, [`path:${mod}`, "--config-root", home]); + await runCmd(ModuleRemove, [ "spec-objects-business", "--config-root", home, @@ -442,26 +484,86 @@ describe("runPlugin", () => { test("remove without a name throws", async () => { const home = tmp("home"); - await expect( - main(["plugin", "remove", "--config-root", home]), - ).rejects.toThrow("plugin remove requires "); + await expect(runCmd(ModuleRemove, ["--config-root", home])).rejects.toThrow( + "module remove requires ", + ); }); +}); - test("unknown plugin subcommand throws", async () => { +// ---- plugin (deprecated alias, TC-017) --------------------------------------- + +describe("plugin (deprecated alias)", () => { + test("plugin list warns to stderr and forwards to module list", async () => { const home = tmp("home"); - await expect( - main(["plugin", "bogus", "--config-root", home]), - ).rejects.toThrow("unknown plugin command bogus"); + const out = captureLog(); + const err = captureError(); + try { + await runCmd(PluginList, ["--config-root", home]); + } finally { + out.restore(); + err.restore(); + } + expect(err.lines.join("\n")).toMatch(/deprecated/i); + expect(JSON.parse(out.lines.join("\n"))).toHaveProperty("plugins"); + }); + + test("plugin install warns and forwards to module install", async () => { + const home = tmp("home"); + const mod = businessModule(tmp("module-src")); + const out = captureLog(); + const err = captureError(); + try { + await runCmd(PluginInstall, [`path:${mod}`, "--config-root", home]); + } finally { + out.restore(); + err.restore(); + } + expect(err.lines.join("\n")).toMatch(/deprecated/i); + expect(JSON.parse(out.lines.join("\n")).name).toBe("spec-objects-business"); + }); + + test("plugin remove warns and forwards to module remove", async () => { + const home = tmp("home"); + const mod = businessModule(tmp("module-src")); + const out = captureLog(); + const err = captureError(); + try { + await runCmd(PluginInstall, [`path:${mod}`, "--config-root", home]); + await runCmd(PluginRemove, [ + "spec-objects-business", + "--config-root", + home, + ]); + } finally { + out.restore(); + err.restore(); + } + expect(err.lines.join("\n")).toMatch(/deprecated/i); + expect(out.lines).toContain("removed spec-objects-business"); + }); + + test("plugin ensure-defaults warns and forwards to module ensure-defaults", async () => { + const home = tmp("home"); + const out = captureLog(); + const err = captureError(); + try { + await runCmd(PluginEnsureDefaults, ["--config-root", home]); + } finally { + out.restore(); + err.restore(); + } + expect(err.lines.join("\n")).toMatch(/deprecated/i); + expect(JSON.parse(out.lines.join("\n")).ensured).toBe(true); }); }); // ---- write ------------------------------------------------------------------- -describe("runWrite", () => { +describe("write", () => { test("missing repo_dir throws", async () => { const home = populatedCatalog(); await expect( - main(["write", "--types", "FR", "--config-root", home]), + runCmd(Write, ["--types", "FR", "--config-root", home]), ).rejects.toThrow(/write requires /); }); @@ -470,8 +572,7 @@ describe("runWrite", () => { const repo = tmp("repo"); const c = captureLog(); try { - await main([ - "write", + await runCmd(Write, [ repo, "--types", "FR,domain", @@ -492,8 +593,7 @@ describe("runWrite", () => { const repo = tmp("repo"); const c = captureLog(); try { - await main([ - "write", + await runCmd(Write, [ repo, "--types", "FR", @@ -510,9 +610,9 @@ describe("runWrite", () => { }); }); -// ---- spec-flow commands via main --------------------------------------------- +// ---- spec-flow launchers ----------------------------------------------------- -describe("spec-flow dispatch", () => { +describe("spec-flow launchers", () => { function packagedRoot(flow: "review" | "matrix" | "to-plan"): string { const packageSkill = { review: "spec-review", @@ -539,8 +639,7 @@ describe("spec-flow dispatch", () => { process.env.IX_SPEC_WORKFLOWS_ROOT = packagedRoot("review"); process.env.PATH = `${fakeIxFlowDir(0)}:${savedEnv.PATH}`; process.exitCode = undefined; - await main([ - "review", + await runCmd(Review, [ "--target", "spec/", "--target", @@ -559,77 +658,45 @@ describe("spec-flow dispatch", () => { process.env.IX_SPEC_WORKFLOWS_ROOT = packagedRoot("matrix"); process.env.PATH = `${fakeIxFlowDir(2)}:${savedEnv.PATH}`; process.exitCode = undefined; - await main(["matrix", "--config-root", home]); + await runCmd(Matrix, ["--config-root", home]); expect(process.exitCode).toBe(2); }); }); -// ---- parseArgs edge cases ---------------------------------------------------- -// parseArgs is private; exercised through main's observable behavior. +// ---- flag parsing (now owned by oclif) --------------------------------------- -describe("parseArgs via main", () => { - test("long flag with = and following-value form both work", async () => { - // --config-root= (eq form) and --types FR (following-value form). +describe("flag parsing", () => { + test("--config-root= (eq form) and repeated/positional flags work", async () => { const home = populatedCatalog(); const repo = tmp("repo"); const c = captureLog(); try { - await main([`--config-root=${home}`, "write", repo, "--types", "FR"]); + await runCmd(Write, [repo, "--types", "FR", `--config-root=${home}`]); } finally { c.restore(); } expect(c.lines.join("\n")).toContain("- FR (artifact)"); }); - test("boolean long flag (no value) is honored", async () => { - // --json with no following non-flag value is treated as boolean true. + test("boolean --json flag (no value) is honored", async () => { const home = populatedCatalog(); const c = captureLog(); try { - await main(["catalog", "list", "--config-root", home, "--json"]); + await runCmd(CatalogList, ["--config-root", home, "--json"]); } finally { c.restore(); } expect(() => JSON.parse(c.lines.join("\n"))).not.toThrow(); }); - test("subcommand is only captured for catalog/plugin; others become positionals", async () => { - // For `write`, the second bareword is a positional (repo_dir), not a subcommand. + test("--no-project-config is accepted", async () => { const home = populatedCatalog(); - const repo = tmp("repo"); const c = captureLog(); try { - await main(["write", repo, "--types", "FR", "--config-root", home]); - } finally { - c.restore(); - } - expect(c.lines.join("\n")).toContain(`Repo: ${repo}`); - }); - - test("--no-project-config short-circuits project config root", async () => { - // Exercises the parsed.flags['no-project-config'] !== true branch (set true). - const home = populatedCatalog(); - const c = captureLog(); - try { - await main([ - "catalog", - "list", - "--no-project-config", - "--config-root", - home, - ]); + await runCmd(CatalogList, ["--no-project-config", "--config-root", home]); } finally { c.restore(); } expect(c.lines.join("\n")).toContain("spec-artifacts-iso"); }); }); - -// ---- packageVersion ---------------------------------------------------------- - -describe("packageVersion", () => { - test("returns a non-empty version string", () => { - expect(typeof packageVersion()).toBe("string"); - expect(packageVersion().length).toBeGreaterThan(0); - }); -}); diff --git a/tests/it-005-sync-discovery.test.ts b/tests/it-005-sync-discovery.test.ts new file mode 100644 index 0000000..933f52a --- /dev/null +++ b/tests/it-005-sync-discovery.test.ts @@ -0,0 +1,61 @@ +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { settings, type Config } from "@oclif/core"; + +import { loadConfig, listCorePlugins } from "@agent-ix/ix-cli-core"; + +// =========================================================================== +// IT-005 / Gate G3 — preinstalled core-plugin discovery (FR-018, NFR-008) +// +// quoin declares `@agent-ix/filament-plan-sync` in its package.json +// `oclif.plugins` array AND as a dependency. ix-cli-core's runner (via +// @oclif/core's Config loader) discovers that package as a *core* plugin and +// surfaces its `sync` command with NO runtime install step. This test asserts +// the discovery wiring end-to-end against the built command graph. +// =========================================================================== + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SYNC_PLUGIN = "@agent-ix/filament-plan-sync"; + +let config: Config; + +beforeAll(async () => { + // Production loads dist/commands directly; pin that behavior so discovery is + // exercised against the built graph (mirrors cli.test.ts). + settings.enableAutoTranspile = false; + if (!existsSync(join(repoRoot, "dist", "commands", "update.js"))) { + execSync("pnpm run build", { cwd: repoRoot, stdio: "inherit" }); + } + // A single loadConfig() — there is deliberately NO install/link step here: + // the plugin must already be discoverable because it ships as a dependency. + config = await loadConfig({ root: repoRoot }); +}); + +describe("IT-005 sync command discovery (Gate G3)", () => { + test("filament-plan-sync is discovered as a CORE plugin contributing `sync`", () => { + const corePlugins = listCorePlugins(config); + const plugin = corePlugins.find((p) => p.name === SYNC_PLUGIN); + expect(plugin).toBeDefined(); + expect(plugin?.type).toBe("core"); + expect(plugin?.commandIDs).toContain("sync"); + }); + + test("the `sync` command resolves with no runtime install step", () => { + const cmd = config.findCommand("sync"); + expect(cmd).toBeDefined(); + // it is contributed by the core plugin, not by quoin's own command dir + expect(cmd?.pluginName).toBe(SYNC_PLUGIN); + expect(cmd?.pluginType).toBe("core"); + }); + + test("an existing host command (catalog) still resolves unchanged", () => { + const cmd = config.findCommand("catalog"); + expect(cmd).toBeDefined(); + // catalog is contributed by quoin itself (the root plugin), not the plugin + expect(cmd?.pluginName).not.toBe(SYNC_PLUGIN); + expect(config.commandIDs).toContain("catalog:list"); + }); +}); diff --git a/tests/scripts.test.ts b/tests/scripts.test.ts index a8d4a9a..deddd52 100644 --- a/tests/scripts.test.ts +++ b/tests/scripts.test.ts @@ -1,6 +1,8 @@ import { execFileSync } from "node:child_process"; import { main, packageVersion } from "../src/cli"; +import CatalogList from "../src/commands/catalog/list"; +import Write from "../src/commands/write"; test("build-tools help exits successfully", () => { const output = execFileSync("node", ["scripts/build-tools.js", "--help"], { @@ -9,35 +11,18 @@ test("build-tools help exits successfully", () => { expect(output).toContain("Build Tools"); }); -test("catalog help explains the installed module dir and default set", async () => { - const output: string[] = []; - const spy = vi.spyOn(console, "log").mockImplementation((message) => { - output.push(String(message)); - }); - try { - await main(["catalog", "--help"]); - } finally { - spy.mockRestore(); - } - const text = output.join("\n"); - expect(text).toContain("~/.ix/filament/modules"); - expect(text).toContain("spec-artifacts-iso"); - expect(text).toContain("spec-objects-security"); +test("catalog help explains the installed module dir", () => { + // Help text is now oclif-generated from the command metadata; assert the + // canonical description still documents the installed module directory. + const help = `${CatalogList.summary}\n${CatalogList.description}`; + expect(help).toContain("~/.ix/filament/modules"); + expect(help).toContain("quoin module install"); }); -test("write help explains authoring packs", async () => { - const output: string[] = []; - const spy = vi.spyOn(console, "log").mockImplementation((message) => { - output.push(String(message)); - }); - try { - await main(["write", "--help"]); - } finally { - spy.mockRestore(); - } - const text = output.join("\n"); - expect(text).toContain("authoring pack"); - expect(text).toContain("quoin write --types"); +test("write help explains authoring packs", () => { + const help = `${Write.summary}\n${Write.description}`; + expect(help).toContain("authoring pack"); + expect(Write.examples.join("\n")).toContain("quoin write"); }); test("version flag prints package version", async () => { diff --git a/tests/update.test.ts b/tests/update.test.ts index 81548a3..180a4e6 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1,24 +1,44 @@ -import { describe, expect, it, vi } from "vitest"; - -// `quoin update` delegates to ix-cli-core's runSelfUpdate. Mock the whole -// module so we assert the delegation contract (package name, flags) without -// touching the network or npm. configureRuntimeContext is also imported by -// main(), so it must be present as a no-op. -// vi.mock is hoisted above module init, so the spy must be created inside -// vi.hoisted (also hoisted) rather than referencing a plain top-level const. +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { Config } from "@oclif/core"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +// `quoin update` delegates to ix-cli-core's runSelfUpdate. Partial-mock the +// module so runSelfUpdate is a spy (no network / npm) while BaseCommand, +// loadConfig, and the rest of the runtime remain real — the Update command is a +// BaseCommand subclass and must run against the genuine oclif lifecycle. const { runSelfUpdate } = vi.hoisted(() => ({ runSelfUpdate: vi.fn(async () => ({ updated: false, latest: "9.9.9" })), })); -vi.mock("@agent-ix/ix-cli-core", () => ({ - configureRuntimeContext: () => {}, - runSelfUpdate, -})); +vi.mock("@agent-ix/ix-cli-core", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runSelfUpdate }; +}); + +import { loadConfig } from "@agent-ix/ix-cli-core"; + +import Update from "../src/commands/update"; +import { packageVersion } from "../src/cli"; -import { main, packageVersion } from "../src/cli"; +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function tmpHome(): string { + return mkdtempSync(join(tmpdir(), "quoin-update-")); +} + +let config: Config; + +beforeAll(async () => { + config = await loadConfig({ root: repoRoot }); +}); describe("quoin update", () => { it("delegates to runSelfUpdate with quoin's package coordinates", async () => { - await main(["update"]); + runSelfUpdate.mockClear(); + await Update.run(["--config-root", tmpHome()], config); expect(runSelfUpdate).toHaveBeenCalledTimes(1); expect(runSelfUpdate).toHaveBeenCalledWith({ packageName: "@agent-ix/quoin", @@ -31,7 +51,7 @@ describe("quoin update", () => { it("defaults the registry to public npm when no --registry is given", async () => { runSelfUpdate.mockClear(); - await main(["update"]); + await Update.run(["--config-root", tmpHome()], config); expect(runSelfUpdate).toHaveBeenCalledWith( expect.objectContaining({ registry: "https://registry.npmjs.org/" }), ); @@ -39,7 +59,7 @@ describe("quoin update", () => { it("passes --check through", async () => { runSelfUpdate.mockClear(); - await main(["update", "--check"]); + await Update.run(["--check", "--config-root", tmpHome()], config); expect(runSelfUpdate).toHaveBeenCalledWith( expect.objectContaining({ check: true }), ); @@ -47,7 +67,10 @@ describe("quoin update", () => { it("passes a custom --registry through", async () => { runSelfUpdate.mockClear(); - await main(["update", "--registry", "http://npm.ix/"]); + await Update.run( + ["--registry", "http://npm.ix/", "--config-root", tmpHome()], + config, + ); expect(runSelfUpdate).toHaveBeenCalledWith( expect.objectContaining({ registry: "http://npm.ix/" }), ); diff --git a/vite.config.ts b/vite.config.ts index a75c80f..f5139e3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,30 @@ export default defineConfig(({ command }) => ({ entry: { index: "src/index.ts", cli: "src/cli.ts", + // oclif discovers commands as individual modules under dist/commands; + // each command file is therefore its own build entry (mirrors the + // canonical ix-cli build). + "commands/update": "src/commands/update.ts", + "commands/write": "src/commands/write.ts", + "commands/review": "src/commands/review.ts", + "commands/matrix": "src/commands/matrix.ts", + "commands/to-plan": "src/commands/to-plan.ts", + "commands/catalog/index": "src/commands/catalog/index.ts", + "commands/catalog/list": "src/commands/catalog/list.ts", + "commands/catalog/show": "src/commands/catalog/show.ts", + "commands/catalog/validate": "src/commands/catalog/validate.ts", + "commands/module/index": "src/commands/module/index.ts", + "commands/module/list": "src/commands/module/list.ts", + "commands/module/install": "src/commands/module/install.ts", + "commands/module/remove": "src/commands/module/remove.ts", + "commands/module/ensure-defaults": + "src/commands/module/ensure-defaults.ts", + "commands/plugin/index": "src/commands/plugin/index.ts", + "commands/plugin/list": "src/commands/plugin/list.ts", + "commands/plugin/install": "src/commands/plugin/install.ts", + "commands/plugin/remove": "src/commands/plugin/remove.ts", + "commands/plugin/ensure-defaults": + "src/commands/plugin/ensure-defaults.ts", }, name: "Ixspec", fileName: (format, entryName) => `${entryName}.js`, From 52f8cd28493415bfd266311885aa068aaed84853 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sun, 28 Jun 2026 11:47:25 -0700 Subject: [PATCH 2/2] Tag sync-discovery test with TC-108 (preinstalled, no runtime install) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/it-005-sync-discovery.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/it-005-sync-discovery.test.ts b/tests/it-005-sync-discovery.test.ts index 933f52a..baeabb7 100644 --- a/tests/it-005-sync-discovery.test.ts +++ b/tests/it-005-sync-discovery.test.ts @@ -43,7 +43,9 @@ describe("IT-005 sync command discovery (Gate G3)", () => { expect(plugin?.commandIDs).toContain("sync"); }); - test("the `sync` command resolves with no runtime install step", () => { + // @requirement TC-108 NFR-008 — fresh install -> `quoin sync` resolves with + // zero extra steps (preinstalled core plugin, no runtime install). + test("TC-108 / NFR-008: the `sync` command resolves with no runtime install step", () => { const cmd = config.findCommand("sync"); expect(cmd).toBeDefined(); // it is contributed by the core plugin, not by quoin's own command dir