diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index 5d55041d0f..65eb80cb3a 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -1,7 +1,7 @@ import { constants } from "node:buffer"; import type { Writable } from "node:stream"; -import { Cli, Filter, Formatter } from "incur"; +import { Filter, Formatter } from "incur"; import { estimateTokenCount, sliceByTokens } from "tokenx"; import { @@ -23,6 +23,10 @@ import { writeOutputText, } from "./jsonOutput.js"; import type { CliInstance } from "./types.js"; +import { + visitRegisteredCommands, + type RegisteredCommand, +} from "./visitRegisteredCommands.js"; /** Result metadata supplied by the command that actually ran. */ export interface CliResultOutputMetadata { @@ -41,40 +45,18 @@ export interface CliResultOutput { recoverFormattingFailure(cause: unknown): Promise; } -type CommandMap = NonNullable>; -type CommandDefinition = Extract< - CommandMap extends Map ? Entry : never, - { run: unknown } ->; -const registeredCommands: WeakMap = Cli.toCommands; - /** Install formatting once, after registration, for every ordinary CLI result. */ export const streamCliCommandResults = ( cli: CliInstance, output: CliResultOutput, ): void => { - const commands = registeredCommands.get(cli); - if (commands !== undefined) wrapCommands(commands, [], output); -}; - -const wrapCommands = ( - commands: CommandMap, - parents: readonly string[], - output: CliResultOutput, -): void => { - for (const [name, entry] of commands) { - const path = [...parents, name]; - if ("_group" in entry) { - wrapCommands(entry.commands, path, output); - if (entry.root !== undefined) - wrapCommand(entry.root, path.join(" "), output); - } else if (!("_alias" in entry) && !("_fetch" in entry)) - wrapCommand(entry, path.join(" "), output); - } + visitRegisteredCommands(cli, (command, path) => + wrapCommand(command, path, output), + ); }; const wrapCommand = ( - command: Pick, + command: Pick, name: string, output: CliResultOutput, ): void => { diff --git a/src/cli/omittableDefaults.ts b/src/cli/omittableDefaults.ts index eaf11c503f..603a7265d8 100644 --- a/src/cli/omittableDefaults.ts +++ b/src/cli/omittableDefaults.ts @@ -1,15 +1,12 @@ -import { Cli, z } from "incur"; +import { z } from "incur"; import type { CliInstance } from "./types.js"; +import { visitRegisteredCommands } from "./visitRegisteredCommands.js"; -type CommandMap = NonNullable>; type CommandInputs = Partial>; const INPUT_GROUPS = ["args", "env", "options"] as const; -// Incur types its router key without vars, env, or globals; accept any CLI. -const registeredCommands: WeakMap = Cli.toCommands; - /** * Advertise parser-filled defaults as omittable CLI inputs. * @@ -20,18 +17,7 @@ const registeredCommands: WeakMap = Cli.toCommands; * value stay unchanged. */ export const presentOmittableDefaults = (cli: CliInstance): void => { - const commands = registeredCommands.get(cli); - if (commands !== undefined) presentCommands(commands); -}; - -const presentCommands = (commands: CommandMap): void => { - for (const entry of commands.values()) { - if ("_group" in entry) { - presentCommands(entry.commands); - if (entry.root !== undefined) presentInputs(entry.root); - } else if (!("_alias" in entry) && !("_fetch" in entry)) - presentInputs(entry); - } + visitRegisteredCommands(cli, (command) => presentInputs(command)); }; const presentInputs = (command: CommandInputs): void => { diff --git a/src/cli/visitRegisteredCommands.ts b/src/cli/visitRegisteredCommands.ts new file mode 100644 index 0000000000..895f9e16a9 --- /dev/null +++ b/src/cli/visitRegisteredCommands.ts @@ -0,0 +1,35 @@ +import { Cli } from "incur"; + +import type { CliInstance } from "./types.js"; + +type CommandMap = NonNullable>; +type CommandEntry = CommandMap extends Map ? Entry : never; +export type RegisteredCommand = Extract; + +// Incur types its router key without vars, env, or globals; accept any CLI. +const registeredCommands: WeakMap = Cli.toCommands; + +/** Visit ordinary commands in map order, children before each group's root. */ +export const visitRegisteredCommands = ( + cli: CliInstance, + visit: (command: RegisteredCommand, path: string) => void, +): void => { + const commands = registeredCommands.get(cli); + if (commands !== undefined) visitCommands(commands, [], visit); +}; + +const visitCommands = ( + commands: CommandMap, + parents: readonly string[], + visit: (command: RegisteredCommand, path: string) => void, +): void => { + for (const [name, entry] of commands) { + const path = [...parents, name]; + if ("_group" in entry) { + visitCommands(entry.commands, path, visit); + if (entry.root !== undefined) visit(entry.root, path.join(" ")); + } else if (!("_alias" in entry) && !("_fetch" in entry)) { + visit(entry, path.join(" ")); + } + } +}; diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 3fae2290dd..95e4480578 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -8,6 +8,7 @@ import { apktoolInputSchemas, MAX_SELECTED_LOCALES, } from "../../../src/domain/apktool/apktoolResourceAnalysis.js"; +import { presentOmittableDefaults } from "../../../src/cli/omittableDefaults.js"; type CliInstance = ReturnType; type CommandMap = NonNullable>; @@ -165,6 +166,22 @@ describe("CLI advertised input schemas", () => { "archive Relative archive path when the target is a bundle", ); }); + + it("advertises defaults as omittable on a mounted group root", async () => { + const group = Cli.create("group", { + args: z.object({ scope: z.enum(["all", "selected"]).default("all") }), + run: () => ({ ok: true }), + }).command("child", { run: () => ({ ok: true }) }); + const cli = Cli.create("group-root-test", { sync: false }).command(group); + + presentOmittableDefaults(cli as unknown as CliInstance); + + const advertised = await advertisedSchema( + cli as unknown as CliInstance, + "group", + ); + expect(advertised.args?.required ?? []).not.toContain("scope"); + }); }); describe("firmware CLI advertised input schema", () => { diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index 0d1ee5a598..53ae17fc24 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -161,6 +161,48 @@ describe("incremental CLI JSON", () => { ); }); +describe("CLI command output wrapping", () => { + it("wraps a mounted group root reached through its alias", async () => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const output = createCliResultOutput( + ["--format", "json", "--full-output"], + destination, + ); + if (output === undefined) + throw new Error("Missing structured output surface"); + + const group = Cli.create("nested", { + aliases: ["nested-alias"], + run: () => ({ kind: "group-root" }), + }).command("child", { run: () => ({ kind: "child" }) }); + const cli = Cli.create("rea-output-group-test", { sync: false }).command( + group, + ); + streamCliCommandResults(cli, output); + + let fallback = ""; + await cli.serve(["nested-alias", "--format", "json"], { + stdout: (text) => { + if (!output.handled) fallback += text; + }, + }); + + expect(output.handled).toBe(true); + expect(fallback).toBe(""); + expect(JSON.parse(Buffer.concat(chunks).toString("utf8"))).toMatchObject({ + ok: true, + data: { kind: "group-root" }, + meta: { command: "nested" }, + }); + }); +}); + describe("CLI result output surface", () => { it.each(["json", "yaml", "toon", "md"] as const)( "places typed analysis failures in the full-output error envelope (%s)",