feat(cli): compact build logs for native build deploys (#4817)

Native build server deploys (`--native-build`) now show compact build
logs by default: one spinner line updated with the latest message, and
the last 20 lines printed when the build fails. The previous timestamped
line-by-line output is behind `--build-logs full`, and is used
automatically in CI, with `--plain`, or when stdout is not a TTY.
This commit is contained in:
Saadi Myftija
2026-08-28 16:54:42 +02:00
committed by GitHub
parent 63b8e6e1f5
commit 34529a4d7e
4 changed files with 715 additions and 511 deletions
+5
View File
@@ -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).
+244 -511
View File
@@ -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 <mode>",
"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,254 +1831,13 @@ async function handleLocalBundleDeploy({
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,
});
}
// Builds the image locally from the bundle and finalizes the deployment.
@@ -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<InitializeDeploymentResponseBody, "version">;
eventStream: NonNullable<InitializeDeploymentResponseBody["eventStream"]>;
options: DeployCommandOptions;
rawDeploymentLink: string;
rawTestLink: string;
}): Promise<never> {
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);
}
}
}
@@ -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();
});
});
+197
View File
@@ -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<typeof BuildLogsMode>;
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<DeploymentEventRecord>,
renderer: BuildLogRenderer,
onFinalized: () => void
): Promise<DeploymentFinalizedEvent["data"] | undefined> {
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;
}