diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c356d..7f7b50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ All notable changes to **basou** are recorded here. The project follows without opening a browser or reading the config file by hand. It reads only the config and probes existence + `.basou` presence — it never runs the `basou view --check` redundancy/footprint safety preflight, which stays its - own surface. + own surface. Both `basou portfolio` and `basou portfolio list` list; passing + `--check` (which belongs to `basou view`) prints a pointer to + `basou view --portfolio --check` instead of a bare `unknown option` error. ## 0.34.0 — 2026-07-14 diff --git a/packages/cli/src/commands/portfolio.test.ts b/packages/cli/src/commands/portfolio.test.ts index 7dfc782..62bbff3 100644 --- a/packages/cli/src/commands/portfolio.test.ts +++ b/packages/cli/src/commands/portfolio.test.ts @@ -7,6 +7,7 @@ import { type PortfolioListContext, type PortfolioListResult, renderPortfolioList, + runPortfolioCommand, runPortfolioList, } from "./portfolio.js"; @@ -122,6 +123,53 @@ describe("doRunPortfolioList", () => { }); }); +describe("runPortfolioCommand", () => { + it("lists on the bare invocation (action undefined) and on the explicit `list`", async () => { + const a = join(getDir(), "alpha-planning"); + const configPath = await writeConfig(`workspaces:\n - path: ${a}\n label: alpha\n`); + const ctx: PortfolioListContext = { + configPath, + pathExists: () => true, + isInitialized: () => true, + }; + for (const action of [undefined, "list"] as const) { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runPortfolioCommand(action, {}, ctx); + expect(log.mock.calls[0]?.[0]).toContain("# Portfolio (workspaces you orient across)"); + log.mockRestore(); + } + }); + + it("redirects --check to `basou view --portfolio --check` (exit 1, never lists or reads the config)", async () => { + const prevExit = process.exitCode; + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + // configPath points nowhere: a working redirect must short-circuit before any read. + await runPortfolioCommand(undefined, { check: true }, { configPath: "/nope/nope.yaml" }); + expect(process.exitCode).toBe(1); + expect(err).toHaveBeenCalledWith(expect.stringMatching(/basou view --portfolio --check/)); + expect(log).not.toHaveBeenCalled(); + } finally { + process.exitCode = prevExit; + } + }); + + it("rejects an unknown action with a pointer (exit 1, never lists or reads the config)", async () => { + const prevExit = process.exitCode; + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + await runPortfolioCommand("bogus", {}, { configPath: "/nope/nope.yaml" }); + expect(process.exitCode).toBe(1); + expect(err).toHaveBeenCalledWith(expect.stringMatching(/Unknown portfolio action 'bogus'/)); + expect(log).not.toHaveBeenCalled(); + } finally { + process.exitCode = prevExit; + } + }); +}); + describe("renderPortfolioList", () => { it("marks a healthy master as initialized", () => { const out = renderPortfolioList({ diff --git a/packages/cli/src/commands/portfolio.ts b/packages/cli/src/commands/portfolio.ts index a804fb5..819a19d 100644 --- a/packages/cli/src/commands/portfolio.ts +++ b/packages/cli/src/commands/portfolio.ts @@ -9,6 +9,12 @@ export type PortfolioListOptions = { verbose?: boolean; }; +/** Options for the `basou portfolio` command surface: the list options plus the `--check` redirect flag. */ +export type PortfolioCommandOptions = PortfolioListOptions & { + /** Set by the natural `basou portfolio --check` mistake; prints a pointer to `basou view --portfolio --check`. */ + check?: boolean; +}; + /** * Injectable seams for {@link doRunPortfolioList} so tests stay hermetic: the * config path to read, and the two on-disk probes (existence and `.basou` @@ -63,10 +69,23 @@ export function registerPortfolioCommand(program: Command): void { .description( "List the workspaces you orient across (read-only): every planning master registered in ~/.basou/portfolio.yaml, with its path and whether it exists / is initialized. The headless text/JSON counterpart to the `basou view --portfolio` GUI — for discovering where a sibling project lives without opening a browser", ) + // Accept the explicit `list` spelling as an alias for the bare command, so + // both `basou portfolio` and `basou portfolio list` work (an agent reaching + // for a `list` sub-verb should not hit a `too many arguments` foot-gun). Any + // other word is a typo — rejected with a pointer, not silently listed. + .argument("[action]", "optional literal `list` (the only, and default, action)") .option("--json", "Output the result as JSON") + // `--check` belongs to `basou view` (the redundancy/footprint safety + // preflight). It is declared here ONLY to turn the natural `basou portfolio + // --check` mistake into a pointer instead of a bare `unknown option` error; + // it never runs a preflight (the scope boundary vs `view --check` holds). + .option( + "--check", + "moved: the redundancy/footprint safety preflight is `basou view --portfolio --check` (this prints that pointer and exits)", + ) .option("-v, --verbose", "Show error causes") - .action(async (opts: PortfolioListOptions) => { - await runPortfolioList(opts); + .action(async (action: string | undefined, opts: PortfolioCommandOptions) => { + await runPortfolioCommand(action, opts); }); } @@ -79,6 +98,36 @@ function isDirectory(path: string): boolean { } } +/** + * Dispatch the `basou portfolio [action]` surface. Bare and the explicit `list` + * spelling both list; `--check` is redirected to `basou view --portfolio + * --check` (this command never runs the preflight); any other action word is a + * typo, rejected with a pointer. Non-list branches write to stderr and set + * exitCode 1 (the CLI's usage-error convention, matching commander's own + * unknown-option/argument exit) rather than throwing. + */ +export async function runPortfolioCommand( + action: string | undefined, + options: PortfolioCommandOptions, + ctx: PortfolioListContext = {}, +): Promise { + if (options.check === true) { + console.error( + "`basou portfolio` is a read-only listing; it has no safety preflight.\nRun `basou view --portfolio --check` for the redundancy/footprint check.", + ); + process.exitCode = 1; + return; + } + if (action !== undefined && action !== "list") { + console.error( + `Unknown portfolio action '${action}'. Run \`basou portfolio\` or \`basou portfolio list\` to list the registered workspaces.`, + ); + process.exitCode = 1; + return; + } + await runPortfolioList(options, ctx); +} + /** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunPortfolioList}. */ export async function runPortfolioList( options: PortfolioListOptions,