diff --git a/knip.json b/knip.json index 2734540923..375339a4e9 100644 --- a/knip.json +++ b/knip.json @@ -3,6 +3,7 @@ "entry": [ "src/main.ts", "src/cli.ts", + "src/cliOneShot.ts", "src/cliOutput.ts", "src/cliBuiltinArguments.ts", "src/cli/cliResultOutput.ts", diff --git a/knip.production.json b/knip.production.json index 5ccf4fd2ad..f45bc681b7 100644 --- a/knip.production.json +++ b/knip.production.json @@ -3,6 +3,7 @@ "entry": [ "src/main.ts!", "src/cli.ts!", + "src/cliOneShot.ts!", "src/cliOutput.ts!", "src/cliBuiltinArguments.ts!", "src/cli/cliResultOutput.ts!", diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs index 66247aa380..c135343c59 100644 --- a/scripts/bundle-runtime.mjs +++ b/scripts/bundle-runtime.mjs @@ -24,7 +24,7 @@ export const bundleRuntime = async (root) => { "cli-runtime", { cli: "cli.js", - cliOutput: "cliOutput.js", + cliOneShot: "cliOneShot.js", cliBuiltinArguments: "cliBuiltinArguments.js", cliResultOutput: "cli/cliResultOutput.js", mcpDoctor: "mcpDoctor.js", diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs index d5ee4f21c7..ccd2d91eb1 100644 --- a/scripts/prepack.mjs +++ b/scripts/prepack.mjs @@ -54,7 +54,7 @@ if (await pathExists(join(root, "src"))) { "dist/main.js", "dist/mcp/main.js", "dist/cli-runtime/cli.js", - "dist/cli-runtime/cliOutput.js", + "dist/cli-runtime/cliOneShot.js", "dist/cli-runtime/cliBuiltinArguments.js", "dist/cli-runtime/cliResultOutput.js", "dist/cli-runtime/mcpDoctor.js", diff --git a/scripts/rea.mjs b/scripts/rea.mjs index f5a25da273..e6050e03a1 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -64,7 +64,7 @@ const runtimeFiles = [ ? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"] : [ "dist/cli-runtime/cli.js", - "dist/cli-runtime/cliOutput.js", + "dist/cli-runtime/cliOneShot.js", "dist/cli-runtime/cliResultOutput.js", ]), ]; @@ -104,154 +104,66 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { ); } } else { - const { - cliConfigurationError, - renderCliOutputArgumentError, - renderEmptyFilteredCliOutput, - renderVersionReport, - parseCliOutputArguments, - } = await import("../dist/cli-runtime/cliOutput.js"); + const { runOneShotCli } = await import("../dist/cli-runtime/cliOneShot.js"); guardClosedStdout(); - const cliArgs = isShellCompletion - ? args - : builtinArguments.normalizeCliArguments(args); - // Completion words are transport data, including partial output flags. - const outputOptions = parseCliOutputArguments( - isShellCompletion ? [] : cliArgs, - ); - const outputArguments = outputOptions.validation; - // `--version` with an explicit format reports the runtime as a document; - // plain `--version` stays Incur's bare version string. - const versionOutput = renderVersionReport( - outputOptions, - { - packageName: packageJson.name, - packageVersion: packageJson.version, - versions: process.versions, - platform: process.platform, - architecture: process.arch, - }, - process.stdout.isTTY === true, - ); - const configurationError = cliConfigurationError(process.env); let dispatchFailure; let closeCliResources; // Incur can exit from the callback below before this function returns. // That callback owns the status, including a closed-pipe exit of 141. let exiting = false; try { - // Doctor is the readiness report for this failure. Other commands still stop - // here so they do not run with an unusable log level. - if ( - configurationError !== undefined && - (isShellCompletion || - builtinArguments.splitIncurBuiltinArguments(cliArgs).command[0] !== - "doctor") - ) { - process.stderr.write(`${configurationError}\n`); - process.exitCode = 1; - } else if (!outputArguments.ok) { - writeStdout(renderCliOutputArgumentError(outputArguments)); - process.exitCode = 1; - } else if (versionOutput !== undefined) { - writeStdout(versionOutput); - } else if ( - !isShellCompletion && - builtinArguments.isProjectScopedAmpRegistration(cliArgs) - ) { - // Amp has global settings only. Share the parsed scope fact with the - // registration workflow and refuse before loading the command runtime. - // - // A caller that asked for a structured document gets the refusal in - // that document, through the same pre-execution error renderer the - // output-argument branch uses; anything else keeps the plain message. - const refusalMessage = - "Amp does not support project-scoped registration. Run without --no-global to register Amp globally, or select a client with project settings. No client settings were changed."; - const refusalFormat = outputOptions.format; - if ( - refusalFormat === "json" || - refusalFormat === "jsonl" || - refusalFormat === "yaml" - ) { - writeStdout( - renderCliOutputArgumentError({ - ok: false, - format: refusalFormat, - code: "AMP_PROJECT_SCOPE_UNSUPPORTED", - message: refusalMessage, - }), - ); - } else { - process.stderr.write(`${refusalMessage}\n`); - } - process.exitCode = 1; - } else { - const cliRuntime = await import("../dist/cli-runtime/cli.js"); - closeCliResources = cliRuntime.closeCliResources; - const { createCli } = cliRuntime; - const { createCliResultOutput } = - await import("../dist/cli-runtime/cliResultOutput.js"); - let wroteOutput = false; - const resultOutput = createCliResultOutput(outputOptions, process.stdout); - try { - await createCli(process.env, resultOutput).serve(cliArgs, { - stdout: (output) => { - if (resultOutput?.handled) { - if (resultOutput.failed && !stdoutClosed) - process.stderr.write(output); - return; - } - if (output.length > 0) wroteOutput = true; - if (resultOutput?.captureSdkOutput(output)) return; - writeStdout(output); - }, - // A command failure caused by the closed pipe keeps the pipe's status. - // Worker shutdown is shared with the dispatch finally below. A failed - // shutdown is reported here because this callback is what exits. - exit: (code) => { - exiting = true; - void (async () => { - let status = typeof code === "number" ? code : 0; - try { - await closeCliResources(); - } catch (cleanup) { - process.stderr.write( - `Failed to close CLI resources: ${cleanupFailureText(cleanup)}\n`, - ); - if (status === 0) status = 1; - } - try { - await resultOutput?.finishSdkOutput(status); - } catch (cause) { - if (!stdoutClosed) - process.stderr.write(`${cleanupFailureText(cause)}\n`); - if (status === 0) status = 1; - } - process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : status); - })(); - }, - }); - } catch (cause) { - if (wroteOutput) throw cause; - const recovered = await resultOutput - ?.recoverFormattingFailure(cause) - .catch((writeFailure) => { - // Recovery writes outside Incur's command boundary. A reader that - // declined this document keeps the same quiet SIGPIPE outcome. - if (!stdoutClosed) throw writeFailure; - return true; - }); - if (!recovered) throw cause; - process.exitCode = stdoutClosed ? SIGPIPE_EXIT_STATUS : 1; - } - if (!wroteOutput && !resultOutput?.handled) - writeStdout(renderEmptyFilteredCliOutput(outputOptions) ?? ""); - // An SDK failure exits through the callback, which owns its pending write. - if (!exiting) - await resultOutput?.finishSdkOutput( - typeof process.exitCode === "number" ? process.exitCode : 0, - ); - } + await runOneShotCli(args, { + environment: process.env, + runtime: { + packageName: packageJson.name, + packageVersion: packageJson.version, + versions: process.versions, + platform: process.platform, + architecture: process.arch, + }, + stdoutIsTerminal: process.stdout.isTTY === true, + resultDestination: process.stdout, + closedStdoutStatus: SIGPIPE_EXIT_STATUS, + writeStdout, + writeStderr: (text) => { + process.stderr.write(text); + }, + stdoutClosed: () => stdoutClosed, + exitCode: () => + typeof process.exitCode === "number" ? process.exitCode : 0, + setExitCode: (code) => { + process.exitCode = code; + }, + ownCliResources: (close) => { + closeCliResources = close; + }, + exitRequested: () => exiting, + // A command failure caused by the closed pipe keeps the pipe's status. + // Worker shutdown is shared with the dispatch finally below. A failed + // shutdown is reported here because this callback is what exits. + exit: (code, finishOutput) => { + exiting = true; + void (async () => { + let status = code; + try { + await closeCliResources(); + } catch (cleanup) { + process.stderr.write( + `Failed to close CLI resources: ${cleanupFailureText(cleanup)}\n`, + ); + if (status === 0) status = 1; + } + try { + await finishOutput(status); + } catch (cause) { + if (!stdoutClosed) + process.stderr.write(`${cleanupFailureText(cause)}\n`); + if (status === 0) status = 1; + } + process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : status); + })(); + }, + }); } catch (cause) { if (cause?.code === "EPIPE") { stdoutClosed = true; diff --git a/src/cliOneShot.ts b/src/cliOneShot.ts new file mode 100644 index 0000000000..49c6148f9d --- /dev/null +++ b/src/cliOneShot.ts @@ -0,0 +1,169 @@ +import type { Writable } from "node:stream"; + +import { + cliCompletionShell, + isProjectScopedAmpRegistration, + normalizeCliArguments, + splitIncurBuiltinArguments, +} from "./cliBuiltinArguments.js"; +import { + cliConfigurationError, + parseCliOutputArguments, + renderCliOutputArgumentError, + renderEmptyFilteredCliOutput, + renderVersionReport, +} from "./cliOutput.js"; +import type { VersionReportHost } from "./domain/versionReport.js"; + +/** + * Process facilities one command needs. The executable binds them to its own + * streams and exit; a driver that serves several commands from one process + * binds them to per-command capture. + */ +export interface OneShotCliHost { + readonly environment: NodeJS.ProcessEnv; + readonly runtime: VersionReportHost; + readonly stdoutIsTerminal: boolean; + /** Destination for result documents the command streams itself. */ + readonly resultDestination: Writable; + /** Status reported after a recovered failure when the reader has closed stdout. */ + readonly closedStdoutStatus: number; + writeStdout(text: string): void; + writeStderr(text: string): void; + stdoutClosed(): boolean; + /** Status the process would report if it ended now. */ + exitCode(): number; + setExitCode(code: number): void; + /** Called once when the command runtime loads, before any command runs. */ + ownCliResources(close: () => Promise): void; + /** + * The command asked to end the process with `code`. The host owns that + * exit and must call `finishOutput` with the status it reports. + */ + exit(code: number, finishOutput: (status: number) => Promise): void; + /** Whether `exit` has run. Its host then owns the pending result write. */ + exitRequested(): boolean; +} + +const AMP_PROJECT_SCOPE_REFUSAL = + "Amp does not support project-scoped registration. Run without --no-global to register Amp globally, or select a client with project settings. No client settings were changed."; + +/** + * Run one CLI command: admit its output arguments and configuration, answer + * the documents that need no command runtime, then dispatch. Failures the + * command did not report propagate to the host. + */ +export const runOneShotCli = async ( + arguments_: readonly string[], + host: OneShotCliHost, +): Promise => { + const isShellCompletion = cliCompletionShell(host.environment) !== undefined; + const cliArguments = isShellCompletion + ? [...arguments_] + : normalizeCliArguments([...arguments_]); + // Completion words are transport data, including partial output flags. + const outputOptions = parseCliOutputArguments( + isShellCompletion ? [] : cliArguments, + ); + const outputArguments = outputOptions.validation; + // `--version` with an explicit format reports the runtime as a document; + // plain `--version` stays Incur's bare version string. + const versionOutput = renderVersionReport( + outputOptions, + host.runtime, + host.stdoutIsTerminal, + ); + const configurationError = cliConfigurationError(host.environment); + // Doctor is the readiness report for this failure. Other commands still stop + // here so they do not run with an unusable log level. + if ( + configurationError !== undefined && + (isShellCompletion || + splitIncurBuiltinArguments(cliArguments).command[0] !== "doctor") + ) { + host.writeStderr(`${configurationError}\n`); + host.setExitCode(1); + return; + } + if (!outputArguments.ok) { + host.writeStdout(renderCliOutputArgumentError(outputArguments)); + host.setExitCode(1); + return; + } + if (versionOutput !== undefined) { + host.writeStdout(versionOutput); + return; + } + if (!isShellCompletion && isProjectScopedAmpRegistration(cliArguments)) { + // Amp has global settings only. Share the parsed scope fact with the + // registration workflow and refuse before loading the command runtime. + // + // A caller that asked for a structured document gets the refusal in + // that document, through the same pre-execution error renderer the + // output-argument branch uses; anything else keeps the plain message. + const refusalFormat = outputOptions.format; + if ( + refusalFormat === "json" || + refusalFormat === "jsonl" || + refusalFormat === "yaml" + ) { + host.writeStdout( + renderCliOutputArgumentError({ + format: refusalFormat, + code: "AMP_PROJECT_SCOPE_UNSUPPORTED", + message: AMP_PROJECT_SCOPE_REFUSAL, + }), + ); + } else { + host.writeStderr(`${AMP_PROJECT_SCOPE_REFUSAL}\n`); + } + host.setExitCode(1); + return; + } + + // Loaded only now: the documents above answer without the command catalog. + const { closeCliResources, createCli } = await import("./cli.js"); + host.ownCliResources(closeCliResources); + const { createCliResultOutput } = await import("./cli/cliResultOutput.js"); + let wroteOutput = false; + const resultOutput = createCliResultOutput( + outputOptions, + host.resultDestination, + ); + try { + await createCli(host.environment, resultOutput).serve(cliArguments, { + stdout: (output) => { + if (resultOutput?.handled) { + if (resultOutput.failed && !host.stdoutClosed()) + host.writeStderr(output); + return; + } + if (output.length > 0) wroteOutput = true; + if (resultOutput?.captureSdkOutput(output)) return; + host.writeStdout(output); + }, + exit: (code) => { + host.exit(typeof code === "number" ? code : 0, async (status) => + resultOutput?.finishSdkOutput(status), + ); + }, + }); + } catch (cause) { + if (wroteOutput) throw cause; + const recovered = await resultOutput + ?.recoverFormattingFailure(cause) + .catch((writeFailure: unknown) => { + // Recovery writes outside Incur's command boundary. A reader that + // declined this document keeps the same quiet closed-pipe outcome. + if (!host.stdoutClosed()) throw writeFailure; + return true; + }); + if (!recovered) throw cause; + host.setExitCode(host.stdoutClosed() ? host.closedStdoutStatus : 1); + } + if (!wroteOutput && !resultOutput?.handled) + host.writeStdout(renderEmptyFilteredCliOutput(outputOptions) ?? ""); + // A requested exit is finished by the host, which owns its pending write. + if (!host.exitRequested()) + await resultOutput?.finishSdkOutput(host.exitCode()); +}; diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 045e9cbc22..928961a39b 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -366,9 +366,18 @@ export const renderEmptyFilteredCliOutput = ({ return undefined; }; +/** A refusal reported as a structured document before any command runs. */ +export interface CliPreExecutionError { + readonly format: StructuredOutputFormat; + readonly code: + | Exclude["code"] + | "AMP_PROJECT_SCOPE_UNSUPPORTED"; + readonly message: string; +} + /** Render one complete structured error before command execution. */ export const renderCliOutputArgumentError = ( - error: Exclude, + error: CliPreExecutionError, ): string => { const value = { ok: false, diff --git a/tests/boundary/cli/cliSessionParity.test.ts b/tests/boundary/cli/cliSessionParity.test.ts new file mode 100644 index 0000000000..71fcf16625 --- /dev/null +++ b/tests/boundary/cli/cliSessionParity.test.ts @@ -0,0 +1,38 @@ +import { expect } from "vitest"; + +import { cliTest, runFileCli } from "../../support/cli/cliFixture.js"; + +// The file session and the executable run one shared one-shot owner. These +// commands are answered before the command runtime loads, which is where a +// separate session implementation previously diverged. +cliTest.for([ + { name: "a formatted version report", arguments: ["--version", "--json"] }, + { + name: "a project-scoped Amp registration refusal", + arguments: ["mcp", "add", "--no-global", "--agent", "amp", "--json"], + }, + { + name: "a rejected output combination", + arguments: ["capabilities", "--json", "--token-limit", "5"], + }, +])( + "the file session reports $name exactly as a fresh process does", + async ({ arguments: arguments_ }, { cli }) => { + const environment = { REA_LOG_LEVEL: "silent" }; + const fresh = await cli.run({ arguments: arguments_, environment }); + const shared = await runFileCli(cli, { + arguments: arguments_, + environment, + }); + expect(fresh.stdout).not.toBe(""); + expect({ + exitCode: shared.exitCode, + stdout: shared.stdout, + stderr: shared.stderr, + }).toEqual({ + exitCode: fresh.exitCode, + stdout: fresh.stdout, + stderr: fresh.stderr, + }); + }, +); diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index f1804a99f8..da05a9c482 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -1,4 +1,4 @@ -import { access, copyFile, cp } from "node:fs/promises"; +import { access, copyFile, cp, symlink } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -8,9 +8,6 @@ import { unsupportedRuntimeMessage } from "../../../scripts/lib/runtime-admissio import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import type { TestWorkspace } from "../../support/workspace/workspaceFixture.js"; -const CLI_OUTPUT_RUNTIME = - "export const cliConfigurationError = () => undefined; export const parseCliOutputArguments = () => ({ validation: { ok: true } }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined; export const renderVersionReport = () => undefined;\n"; - const installCliRuntime = async ( workspace: TestWorkspace, cliSource: string, @@ -18,10 +15,18 @@ const installCliRuntime = async ( ): Promise => { await workspace.mkdir("scripts/lib"); await workspace.mkdir("dist"); - // The bundle's entrypoints and shared chunks form one emitted runtime. + // The bundle's entrypoints and shared chunks form one emitted runtime. The + // one-shot owner and its output admission stay production code; only the + // command runtime and result output are replaced below. await cp("dist/cli-runtime", workspace.path("dist/cli-runtime"), { recursive: true, }); + // Production chunks import their packages from the installation root. + await symlink( + resolve("node_modules"), + workspace.path("node_modules"), + "junction", + ); await Promise.all([ copyFile("scripts/rea.mjs", workspace.path("scripts/rea.mjs")), copyFile( @@ -37,7 +42,6 @@ const installCliRuntime = async ( JSON.stringify({ type: "module", version: "1.0.0" }), ), workspace.write("dist/cli-runtime/cli.js", cliSource), - workspace.write("dist/cli-runtime/cliOutput.js", CLI_OUTPUT_RUNTIME), workspace.write("dist/cli-runtime/cliResultOutput.js", resultOutput), ]); }; @@ -57,11 +61,6 @@ describe("version report dispatch", () => { version: "1.0.0", }), ); - // Use the built production preflight, while making full CLI imports fail. - await workspace.write( - "dist/cli-runtime/cliOutput.js", - `export * from ${JSON.stringify(pathToFileURL(resolve("dist/cli-runtime/cliOutput.js")).href)};\n`, - ); const invoke = (arguments_: readonly string[], invalidLogLevel = false) => processes.run( process.execPath, @@ -112,11 +111,6 @@ describe("doctor configuration dispatch", () => { workspace, `process.stderr.write(${JSON.stringify(`${imported}\n`)}); export const closeCliResources = () => {}; export const createCli = () => ({ serve: (args, options) => options.stdout(JSON.stringify({ adapter: "cli", args })) });\n`, ); - // Both token ownership and output/configuration admission use production code. - await workspace.write( - "dist/cli-runtime/cliOutput.js", - `export * from ${JSON.stringify(pathToFileURL(resolve("dist/cli-runtime/cliOutput.js")).href)};\n`, - ); const invoke = (args: readonly string[], completion = "") => processes.run( process.execPath, @@ -595,19 +589,10 @@ describe("project-scoped Amp registration dispatch", () => { }, ); - const installProductionOutputRuntime = async (workspace: TestWorkspace) => { - await installGuardRuntime(workspace); - // The guard's structured refusal renders through production output code. - await workspace.write( - "dist/cli-runtime/cliOutput.js", - `export * from ${JSON.stringify(pathToFileURL(resolve("dist/cli-runtime/cliOutput.js")).href)};\n`, - ); - }; - workspaceCliTest( "renders the Amp scope refusal as a structured document when a structured format is requested", async ({ processes, workspace }) => { - await installProductionOutputRuntime(workspace); + await installGuardRuntime(workspace); const invoke = (arguments_: readonly string[]) => processes.run( process.execPath, diff --git a/tests/boundary/process/runtimeBundle.test.ts b/tests/boundary/process/runtimeBundle.test.ts index 7a642da892..1ba446ee7a 100644 --- a/tests/boundary/process/runtimeBundle.test.ts +++ b/tests/boundary/process/runtimeBundle.test.ts @@ -50,7 +50,7 @@ export const lazyUrl = import.meta.url;`, 'export const generatedPackageMetadata = { version: "1.0.0" };', "dist/cli.js": 'export { facts, run } from "./main.js";', "dist/cliBuiltinArguments.js": "export {};\n", - "dist/cliOutput.js": 'export { facts } from "./provider/probe λ.js";', + "dist/cliOneShot.js": 'export { facts } from "./provider/probe λ.js";', "dist/cli/cliResultOutput.js": 'export { facts } from "../provider/probe λ.js";', "dist/mcpDoctor.js": 'export { run } from "./main.js";', @@ -99,7 +99,7 @@ export const run = async () => { [ "--input-type=module", "--eval", - `const { facts } = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cli.js")).href)}); const output = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cliOutput.js")).href)}); const streamed = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cliResultOutput.js")).href)}); process.stdout.write(JSON.stringify(facts === output.facts && facts === streamed.facts));`, + `const { facts } = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cli.js")).href)}); const oneShot = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cliOneShot.js")).href)}); const streamed = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cliResultOutput.js")).href)}); process.stdout.write(JSON.stringify(facts === oneShot.facts && facts === streamed.facts));`, ], { cwd: workspace.root }, ); @@ -137,7 +137,7 @@ export const run = async () => { await workspace.write("package.json", '{"type":"module"}'); for (const path of [ "dist/cliBuiltinArguments.js", - "dist/cliOutput.js", + "dist/cliOneShot.js", "dist/cli/cliResultOutput.js", "dist/mcpDoctor.js", ]) diff --git a/tests/support/cli/cliCommandSessionWorker.mjs b/tests/support/cli/cliCommandSessionWorker.mjs index c98d0f6ba1..653c2ddf5b 100644 --- a/tests/support/cli/cliCommandSessionWorker.mjs +++ b/tests/support/cli/cliCommandSessionWorker.mjs @@ -10,8 +10,8 @@ import { encodeCliSessionFrame, } from "./cliCommandSessionFrames.mjs"; -// One-shot command sequence matches scripts/rea.mjs. Output is captured per -// command so this process can serve the next invocation without restarting. +// Commands run through the same one-shot owner as scripts/rea.mjs. Output is +// captured per command so this process can serve the next invocation. const packageRoot = resolve( dirname(fileURLToPath(import.meta.url)), "../../..", @@ -23,9 +23,7 @@ const { default: packageJson } = await import( const runtimeFiles = [ "dist/cli-runtime/cli.js", - "dist/cli-runtime/cliOutput.js", - "dist/cli-runtime/cliBuiltinArguments.js", - "dist/cli-runtime/cliResultOutput.js", + "dist/cli-runtime/cliOneShot.js", ]; prepareCliProcessEnvironment( @@ -40,21 +38,9 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.exit(1); } -const { closeCliResources, createCli } = - await import("../../../dist/cli-runtime/cli.js"); -const { createCliResultOutput } = - await import("../../../dist/cli-runtime/cliResultOutput.js"); -const { - cliCompletionShell, - normalizeCliArguments, - splitIncurBuiltinArguments, -} = await import("../../../dist/cli-runtime/cliBuiltinArguments.js"); -const { - cliConfigurationError, - renderCliOutputArgumentError, - renderEmptyFilteredCliOutput, - parseCliOutputArguments, -} = await import("../../../dist/cli-runtime/cliOutput.js"); +const { closeCliResources } = await import("../../../dist/cli-runtime/cli.js"); +const { runOneShotCli } = + await import("../../../dist/cli-runtime/cliOneShot.js"); const writeFrame = (value) => new Promise((resolveWrite, rejectWrite) => { @@ -181,57 +167,45 @@ async function runOneShot(args) { const previousExitCode = process.exitCode; process.exitCode = undefined; let explicitExit; + let exitOutput; try { - const isShellCompletion = cliCompletionShell(process.env) !== undefined; - const cliArgs = isShellCompletion ? args : normalizeCliArguments(args); - const outputOptions = parseCliOutputArguments( - isShellCompletion ? [] : cliArgs, - ); - const outputArguments = outputOptions.validation; - const configurationError = cliConfigurationError(process.env); - if ( - configurationError !== undefined && - (isShellCompletion || - splitIncurBuiltinArguments(cliArgs).command[0] !== "doctor") - ) { - process.stderr.write(`${configurationError}\n`); - process.exitCode = 1; - } else if (!outputArguments.ok) { - process.stdout.write(renderCliOutputArgumentError(outputArguments)); - process.exitCode = 1; - } else { - let wroteOutput = false; - const resultOutput = createCliResultOutput(outputOptions, destination); - try { - await createCli(process.env, resultOutput).serve(cliArgs, { - stdout: (output) => { - if (resultOutput?.handled) { - if (resultOutput.failed) process.stderr.write(output); - return; - } - if (output.length > 0) wroteOutput = true; - if (resultOutput?.captureSdkOutput(output)) return; - process.stdout.write(output); - }, - exit: (code) => { - explicitExit = code; - }, - }); - } catch (cause) { - if (wroteOutput) throw cause; - const recovered = await resultOutput?.recoverFormattingFailure(cause); - if (!recovered) throw cause; - process.exitCode = 1; - } - if (!wroteOutput && !resultOutput?.handled) { - const empty = renderEmptyFilteredCliOutput(outputOptions); - if (empty) process.stdout.write(empty); - } - await resultOutput?.finishSdkOutput( - explicitExit ?? - (typeof process.exitCode === "number" ? process.exitCode : 0), - ); - } + await runOneShotCli(args, { + environment: process.env, + runtime: { + packageName: packageJson.name, + packageVersion: packageJson.version, + versions: process.versions, + platform: process.platform, + architecture: process.arch, + }, + stdoutIsTerminal: destination.isTTY, + resultDestination: destination, + // The session frames own this process's stdout; a command's reader + // cannot close it. + closedStdoutStatus: 1, + writeStdout: (text) => { + process.stdout.write(text); + }, + writeStderr: (text) => { + process.stderr.write(text); + }, + stdoutClosed: () => false, + exitCode: () => + typeof process.exitCode === "number" ? process.exitCode : 0, + setExitCode: (code) => { + process.exitCode = code; + }, + // The session closes CLI resources once, after its last command. + ownCliResources: () => {}, + exitRequested: () => exitOutput !== undefined, + // This process serves the next command; record the status and finish + // the pending result in place of exiting. + exit: (code, finishOutput) => { + explicitExit = code; + exitOutput = finishOutput(code); + }, + }); + await exitOutput; } finally { process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite;