From 7c52e7b4ab5f38dfe536f24700f88f5cddda0938 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Tue, 4 Aug 2026 12:17:09 -0400 Subject: [PATCH] fix piped CLI output truncation --- .changeset/early-falcons-smile.md | 5 + packages/ghost/src/commands/checks-command.ts | 10 +- packages/ghost/src/commands/errors.ts | 18 +- packages/ghost/src/commands/export-command.ts | 10 +- .../src/commands/fingerprint-commands.ts | 8 +- packages/ghost/src/commands/gather-command.ts | 8 +- packages/ghost/src/commands/init-command.ts | 8 +- .../ghost/src/commands/manifest-command.ts | 10 +- packages/ghost/src/commands/pull-command.ts | 12 +- packages/ghost/src/commands/pulse-command.ts | 8 +- packages/ghost/src/commands/review-command.ts | 10 +- packages/ghost/src/commands/skill-command.ts | 10 +- packages/ghost/test/cli-exit.test.ts | 206 ++++++++++++++++++ 13 files changed, 273 insertions(+), 50 deletions(-) create mode 100644 .changeset/early-falcons-smile.md create mode 100644 packages/ghost/test/cli-exit.test.ts diff --git a/.changeset/early-falcons-smile.md b/.changeset/early-falcons-smile.md new file mode 100644 index 00000000..c499cc15 --- /dev/null +++ b/.changeset/early-falcons-smile.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Flush stdout and stderr before exit so piped command output is never truncated. diff --git a/packages/ghost/src/commands/checks-command.ts b/packages/ghost/src/commands/checks-command.ts index e05f733f..e926f4c5 100644 --- a/packages/ghost/src/commands/checks-command.ts +++ b/packages/ghost/src/commands/checks-command.ts @@ -1,7 +1,7 @@ import type { CAC } from "cac"; import { resolveGhostPackage } from "../package.js"; import { addChecksDir } from "../scan/check-scaffold.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; /** * `ghost checks ` — manage the flat `.ghost/checks/` directory of @@ -21,12 +21,12 @@ export function registerChecksCommand(cli: CAC): void { try { if (opts.format !== "cli" && opts.format !== "json") { console.error("Error: --format must be 'cli' or 'json'"); - process.exit(2); + await exitCli(2); return; } if (action !== "init") { console.error("Error: ghost checks supports `init`"); - process.exit(2); + await exitCli(2); return; } @@ -53,9 +53,9 @@ export function registerChecksCommand(cli: CAC): void { process.stdout.write(` skipped ${file}\n`); } } - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/errors.ts b/packages/ghost/src/commands/errors.ts index 6a6e5612..451384d5 100644 --- a/packages/ghost/src/commands/errors.ts +++ b/packages/ghost/src/commands/errors.ts @@ -1,14 +1,26 @@ import { EXIT } from "#ghost-core"; +function flushStream(stream: NodeJS.WriteStream): Promise { + return new Promise((resolve) => { + stream.write("", () => resolve()); + }); +} + +export async function exitCli(code: number): Promise { + await flushStream(process.stdout); + await flushStream(process.stderr); + process.exit(code); +} + /** * Report a thrown error and exit. A `UsageError` (or anything carrying a numeric * `exitCode`) exits with that code; everything else is an unexpected crash and * exits `1`. Pass `stream` to match a command's existing output channel. */ -export function failFromError( +export async function failFromError( err: unknown, stream: "stderr" | "stdout" = "stderr", -): never { +): Promise { const message = err instanceof Error ? err.message : String(err); const line = `Error: ${message}\n`; if (stream === "stdout") process.stdout.write(line); @@ -18,5 +30,5 @@ export function failFromError( typeof (err as { exitCode?: unknown })?.exitCode === "number" ? (err as { exitCode: number }).exitCode : EXIT.failure; - process.exit(exitCode); + return exitCli(exitCode); } diff --git a/packages/ghost/src/commands/export-command.ts b/packages/ghost/src/commands/export-command.ts index dcfa7f91..46ea6c61 100644 --- a/packages/ghost/src/commands/export-command.ts +++ b/packages/ghost/src/commands/export-command.ts @@ -22,7 +22,7 @@ import { } from "../scan/fingerprint-package.js"; import { resolveGitRoot } from "../scan/package-paths.js"; import { defaultArchiveName, writeDirectoryTarball } from "../scan/tarball.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; interface ExportAuditTravelingLocator { nodeId: string; @@ -66,7 +66,7 @@ export function registerExportCommand(cli: CAC): void { try { if (opts.format !== "markdown" && opts.format !== "json") { console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); + await exitCli(2); return; } @@ -76,7 +76,7 @@ export function registerExportCommand(cli: CAC): void { console.error( "Error: ghost package has validation errors. Run `ghost validate` and fix them before exporting.", ); - process.exit(2); + await exitCli(2); return; } const loaded = await loadGhostPackage(paths); @@ -137,9 +137,9 @@ export function registerExportCommand(cli: CAC): void { ); } - process.exit(opts.strict && audit.stranded.length > 0 ? 2 : 0); + await exitCli(opts.strict && audit.stranded.length > 0 ? 2 : 0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/fingerprint-commands.ts b/packages/ghost/src/commands/fingerprint-commands.ts index 74f23fd1..8332011a 100644 --- a/packages/ghost/src/commands/fingerprint-commands.ts +++ b/packages/ghost/src/commands/fingerprint-commands.ts @@ -7,7 +7,7 @@ import { resolveGhostPackage, } from "../package.js"; import { detectFileKind, lintDetectedFileKind } from "../scan/file-kind.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; import { registerInitCommand } from "./init-command.js"; /** @@ -39,7 +39,7 @@ export function registerFingerprintCommands(cli: CAC): void { if (path === undefined || (await isDirectory(target))) { report = await lintGhostPackage(packagePath, process.cwd()); writeLintReport(report, opts.format); - process.exit(report.errors > 0 ? 1 : 0); + await exitCli(report.errors > 0 ? 1 : 0); return; } @@ -50,9 +50,9 @@ export function registerFingerprintCommands(cli: CAC): void { writeLintReport(report, opts.format); - process.exit(report.errors > 0 ? 1 : 0); + await exitCli(report.errors > 0 ? 1 : 0); } catch (err) { - failFromError(err); + await failFromError(err); } }); diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index 49cb19af..a70c9538 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -4,7 +4,7 @@ import type { GhostGatherResult } from "../embed/index.js"; import { gatherGhostPackage, loadGhostSnapshot } from "../embed/index.js"; import { appendGhostEvent, resolveRunId } from "../observability-events.js"; import { resolveGhostPackage } from "../package.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; export function registerGatherCommand(cli: CAC): void { cli @@ -27,7 +27,7 @@ export function registerGatherCommand(cli: CAC): void { try { if (opts.format !== "markdown" && opts.format !== "json") { console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); + await exitCli(2); return; } @@ -53,9 +53,9 @@ export function registerGatherCommand(cli: CAC): void { } else { process.stdout.write(formatMenuMarkdown(menu)); } - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/init-command.ts b/packages/ghost/src/commands/init-command.ts index 75646f15..3e4d633b 100644 --- a/packages/ghost/src/commands/init-command.ts +++ b/packages/ghost/src/commands/init-command.ts @@ -3,7 +3,7 @@ import { UsageError } from "#ghost-core"; import { initGhostPackage } from "../package.js"; import { addChecksDir } from "../scan/check-scaffold.js"; import { getInitBody } from "../scan/templates.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; export function registerInitCommand(cli: CAC): void { cli @@ -29,7 +29,7 @@ export function registerInitCommand(cli: CAC): void { console.error( "Error: ghost init no longer accepts a positional directory. Use --package for an exact package directory.", ); - process.exit(2); + await exitCli(2); return; } const exactPackage = @@ -97,9 +97,9 @@ export function registerInitCommand(cli: CAC): void { } } } - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/manifest-command.ts b/packages/ghost/src/commands/manifest-command.ts index 33825d82..08e560a9 100644 --- a/packages/ghost/src/commands/manifest-command.ts +++ b/packages/ghost/src/commands/manifest-command.ts @@ -1,6 +1,6 @@ import type { CAC } from "cac"; import { buildCliManifest } from "./command-discovery.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; /** * Emit a self-describing manifest of the CLI: every command, its curated @@ -15,11 +15,11 @@ export function registerManifestCommand(cli: CAC): void { "Emit a self-describing JSON manifest of every command and flag.", ) .option("--format ", "Output format: json", { default: "json" }) - .action((opts) => { + .action(async (opts) => { try { if (opts.format !== "json") { console.error("Error: ghost manifest supports only --format json"); - process.exit(2); + await exitCli(2); return; } const manifest = { @@ -28,9 +28,9 @@ export function registerManifestCommand(cli: CAC): void { data: buildCliManifest(cli, cli.name), }; process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/pull-command.ts b/packages/ghost/src/commands/pull-command.ts index 2d08abde..45e1d4b7 100644 --- a/packages/ghost/src/commands/pull-command.ts +++ b/packages/ghost/src/commands/pull-command.ts @@ -6,7 +6,7 @@ import { appendGhostEvent, resolveRunId } from "../observability-events.js"; import { resolveGhostPackage } from "../package.js"; import { GHOST_EVENTS_FILENAME } from "../scan/constants.js"; import { resolveGitRoot } from "../scan/package-paths.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; export function registerPullCommand(cli: CAC): void { cli @@ -37,12 +37,12 @@ export function registerPullCommand(cli: CAC): void { try { if (opts.format !== "markdown" && opts.format !== "json") { console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); + await exitCli(2); return; } if (opts.order !== "steering" && opts.order !== "given") { console.error("Error: --order must be 'steering' or 'given'"); - process.exit(2); + await exitCli(2); return; } @@ -80,7 +80,7 @@ export function registerPullCommand(cli: CAC): void { } if (result.ids.length === 0) { - process.exit(2); + await exitCli(2); return; } @@ -91,9 +91,9 @@ export function registerPullCommand(cli: CAC): void { } else { process.stdout.write(formatPullMarkdown(result)); } - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/pulse-command.ts b/packages/ghost/src/commands/pulse-command.ts index 3b16c431..112dd271 100644 --- a/packages/ghost/src/commands/pulse-command.ts +++ b/packages/ghost/src/commands/pulse-command.ts @@ -7,7 +7,7 @@ import { } from "../observability-events.js"; import { resolveGhostPackage } from "../package.js"; import { loadGhostPackage } from "../scan/fingerprint-package.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; export function registerPulseCommand(cli: CAC): void { cli @@ -23,7 +23,7 @@ export function registerPulseCommand(cli: CAC): void { try { if (opts.format !== "markdown" && opts.format !== "json") { console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); + await exitCli(2); return; } @@ -38,9 +38,9 @@ export function registerPulseCommand(cli: CAC): void { } else { process.stdout.write(formatPulseMarkdown(report)); } - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/review-command.ts b/packages/ghost/src/commands/review-command.ts index a2a0cdc5..11c21e21 100644 --- a/packages/ghost/src/commands/review-command.ts +++ b/packages/ghost/src/commands/review-command.ts @@ -9,7 +9,7 @@ import { formatReviewPacket, } from "../review/review-packet.js"; import { loadGhostPackage } from "../scan/fingerprint-package.js"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; const execFileAsync = promisify(execFile); @@ -35,7 +35,7 @@ export function registerReviewCommand(cli: CAC): void { const format = opts.json ? "json" : opts.format; if (format !== "markdown" && format !== "json") { console.error("Error: --format must be 'markdown' or 'json'"); - process.exit(2); + await exitCli(2); return; } @@ -45,7 +45,7 @@ export function registerReviewCommand(cli: CAC): void { console.error( "No checks directory. Run `ghost checks init` to add review assertions.", ); - process.exit(2); + await exitCli(2); return; } const diffText = await resolveDiff({ @@ -62,9 +62,9 @@ export function registerReviewCommand(cli: CAC): void { ? `${JSON.stringify(packet, null, 2)}\n` : formatReviewPacket(packet), ); - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/src/commands/skill-command.ts b/packages/ghost/src/commands/skill-command.ts index 1108e015..6cddd681 100644 --- a/packages/ghost/src/commands/skill-command.ts +++ b/packages/ghost/src/commands/skill-command.ts @@ -5,7 +5,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { CAC } from "cac"; import { loadSkillBundle, UsageError } from "#ghost-core"; -import { failFromError } from "./errors.js"; +import { exitCli, failFromError } from "./errors.js"; // The bundle assets are copied to `dist/skill-bundle` (sibling of `commands/`). const SKILL_BUNDLE_ROOT = fileURLToPath( @@ -37,7 +37,7 @@ export function registerSkillCommand(cli: CAC): void { try { if (action !== "install") { console.error("Error: ghost skill currently supports only `install`"); - process.exit(2); + await exitCli(2); return; } @@ -53,7 +53,7 @@ export function registerSkillCommand(cli: CAC): void { console.error( `Error: ${outDir} already contains SKILL.md. Pass --force to reinstall.`, ); - process.exit(3); + await exitCli(3); return; } @@ -70,9 +70,9 @@ export function registerSkillCommand(cli: CAC): void { `Wrote ${written.length} file${written.length === 1 ? "" : "s"} to ${outDir}:\n`, ); for (const file of written) process.stdout.write(` ${file}\n`); - process.exit(0); + await exitCli(0); } catch (err) { - failFromError(err); + await failFromError(err); } }); } diff --git a/packages/ghost/test/cli-exit.test.ts b/packages/ghost/test/cli-exit.test.ts new file mode 100644 index 00000000..97fe588f --- /dev/null +++ b/packages/ghost/test/cli-exit.test.ts @@ -0,0 +1,206 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { exitCli } from "../src/commands/errors.js"; + +const execFileAsync = promisify(execFile); +const TERMINAL_SENTINEL = "ghost-terminal-sentinel-9c25894d1b2e4d58"; + +describe("CLI process exit lifecycle", () => { + let dirs: string[] = []; + + afterEach(async () => { + for (const dir of dirs) { + await rm(dir, { recursive: true, force: true }); + } + dirs = []; + vi.restoreAllMocks(); + }); + + it("keeps raw process.exit calls centralized in commands/errors.ts", async () => { + const root = resolve("packages/ghost/src/commands"); + const offenders: string[] = []; + + for (const file of await listTypeScriptFiles(root)) { + const source = await readFile(file, "utf-8"); + if (!source.includes("process.exit(")) continue; + const normalized = file.split(sep).join("/"); + if (!normalized.endsWith("packages/ghost/src/commands/errors.ts")) { + offenders.push(normalized); + } + } + + expect(offenders).toEqual([]); + }); + + it("waits for stdout and stderr flush callbacks before exiting with the exact code", async () => { + const callbacks: Array<() => void> = []; + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation((_chunk: string | Uint8Array, callback?: unknown) => { + if (typeof callback === "function") callbacks.push(callback); + return true; + }); + const stderrWrite = vi + .spyOn(process.stderr, "write") + .mockImplementation((_chunk: string | Uint8Array, callback?: unknown) => { + if (typeof callback === "function") callbacks.push(callback); + return true; + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + return undefined as never; + }); + + const exiting = exitCli(7); + await Promise.resolve(); + + expect(stdoutWrite).toHaveBeenCalledTimes(1); + expect(stderrWrite).toHaveBeenCalledTimes(0); + expect(exitSpy).not.toHaveBeenCalled(); + + callbacks.shift()?.(); + await Promise.resolve(); + + expect(stderrWrite).toHaveBeenCalledTimes(1); + expect(exitSpy).not.toHaveBeenCalled(); + + callbacks.shift()?.(); + await exiting; + + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(7); + }); + + it("preserves large piped pull output through process exit", async () => { + const bin = resolve("packages/ghost/dist/bin.js"); + expect( + existsSync(bin), + "packages/ghost/dist/bin.js is missing. Run `pnpm build` before this subprocess regression test.", + ).toBe(true); + + const dir = await makeTempDir(); + await writeLargePullFixture(dir); + + const markdown = await execGhost(bin, ["pull", "principle.long"], dir); + expect(markdown.code).toBe(0); + expect(markdown.stdout.length).toBeGreaterThan(64 * 1024); + expect(markdown.stdout).toContain(TERMINAL_SENTINEL); + + const json = await execGhost( + bin, + ["pull", "principle.long", "--format", "json"], + dir, + ); + expect(json.code).toBe(0); + const payload = JSON.parse(json.stdout) as { + nodes: Array<{ body: string }>; + }; + expect(payload.nodes[0].body.length).toBeGreaterThan(64 * 1024); + expect(payload.nodes[0].body).toContain(TERMINAL_SENTINEL); + + const unknown = await execGhost(bin, ["pull", "missing.only"], dir); + expect(unknown.code).toBe(2); + expect(unknown.stdout).toBe(""); + expect(unknown.stderr).toContain("Warning: unknown node `missing.only`"); + expect(unknown.stderr).toContain("Run `ghost gather` to list every node."); + }); + + async function makeTempDir(): Promise { + const dir = join( + tmpdir(), + `ghost-cli-exit-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + dirs.push(dir); + return dir; + } +}); + +async function listTypeScriptFiles(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...(await listTypeScriptFiles(path))); + } else if (entry.isFile() && path.endsWith(".ts")) { + files.push(path); + } + } + return files; +} + +async function writeLargePullFixture(dir: string): Promise { + const ghost = join(dir, ".ghost"); + await mkdir(ghost, { recursive: true }); + await Promise.all([ + writeFile( + join(ghost, "manifest.yml"), + "schema: ghost.package/v1\nid: pipe-regression\ncover: index\n", + ), + writeFile( + join(ghost, "glossary.md"), + [ + "---", + "kinds:", + " - name: principle", + "---", + "", + "# principle", + "", + "Rules.", + "", + ].join("\n"), + ), + writeFile( + join(ghost, "index.md"), + "---\ndescription: Cover.\n---\n\nCover.\n", + ), + writeFile( + join(ghost, "principle.long.md"), + [ + "---", + "description: Large pull body.", + "---", + "", + "x".repeat(2 * 1024 * 1024), + TERMINAL_SENTINEL, + "", + ].join("\n"), + ), + ]); +} + +async function execGhost( + bin: string, + args: string[], + cwd: string, +): Promise<{ code: number; stdout: string; stderr: string }> { + try { + const { stdout, stderr } = await execFileAsync( + process.execPath, + [bin, ...args], + { + cwd, + encoding: "utf-8", + maxBuffer: 16 * 1024 * 1024, + }, + ); + return { code: 0, stdout, stderr }; + } catch (err) { + const failed = err as { + code?: number; + stdout?: string; + stderr?: string; + }; + return { + code: typeof failed.code === "number" ? failed.code : 1, + stdout: failed.stdout ?? "", + stderr: failed.stderr ?? "", + }; + } +}