From 91d85e70c3e7bd06ef6bfe090e8493030652c914 Mon Sep 17 00:00:00 2001 From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Date: Fri, 22 May 2026 15:51:34 -0700 Subject: [PATCH] Scope config preflight note suppression (#84439) --- src/cli/program/config-guard.test.ts | 62 ++++++++++++++++++++++++---- src/cli/program/config-guard.ts | 16 +------ src/terminal/note.ts | 11 ++++- 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 3462b39f82c..f6c1cab98e1 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { note } from "../../terminal/note.js"; import { formatCliCommand } from "../command-format.js"; import { ensureConfigReady, testApi } from "./config-guard.js"; @@ -39,10 +40,20 @@ function plainErrorCalls(runtime: ReturnType): string[] { async function withCapturedStdout(run: () => Promise): Promise { const writes: string[] = []; - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { - writes.push(String(chunk)); - return true; - }) as typeof process.stdout.write); + const writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation( + (( + chunk: unknown, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) => { + writes.push(String(chunk)); + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + done?.(); + return true; + }) as typeof process.stdout.write, + ); try { await run(); return writes.join(""); @@ -212,9 +223,9 @@ describe("ensureConfigReady", () => { expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1); }); - it("prevents preflight stdout noise when suppression is enabled", async () => { + it("prevents preflight note noise when suppression is enabled", async () => { loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => { - process.stdout.write("Doctor warnings\n"); + note("Doctor warnings", "Config warnings"); return { snapshot: makeSnapshot(), baseConfig: {}, @@ -226,9 +237,9 @@ describe("ensureConfigReady", () => { expect(output).not.toContain("Doctor warnings"); }); - it("allows preflight stdout noise when suppression is not enabled", async () => { + it("allows preflight note noise when suppression is not enabled", async () => { loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => { - process.stdout.write("Doctor warnings\n"); + note("Doctor warnings", "Config warnings"); return { snapshot: makeSnapshot(), baseConfig: {}, @@ -239,4 +250,39 @@ describe("ensureConfigReady", () => { }); expect(output).toContain("Doctor warnings"); }); + + it("does not suppress unrelated concurrent stdout writes while suppressing preflight notes", async () => { + let releasePreflight: (() => void) | undefined; + let preflightStarted: (() => void) | undefined; + const preflightStartedPromise = new Promise((resolve) => { + preflightStarted = resolve; + }); + const releasePreflightPromise = new Promise((resolve) => { + releasePreflight = resolve; + }); + loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => { + note("Doctor warnings", "Config warnings"); + preflightStarted?.(); + await releasePreflightPromise; + return { + snapshot: makeSnapshot(), + baseConfig: {}, + }; + }); + + let callbackCalled = false; + const output = await withCapturedStdout(async () => { + const ready = runEnsureConfigReady(["message"], true); + await preflightStartedPromise; + process.stdout.write("Concurrent output\n", () => { + callbackCalled = true; + }); + releasePreflight?.(); + await ready; + }); + + expect(output).toContain("Concurrent output"); + expect(output).not.toContain("Doctor warnings"); + expect(callbackCalled).toBe(true); + }); }); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 770059f0b1a..23d7c12068d 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -1,5 +1,6 @@ import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { withSuppressedNotes } from "../../terminal/note.js"; import { shouldMigrateStateFromPath } from "../argv.js"; const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]); @@ -62,20 +63,7 @@ export async function ensureConfigReady(params: { if (!params.suppressDoctorStdout) { preflightSnapshot = (await runDoctorConfigPreflight()).snapshot; } else { - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalSuppressNotes = process.env.OPENCLAW_SUPPRESS_NOTES; - process.stdout.write = (() => true) as unknown as typeof process.stdout.write; - process.env.OPENCLAW_SUPPRESS_NOTES = "1"; - try { - preflightSnapshot = (await runDoctorConfigPreflight()).snapshot; - } finally { - process.stdout.write = originalStdoutWrite; - if (originalSuppressNotes === undefined) { - delete process.env.OPENCLAW_SUPPRESS_NOTES; - } else { - process.env.OPENCLAW_SUPPRESS_NOTES = originalSuppressNotes; - } - } + preflightSnapshot = (await withSuppressedNotes(runDoctorConfigPreflight)).snapshot; } } diff --git a/src/terminal/note.ts b/src/terminal/note.ts index bb64ad3efb5..798eda4cf76 100644 --- a/src/terminal/note.ts +++ b/src/terminal/note.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { note as clackNote } from "@clack/prompts"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { visibleWidth } from "./ansi.js"; @@ -7,6 +8,7 @@ const MIN_NOTE_COLUMNS = 80; const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i; const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/; +const suppressNotesStorage = new AsyncLocalStorage(); function isSuppressedByEnv(value: string | undefined): boolean { if (!value) { @@ -197,7 +199,10 @@ function createNoteOutput(columns: number): NodeJS.WriteStream { } export function note(message: unknown, title?: string) { - if (isSuppressedByEnv(process.env.OPENCLAW_SUPPRESS_NOTES)) { + if ( + suppressNotesStorage.getStore() === true || + isSuppressedByEnv(process.env.OPENCLAW_SUPPRESS_NOTES) + ) { return; } const columns = resolveNoteColumns(process.stdout.columns); @@ -206,3 +211,7 @@ export function note(message: unknown, title?: string) { format: (line) => line, }); } + +export function withSuppressedNotes(callback: () => T): T { + return suppressNotesStorage.run(true, callback); +}