diff --git a/sdk/cli/src/commands/index.ts b/sdk/cli/src/commands/index.ts index e3dfbf93a..2dde52d98 100644 --- a/sdk/cli/src/commands/index.ts +++ b/sdk/cli/src/commands/index.ts @@ -1,7 +1,7 @@ import { Command, CommanderError } from "@commander-js/extra-typings"; import { AbortPromptError, CancelPromptError, ExitPromptError, ValidationError } from "@inquirer/core"; import { ascii, CancelError, maskTokens, note, SpinnerError } from "@settlemint/sdk-utils/terminal"; -import { magentaBright, redBright } from "yoctocolors"; +import { magentaBright } from "yoctocolors"; import { telemetry } from "@/utils/telemetry"; import pkg from "../../package.json"; import { getInstalledSdkVersion, validateSdkVersionFromCommand } from "../utils/sdk-version"; @@ -113,8 +113,7 @@ async function onError(sdkcli: ExtendedCommand, argv: string[], error: Error) { } if (!(error instanceof CancelError || error instanceof SpinnerError)) { - const errorMessage = maskTokens(error.message); - note(redBright(`Unknown error: ${errorMessage}\n\n${error.stack}`)); + note(error, "error"); } // Get the command path from the command that threw the error diff --git a/sdk/utils/src/environment/write-env.test.ts b/sdk/utils/src/environment/write-env.test.ts index f478a31f2..36048337e 100644 --- a/sdk/utils/src/environment/write-env.test.ts +++ b/sdk/utils/src/environment/write-env.test.ts @@ -80,8 +80,7 @@ describe("writeEnv", () => { }); it("should merge with existing environment variables", async () => { - const existingEnv = - "EXISTING_VAR=existing\nSETTLEMINT_INSTANCE=https://old.example.com"; + const existingEnv = "EXISTING_VAR=existing\nSETTLEMINT_INSTANCE=https://old.example.com"; await writeFile(ENV_FILE, existingEnv); const newEnv = { @@ -104,10 +103,7 @@ describe("writeEnv", () => { it("should handle arrays and objects", async () => { const env = { - SETTLEMINT_THEGRAPH_SUBGRAPHS_ENDPOINTS: [ - "https://graph1.example.com", - "https://graph2.example.com", - ], + SETTLEMINT_THEGRAPH_SUBGRAPHS_ENDPOINTS: ["https://graph1.example.com", "https://graph2.example.com"], }; await writeEnv({ @@ -137,18 +133,11 @@ describe("writeEnv", () => { cwd: TEST_DIR, }); const initialContent = await Bun.file(ENV_FILE).text(); - expect(initialContent).toContain( - "SETTLEMINT_INSTANCE=https://dev.example.com", - ); - expect(initialContent).toContain( - "SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment", - ); + expect(initialContent).toContain("SETTLEMINT_INSTANCE=https://dev.example.com"); + expect(initialContent).toContain("SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment"); expect(initialContent).toContain("SETTLEMINT_WORKSPACE=test-workspace"); expect(initialContent).toContain("MY_VAR=my-value"); - const { - SETTLEMINT_CUSTOM_DEPLOYMENT: _SETTLEMINT_CUSTOM_DEPLOYMENT, - ...existingEnv - } = initialEnv; + const { SETTLEMINT_CUSTOM_DEPLOYMENT: _SETTLEMINT_CUSTOM_DEPLOYMENT, ...existingEnv } = initialEnv; await writeEnv({ prod: false, @@ -159,12 +148,8 @@ describe("writeEnv", () => { const updatedContent = await Bun.file(ENV_FILE).text(); expect(updatedContent).toContain("SETTLEMINT_WORKSPACE=test-workspace"); - expect(updatedContent).toContain( - "SETTLEMINT_INSTANCE=https://dev.example.com", - ); - expect(updatedContent).not.toContain( - "SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment", - ); + expect(updatedContent).toContain("SETTLEMINT_INSTANCE=https://dev.example.com"); + expect(updatedContent).not.toContain("SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment"); expect(updatedContent).toContain("MY_VAR=my-value"); }); }); diff --git a/sdk/utils/src/terminal/execute-command.test.ts b/sdk/utils/src/terminal/execute-command.test.ts index bc317b6b3..30520c3a5 100644 --- a/sdk/utils/src/terminal/execute-command.test.ts +++ b/sdk/utils/src/terminal/execute-command.test.ts @@ -109,4 +109,140 @@ describe("executeCommand", () => { expect(output.some((line) => line.includes("test"))).toBe(true); }); + + test("quiet mode suppresses output on success", async () => { + const originalCLAUDECODE = process.env.CLAUDECODE; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.CLAUDECODE = "true"; + await executeCommand("echo", ["quiet mode test"]); + expect(stdoutWritten).toBe(false); + } finally { + process.stdout.write = originalWrite; + if (originalCLAUDECODE === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = originalCLAUDECODE; + } + } + }); + + test("quiet mode shows output on error", async () => { + const originalCLAUDECODE = process.env.CLAUDECODE; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + let outputShown = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + outputShown = true; + return true; + }); + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stderr.write = mock((_chunk: any) => { + outputShown = true; + return true; + }); + + try { + process.env.CLAUDECODE = "true"; + await expect(() => + executeCommand("node", ["-e", "console.log('output'); console.error('error'); process.exit(1);"]), + ).toThrow(); + // Output should be shown on error even in quiet mode + expect(outputShown).toBe(true); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + if (originalCLAUDECODE === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = originalCLAUDECODE; + } + } + }); + + test("quiet mode respects silent: false to force output", async () => { + const originalCLAUDECODE = process.env.CLAUDECODE; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.CLAUDECODE = "true"; + await executeCommand("echo", ["force output in quiet mode"], { silent: false }); + expect(stdoutWritten).toBe(true); + } finally { + process.stdout.write = originalWrite; + if (originalCLAUDECODE === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = originalCLAUDECODE; + } + } + }); + + test("quiet mode works with REPL_ID environment variable", async () => { + const originalREPL_ID = process.env.REPL_ID; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.REPL_ID = "test-repl"; + await executeCommand("echo", ["repl quiet test"]); + expect(stdoutWritten).toBe(false); + } finally { + process.stdout.write = originalWrite; + if (originalREPL_ID === undefined) { + delete process.env.REPL_ID; + } else { + process.env.REPL_ID = originalREPL_ID; + } + } + }); + + test("quiet mode works with AGENT environment variable", async () => { + const originalAGENT = process.env.AGENT; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.AGENT = "true"; + await executeCommand("echo", ["agent quiet test"]); + expect(stdoutWritten).toBe(false); + } finally { + process.stdout.write = originalWrite; + if (originalAGENT === undefined) { + delete process.env.AGENT; + } else { + process.env.AGENT = originalAGENT; + } + } + }); }); diff --git a/sdk/utils/src/terminal/execute-command.ts b/sdk/utils/src/terminal/execute-command.ts index 4ebc470aa..3f3dc380e 100644 --- a/sdk/utils/src/terminal/execute-command.ts +++ b/sdk/utils/src/terminal/execute-command.ts @@ -29,10 +29,19 @@ export class CommandError extends Error { } } +/** + * Checks if we're in quiet mode (Claude Code environment) + */ +function isQuietMode(): boolean { + return !!(process.env.CLAUDECODE || process.env.REPL_ID || process.env.AGENT); +} + /** * Executes a command with the given arguments in a child process. * Pipes stdin to the child process and captures stdout/stderr output. * Masks any sensitive tokens in the output before displaying or returning. + * In quiet mode (when CLAUDECODE, REPL_ID, or AGENT env vars are set), + * output is suppressed unless the command errors out. * * @param command - The command to execute * @param args - Array of arguments to pass to the command @@ -54,26 +63,50 @@ export async function executeCommand( options?: ExecuteCommandOptions, ): Promise { const { silent, ...spawnOptions } = options ?? {}; + const quietMode = isQuietMode(); + // In quiet mode, suppress output unless explicitly overridden with silent: false + const shouldSuppressOutput = quietMode ? silent !== false : !!silent; + const child = spawn(command, args, { ...spawnOptions, env: { ...process.env, ...options?.env } }); process.stdin.pipe(child.stdin); const output: string[] = []; + const stdoutOutput: string[] = []; + const stderrOutput: string[] = []; + return new Promise((resolve, reject) => { child.stdout.on("data", (data: Buffer | string) => { const maskedData = maskTokens(data.toString()); - if (!silent) { + if (!shouldSuppressOutput) { process.stdout.write(maskedData); } output.push(maskedData); + stdoutOutput.push(maskedData); }); child.stderr.on("data", (data: Buffer | string) => { const maskedData = maskTokens(data.toString()); - if (!silent) { + if (!shouldSuppressOutput) { process.stderr.write(maskedData); } output.push(maskedData); + stderrOutput.push(maskedData); }); + + const showErrorOutput = () => { + // In quiet mode, show output on error + if (quietMode && shouldSuppressOutput && output.length > 0) { + // Write stdout to stdout and stderr to stderr + if (stdoutOutput.length > 0) { + process.stdout.write(stdoutOutput.join("")); + } + if (stderrOutput.length > 0) { + process.stderr.write(stderrOutput.join("")); + } + } + }; + child.on("error", (err) => { process.stdin.unpipe(child.stdin); + showErrorOutput(); reject(new CommandError(err.message, "code" in err && typeof err.code === "number" ? err.code : 1, output)); }); child.on("close", (code) => { @@ -82,6 +115,8 @@ export async function executeCommand( resolve(output); return; } + // In quiet mode, show output on error + showErrorOutput(); reject(new CommandError(`Command "${command}" exited with code ${code}`, code, output)); }); }); diff --git a/sdk/utils/src/terminal/note.ts b/sdk/utils/src/terminal/note.ts index 0dff7f3b9..d13ee59b1 100644 --- a/sdk/utils/src/terminal/note.ts +++ b/sdk/utils/src/terminal/note.ts @@ -1,14 +1,72 @@ import { maskTokens } from "@/logging/mask-tokens.js"; -import { yellowBright } from "yoctocolors"; +import { redBright, yellowBright } from "yoctocolors"; import { shouldPrint } from "./should-print.js"; /** - * Displays a note message with optional warning level formatting. - * Regular notes are displayed in normal text, while warnings are shown in yellow. + * Applies color to a message if not already colored. + * @param msg - The message to colorize + * @param level - The severity level determining the color + * @returns Colorized message (yellow for warnings, red for errors, unchanged for info) + */ +function colorize(msg: string, level: "info" | "warn" | "error"): string { + // Don't re-colorize messages that already contain ANSI escape codes + if (msg.includes("\u001b[")) { + return msg; + } + if (level === "warn") { + return yellowBright(msg); + } + if (level === "error") { + return redBright(msg); + } + return msg; +} + +/** + * Determines whether a message should be printed based on its level and quiet mode. + * @param level - The severity level of the message + * @returns true if the message should be printed, false otherwise + */ +function canPrint(level: "info" | "warn" | "error"): boolean { + // Warnings and errors always print, even in quiet mode + if (level !== "info") { + return true; + } + // Info messages respect shouldPrint() which checks for quiet mode + return shouldPrint(); +} + +/** + * Prepares a message for display by converting Error objects and masking tokens. + * @param value - The message string or Error object + * @param level - The severity level (stack traces are included for errors) + * @returns Masked message text, optionally with stack trace + */ +function prepareMessage(value: string | Error, level: "info" | "warn" | "error"): string { + let text: string; + if (value instanceof Error) { + text = value.message; + // For errors, automatically include stack trace + if (level === "error" && value.stack) { + text = `${text}\n\n${value.stack}`; + } + } else { + text = value; + } + return maskTokens(text); +} + +/** + * Displays a note message with optional warning or error level formatting. + * Regular notes are displayed in normal text, warnings are shown in yellow, and errors in red. * Any sensitive tokens in the message are masked before display. + * Warnings and errors are always displayed, even in quiet mode (when CLAUDECODE, REPL_ID, or AGENT env vars are set). + * When an Error object is provided with level "error", the stack trace is automatically included. * - * @param message - The message to display as a note - * @param level - The note level: "info" (default) or "warn" for warning styling + * @param message - The message to display as a note. Can be either: + * - A string: Displayed directly with appropriate styling + * - An Error object: The error message is displayed, and for level "error", the stack trace is automatically included + * @param level - The note level: "info" (default), "warn" for warning styling, or "error" for error styling * @example * import { note } from "@settlemint/sdk-utils/terminal"; * @@ -17,18 +75,31 @@ import { shouldPrint } from "./should-print.js"; * * // Display warning note * note("Low disk space remaining", "warn"); + * + * // Display error note (string) + * note("Operation failed", "error"); + * + * // Display error with stack trace automatically (Error object) + * try { + * // some operation + * } catch (error) { + * // If error is an Error object and level is "error", stack trace is included automatically + * note(error, "error"); + * } */ -export const note = (message: string, level: "info" | "warn" = "info"): void => { - if (!shouldPrint()) { +export const note = (message: string | Error, level: "info" | "warn" | "error" = "info"): void => { + if (!canPrint(level)) { return; } - const maskedMessage = maskTokens(message); + const msg = prepareMessage(message, level); console.log(""); + if (level === "warn") { - console.warn(yellowBright(maskedMessage)); - return; + console.warn(colorize(msg, level)); + } else if (level === "error") { + console.error(colorize(msg, level)); + } else { + console.log(msg); } - - console.log(maskedMessage); }; diff --git a/sdk/utils/src/terminal/should-print.ts b/sdk/utils/src/terminal/should-print.ts index c9b2bde7f..24b95bebf 100644 --- a/sdk/utils/src/terminal/should-print.ts +++ b/sdk/utils/src/terminal/should-print.ts @@ -1,7 +1,25 @@ /** - * Returns true if the terminal should print, false otherwise. - * @returns true if the terminal should print, false otherwise. + * Determines whether terminal output should be printed based on environment variables. + * + * **Environment Variable Precedence:** + * 1. `SETTLEMINT_DISABLE_TERMINAL="true"` - Completely disables all terminal output (highest priority) + * 2. `CLAUDECODE`, `REPL_ID`, or `AGENT` (any truthy value) - Enables quiet mode, suppressing info/debug/status messages + * + * **Quiet Mode Behavior:** + * When quiet mode is active (Claude Code environments), this function returns `false` to suppress + * informational output. However, warnings and errors are always displayed regardless of quiet mode, + * as they are handled separately in the `note()` function with level-based filtering. + * + * @returns `true` if terminal output should be printed, `false` if suppressed */ -export function shouldPrint() { - return process.env.SETTLEMINT_DISABLE_TERMINAL !== "true"; +export function shouldPrint(): boolean { + if (process.env.SETTLEMINT_DISABLE_TERMINAL === "true") { + return false; + } + // In quiet mode (Claude Code), suppress info/debug/status messages + // Warnings and errors will still be displayed via note() with appropriate levels + if (process.env.CLAUDECODE || process.env.REPL_ID || process.env.AGENT) { + return false; + } + return true; } diff --git a/sdk/utils/src/terminal/spinner.ts b/sdk/utils/src/terminal/spinner.ts index 303f049b3..79a05297f 100644 --- a/sdk/utils/src/terminal/spinner.ts +++ b/sdk/utils/src/terminal/spinner.ts @@ -1,7 +1,6 @@ import isInCi from "is-in-ci"; import yoctoSpinner, { type Spinner } from "yocto-spinner"; import { redBright } from "yoctocolors"; -import { maskTokens } from "../logging/mask-tokens.js"; import { note } from "./note.js"; import { shouldPrint } from "./should-print.js"; @@ -54,9 +53,8 @@ export interface SpinnerOptions { */ export const spinner = async (options: SpinnerOptions): Promise => { const handleError = (error: Error) => { - const errorMessage = maskTokens(error.message); - note(redBright(`${errorMessage}\n\n${error.stack}`)); - throw new SpinnerError(errorMessage, error); + note(error, "error"); + throw new SpinnerError(error.message, error); }; if (isInCi || !shouldPrint()) { try { diff --git a/turbo.json b/turbo.json index fcd4a3291..462a275f4 100644 --- a/turbo.json +++ b/turbo.json @@ -6,7 +6,8 @@ "build": { "dependsOn": ["^build", "codegen"], "inputs": ["$TURBO_DEFAULT$", ".env*", "tsdown.config.ts", "../../shared/tsdown-factory.ts"], - "outputs": ["dist/**", ".next/**", "!.next/cache/**", "artifacts/**"] + "outputs": ["dist/**", ".next/**", "!.next/cache/**", "artifacts/**"], + "outputLogs": "new-only" }, "codegen": { "inputs": ["graphql/**"], @@ -16,50 +17,62 @@ "src/examples/schemas/**", "src/portal/portal-cache.d.ts", "src/portal/portal-env.d.ts" - ] + ], + "outputLogs": "new-only" }, "publish-npm": { "dependsOn": ["build"], - "cache": false + "cache": false, + "outputLogs": "new-only" }, "//#lint:biome": { "dependsOn": ["//#format:biome"] }, "//#format:biome": {}, "lint": { - "dependsOn": ["^build", "//#lint:biome"] + "dependsOn": ["^build", "//#lint:biome"], + "outputLogs": "new-only" }, "//#knip": { - "cache": false + "cache": false, + "outputLogs": "new-only" }, "docker": { - "cache": false + "cache": false, + "outputLogs": "new-only" }, "attw": { - "dependsOn": ["build"] + "dependsOn": ["build"], + "outputLogs": "new-only" }, - "publint": { "dependsOn": ["build"] }, + "publint": { "dependsOn": ["build"], "outputLogs": "new-only" }, "dev": { "dependsOn": ["^build", "codegen"], "cache": false, - "persistent": true + "persistent": true, + "outputLogs": "new-only" }, "test": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "outputLogs": "new-only" }, "test:coverage": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "outputLogs": "new-only" }, "translate": { - "inputs": ["messages/**"] + "inputs": ["messages/**"], + "outputLogs": "new-only" }, "typecheck": { - "dependsOn": ["^build", "codegen"] + "dependsOn": ["^build", "codegen"], + "outputLogs": "new-only" }, "docs": { "dependsOn": ["^build", "codegen"], "inputs": ["src/**", "scripts/create-docs.ts", "../../typedoc.config.mjs", "../../package.json"], - "outputs": ["docs/**", "!docs/ABOUT.md"] + "outputs": ["docs/**", "!docs/ABOUT.md"], + "outputLogs": "new-only" }, "//#generate-readme": { "dependsOn": ["^build"], @@ -69,7 +82,8 @@ "sdk/**/docs/**", "sdk/**/examples/**" ], - "outputs": ["sdk/**/README.md"] + "outputs": ["sdk/**/README.md"], + "outputLogs": "new-only" } } }