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:
A 2026-03-23 13:18:50 -07:00 committed by GitHub
parent c1e6fb76f9
commit e0db833307
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 103 additions and 7 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.25.21",
"version": "0.25.22",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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 = [

View file

@ -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);

View file

@ -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);

View file

@ -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));