From d4aec775e9c9ec9ed2a024219d4f60322dab3cf2 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 28 Aug 2026 14:12:56 +0200 Subject: [PATCH 1/6] feat(cli): compact build log output for native build server deploys Native and native-local-bundle deploys now render build server logs the way Depot builds do: a single spinner line that is updated with the latest log message, with the last 20 lines printed if the build fails. The previous timestamped line-by-line output is available with `--build-logs full` and is used automatically in CI, with --plain, or when stdout is not a TTY. The flag also applies to Depot and local docker builds. The two identical event-stream loops are replaced by one followBuildServerDeployment helper. --- .changeset/compact-native-build-logs.md | 5 + packages/cli-v3/src/commands/deploy.ts | 815 +++++++------------ packages/cli-v3/src/deploy/buildLogs.test.ts | 204 +++++ packages/cli-v3/src/deploy/buildLogs.ts | 194 +++++ 4 files changed, 677 insertions(+), 541 deletions(-) create mode 100644 .changeset/compact-native-build-logs.md create mode 100644 packages/cli-v3/src/deploy/buildLogs.test.ts create mode 100644 packages/cli-v3/src/deploy/buildLogs.ts diff --git a/.changeset/compact-native-build-logs.md b/.changeset/compact-native-build-logs.md new file mode 100644 index 00000000000..eb80c15ad9b --- /dev/null +++ b/.changeset/compact-native-build-logs.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Native build server deploys now show build logs the same way Depot builds do: a single spinner line updated with the latest message, with the last 20 lines printed if the build fails. Pass `--build-logs full` to stream every line; CI, `--plain` and piped output always use full. diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index f2661f412d4..e600215d2c2 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -10,10 +10,9 @@ import type { InitializeDeploymentRequestBody, InitializeDeploymentResponseBody, GitMeta, - DeploymentFinalizedEvent, DeploymentTriggeredVia, } from "@trigger.dev/core/v3/schemas"; -import { BuildManifest, DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; +import { BuildManifest } from "@trigger.dev/core/v3/schemas"; import type { Command } from "commander"; import { Option as CommandOption } from "commander"; import { join, relative, resolve } from "node:path"; @@ -26,6 +25,12 @@ import { buildWorker } from "../build/buildWorker.js"; import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; import { createBundleArchive } from "../deploy/bundleArchive.js"; +import { + BuildLogsMode, + createBuildLogRenderer, + resolveBuildLogsMode, + streamDeploymentEvents, +} from "../deploy/buildLogs.js"; import { applyBuildPathOptions, nativeOnlyFlagError, @@ -103,6 +108,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({ fromBundle: z.string().optional(), detach: z.boolean().default(false), plain: z.boolean().default(false), + buildLogs: BuildLogsMode.default("compact"), compression: z.enum(["zstd", "gzip"]).default("zstd"), cacheCompression: z.enum(["zstd", "gzip"]).default("zstd"), compressionLevel: z.number().optional(), @@ -301,6 +307,14 @@ export function configureDeployCommand(program: Command) { ) ) .addOption(new CommandOption("--plain", "Plain output").hideHelp()) + .addOption( + new CommandOption( + "--build-logs ", + "How to show the build logs: compact (a single updating line) or full (every line). CI and piped output always use full." + ) + .choices(["compact", "full"]) + .default("compact") + ) .action(async (path, options) => { await handleTelemetry(async () => { await printStandloneInitialBanner(true, options.profile); @@ -749,7 +763,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { compressionLevel: options.compressionLevel, forceCompression: options.forceCompression, onLog: (logMessage) => { - if (options.plain || isCI) { + if (showFullBuildLogs(options)) { console.log(logMessage); return; } @@ -867,7 +881,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { skipPushToRegistry: skipServerSideRegistryPush, }, (logMessage) => { - if (options.plain || isCI) { + if (showFullBuildLogs(options)) { console.log(logMessage); return; } @@ -1488,254 +1502,13 @@ async function handleNativeBuildServerDeploy({ return process.exit(0); } - const $queuedSpinner = spinner(); - $queuedSpinner.start("Build queued"); - - const abortController = new AbortController(); - - const s2 = new S2({ accessToken: eventStream.s2.accessToken }); - const basin = s2.basin(eventStream.s2.basin); - const stream = basin.stream(eventStream.s2.stream); - - const [readSessionError, readSession] = await tryCatch( - stream.readSession( - { - start: { from: { seqNum: 0 }, clamp: true }, - stop: { waitSecs: 60 * 20 }, // 20 minutes - }, - { signal: abortController.signal } - ) - ); - - if (readSessionError) { - $queuedSpinner.stop("Failed to query build progress"); - log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); - - outro( - `Version ${deployment.version} is being deployed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - - return process.exit(0); - } - - let finalDeploymentEvent: DeploymentFinalizedEvent["data"] | undefined; - let queuedSpinnerStopped = false; - - for await (const record of readSession) { - const decoded = record.body; - const result = DeploymentEventFromString.safeParse(decoded); - if (!result.success) { - logger.debug("Failed to parse deployment event, skipping", { - error: result.error, - record: decoded, - }); - continue; - } - - const event = result.data; - - switch (event.type) { - case "log": { - if (record.seqNum === 0) { - $queuedSpinner.stop("Build started"); - console.log("│"); - queuedSpinnerStopped = true; - } - - const formattedTimestamp = chalkGrey( - new Date(record.timestamp).toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }) - ); - - const { level, message } = event.data; - const formattedMessage = - level === "error" - ? chalk.bold(chalkError(message)) - : level === "warn" - ? chalkWarning(message) - : level === "debug" - ? chalkGrey(message) - : message; - - // We use console.log here instead of clack's logger as the current version does not support changing the line spacing. - // And the logs look verbose with the default spacing. - // We cannot upgrade because the newer versions introduced some weird issues with the spinner. - // Ideally, we'd use clack's `taskLog` to only show the recent n lines of logs as they are streamed, but that also seems brittle - // and has some issues with cursor movements/clearing lines that it shouldn't clear. - // We can revisit this on future versions of `@clack/prompts`. - console.log(`│ ${formattedTimestamp} ${formattedMessage}`); - break; - } - case "finalized": { - finalDeploymentEvent = event.data; - abortController.abort(); // stop the stream - break; - } - default: { - event satisfies never; - logger.debug("Unknown deployment event, skipping", { event }); - continue; - } - } - } - - if (!queuedSpinnerStopped && !finalDeploymentEvent) { - // unlikely that it happens in practice, only in rare corner cases - // the timeout would kick in earlier if the build server fails to dequeue the build - - $queuedSpinner.stop("Log stream stopped"); - - log.error("Failed dequeueing build, please try again shortly"); - - throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - - if (!finalDeploymentEvent) { - log.error( - "Stopped receiving updates from the build server, please check the deployment status in the dashboard" - ); - - if (!isLinksSupported) { - log.info(`View deployment: ${rawDeploymentLink}`); - } - - throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - - switch (finalDeploymentEvent.result) { - case "succeeded": { - queuedSpinnerStopped - ? log.success("Deployment completed successfully") - : $queuedSpinner.stop("Deployment completed successfully"); - - if (finalDeploymentEvent.message) { - log.success(finalDeploymentEvent.message); - } - - if (options.skipPromotion) { - log.info( - `This deployment was not automatically promoted. You can promote in the dashboard or via the promote command, e.g, \`npx trigger.dev promote ${deployment.version}\`.` - ); - } - - if (!isLinksSupported) { - log.info(`Test tasks: ${rawTestLink}`); - } - - outro( - `Version ${deployment.version} was deployed ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" - }` - ); - return process.exit(0); - } - case "failed": { - if (!queuedSpinnerStopped) { - $queuedSpinner.stop("Deployment failed"); - } - - log.error( - chalk.bold( - chalkError( - "Deployment failed" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") - ) - ) - ); - - throw new OutroCommandError( - `Version ${deployment.version} deployment failed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - case "timed_out": { - if (!queuedSpinnerStopped) { - $queuedSpinner.stop("Deployment timed out"); - } - - log.error( - chalk.bold( - chalkError( - "Deployment timed out" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") - ) - ) - ); - - throw new OutroCommandError( - `Version ${deployment.version} deployment timed out ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - case "canceled": { - if (!queuedSpinnerStopped) { - $queuedSpinner.stop("Deployment was canceled"); - } - - log.error( - chalk.bold( - chalkError( - "Deployment was canceled" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") - ) - ) - ); - - throw new OutroCommandError( - `Version ${deployment.version} deployment canceled ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - default: { - // This case is only relevant in case we extend the enum in the future. - // New enum values will not be treated as errors in older cli versions. - queuedSpinnerStopped - ? log.success("Log stream finished") - : $queuedSpinner.stop("Log stream finished"); - if (finalDeploymentEvent.message) { - log.message(finalDeploymentEvent.message); - } - - if (!isLinksSupported) { - log.info(`Test tasks: ${rawTestLink}`); - } - - outro( - `Version ${deployment.version} ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" - }` - ); - return process.exit(0); - } - } + await followBuildServerDeployment({ + deployment, + eventStream, + options, + rawDeploymentLink, + rawTestLink, + }); } export function verifyDirectory(dir: string, projectPath: string) { @@ -2060,298 +1833,57 @@ async function handleLocalBundleDeploy({ return process.exit(0); } - const $queuedSpinner = spinner(); - $queuedSpinner.start("Build queued"); + await followBuildServerDeployment({ + deployment, + eventStream, + options, + rawDeploymentLink, + rawTestLink, + }); +} - const abortController = new AbortController(); +// Builds the image locally from the bundle and finalizes the deployment. +async function buildAndFinalizeFromBundle({ + apiClient, + projectId, + projectRef, + deployment, + options, + dashboardUrl, + authAccessToken, + compilationPath, + buildEnvVars, + branch, + isLocalBuild, +}: { + apiClient: CliApiClient; + projectId: string; + projectRef: string; + deployment: Deployment; + options: DeployCommandOptions; + dashboardUrl: string; + authAccessToken: string; + compilationPath: string; + buildEnvVars: Record | undefined; + branch: string | undefined; + isLocalBuild: boolean; +}) { + const authenticateToTriggerRegistry = options.localBuild; + const skipServerSideRegistryPush = options.localBuild; - const s2 = new S2({ accessToken: eventStream.s2.accessToken }); - const basin = s2.basin(eventStream.s2.basin); - const stream = basin.stream(eventStream.s2.stream); + const version = deployment.version; - const [readSessionError, readSession] = await tryCatch( - stream.readSession( - { - start: { from: { seqNum: 0 }, clamp: true }, - stop: { waitSecs: 60 * 20 }, // 20 minutes - }, - { signal: abortController.signal } - ) - ); + const { rawDeploymentLink, rawTestLink } = buildDeploymentLinks({ + dashboardUrl, + projectRef, + env: options.env, + shortCode: deployment.shortCode, + }); - if (readSessionError) { - $queuedSpinner.stop("Failed to query build progress"); - log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); + const deploymentLink = cliLink("View deployment", rawDeploymentLink); + const testLink = cliLink("Test tasks", rawTestLink); - outro( - `Version ${deployment.version} is being deployed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - - return process.exit(0); - } - - let finalDeploymentEvent: DeploymentFinalizedEvent["data"] | undefined; - let queuedSpinnerStopped = false; - - for await (const record of readSession) { - const decoded = record.body; - const result = DeploymentEventFromString.safeParse(decoded); - if (!result.success) { - logger.debug("Failed to parse deployment event, skipping", { - error: result.error, - record: decoded, - }); - continue; - } - - const event = result.data; - - switch (event.type) { - case "log": { - if (record.seqNum === 0) { - $queuedSpinner.stop("Build started"); - console.log("│"); - queuedSpinnerStopped = true; - } - - const formattedTimestamp = chalkGrey( - new Date(record.timestamp).toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }) - ); - - const { level, message } = event.data; - const formattedMessage = - level === "error" - ? chalk.bold(chalkError(message)) - : level === "warn" - ? chalkWarning(message) - : level === "debug" - ? chalkGrey(message) - : message; - - // We use console.log here instead of clack's logger as the current version does not support changing the line spacing. - // And the logs look verbose with the default spacing. - // We cannot upgrade because the newer versions introduced some weird issues with the spinner. - // Ideally, we'd use clack's `taskLog` to only show the recent n lines of logs as they are streamed, but that also seems brittle - // and has some issues with cursor movements/clearing lines that it shouldn't clear. - // We can revisit this on future versions of `@clack/prompts`. - console.log(`│ ${formattedTimestamp} ${formattedMessage}`); - break; - } - case "finalized": { - finalDeploymentEvent = event.data; - abortController.abort(); // stop the stream - break; - } - default: { - event satisfies never; - logger.debug("Unknown deployment event, skipping", { event }); - continue; - } - } - } - - if (!queuedSpinnerStopped && !finalDeploymentEvent) { - // unlikely that it happens in practice, only in rare corner cases - // the timeout would kick in earlier if the build server fails to dequeue the build - - $queuedSpinner.stop("Log stream stopped"); - - log.error("Failed dequeueing build, please try again shortly"); - - throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - - if (!finalDeploymentEvent) { - log.error( - "Stopped receiving updates from the build server, please check the deployment status in the dashboard" - ); - - if (!isLinksSupported) { - log.info(`View deployment: ${rawDeploymentLink}`); - } - - throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - - switch (finalDeploymentEvent.result) { - case "succeeded": { - queuedSpinnerStopped - ? log.success("Deployment completed successfully") - : $queuedSpinner.stop("Deployment completed successfully"); - - if (finalDeploymentEvent.message) { - log.success(finalDeploymentEvent.message); - } - - if (options.skipPromotion) { - log.info( - `This deployment was not automatically promoted. You can promote in the dashboard or via the promote command, e.g, \`npx trigger.dev promote ${deployment.version}\`.` - ); - } - - if (!isLinksSupported) { - log.info(`Test tasks: ${rawTestLink}`); - } - - outro( - `Version ${deployment.version} was deployed ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" - }` - ); - return process.exit(0); - } - case "failed": { - if (!queuedSpinnerStopped) { - $queuedSpinner.stop("Deployment failed"); - } - - log.error( - chalk.bold( - chalkError( - "Deployment failed" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") - ) - ) - ); - - throw new OutroCommandError( - `Version ${deployment.version} deployment failed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - case "timed_out": { - if (!queuedSpinnerStopped) { - $queuedSpinner.stop("Deployment timed out"); - } - - log.error( - chalk.bold( - chalkError( - "Deployment timed out" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") - ) - ) - ); - - throw new OutroCommandError( - `Version ${deployment.version} deployment timed out ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - case "canceled": { - if (!queuedSpinnerStopped) { - $queuedSpinner.stop("Deployment was canceled"); - } - - log.error( - chalk.bold( - chalkError( - "Deployment was canceled" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") - ) - ) - ); - - throw new OutroCommandError( - `Version ${deployment.version} deployment canceled ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" - }` - ); - } - default: { - // This case is only relevant in case we extend the enum in the future. - // New enum values will not be treated as errors in older cli versions. - queuedSpinnerStopped - ? log.success("Log stream finished") - : $queuedSpinner.stop("Log stream finished"); - if (finalDeploymentEvent.message) { - log.message(finalDeploymentEvent.message); - } - - if (!isLinksSupported) { - log.info(`Test tasks: ${rawTestLink}`); - } - - outro( - `Version ${deployment.version} ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" - }` - ); - return process.exit(0); - } - } -} - -// Builds the image locally from the bundle and finalizes the deployment. -async function buildAndFinalizeFromBundle({ - apiClient, - projectId, - projectRef, - deployment, - options, - dashboardUrl, - authAccessToken, - compilationPath, - buildEnvVars, - branch, - isLocalBuild, -}: { - apiClient: CliApiClient; - projectId: string; - projectRef: string; - deployment: Deployment; - options: DeployCommandOptions; - dashboardUrl: string; - authAccessToken: string; - compilationPath: string; - buildEnvVars: Record | undefined; - branch: string | undefined; - isLocalBuild: boolean; -}) { - const authenticateToTriggerRegistry = options.localBuild; - const skipServerSideRegistryPush = options.localBuild; - - const version = deployment.version; - - const { rawDeploymentLink, rawTestLink } = buildDeploymentLinks({ - dashboardUrl, - projectRef, - env: options.env, - shortCode: deployment.shortCode, - }); - - const deploymentLink = cliLink("View deployment", rawDeploymentLink); - const testLink = cliLink("Test tasks", rawTestLink); - - const $spinner = spinner({ plain: options.plain }); + const $spinner = spinner({ plain: options.plain }); const buildSuffix = isLocalBuild && process.env.TRIGGER_LOCAL_BUILD_LABEL_DISABLED !== "1" ? " (local)" : ""; @@ -2397,7 +1929,7 @@ async function buildAndFinalizeFromBundle({ compressionLevel: options.compressionLevel, forceCompression: options.forceCompression, onLog: (logMessage) => { - if (options.plain || isCI) { + if (showFullBuildLogs(options)) { console.log(logMessage); return; } @@ -2515,7 +2047,7 @@ async function buildAndFinalizeFromBundle({ skipPushToRegistry: skipServerSideRegistryPush, }, (logMessage) => { - if (options.plain || isCI) { + if (showFullBuildLogs(options)) { console.log(logMessage); return; } @@ -2745,3 +2277,204 @@ async function handleFromBundleDeploy({ isLocalBuild: true, }); } + +function buildLogsEnv(options: DeployCommandOptions) { + return { plain: options.plain, ci: isCI, tty: Boolean(process.stdout.isTTY) }; +} + +function showFullBuildLogs(options: DeployCommandOptions) { + return resolveBuildLogsMode(options.buildLogs, buildLogsEnv(options)) === "full"; +} + +async function followBuildServerDeployment({ + deployment, + eventStream, + options, + rawDeploymentLink, + rawTestLink, +}: { + deployment: Pick; + eventStream: NonNullable; + options: DeployCommandOptions; + rawDeploymentLink: string; + rawTestLink: string; +}): Promise { + const renderer = createBuildLogRenderer({ + mode: resolveBuildLogsMode(options.buildLogs, buildLogsEnv(options)), + title: `Building version ${deployment.version}`, + }); + + const abortController = new AbortController(); + + const s2 = new S2({ accessToken: eventStream.s2.accessToken }); + const basin = s2.basin(eventStream.s2.basin); + const stream = basin.stream(eventStream.s2.stream); + + const [readSessionError, readSession] = await tryCatch( + stream.readSession( + { + start: { from: { seqNum: 0 }, clamp: true }, + stop: { waitSecs: 60 * 20 }, // 20 minutes + }, + { signal: abortController.signal } + ) + ); + + if (readSessionError) { + renderer.finish("Failed to query build progress", "failure"); + log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); + + outro( + `Version ${deployment.version} is being deployed ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + + return process.exit(0); + } + + const finalDeploymentEvent = await streamDeploymentEvents(readSession, renderer, () => + abortController.abort() + ); + + if (!renderer.started && !finalDeploymentEvent) { + // unlikely that it happens in practice, only in rare corner cases + // the timeout would kick in earlier if the build server fails to dequeue the build + + renderer.finish("Log stream stopped", "failure"); + + log.error("Failed dequeueing build, please try again shortly"); + + throw new OutroCommandError( + `Version ${deployment.version} ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + + if (!finalDeploymentEvent) { + log.error( + "Stopped receiving updates from the build server, please check the deployment status in the dashboard" + ); + + if (!isLinksSupported) { + log.info(`View deployment: ${rawDeploymentLink}`); + } + + throw new OutroCommandError( + `Version ${deployment.version} ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + + switch (finalDeploymentEvent.result) { + case "succeeded": { + renderer.finish("Deployment completed successfully", "success"); + + if (finalDeploymentEvent.message) { + log.success(finalDeploymentEvent.message); + } + + if (options.skipPromotion) { + log.info( + `This deployment was not automatically promoted. You can promote in the dashboard or via the promote command, e.g, \`npx trigger.dev promote ${deployment.version}\`.` + ); + } + + if (!isLinksSupported) { + log.info(`Test tasks: ${rawTestLink}`); + } + + outro( + `Version ${deployment.version} was deployed ${ + isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" + }` + ); + return process.exit(0); + } + case "failed": { + renderer.finish("Deployment failed", "failure"); + + log.error( + chalk.bold( + chalkError( + "Deployment failed" + + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + ) + ) + ); + + throw new OutroCommandError( + `Version ${deployment.version} deployment failed ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + case "timed_out": { + renderer.finish("Deployment timed out", "failure"); + + log.error( + chalk.bold( + chalkError( + "Deployment timed out" + + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + ) + ) + ); + + throw new OutroCommandError( + `Version ${deployment.version} deployment timed out ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + case "canceled": { + renderer.finish("Deployment was canceled", "failure"); + + log.error( + chalk.bold( + chalkError( + "Deployment was canceled" + + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + ) + ) + ); + + throw new OutroCommandError( + `Version ${deployment.version} deployment canceled ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + default: { + // This case is only relevant in case we extend the enum in the future. + // New enum values will not be treated as errors in older cli versions. + renderer.finish("Log stream finished", "success"); + if (finalDeploymentEvent.message) { + log.message(finalDeploymentEvent.message); + } + + if (!isLinksSupported) { + log.info(`Test tasks: ${rawTestLink}`); + } + + outro( + `Version ${deployment.version} ${ + isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" + }` + ); + return process.exit(0); + } + } +} diff --git a/packages/cli-v3/src/deploy/buildLogs.test.ts b/packages/cli-v3/src/deploy/buildLogs.test.ts new file mode 100644 index 00000000000..7a1c6a888ea --- /dev/null +++ b/packages/cli-v3/src/deploy/buildLogs.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createBuildLogRenderer, + resolveBuildLogsMode, + streamDeploymentEvents, + type BuildLogEntry, +} from "./buildLogs.js"; + +function fakeSpinner() { + const calls: string[] = []; + return { + calls, + start: (m?: string) => void calls.push(`start:${m}`), + message: (m?: string) => void calls.push(`message:${m}`), + stop: (m?: string, code?: number) => void calls.push(`stop:${m}:${code ?? 0}`), + }; +} + +const entry = (message: string, level: BuildLogEntry["level"] = "info"): BuildLogEntry => ({ + timestamp: new Date("2026-08-28T10:00:00.000Z"), + level, + message, +}); + +describe("resolveBuildLogsMode", () => { + it("honors the request in an interactive terminal", () => { + const tty = { plain: false, ci: false, tty: true }; + expect(resolveBuildLogsMode("compact", tty)).toBe("compact"); + expect(resolveBuildLogsMode("full", tty)).toBe("full"); + }); + + it("forces full output for CI, --plain and piped output", () => { + expect(resolveBuildLogsMode("compact", { plain: false, ci: true, tty: true })).toBe("full"); + expect(resolveBuildLogsMode("compact", { plain: true, ci: false, tty: true })).toBe("full"); + expect(resolveBuildLogsMode("compact", { plain: false, ci: false, tty: false })).toBe("full"); + }); +}); + +describe("createBuildLogRenderer compact", () => { + it("keeps one updating spinner line and stops it on success", () => { + const s = fakeSpinner(); + const print = vi.fn(); + const r = createBuildLogRenderer({ + mode: "compact", + title: "Building version 1", + spinner: s, + print, + columns: 200, + }); + expect(r.started).toBe(false); + r.log(entry("Installing dependencies")); + r.log(entry("Building image")); + expect(r.started).toBe(true); + r.finish("Deployment completed successfully", "success"); + expect(s.calls).toEqual([ + "start:Build queued", + "message:Building version 1: Installing dependencies", + "message:Building version 1: Building image", + "stop:Deployment completed successfully:0", + ]); + expect(print).not.toHaveBeenCalled(); + }); + + it("prints only the last N lines when the build fails", () => { + const s = fakeSpinner(); + const print = vi.fn(); + const r = createBuildLogRenderer({ + mode: "compact", + title: "t", + spinner: s, + print, + tailSize: 3, + columns: 200, + }); + for (let i = 1; i <= 5; i++) r.log(entry(`line ${i}`, i === 5 ? "error" : "info")); + r.finish("Deployment failed", "failure"); + expect(s.calls.at(-1)).toBe("stop:Deployment failed:2"); + const printed = print.mock.calls.map((c) => String(c[0])); + expect(printed[1]).toContain("Last 3 lines of the build log"); + expect( + printed + .filter((l) => /line \d/.test(l)) + .map((l) => + l + .replace(/\u001b\[[0-9;]*m/g, "") + .split(" ") + .at(-1) + ) + ).toEqual(["line 3", "line 4", "line 5"]); + }); + + it("collapses multi-line messages and truncates to the terminal width", () => { + const s = fakeSpinner(); + const r = createBuildLogRenderer({ + mode: "compact", + title: "Building version 1", + spinner: s, + print: vi.fn(), + columns: 60, + }); + r.log(entry("first line\n second line " + "x".repeat(100))); + const msg = s.calls.at(-1)!; + expect(msg).toContain("Building version 1: first line second line"); + expect(msg.endsWith("…")).toBe(true); + expect(msg.length).toBeLessThanOrEqual("message:".length + 60); + }); + + it("does not update the spinner for separator-only messages", () => { + const s = fakeSpinner(); + const r = createBuildLogRenderer({ + mode: "compact", + title: "t", + spinner: s, + print: vi.fn(), + columns: 200, + }); + r.log(entry("------------------------------")); + r.log(entry(" ")); + r.log(entry("real progress")); + expect(s.calls).toEqual(["start:Build queued", "message:t: real progress"]); + }); + + it("stops the queued spinner without a tail when nothing was logged", () => { + const s = fakeSpinner(); + const print = vi.fn(); + const r = createBuildLogRenderer({ mode: "compact", title: "t", spinner: s, print }); + r.finish("Log stream stopped", "failure"); + expect(s.calls).toEqual(["start:Build queued", "stop:Log stream stopped:2"]); + expect(print).not.toHaveBeenCalled(); + }); +}); + +describe("createBuildLogRenderer full", () => { + it("prints every line after stopping the queued spinner", () => { + const s = fakeSpinner(); + const print = vi.fn(); + const success = vi.fn(); + const r = createBuildLogRenderer({ mode: "full", title: "t", spinner: s, print, success }); + r.log(entry("one")); + r.log(entry("two", "warn")); + r.finish("Deployment completed successfully", "success"); + expect(s.calls).toEqual(["start:Build queued", "stop:Build started:0"]); + const printed = print.mock.calls.map((c) => String(c[0]).replace(/\u001b\[[0-9;]*m/g, "")); + expect(printed[0]).toBe("│"); + expect(printed[1]).toMatch(/^│ \d\d:\d\d:\d\d\.\d{3} one$/); + expect(printed[2]).toMatch(/two$/); + expect(success).toHaveBeenCalledWith("Deployment completed successfully"); + }); + + it("leaves the failure message to the caller once lines were printed", () => { + const s = fakeSpinner(); + const print = vi.fn(); + const r = createBuildLogRenderer({ + mode: "full", + title: "t", + spinner: s, + print, + success: vi.fn(), + }); + r.log(entry("one")); + r.finish("Deployment failed", "failure"); + expect(s.calls).toEqual(["start:Build queued", "stop:Build started:0"]); + expect(print).toHaveBeenCalledTimes(2); + }); +}); + +describe("streamDeploymentEvents", () => { + async function* records(bodies: string[]) { + let seq = 0; + for (const body of bodies) yield { seqNum: seq++, timestamp: 1_700_000_000_000, body }; + } + + it("forwards logs, skips garbage and returns the finalized event", async () => { + const logged: string[] = []; + const onFinalized = vi.fn(); + const renderer = { + started: false, + log: (e: BuildLogEntry) => void logged.push(`${e.level}:${e.message}`), + finish: vi.fn(), + }; + const final = await streamDeploymentEvents( + records([ + JSON.stringify({ type: "log", data: { message: "a" } }), + "not json", + JSON.stringify({ type: "log", data: { level: "error", message: "b" } }), + JSON.stringify({ type: "finalized", data: { result: "failed", message: "boom" } }), + ]), + renderer, + onFinalized + ); + expect(logged).toEqual(["info:a", "error:b"]); + expect(final).toEqual({ result: "failed", message: "boom" }); + expect(onFinalized).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when the stream ends without a finalized event", async () => { + const final = await streamDeploymentEvents( + records([JSON.stringify({ type: "log", data: { message: "a" } })]), + { started: false, log: vi.fn(), finish: vi.fn() }, + vi.fn() + ); + expect(final).toBeUndefined(); + }); +}); diff --git a/packages/cli-v3/src/deploy/buildLogs.ts b/packages/cli-v3/src/deploy/buildLogs.ts new file mode 100644 index 00000000000..97a84de52d3 --- /dev/null +++ b/packages/cli-v3/src/deploy/buildLogs.ts @@ -0,0 +1,194 @@ +import { log } from "@clack/prompts"; +import { + DeploymentEventFromString, + type DeploymentFinalizedEvent, +} from "@trigger.dev/core/v3/schemas"; +import chalk from "chalk"; +import { z } from "zod"; +import { chalkError, chalkGrey, chalkWarning } from "../utilities/cliOutput.js"; +import { logger } from "../utilities/logger.js"; +import { spinner } from "../utilities/windows.js"; + +export const BuildLogsMode = z.enum(["compact", "full"]); +export type BuildLogsMode = z.infer; + +export function resolveBuildLogsMode( + requested: BuildLogsMode, + env: { plain: boolean; ci: boolean; tty: boolean } +): BuildLogsMode { + // A spinner cannot redraw without a TTY, so CI and piped output always get every line. + if (env.plain || env.ci || !env.tty) { + return "full"; + } + return requested; +} + +type BuildLogLevel = "debug" | "info" | "warn" | "error"; + +export type BuildLogEntry = { + timestamp: Date; + level: BuildLogLevel; + message: string; +}; + +type BuildLogOutcome = "success" | "failure"; + +export type BuildLogRenderer = { + readonly started: boolean; + log(entry: BuildLogEntry): void; + finish(message: string, outcome: BuildLogOutcome): void; +}; + +type SpinnerLike = { + start(msg?: string): void; + message(msg?: string): void; + stop(msg?: string, code?: number): void; +}; + +export type BuildLogRendererOptions = { + mode: BuildLogsMode; + title: string; + tailSize?: number; + columns?: number; + spinner?: SpinnerLike; + print?: (line: string) => void; + success?: (message: string) => void; +}; + +function formatBuildLogLine(entry: BuildLogEntry): string { + const timestamp = chalkGrey( + entry.timestamp.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }) + ); + + const message = + entry.level === "error" + ? chalk.bold(chalkError(entry.message)) + : entry.level === "warn" + ? chalkWarning(entry.message) + : entry.level === "debug" + ? chalkGrey(entry.message) + : entry.message; + + return `│ ${timestamp} ${message}`; +} + +export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildLogRenderer { + const $spinner = options.spinner ?? spinner(); + const print = options.print ?? ((line: string) => console.log(line)); + const success = options.success ?? ((message: string) => log.success(message)); + const tailSize = options.tailSize ?? 20; + const tail: string[] = []; + let started = false; + + $spinner.start("Build queued"); + + const compactMessage = (message: string) => { + const columns = options.columns ?? process.stdout.columns ?? 120; + const available = Math.max(columns - options.title.length - 6, 20); + const singleLine = message.replace(/\s+/g, " ").trim(); + return singleLine.length > available ? `${singleLine.slice(0, available - 1)}…` : singleLine; + }; + + return { + get started() { + return started; + }, + log(entry) { + const line = formatBuildLogLine(entry); + + if (options.mode === "full") { + if (!started) { + $spinner.stop("Build started"); + print("│"); + } + started = true; + print(line); + return; + } + + started = true; + tail.push(line); + if (tail.length > tailSize) { + tail.shift(); + } + const message = compactMessage(entry.message); + if (message.length > 0 && !/^[-=#*_.\s]+$/.test(message)) { + $spinner.message(`${options.title}: ${message}`); + } + }, + finish(message, outcome) { + if (options.mode === "full" && started) { + if (outcome === "success") { + success(message); + } + return; + } + + $spinner.stop(message, outcome === "failure" ? 2 : 0); + + if (options.mode === "compact" && outcome === "failure" && tail.length > 0) { + print("│"); + print(`│ ${chalkGrey(`Last ${tail.length} lines of the build log:`)}`); + for (const line of tail) { + print(line); + } + print("│"); + } + }, + }; +} + +export type DeploymentEventRecord = { + seqNum: number; + timestamp: number | string | Date; + body: string; +}; + +export async function streamDeploymentEvents( + records: AsyncIterable, + renderer: BuildLogRenderer, + onFinalized: () => void +): Promise { + let finalEvent: DeploymentFinalizedEvent["data"] | undefined; + + for await (const record of records) { + const result = DeploymentEventFromString.safeParse(record.body); + if (!result.success) { + logger.debug("Failed to parse deployment event, skipping", { + error: result.error, + record: record.body, + }); + continue; + } + + const event = result.data; + + switch (event.type) { + case "log": { + renderer.log({ + timestamp: new Date(record.timestamp), + level: event.data.level, + message: event.data.message, + }); + break; + } + case "finalized": { + finalEvent = event.data; + onFinalized(); + break; + } + default: { + event satisfies never; + logger.debug("Unknown deployment event, skipping", { event }); + } + } + } + + return finalEvent; +} From 9e881f59b0ff8d588f9b8e56597de304e3fe8a46 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 28 Aug 2026 15:10:45 +0200 Subject: [PATCH 2/6] shorten the build logs changeset --- .changeset/compact-native-build-logs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/compact-native-build-logs.md b/.changeset/compact-native-build-logs.md index eb80c15ad9b..b908c4147b9 100644 --- a/.changeset/compact-native-build-logs.md +++ b/.changeset/compact-native-build-logs.md @@ -2,4 +2,4 @@ "trigger.dev": patch --- -Native build server deploys now show build logs the same way Depot builds do: a single spinner line updated with the latest message, with the last 20 lines printed if the build fails. Pass `--build-logs full` to stream every line; CI, `--plain` and piped output always use full. +Native build server deploys now show a single updating build log line by default; pass `--build-logs full` to stream every line (always used in CI and when output is not a terminal). From c1c2db1bd955f092d03a8159b91d62b00390b32e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 28 Aug 2026 15:28:43 +0200 Subject: [PATCH 3/6] fix(cli): full build logs on every path, Windows fallback, ANSI-safe spinner line With --build-logs full the Depot and local docker paths now take the line-printing branch instead of starting the animated spinner over the printed lines. Windows always uses full output because its fallback spinner prints one block per message. The compact spinner line strips ANSI codes before fitting the terminal width and leaves room for the spinner's own truncation. Warn/error lines collected during a successful compact build are printed after the success message, and the non-fatal "failed to query build progress" path no longer renders as an error. --- packages/cli-v3/src/commands/deploy.ts | 18 ++-- packages/cli-v3/src/deploy/buildLogs.test.ts | 104 +++++++++++++++++-- packages/cli-v3/src/deploy/buildLogs.ts | 46 +++++--- 3 files changed, 135 insertions(+), 33 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index e600215d2c2..c3648020126 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -16,7 +16,7 @@ import { BuildManifest } from "@trigger.dev/core/v3/schemas"; import type { Command } from "commander"; import { Option as CommandOption } from "commander"; import { join, relative, resolve } from "node:path"; -import { isCI } from "std-env"; +import { isCI, isWindows } from "std-env"; import { x } from "tinyexec"; import { z } from "zod"; import chalk from "chalk"; @@ -726,7 +726,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (options.plain) { $spinner.start(`Building version ${version}${buildSuffix}`); - } else if (isCI) { + } else if (showFullBuildLogs(options)) { log.step(`Building version ${version}\n`); } else { if (isLinksSupported) { @@ -863,7 +863,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (options.plain) { $spinner.message(`Deploying version ${version}${deploySuffix}`); - } else if (isCI) { + } else if (showFullBuildLogs(options)) { log.step(`Deploying version ${version}${deploySuffix}\n`); } else { if (isLinksSupported) { @@ -910,7 +910,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (options.plain) { console.log(`Successfully deployed version ${version}${deploySuffix}`); - } else if (isCI) { + } else if (showFullBuildLogs(options)) { log.step(`Successfully deployed version ${version}${deploySuffix}`); } else { $spinner.stop(`Successfully deployed version ${version}${deploySuffix}`); @@ -1892,7 +1892,7 @@ async function buildAndFinalizeFromBundle({ if (options.plain) { $spinner.start(`Building version ${version}${buildSuffix}`); - } else if (isCI) { + } else if (showFullBuildLogs(options)) { log.step(`Building version ${version}\n`); } else { if (isLinksSupported) { @@ -2029,7 +2029,7 @@ async function buildAndFinalizeFromBundle({ if (options.plain) { $spinner.message(`Deploying version ${version}${deploySuffix}`); - } else if (isCI) { + } else if (showFullBuildLogs(options)) { log.step(`Deploying version ${version}${deploySuffix}\n`); } else { if (isLinksSupported) { @@ -2076,7 +2076,7 @@ async function buildAndFinalizeFromBundle({ if (options.plain) { console.log(`Successfully deployed version ${version}${deploySuffix}`); - } else if (isCI) { + } else if (showFullBuildLogs(options)) { log.step(`Successfully deployed version ${version}${deploySuffix}`); } else { $spinner.stop(`Successfully deployed version ${version}${deploySuffix}`); @@ -2279,7 +2279,7 @@ async function handleFromBundleDeploy({ } function buildLogsEnv(options: DeployCommandOptions) { - return { plain: options.plain, ci: isCI, tty: Boolean(process.stdout.isTTY) }; + return { plain: options.plain, ci: isCI, tty: Boolean(process.stdout.isTTY), windows: isWindows }; } function showFullBuildLogs(options: DeployCommandOptions) { @@ -2321,7 +2321,7 @@ async function followBuildServerDeployment({ ); if (readSessionError) { - renderer.finish("Failed to query build progress", "failure"); + renderer.finish("Failed to query build progress", "abandoned"); log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); outro( diff --git a/packages/cli-v3/src/deploy/buildLogs.test.ts b/packages/cli-v3/src/deploy/buildLogs.test.ts index 7a1c6a888ea..bd1d6a4be2c 100644 --- a/packages/cli-v3/src/deploy/buildLogs.test.ts +++ b/packages/cli-v3/src/deploy/buildLogs.test.ts @@ -6,13 +6,12 @@ import { type BuildLogEntry, } from "./buildLogs.js"; -function fakeSpinner() { - const calls: string[] = []; +function fakeSpinner(calls: string[] = []) { return { calls, start: (m?: string) => void calls.push(`start:${m}`), message: (m?: string) => void calls.push(`message:${m}`), - stop: (m?: string, code?: number) => void calls.push(`stop:${m}:${code ?? 0}`), + stop: (m?: string, code?: number) => void calls.push(`stop:${m}:${code}`), }; } @@ -24,15 +23,17 @@ const entry = (message: string, level: BuildLogEntry["level"] = "info"): BuildLo describe("resolveBuildLogsMode", () => { it("honors the request in an interactive terminal", () => { - const tty = { plain: false, ci: false, tty: true }; + const tty = { plain: false, ci: false, tty: true, windows: false }; expect(resolveBuildLogsMode("compact", tty)).toBe("compact"); expect(resolveBuildLogsMode("full", tty)).toBe("full"); }); it("forces full output for CI, --plain and piped output", () => { - expect(resolveBuildLogsMode("compact", { plain: false, ci: true, tty: true })).toBe("full"); - expect(resolveBuildLogsMode("compact", { plain: true, ci: false, tty: true })).toBe("full"); - expect(resolveBuildLogsMode("compact", { plain: false, ci: false, tty: false })).toBe("full"); + const tty = { plain: false, ci: false, tty: true, windows: false }; + expect(resolveBuildLogsMode("compact", { ...tty, ci: true })).toBe("full"); + expect(resolveBuildLogsMode("compact", { ...tty, plain: true })).toBe("full"); + expect(resolveBuildLogsMode("compact", { ...tty, tty: false })).toBe("full"); + expect(resolveBuildLogsMode("compact", { ...tty, windows: true })).toBe("full"); }); }); @@ -56,7 +57,7 @@ describe("createBuildLogRenderer compact", () => { "start:Build queued", "message:Building version 1: Installing dependencies", "message:Building version 1: Building image", - "stop:Deployment completed successfully:0", + "stop:Deployment completed successfully:undefined", ]); expect(print).not.toHaveBeenCalled(); }); @@ -130,7 +131,90 @@ describe("createBuildLogRenderer compact", () => { }); }); +describe("createBuildLogRenderer compact extras", () => { + it("prints the tail after the spinner stops, in order", () => { + const calls: string[] = []; + const s = fakeSpinner(calls); + const r = createBuildLogRenderer({ + mode: "compact", + title: "t", + spinner: s, + print: (l) => void calls.push(`print:${l.replace(/\u001b\[[0-9;]*m/g, "")}`), + columns: 200, + }); + r.log(entry("a")); + r.finish("Deployment failed", "failure"); + expect(calls[0]).toBe("start:Build queued"); + expect(calls[1]).toBe("message:t: a"); + expect(calls[2]).toBe("stop:Deployment failed:2"); + expect(calls[3]).toBe("print:│"); + expect(calls[4]).toContain("Last 1 lines of the build log"); + expect(calls[5]).toMatch(/ a$/); + }); + + it("strips ANSI codes before fitting the spinner line", () => { + const s = fakeSpinner(); + const r = createBuildLogRenderer({ + mode: "compact", + title: "t", + spinner: s, + print: vi.fn(), + columns: 200, + }); + r.log(entry("\u001b[32mgreen\u001b[0m and \u001b[1mbold\u001b[0m")); + expect(s.calls.at(-1)).toBe("message:t: green and bold"); + }); + + it("surfaces warn and error lines after a successful build", () => { + const s = fakeSpinner(); + const print = vi.fn(); + const r = createBuildLogRenderer({ + mode: "compact", + title: "t", + spinner: s, + print, + columns: 200, + }); + r.log(entry("fine")); + r.log(entry("deprecated thing", "warn")); + r.finish("Deployment completed successfully", "success"); + const printed = print.mock.calls.map((c) => String(c[0]).replace(/\u001b\[[0-9;]*m/g, "")); + expect(printed[1]).toContain("Build warnings (1)"); + expect(printed[2]).toMatch(/deprecated thing$/); + expect(printed.some((l) => / fine$/.test(l))).toBe(false); + }); + + it("stops without a tail when the stream was abandoned", () => { + const s = fakeSpinner(); + const print = vi.fn(); + const r = createBuildLogRenderer({ + mode: "compact", + title: "t", + spinner: s, + print, + columns: 200, + }); + r.log(entry("a", "error")); + r.finish("Failed to query build progress", "abandoned"); + expect(s.calls.at(-1)).toBe("stop:Failed to query build progress:undefined"); + expect(print).not.toHaveBeenCalled(); + }); +}); + describe("createBuildLogRenderer full", () => { + it("stops the queued spinner with the outcome when nothing was logged", () => { + const s = fakeSpinner(); + const r = createBuildLogRenderer({ + mode: "full", + title: "t", + spinner: s, + print: vi.fn(), + success: vi.fn(), + }); + r.finish("Log stream stopped", "failure"); + expect(s.calls).toEqual(["start:Build queued", "stop:Log stream stopped:2"]); + }); + it("prints every line after stopping the queued spinner", () => { const s = fakeSpinner(); const print = vi.fn(); @@ -139,7 +223,7 @@ describe("createBuildLogRenderer full", () => { r.log(entry("one")); r.log(entry("two", "warn")); r.finish("Deployment completed successfully", "success"); - expect(s.calls).toEqual(["start:Build queued", "stop:Build started:0"]); + expect(s.calls).toEqual(["start:Build queued", "stop:Build started:undefined"]); const printed = print.mock.calls.map((c) => String(c[0]).replace(/\u001b\[[0-9;]*m/g, "")); expect(printed[0]).toBe("│"); expect(printed[1]).toMatch(/^│ \d\d:\d\d:\d\d\.\d{3} one$/); @@ -159,7 +243,7 @@ describe("createBuildLogRenderer full", () => { }); r.log(entry("one")); r.finish("Deployment failed", "failure"); - expect(s.calls).toEqual(["start:Build queued", "stop:Build started:0"]); + expect(s.calls).toEqual(["start:Build queued", "stop:Build started:undefined"]); expect(print).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/cli-v3/src/deploy/buildLogs.ts b/packages/cli-v3/src/deploy/buildLogs.ts index 97a84de52d3..4152f730b40 100644 --- a/packages/cli-v3/src/deploy/buildLogs.ts +++ b/packages/cli-v3/src/deploy/buildLogs.ts @@ -1,4 +1,5 @@ import { log } from "@clack/prompts"; +import { stripVTControlCharacters } from "node:util"; import { DeploymentEventFromString, type DeploymentFinalizedEvent, @@ -14,10 +15,10 @@ export type BuildLogsMode = z.infer; export function resolveBuildLogsMode( requested: BuildLogsMode, - env: { plain: boolean; ci: boolean; tty: boolean } + env: { plain: boolean; ci: boolean; tty: boolean; windows: boolean } ): BuildLogsMode { - // A spinner cannot redraw without a TTY, so CI and piped output always get every line. - if (env.plain || env.ci || !env.tty) { + // No redrawable spinner in CI, piped output, or the Windows fallback spinner. + if (env.plain || env.ci || !env.tty || env.windows) { return "full"; } return requested; @@ -31,7 +32,7 @@ export type BuildLogEntry = { message: string; }; -type BuildLogOutcome = "success" | "failure"; +type BuildLogOutcome = "success" | "failure" | "abandoned"; export type BuildLogRenderer = { readonly started: boolean; @@ -84,14 +85,15 @@ export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildL const success = options.success ?? ((message: string) => log.success(message)); const tailSize = options.tailSize ?? 20; const tail: string[] = []; + const notices: string[] = []; let started = false; $spinner.start("Build queued"); const compactMessage = (message: string) => { const columns = options.columns ?? process.stdout.columns ?? 120; - const available = Math.max(columns - options.title.length - 6, 20); - const singleLine = message.replace(/\s+/g, " ").trim(); + const available = Math.max(columns - options.title.length - 8, 20); + const singleLine = stripVTControlCharacters(message).replace(/\s+/g, " ").trim(); return singleLine.length > available ? `${singleLine.slice(0, available - 1)}…` : singleLine; }; @@ -117,6 +119,9 @@ export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildL if (tail.length > tailSize) { tail.shift(); } + if ((entry.level === "warn" || entry.level === "error") && notices.length < tailSize) { + notices.push(line); + } const message = compactMessage(entry.message); if (message.length > 0 && !/^[-=#*_.\s]+$/.test(message)) { $spinner.message(`${options.title}: ${message}`); @@ -130,16 +135,29 @@ export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildL return; } - $spinner.stop(message, outcome === "failure" ? 2 : 0); + $spinner.stop(message, outcome === "failure" ? 2 : undefined); - if (options.mode === "compact" && outcome === "failure" && tail.length > 0) { - print("│"); - print(`│ ${chalkGrey(`Last ${tail.length} lines of the build log:`)}`); - for (const line of tail) { - print(line); - } - print("│"); + if (options.mode !== "compact") { + return; + } + + const lines = outcome === "failure" ? tail : outcome === "success" ? notices : []; + if (lines.length === 0) { + return; + } + + print("│"); + print( + `│ ${chalkGrey( + outcome === "failure" + ? `Last ${lines.length} lines of the build log:` + : `Build warnings (${lines.length}):` + )}` + ); + for (const line of lines) { + print(line); } + print("│"); }, }; } From dbe4d00a7297f27f55313e23d626dee762733477 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 28 Aug 2026 15:31:39 +0200 Subject: [PATCH 4/6] fix(cli): do not echo warn-level build lines after a successful compact build --- packages/cli-v3/src/deploy/buildLogs.test.ts | 19 ------------ packages/cli-v3/src/deploy/buildLogs.ts | 31 +++++--------------- 2 files changed, 7 insertions(+), 43 deletions(-) diff --git a/packages/cli-v3/src/deploy/buildLogs.test.ts b/packages/cli-v3/src/deploy/buildLogs.test.ts index bd1d6a4be2c..1de1d5b49b2 100644 --- a/packages/cli-v3/src/deploy/buildLogs.test.ts +++ b/packages/cli-v3/src/deploy/buildLogs.test.ts @@ -165,25 +165,6 @@ describe("createBuildLogRenderer compact extras", () => { expect(s.calls.at(-1)).toBe("message:t: green and bold"); }); - it("surfaces warn and error lines after a successful build", () => { - const s = fakeSpinner(); - const print = vi.fn(); - const r = createBuildLogRenderer({ - mode: "compact", - title: "t", - spinner: s, - print, - columns: 200, - }); - r.log(entry("fine")); - r.log(entry("deprecated thing", "warn")); - r.finish("Deployment completed successfully", "success"); - const printed = print.mock.calls.map((c) => String(c[0]).replace(/\u001b\[[0-9;]*m/g, "")); - expect(printed[1]).toContain("Build warnings (1)"); - expect(printed[2]).toMatch(/deprecated thing$/); - expect(printed.some((l) => / fine$/.test(l))).toBe(false); - }); - it("stops without a tail when the stream was abandoned", () => { const s = fakeSpinner(); const print = vi.fn(); diff --git a/packages/cli-v3/src/deploy/buildLogs.ts b/packages/cli-v3/src/deploy/buildLogs.ts index 4152f730b40..8c710837dfc 100644 --- a/packages/cli-v3/src/deploy/buildLogs.ts +++ b/packages/cli-v3/src/deploy/buildLogs.ts @@ -85,7 +85,6 @@ export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildL const success = options.success ?? ((message: string) => log.success(message)); const tailSize = options.tailSize ?? 20; const tail: string[] = []; - const notices: string[] = []; let started = false; $spinner.start("Build queued"); @@ -119,9 +118,6 @@ export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildL if (tail.length > tailSize) { tail.shift(); } - if ((entry.level === "warn" || entry.level === "error") && notices.length < tailSize) { - notices.push(line); - } const message = compactMessage(entry.message); if (message.length > 0 && !/^[-=#*_.\s]+$/.test(message)) { $spinner.message(`${options.title}: ${message}`); @@ -137,27 +133,14 @@ export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildL $spinner.stop(message, outcome === "failure" ? 2 : undefined); - if (options.mode !== "compact") { - return; - } - - const lines = outcome === "failure" ? tail : outcome === "success" ? notices : []; - if (lines.length === 0) { - return; - } - - print("│"); - print( - `│ ${chalkGrey( - outcome === "failure" - ? `Last ${lines.length} lines of the build log:` - : `Build warnings (${lines.length}):` - )}` - ); - for (const line of lines) { - print(line); + if (options.mode === "compact" && outcome === "failure" && tail.length > 0) { + print("│"); + print(`│ ${chalkGrey(`Last ${tail.length} lines of the build log:`)}`); + for (const line of tail) { + print(line); + } + print("│"); } - print("│"); }, }; } From 1cd611ee3672580194ed0e5308c2c800c225e844 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 28 Aug 2026 15:44:19 +0200 Subject: [PATCH 5/6] fix(cli): drop unused chalk imports from deploy --- packages/cli-v3/src/commands/deploy.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index c3648020126..3138415295c 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -58,8 +58,6 @@ import { } from "../deploy/logs.js"; import { chalkError, - chalkGrey, - chalkWarning, cliLink, isLinksSupported, prettyError, From f0ec08fbcf9a41dcd2c5e113671d33a126f5fc05 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 28 Aug 2026 15:56:29 +0200 Subject: [PATCH 6/6] fix(cli): stop the build log renderer when the event stream ends without a result --- packages/cli-v3/src/commands/deploy.ts | 2 ++ packages/cli-v3/src/deploy/buildLogs.test.ts | 2 +- packages/cli-v3/src/deploy/buildLogs.ts | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 3138415295c..0868c05dfcf 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -2351,6 +2351,8 @@ async function followBuildServerDeployment({ } if (!finalDeploymentEvent) { + renderer.finish("Log stream stopped", "failure"); + log.error( "Stopped receiving updates from the build server, please check the deployment status in the dashboard" ); diff --git a/packages/cli-v3/src/deploy/buildLogs.test.ts b/packages/cli-v3/src/deploy/buildLogs.test.ts index 1de1d5b49b2..62efac5dd51 100644 --- a/packages/cli-v3/src/deploy/buildLogs.test.ts +++ b/packages/cli-v3/src/deploy/buildLogs.test.ts @@ -148,7 +148,7 @@ describe("createBuildLogRenderer compact extras", () => { expect(calls[1]).toBe("message:t: a"); expect(calls[2]).toBe("stop:Deployment failed:2"); expect(calls[3]).toBe("print:│"); - expect(calls[4]).toContain("Last 1 lines of the build log"); + expect(calls[4]).toContain("Last 1 line of the build log"); expect(calls[5]).toMatch(/ a$/); }); diff --git a/packages/cli-v3/src/deploy/buildLogs.ts b/packages/cli-v3/src/deploy/buildLogs.ts index 8c710837dfc..8241ca7a5d9 100644 --- a/packages/cli-v3/src/deploy/buildLogs.ts +++ b/packages/cli-v3/src/deploy/buildLogs.ts @@ -135,7 +135,9 @@ export function createBuildLogRenderer(options: BuildLogRendererOptions): BuildL if (options.mode === "compact" && outcome === "failure" && tail.length > 0) { print("│"); - print(`│ ${chalkGrey(`Last ${tail.length} lines of the build log:`)}`); + print( + `│ ${chalkGrey(`Last ${tail.length} ${tail.length === 1 ? "line" : "lines"} of the build log:`)}` + ); for (const line of tail) { print(line); }