mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
fix(update-check): redirect install script stdout to stderr in --output json mode (#2919)
When --output json is requested, the auto-update install script was running with stdio: "inherit", causing [spawn] install messages to pollute stdout before the JSON result, breaking JSON consumers. Fix: - Pre-scan process.argv for --output json before checkForUpdates() is called in index.ts (formal flag parsing happens later at line 944) - Pass jsonOutput flag through checkForUpdates() -> performAutoUpdate() - When jsonOutput=true, use stdio: ["pipe", stderr, stderr] for the install script execution so all output goes to stderr only - Set SPAWN_CLI_UPDATED=1 env var on re-exec so JSON consumers can detect the update via cli_updated: true in SpawnResult - Add cli_updated?: boolean to SpawnResult interface in commands/run.ts - Add tests covering both json and non-json stdio behavior Fixes #2918 Agent: issue-fixer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c1e6fb76f9
commit
e0db833307
5 changed files with 103 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.25.21",
|
||||
"version": "0.25.22",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ExecFileSyncOptions } from "node:child_process";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
|
@ -208,6 +210,75 @@ describe("update-check", () => {
|
|||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should redirect install script stdout to stderr when jsonOutput=true", async () => {
|
||||
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
|
||||
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
|
||||
|
||||
const { executor } = await import("../update-check.js");
|
||||
const execFileSyncCalls: {
|
||||
file: string;
|
||||
args: string[];
|
||||
options?: ExecFileSyncOptions;
|
||||
}[] = [];
|
||||
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(
|
||||
(file: string, args: string[], options?: ExecFileSyncOptions) => {
|
||||
execFileSyncCalls.push({
|
||||
file,
|
||||
args,
|
||||
options,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { checkForUpdates } = await import("../update-check.js");
|
||||
await checkForUpdates(true); // jsonOutput = true
|
||||
|
||||
// bash call (install script) should have stdio redirected to stderr (not inherit)
|
||||
const bashCall = execFileSyncCalls.find((c) => c.file === "bash");
|
||||
expect(bashCall).toBeDefined();
|
||||
// stdio should be an array (not "inherit") to avoid stdout pollution
|
||||
expect(Array.isArray(bashCall?.options?.stdio)).toBe(true);
|
||||
|
||||
// re-exec should set SPAWN_CLI_UPDATED=1
|
||||
const reexecCall = execFileSyncCalls[execFileSyncCalls.length - 1];
|
||||
expect(reexecCall?.options?.env?.SPAWN_CLI_UPDATED).toBe("1");
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
execFileSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should use inherit stdio for install script when jsonOutput=false", async () => {
|
||||
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
|
||||
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
|
||||
|
||||
const { executor } = await import("../update-check.js");
|
||||
const execFileSyncCalls: {
|
||||
file: string;
|
||||
args: string[];
|
||||
options?: ExecFileSyncOptions;
|
||||
}[] = [];
|
||||
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(
|
||||
(file: string, args: string[], options?: ExecFileSyncOptions) => {
|
||||
execFileSyncCalls.push({
|
||||
file,
|
||||
args,
|
||||
options,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { checkForUpdates } = await import("../update-check.js");
|
||||
await checkForUpdates(false); // jsonOutput = false (default)
|
||||
|
||||
// bash call (install script) should use "inherit" when not in JSON mode
|
||||
const bashCall = execFileSyncCalls.find((c) => c.file === "bash");
|
||||
expect(bashCall).toBeDefined();
|
||||
expect(bashCall?.options?.stdio).toBe("inherit");
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
execFileSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should re-exec with original args after successful update", async () => {
|
||||
const originalArgv = process.argv;
|
||||
process.argv = [
|
||||
|
|
|
|||
|
|
@ -764,6 +764,7 @@ interface SpawnResult {
|
|||
ssh_user?: string;
|
||||
error_message?: string;
|
||||
error_code?: string;
|
||||
cli_updated?: boolean;
|
||||
}
|
||||
|
||||
function headlessOutput(result: SpawnResult, outputFormat?: string): void {
|
||||
|
|
@ -1149,6 +1150,11 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles
|
|||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(process.env.SPAWN_CLI_UPDATED === "1"
|
||||
? {
|
||||
cli_updated: true,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
headlessOutput(result, outputFormat);
|
||||
|
|
|
|||
|
|
@ -799,7 +799,12 @@ async function main(): Promise<void> {
|
|||
|
||||
const args = expandEqualsFlags(rawArgs);
|
||||
|
||||
await checkForUpdates();
|
||||
// Pre-scan for --output json before checkForUpdates() so install script
|
||||
// stdout can be redirected to stderr, preventing JSON output pollution.
|
||||
const preOutputIdx = args.indexOf("--output");
|
||||
const isJsonOutput = preOutputIdx !== -1 && args[preOutputIdx + 1] === "json";
|
||||
|
||||
await checkForUpdates(isJsonOutput);
|
||||
|
||||
const [prompt, filteredArgs] = await resolvePrompt(args);
|
||||
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ function reExecWithArgs(): void {
|
|||
env: {
|
||||
...process.env,
|
||||
SPAWN_NO_UPDATE_CHECK: "1",
|
||||
SPAWN_CLI_UPDATED: "1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -231,12 +232,22 @@ function reExecWithArgs(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function performAutoUpdate(latestVersion: string): void {
|
||||
function performAutoUpdate(latestVersion: string, jsonOutput = false): void {
|
||||
printUpdateBanner(latestVersion);
|
||||
|
||||
const installUrl = getInstallScriptUrl(SPAWN_CDN);
|
||||
const installCmd = getInstallCmd(SPAWN_CDN);
|
||||
|
||||
// When JSON output is active, redirect install script stdout to stderr to
|
||||
// avoid polluting stdout with [spawn] install messages before the JSON result.
|
||||
const installStdio: ExecFileSyncOptions["stdio"] = jsonOutput
|
||||
? [
|
||||
"pipe",
|
||||
process.stderr,
|
||||
process.stderr,
|
||||
]
|
||||
: "inherit";
|
||||
|
||||
const updateResult = tryCatch(() => {
|
||||
// Fetch script bytes with curl (available on all modern platforms)
|
||||
const scriptBytes = executor.execFileSync(
|
||||
|
|
@ -272,7 +283,7 @@ function performAutoUpdate(latestVersion: string): void {
|
|||
tmpFile,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
stdio: installStdio,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -290,7 +301,7 @@ function performAutoUpdate(latestVersion: string): void {
|
|||
scriptContent,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
stdio: installStdio,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -318,8 +329,11 @@ function performAutoUpdate(latestVersion: string): void {
|
|||
/**
|
||||
* Check for updates and auto-update if available.
|
||||
* Caches successful checks for 1 hour to avoid blocking every run with network I/O.
|
||||
*
|
||||
* @param jsonOutput - When true, redirects install script stdout to stderr so
|
||||
* [spawn] install messages do not pollute structured JSON output on stdout.
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
export async function checkForUpdates(jsonOutput = false): Promise<void> {
|
||||
// Skip in test environment
|
||||
if (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test") {
|
||||
return;
|
||||
|
|
@ -350,7 +364,7 @@ export async function checkForUpdates(): Promise<void> {
|
|||
|
||||
// Auto-update if newer version is available
|
||||
if (compareVersions(VERSION, latestVersion)) {
|
||||
const r = tryCatch(() => performAutoUpdate(latestVersion));
|
||||
const r = tryCatch(() => performAutoUpdate(latestVersion, jsonOutput));
|
||||
if (!r.ok) {
|
||||
logWarn("Auto-update encountered an error");
|
||||
logDebug(getErrorMessage(r.error));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue