diff --git a/package.json b/package.json index 24841e9..99ba551 100644 --- a/package.json +++ b/package.json @@ -42,16 +42,13 @@ "@agent-ix/ts-plugin-kit": ">=0.1.3", "@clack/prompts": ">=1.5.1", "@napi-rs/keyring": ">=1.3.0", + "@oclif/core": ">=4.11.4", "age-encryption": ">=0.3.0", "react": ">=19.2.7", "yaml": ">=2.9.0", "zod": ">=4.4.3" }, - "peerDependencies": { - "@oclif/core": ">=4.11.4" - }, "devDependencies": { - "@oclif/core": ">=4.11.4", "@types/node": ">=25.9.2", "@types/react": ">=19.2.17", "@typescript-eslint/eslint-plugin": ">=8.60.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9097561..7b1236a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@napi-rs/keyring': specifier: '>=1.3.0' version: 1.3.0 + '@oclif/core': + specifier: '>=4.11.4' + version: 4.11.4 age-encryption: specifier: '>=0.3.0' version: 0.3.0 @@ -33,9 +36,6 @@ importers: specifier: '>=4.4.3' version: 4.4.3 devDependencies: - '@oclif/core': - specifier: '>=4.11.4' - version: 4.11.4 '@types/node': specifier: '>=25.9.2' version: 25.9.2 diff --git a/spec/functional/FR-025-oclif-runner-host.md b/spec/functional/FR-025-oclif-runner-host.md new file mode 100644 index 0000000..6972978 --- /dev/null +++ b/spec/functional/FR-025-oclif-runner-host.md @@ -0,0 +1,70 @@ +--- +id: FR-025 +title: "oclif Runner and Core-Plugin Host" +type: FR +relationships: + - target: "ix://agent-ix/ix-cli-core/spec/stakeholder/StR-003" + type: "implements" + cardinality: "1:1" +--- + +## Description + +ix-cli-core SHALL provide the oclif entry points a consuming CLI needs to run, +so that a binary composed per [FR-010](./FR-010-cli-binary-composition.md) ships +a thin `bin` script rather than re-deriving oclif wiring. The runner is a +wrapper over `@oclif/core`: command discovery and core-plugin discovery are +performed by oclif's own `Config` loader, and ix-cli-core adds no registry, +manifest loader, or plugin resolution of its own. + +`run(argv?, options?)` loads the consuming CLI's `Config` from `options` and +dispatches the requested command, returning the command's result. It SHALL +throw on error rather than calling `process.exit`, so it is safe to drive from +a test. `argv` SHALL default to `process.argv.slice(2)`. + +`execute(options)` is the load-and-run entry point for a top-level bin: it +loads the config, runs the command, flushes output, and owns error handling and +the process exit code. + +`loadConfig(options?)` SHALL expose the resolved plugin and command graph +without dispatching, so a CLI or a test can introspect what oclif discovered. +The returned `Config` SHALL be accepted back by `run` as its `options`, so a +caller resolves the graph at most once. + +`listCorePlugins(config)` SHALL report the core plugins loaded into a config — +those the host declared in `oclif.plugins` and oclif resolved from its +dependencies — excluding the root host plugin itself, for diagnostics and for +asserting plugin-host wiring. + +Only **bundled** core plugins are in scope. ix-cli-core SHALL NOT import +`@oclif/plugin-plugins`; runtime, user-installed plugins are out of scope. + +A `BaseCommand` subclass contributed by either the host or a core plugin SHALL +run unchanged through the runner: its base flags (`--config-root`, +`--no-project-config`) and its capability resolution +([FR-013](./FR-013-per-command-capability-binding.md)) are wired through the +oclif lifecycle, so a command whose required capability is unavailable is +short-circuited before its `run` body executes. + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ----------- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | +| FR-025-AC-1 | A loaded config exposes both the host's own commands and the commands contributed by a declared core plugin | Test | +| FR-025-AC-2 | A host `BaseCommand` subclass runs end-to-end through the runner with its base flags parsed | Test | +| FR-025-AC-3 | A command contributed by a core plugin runs through the runner | Test | +| FR-025-AC-4 | A command whose required capability is unavailable is short-circuited before its `run` body executes | Test | +| FR-025-AC-5 | `run` rejects on a command error rather than calling `process.exit`, so a caller or test observes the failure | Test | +| FR-025-AC-6 | A `Config` obtained from `loadConfig` is accepted by `run` as its `options`, dispatching without re-resolving the graph | Test | +| FR-025-AC-7 | `listCorePlugins` reports declared core plugins and excludes the root host plugin | Test | +| FR-025-AC-8 | ix-cli-core declares no dependency on `@oclif/plugin-plugins` | Inspection | + +## Dependencies + +- **Upstream**: [StR-003](../stakeholder/StR-003-reusable-cli-runtime.md) + (reusable CLI runtime — "no bespoke per-CLI re-implementation"). + Consumes [FR-013](./FR-013-per-command-capability-binding.md) for the + capability wiring the runner inherits. +- **Downstream**: [FR-010](./FR-010-cli-binary-composition.md) — a binary + composed per FR-010 runs on this runner; consuming CLIs (e.g. + `@agent-ix/quoin`) depend on `run` / `loadConfig` being exported. diff --git a/spec/functional/index.md b/spec/functional/index.md index 1ea7225..3b724d6 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -31,3 +31,5 @@ description: "Index of artifacts in this directory." - [FR-021: Bootstrap Into Preferred Agent](./FR-021-bootstrap-into-agent.md) - [FR-022: Preferred-Agent Config and Interactive Chooser](./FR-022-agent-config-chooser.md) - [FR-023: Self-Update Helper](./FR-023-self-update-helper.md) +- [FR-024: Update Notifier](./FR-024-update-notifier.md) +- [FR-025: oclif Runner and Core-Plugin Host](./FR-025-oclif-runner-host.md) diff --git a/spec/tests.md b/spec/tests.md index 20e0033..9fb75ab 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -53,16 +53,17 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | `tests/agent.test.ts` | FR-020, FR-021, FR-022, NFR-007 | | `tests/self-update.test.ts` | FR-023 | | `tests/update-notifier.test.ts` | FR-024 | +| `tests/runner.test.ts` | FR-025 | --- ## Stakeholder Requirement Coverage -| Stakeholder Req | Trace to FR/NFR | Coverage Status | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| StR-001 (pluggable config) | FR-001, FR-002, FR-003, FR-004, FR-008, NFR-003 | ✅ Unit + static | -| StR-002 (secrets never plaintext) | FR-005, FR-006, FR-007, FR-009, NFR-001, NFR-002, NFR-004 | ✅ Unit + static (keyring round-trip via CI matrix) | -| StR-003 (reusable runtime) | FR-010, FR-011, FR-012, FR-013, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, FR-020, FR-021, FR-022, FR-023, FR-024 | ⚠️ FR-013/14/15/16/17/18/19 unit-covered; FR-020/021/022 (agent bootstrap) + FR-023 (self-update) + FR-024 (update notifier) unit-covered; FR-010/011/012 BaseCommand wiring covered at host-binary level | +| Stakeholder Req | Trace to FR/NFR | Coverage Status | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| StR-001 (pluggable config) | FR-001, FR-002, FR-003, FR-004, FR-008, NFR-003 | ✅ Unit + static | +| StR-002 (secrets never plaintext) | FR-005, FR-006, FR-007, FR-009, NFR-001, NFR-002, NFR-004 | ✅ Unit + static (keyring round-trip via CI matrix) | +| StR-003 (reusable runtime) | FR-010, FR-011, FR-012, FR-013, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, FR-020, FR-021, FR-022, FR-023, FR-024, FR-025 | ⚠️ FR-013/14/15/16/17/18/19 unit-covered; FR-020/021/022 (agent bootstrap) + FR-023 (self-update) + FR-024 (update notifier) + FR-025 (oclif runner) unit-covered; FR-010/011/012 BaseCommand wiring covered at host-binary level | ## User Story Coverage @@ -154,7 +155,7 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | FR-013 | AC-3: optional missing never blocks; resolved surfaced | `capabilities.test.ts` — "does not block on missing optional capabilities" | ✅ Unit | | FR-013 | AC-4: resolver reads through Config/Secrets context | `capabilities.test.ts` — provider context | ✅ Unit | | FR-013 | AC-5: capability errors carry machine-readable code | `capabilities.test.ts` — "preserves provider errors"; `capabilityErrorToJson` | ✅ Unit | -| FR-014 | AC-1..AC-7: ixSchema convention + registration | `plugin-schema.test.ts` — "registerPluginSchema — FR-025 oclif ixSchema convention" | ✅ Unit | +| FR-014 | AC-1..AC-7: ixSchema convention + registration | `plugin-schema.test.ts` — "registerPluginSchema — FR-014 ixSchema plugin convention" | ✅ Unit | | FR-015 | AC-1/AC-2: host normalization (https default, port preserved) | `auth-discovery.test.ts` — "assumes https for a bare host…", "preserves an explicit port" | ✅ Unit | | FR-015 | AC-3: http refused unless dev.ix / insecure | `auth-discovery.test.ts` — "allows http for \*.dev.ix hosts", "rejects http for non-dev.ix hosts unless insecure" | ✅ Unit | | FR-015 | AC-4: GET well-known returns parsed doc | `auth-discovery.test.ts` — "GETs the well-known path and returns the parsed doc" | ✅ Unit | @@ -208,6 +209,14 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | FR-024 | AC-4: newer + decline → `updateAvailable:true, updated:false`, no install | `update-notifier.test.ts` — "reports availability but does not install when the user declines" | ✅ Unit | | FR-024 | AC-5: equal or dev-build-ahead → `updateAvailable:false`, never prompts | `update-notifier.test.ts` — "does not prompt when already on the latest" / "does not prompt a dev build that is ahead of the published version" | ✅ Unit | | FR-024 | AC-6: success/failure records `lastCheck` (throttles next); registry failure → `reason:"error"` | `update-notifier.test.ts` — "records the check in the cache…" / "swallows a registry failure and throttles without breaking the host" | ✅ Unit | +| FR-025 | AC-1: host + core-plugin commands both discovered | `runner.test.ts` — "discovers the host commands AND the core-plugin's commands" | ✅ Unit | +| FR-025 | AC-2: host BaseCommand runs end-to-end with base flags parsed | `runner.test.ts` — "runs a host BaseCommand subclass end-to-end, with base flags parsed" | ✅ Unit | +| FR-025 | AC-3: core-plugin command runs through the runner | `runner.test.ts` — "runs a command contributed by the core plugin via the runner" | ✅ Unit | +| FR-025 | AC-4: unavailable required capability short-circuits before `run` | `runner.test.ts` — "short-circuits a command whose required capability is unavailable" | ✅ Unit | +| FR-025 | AC-5: `run` rejects on error rather than calling `process.exit` | `runner.test.ts` — "rejects on a command error rather than exiting the process" | ✅ Unit | +| FR-025 | AC-6: a `loadConfig` Config is accepted back by `run` as its options | `runner.test.ts` — every dispatch case passes the pre-loaded config into `run` | ✅ Unit | +| FR-025 | AC-7: `listCorePlugins` reports core plugins, excluding the root host | `runner.test.ts` — "lists core plugins without the root host plugin" | ✅ Unit | +| FR-025 | AC-8: no `@oclif/plugin-plugins` dependency | static check (`package.json` declares no `@oclif/plugin-plugins`) | ⚠️ Static | ## Non-Functional Requirement Coverage diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 0ed56fa..46d00df 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -61,6 +61,11 @@ export abstract class BaseCommand extends Command { projectConfigRoot: noProject ? undefined : join(process.cwd(), ".ix"), projectConfigEnabled: noProject !== true, }); + // Enforce declared capability requirements as part of the oclif lifecycle + // so the runner (FR-025) short-circuits commands whose required + // capabilities are unavailable before `run()` executes. `prerun()` is a + // no-op for commands that declare no capabilities. + await this.prerun(); } public async prerun(): Promise { diff --git a/src/index.ts b/src/index.ts index 2b5cf8f..d3057bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,7 +47,7 @@ export { type ConfigIncident, } from "./config/registry.js"; -// ── ixSchema plugin convention (FR-025 revised) ──────────────────────── +// ── ixSchema plugin convention (FR-014) ─────────────────────────────── export { registerPluginSchema, getRegisteredPluginSchema, @@ -62,6 +62,19 @@ export { export { BaseCommand } from "./commands/base-command.js"; export type { CommandCapabilities } from "./runtime/capability-spec.js"; +// ── oclif runner + core-plugin host (FR-025) ─────────────────────────── +// Lets a consuming CLI (quoin) run BaseCommand subclasses and commands +// contributed by packages declared as oclif core plugins via a single +// `import { run } from "@agent-ix/ix-cli-core"; run()` entry point. +export { + run, + execute, + loadConfig, + listCorePlugins, + type RunnerLoadOptions, + type CorePluginInfo, +} from "./runtime/runner.js"; + // ── Marketplace adapter over @agent-ix/ts-plugin-kit (FR-019) ────────── // Thin wiring: ix-cli-core adapts the external marketplace library (cache // layout + oclif command-plugin bridge); it does NOT implement an installer. diff --git a/src/plugins/schema.ts b/src/plugins/schema.ts index d71c048..fde2024 100644 --- a/src/plugins/schema.ts +++ b/src/plugins/schema.ts @@ -7,7 +7,7 @@ import type { SecretDeclaration } from "../secrets/types.js"; /** * Convention shape exposed by an IX-compatible plugin from its package - * main as the named export `ixSchema`. See FR-025. + * main as the named export `ixSchema`. See FR-014. * * The host's `init` hook walks the oclif-loaded plugin list, reads each * plugin's `ixSchema`, and registers schemas with `ConfigService` / diff --git a/src/runtime/runner.ts b/src/runtime/runner.ts new file mode 100644 index 0000000..46393a6 --- /dev/null +++ b/src/runtime/runner.ts @@ -0,0 +1,115 @@ +import { + Config, + execute as oclifExecute, + run as oclifRun, + type Interfaces, +} from "@oclif/core"; + +/** + * oclif runner + core-plugin host for IX CLIs (FR-025). + * + * A consuming binary (e.g. quoin) ships a thin `bin` script that simply + * delegates to this runner: + * + * ```js + * #!/usr/bin/env node + * import { run } from "@agent-ix/ix-cli-core"; + * await run(undefined, import.meta.url); + * ``` + * + * The runner is a wafer-thin wrapper over `@oclif/core`. Command discovery + * (the consumer's own `oclif.commands` dir) and **core-plugin** discovery + * (packages listed in the consumer's `package.json` `oclif.plugins` array + * that are also declared as `dependencies`) are performed by `@oclif/core`'s + * own `Config` loader. ix-cli-core never imports `@oclif/plugin-plugins`: + * runtime, user-installed plugins are out of scope — only **bundled** core + * plugins shipped as dependencies of the host CLI are loaded. + * + * `BaseCommand` subclasses contributed by either the host or a core plugin + * run unchanged: their base flags (`--config-root`, `--no-project-config`) + * and capability hooks are wired through `init()`/`prerun()` exactly as they + * are when run directly. + */ + +/** + * Options accepted by {@link run}: a pre-loaded {@link Config}, a directory / + * file-URL string (e.g. `import.meta.url`), an oclif `Options` object, or + * `undefined` to fall back to the caller's module location. + */ +export type RunnerLoadOptions = Interfaces.LoadOptions; + +/** + * Run an IX CLI from `argv`. + * + * Loads the consuming CLI's oclif {@link Config} from `options` (resolving its + * `commands` dir and core `plugins`), then dispatches the requested command. + * Returns the command's result; throws on error (it does **not** call + * `process.exit`, so it is safe to use in tests). Use {@link execute} for a + * top-level bin that should handle errors and set the process exit code. + * + * @param argv argument vector (defaults to `process.argv.slice(2)`) + * @param options config source — defaults to oclif's own resolution + */ +export async function run( + argv?: string[], + options?: RunnerLoadOptions, +): Promise { + return oclifRun(argv ?? process.argv.slice(2), options); +} + +/** + * Load-and-run entry point for a top-level bin script. + * + * Thin pass-through to `@oclif/core`'s `execute`, which loads the config from + * `dir` (typically `import.meta.url`), runs the command, flushes output, and + * handles errors / process exit codes. Prefer {@link run} in tests. + */ +export async function execute(options: { + args?: string[]; + development?: boolean; + dir?: string; + loadOptions?: RunnerLoadOptions; +}): Promise { + return oclifExecute(options); +} + +/** + * Load the consuming CLI's oclif {@link Config} without running a command. + * + * Exposes the host's resolved plugin/command graph so a CLI (or a test) can + * introspect what was discovered — including core plugins — before dispatch. + * The returned config can be passed straight back into {@link run} as + * `options` to avoid re-resolving. + */ +export async function loadConfig(options?: RunnerLoadOptions): Promise { + return Config.load(options); +} + +/** A core plugin discovered and loaded by the host. */ +export interface CorePluginInfo { + /** Package name of the plugin. */ + name: string; + /** Absolute path to the plugin package root. */ + root: string; + /** oclif plugin type — `core` for bundled host plugins. */ + type: string; + /** Command ids contributed by this plugin. */ + commandIDs: string[]; +} + +/** + * List the **core plugins** loaded into a {@link Config} (excludes the root/host + * plugin itself). These are the packages from the host's `oclif.plugins` that + * `@oclif/core` resolved from its dependencies. Useful for `doctor`/diagnostic + * output and for asserting plugin-host wiring in tests. + */ +export function listCorePlugins(config: Config): CorePluginInfo[] { + return [...config.plugins.values()] + .filter((p) => !p.isRoot && p.type === "core") + .map((p) => ({ + name: p.name, + root: p.root, + type: p.type, + commandIDs: [...p.commandIDs], + })); +} diff --git a/tests/plugin-schema.test.ts b/tests/plugin-schema.test.ts index e643b1d..c9b70df 100644 --- a/tests/plugin-schema.test.ts +++ b/tests/plugin-schema.test.ts @@ -29,7 +29,7 @@ afterEach(() => { _resetPluginSchemaRegistryForTests(); }); -describe("registerPluginSchema — FR-025 oclif ixSchema convention", () => { +describe("registerPluginSchema — FR-014 ixSchema plugin convention", () => { it("registers ixSchema config and secrets into the runtime registries", () => { const result = registerPluginSchema("@agent-ix/workflow-cli-plugin", { id: "workflow", diff --git a/tests/runner.test.ts b/tests/runner.test.ts new file mode 100644 index 0000000..b7a6a8b --- /dev/null +++ b/tests/runner.test.ts @@ -0,0 +1,217 @@ +import { execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { listCorePlugins, loadConfig, run } from "../src/index.js"; + +/** + * FR-015 / TC-015 — oclif runner + core-plugin host. + * + * Builds a throwaway "consumer CLI" on disk (a host package with its own + * `oclif.commands` dir plus a bundled core plugin declared in `oclif.plugins`) + * and drives it through the exported {@link run} runner. The fixture command + * modules are loaded natively by `@oclif/core`, so they import `BaseCommand` + * from the built package via a symlink into the fixture's `node_modules` — + * exactly how a real consumer (quoin) imports it. + */ + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const distEntry = join(repoRoot, "dist", "index.js"); + +let tmp: string; + +const consumerPkg = { + name: "@ixcc-fixture/consumer", + version: "0.0.0", + type: "module", + bin: { ixfix: "./bin/run.js" }, + oclif: { + bin: "ixfix", + commands: "./commands", + // Declare the bundled package as an oclif *core plugin*. oclif matches + // this against `dependencies` and loads it from node_modules. + plugins: ["@ixcc-fixture/hello-plugin"], + }, + dependencies: { "@ixcc-fixture/hello-plugin": "*" }, +}; + +const pluginPkg = { + name: "@ixcc-fixture/hello-plugin", + version: "0.0.0", + type: "module", + oclif: { commands: "./commands" }, +}; + +// Host command: a BaseCommand subclass. Writes its parsed base flags to a file +// so the test can assert end-to-end dispatch AND base-flag plumbing. +const greetCmd = ` +import { BaseCommand } from "@agent-ix/ix-cli-core"; +import { Flags } from "@oclif/core"; +import { writeFileSync } from "node:fs"; + +export default class Greet extends BaseCommand { + static description = "greet (host BaseCommand subclass)"; + static flags = { out: Flags.string({ required: true }) }; + async run() { + const { flags } = await this.parse(Greet); + writeFileSync( + flags.out, + "greet|config-root=" + + (flags["config-root"] ?? "") + + "|no-project=" + + flags["no-project-config"], + ); + this.log("greet ran"); + } +} +`; + +// Core-plugin command: also a BaseCommand subclass, contributed by the plugin. +const helloCmd = ` +import { BaseCommand } from "@agent-ix/ix-cli-core"; +import { Flags } from "@oclif/core"; +import { writeFileSync } from "node:fs"; + +export default class Hello extends BaseCommand { + static description = "hello (contributed by a core plugin)"; + static flags = { out: Flags.string({ required: true }) }; + async run() { + const { flags } = await this.parse(Hello); + writeFileSync(flags.out, "hello-plugin ran"); + } +} +`; + +// Host command declaring an unsatisfiable required capability. Used to prove +// the capability hook (prerun) fires through the runner's lifecycle. +const guardedCmd = ` +import { BaseCommand } from "@agent-ix/ix-cli-core"; + +export default class Guarded extends BaseCommand { + static description = "requires the github capability"; + static capabilities = { required: ["github"] }; + async run() { + this.log("should never run"); + } +} +`; + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, JSON.stringify(value, null, 2)); +} + +beforeAll(() => { + // The fixture command modules import the *built* package, mirroring a real + // consumer. Ensure dist exists (CI builds before test; build on demand for a + // clean local checkout). + if (!existsSync(distEntry)) { + execSync("pnpm run build", { cwd: repoRoot, stdio: "inherit" }); + } + + tmp = mkdtempSync(join(tmpdir(), "ixcc-runner-")); + + // ── host package ────────────────────────────────────────────────────── + writeJson(join(tmp, "package.json"), consumerPkg); + mkdirSync(join(tmp, "commands"), { recursive: true }); + writeFileSync(join(tmp, "commands", "greet.js"), greetCmd); + writeFileSync(join(tmp, "commands", "guarded.js"), guardedCmd); + + // ── node_modules: symlink the package under test + @oclif/core ───────── + mkdirSync(join(tmp, "node_modules", "@agent-ix"), { recursive: true }); + mkdirSync(join(tmp, "node_modules", "@oclif"), { recursive: true }); + mkdirSync(join(tmp, "node_modules", "@ixcc-fixture"), { recursive: true }); + symlinkSync( + repoRoot, + join(tmp, "node_modules", "@agent-ix", "ix-cli-core"), + "dir", + ); + symlinkSync( + join(repoRoot, "node_modules", "@oclif", "core"), + join(tmp, "node_modules", "@oclif", "core"), + "dir", + ); + + // ── core plugin (bundled dependency) ─────────────────────────────────── + const pluginRoot = join(tmp, "node_modules", "@ixcc-fixture", "hello-plugin"); + mkdirSync(join(pluginRoot, "commands"), { recursive: true }); + writeJson(join(pluginRoot, "package.json"), pluginPkg); + writeFileSync(join(pluginRoot, "commands", "hello.js"), helloCmd); +}); + +afterAll(() => { + if (tmp) rmSync(tmp, { recursive: true, force: true }); +}); + +describe("oclif runner + core-plugin host (FR-025)", () => { + it("discovers the host commands AND the core-plugin's commands", async () => { + const config = await loadConfig({ root: tmp }); + + const ids = config.commandIDs; + expect(ids).toContain("greet"); // host command + expect(ids).toContain("hello"); // contributed by the core plugin + + const core = listCorePlugins(config); + const plugin = core.find((p) => p.name === "@ixcc-fixture/hello-plugin"); + expect(plugin).toBeDefined(); + expect(plugin?.type).toBe("core"); + expect(plugin?.commandIDs).toContain("hello"); + }); + + it("runs a host BaseCommand subclass end-to-end, with base flags parsed", async () => { + const config = await loadConfig({ root: tmp }); + const out = join(tmp, "greet.out"); + + await run(["greet", "--out", out, "--config-root", "/custom/root"], config); + + expect(readFileSync(out, "utf8")).toBe( + "greet|config-root=/custom/root|no-project=false", + ); + }); + + it("runs a command contributed by the core plugin via the runner", async () => { + const config = await loadConfig({ root: tmp }); + const out = join(tmp, "hello.out"); + + await run(["hello", "--out", out], config); + + expect(readFileSync(out, "utf8")).toBe("hello-plugin ran"); + }); + + it("short-circuits a command whose required capability is unavailable", async () => { + const config = await loadConfig({ root: tmp }); + // The capability hook (prerun) runs in BaseCommand.init via the runner; + // with no provider registered, `github` is unavailable and the command + // must error before its run() body executes. + await expect(run(["guarded"], config)).rejects.toThrow(); + }); + + it("lists core plugins without the root host plugin", async () => { + const config = await loadConfig({ root: tmp }); + const core = listCorePlugins(config); + + // The host itself is a plugin in oclif's graph; a core-plugin listing that + // included it would misreport the host as one of its own dependencies. + expect(core.map((p) => p.name)).toEqual(["@ixcc-fixture/hello-plugin"]); + expect(core.every((p) => p.type === "core")).toBe(true); + expect(config.plugins.size).toBeGreaterThan(core.length); + }); + + it("rejects on a command error rather than exiting the process", async () => { + const config = await loadConfig({ root: tmp }); + // run() must be safe to drive from a test: a failure surfaces as a + // rejection the caller can catch, never as a process.exit that would take + // the test runner down with it. + await expect(run(["no-such-command"], config)).rejects.toThrow(); + }); +});