diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index ea836fc2..b0654d8f 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,4 +1,4 @@ -import { createCli } from "bailian-cli-runtime"; +import { createCli, runToExit } from "bailian-cli-runtime"; import { commandAliases, commands } from "./commands.ts"; import { commandPackPolicy } from "./command-pack-policy.ts"; import pkg from "../package.json" with { type: "json" }; @@ -12,7 +12,7 @@ const quickStartTasks = [ "介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。\n Explain what Bailian CLI can help me accomplish, and recommend how to use it based on my needs.", ] as const; -void createCli( +const cli = createCli( { ...commands, ...commandAliases }, { binName: "bl", @@ -23,4 +23,6 @@ void createCli( quickStartTasks, commandPacks: commandPackPolicy, }, -).run(); +); + +void runToExit(cli); diff --git a/packages/kscli/src/main.ts b/packages/kscli/src/main.ts index fbd0f045..fb2a10ba 100644 --- a/packages/kscli/src/main.ts +++ b/packages/kscli/src/main.ts @@ -1,10 +1,12 @@ -import { createCli } from "bailian-cli-runtime"; +import { createCli, runToExit } from "bailian-cli-runtime"; import { commands } from "./commands.ts"; import pkg from "../package.json" with { type: "json" }; -void createCli(commands, { +const cli = createCli(commands, { binName: "kscli", version: pkg.version, clientName: "knowledge-studio-cli", npmPackage: "knowledge-studio-cli", -}).run(); +}); + +void runToExit(cli); diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index cbaa26a6..f7095265 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -134,6 +134,26 @@ function handleInterrupt(): void { void flushTelemetry(500).finally(() => process.exit(130)); } +/** + * Run the CLI and end the process once the invocation is complete. + * + * Every background task (update check, telemetry flush) is awaited inside `run()`, so nothing + * useful is left once it resolves. Without an explicit exit, keep-alive sockets held by the HTTP + * client can keep a finished process alive for tens of seconds (observed on the Bun release binary). + * Errors still exit through `handleError`. + */ +export async function runToExit(cli: Cli, argv?: string[]): Promise { + await cli.run(argv); + const code = process.exitCode ?? 0; + const flush = (stream: NodeJS.WriteStream) => + new Promise((done) => { + stream.write("", () => done()); + }); + await flush(process.stdout); + await flush(process.stderr); + process.exit(code); +} + /** Keep EPIPE protection after run() returns, without retaining completed invocations. */ function handleStdoutPipeError(error: NodeJS.ErrnoException): void { if (error.code === "EPIPE") process.exit(0); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6203e5f0..121a86f4 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -2,7 +2,7 @@ // each entrypoint (bl / rag / …) injects its own command set via createCli(). // Entrypoint factory + identity -export { createCli, registerInterruptHandler } from "./create-cli.ts"; +export { createCli, registerInterruptHandler, runToExit } from "./create-cli.ts"; export { buildCommandSchema, serializeCommandSchema } from "./introspect.ts"; export type { CommandSchema, CommandSchemaNode } from "./introspect.ts"; export type { Cli, CliOptions } from "./create-cli.ts"; diff --git a/packages/runtime/tests/run-to-exit.test.ts b/packages/runtime/tests/run-to-exit.test.ts new file mode 100644 index 00000000..e617f16f --- /dev/null +++ b/packages/runtime/tests/run-to-exit.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { runToExit } from "../src/index.ts"; + +const originalExitCode = process.exitCode; + +afterEach(() => { + process.exitCode = originalExitCode; + vi.restoreAllMocks(); +}); + +function stubExit() { + return vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); +} + +test("runToExit exits with code 0 once run() resolves", async () => { + const exit = stubExit(); + process.exitCode = undefined; + const events: string[] = []; + const run = vi.fn(async () => { + events.push("run"); + }); + exit.mockImplementation((() => { + events.push("exit"); + }) as never); + + await runToExit({ run }, ["x"]); + + expect(run).toHaveBeenCalledWith(["x"]); + expect(events).toEqual(["run", "exit"]); + expect(exit).toHaveBeenCalledWith(0); +}); + +test("runToExit keeps the exit code the run set", async () => { + const exit = stubExit(); + await runToExit({ + async run() { + process.exitCode = 3; + }, + }); + + expect(exit).toHaveBeenCalledWith(3); +}); + +test("runToExit does not exit while run() is still pending", async () => { + const exit = stubExit(); + let finish!: () => void; + const pending = runToExit({ run: () => new Promise((resolve) => (finish = resolve)) }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(exit).not.toHaveBeenCalled(); + + finish(); + await pending; + expect(exit).toHaveBeenCalledTimes(1); +});