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:
@@ -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).
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user