diff --git a/.changeset/compact-native-build-logs.md b/.changeset/compact-native-build-logs.md new file mode 100644 index 00000000000..b908c4147b9 --- /dev/null +++ b/.changeset/compact-native-build-logs.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +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). diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index f2661f412d4..0868c05dfcf 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -10,14 +10,13 @@ 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"; -import { isCI } from "std-env"; +import { isCI, isWindows } from "std-env"; import { x } from "tinyexec"; import { z } from "zod"; import chalk from "chalk"; @@ -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, @@ -53,8 +58,6 @@ import { } from "../deploy/logs.js"; import { chalkError, - chalkGrey, - chalkWarning, cliLink, isLinksSupported, prettyError, @@ -103,6 +106,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 +305,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); @@ -712,7 +724,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) { @@ -749,7 +761,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; } @@ -849,7 +861,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) { @@ -867,7 +879,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { skipPushToRegistry: skipServerSideRegistryPush, }, (logMessage) => { - if (options.plain || isCI) { + if (showFullBuildLogs(options)) { console.log(logMessage); return; } @@ -896,7 +908,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}`); @@ -1488,254 +1500,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 +1831,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)" : ""; @@ -2360,7 +1890,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) { @@ -2397,7 +1927,7 @@ async function buildAndFinalizeFromBundle({ compressionLevel: options.compressionLevel, forceCompression: options.forceCompression, onLog: (logMessage) => { - if (options.plain || isCI) { + if (showFullBuildLogs(options)) { console.log(logMessage); return; } @@ -2497,7 +2027,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) { @@ -2515,7 +2045,7 @@ async function buildAndFinalizeFromBundle({ skipPushToRegistry: skipServerSideRegistryPush, }, (logMessage) => { - if (options.plain || isCI) { + if (showFullBuildLogs(options)) { console.log(logMessage); return; } @@ -2544,7 +2074,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}`); @@ -2745,3 +2275,206 @@ async function handleFromBundleDeploy({ isLocalBuild: true, }); } + +function buildLogsEnv(options: DeployCommandOptions) { + return { plain: options.plain, ci: isCI, tty: Boolean(process.stdout.isTTY), windows: isWindows }; +} + +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", "abandoned"); + 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) { + renderer.finish("Log stream stopped", "failure"); + + 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..62efac5dd51 --- /dev/null +++ b/packages/cli-v3/src/deploy/buildLogs.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createBuildLogRenderer, + resolveBuildLogsMode, + streamDeploymentEvents, + type BuildLogEntry, +} from "./buildLogs.js"; + +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}`), + }; +} + +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, windows: false }; + expect(resolveBuildLogsMode("compact", tty)).toBe("compact"); + expect(resolveBuildLogsMode("full", tty)).toBe("full"); + }); + + it("forces full output for CI, --plain and piped output", () => { + 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"); + }); +}); + +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:undefined", + ]); + 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 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 line 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("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(); + 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: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$/); + 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:undefined"]); + 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..8241ca7a5d9 --- /dev/null +++ b/packages/cli-v3/src/deploy/buildLogs.ts @@ -0,0 +1,197 @@ +import { log } from "@clack/prompts"; +import { stripVTControlCharacters } from "node:util"; +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; windows: boolean } +): BuildLogsMode { + // 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; +} + +type BuildLogLevel = "debug" | "info" | "warn" | "error"; + +export type BuildLogEntry = { + timestamp: Date; + level: BuildLogLevel; + message: string; +}; + +type BuildLogOutcome = "success" | "failure" | "abandoned"; + +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 - 8, 20); + const singleLine = stripVTControlCharacters(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 : undefined); + + if (options.mode === "compact" && outcome === "failure" && tail.length > 0) { + print("│"); + print( + `│ ${chalkGrey(`Last ${tail.length} ${tail.length === 1 ? "line" : "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; +}