Skip to content

Commit 5134ca5

Browse files
kristof-siketclaude
andcommitted
feat(build): add build logs <build_id> command
Streams a build's logs from the Management API (GET /v1/builds/{buildId}/logs, newline-delimited JSON) to the terminal — the read counterpart to a git-push / Console deploy. Build logs are a separate system from runtime `app logs`: keyed by `Build.id`, not a compute version. - New top-level `build` group with a `logs <buildId>` subcommand (`--follow`, `--cursor`), mirroring `app logs` (runStreamingCommand + the auth context). - Uses the typed `@prisma/management-api-sdk` client (bumped to ^1.45.0, which regenerated the builds endpoint) with `parseAs: "stream"` — no hand-rolled fetch; auth + base URL come from the shared client. - Renders `log` records as text and surfaces non-`end` terminal messages (no_logs / upstream errors); `--json` emits one structured event per record. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e9666a6 commit 5134ca5

6 files changed

Lines changed: 249 additions & 9 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"@clack/prompts": "^1.5.0",
4747
"@prisma/compute-sdk": "0.31.0",
4848
"@prisma/credentials-store": "^7.8.0",
49-
"@prisma/management-api-sdk": "^1.44.0",
49+
"@prisma/management-api-sdk": "^1.45.0",
5050
"better-result": "^2.9.2",
5151
"colorette": "^2.0.20",
5252
"commander": "^14.0.3",

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createAgentCommand } from "./commands/agent";
66
import { createAppCommand } from "./commands/app";
77
import { createAuthCommand } from "./commands/auth";
88
import { createBranchCommand } from "./commands/branch";
9+
import { createBuildCommand } from "./commands/build";
910
import { createDatabaseCommand } from "./commands/database";
1011
import { createGitCommand } from "./commands/git";
1112
import { createProjectCommand } from "./commands/project";
@@ -87,6 +88,7 @@ export function createProgram(runtime: CliRuntime): Command {
8788
program.addCommand(createProjectCommand(runtime));
8889
program.addCommand(createGitCommand(runtime));
8990
program.addCommand(createBranchCommand(runtime));
91+
program.addCommand(createBuildCommand(runtime));
9092
program.addCommand(createDatabaseCommand(runtime));
9193
program.addCommand(createAppCommand(runtime));
9294

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Command, Option } from "commander";
2+
import { runBuildLogs } from "../../controllers/build";
3+
import { attachCommandDescriptor } from "../../shell/command-meta";
4+
import { runStreamingCommand } from "../../shell/command-runner";
5+
import {
6+
addCompactGlobalFlags,
7+
addGlobalFlags,
8+
} from "../../shell/global-flags";
9+
import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime";
10+
11+
export function createBuildCommand(runtime: CliRuntime): Command {
12+
const build = attachCommandDescriptor(
13+
configureRuntimeCommand(new Command("build"), runtime),
14+
"build",
15+
);
16+
17+
addCompactGlobalFlags(build);
18+
19+
build.addCommand(createLogsCommand(runtime));
20+
21+
return build;
22+
}
23+
24+
function createLogsCommand(runtime: CliRuntime): Command {
25+
const command = attachCommandDescriptor(
26+
configureRuntimeCommand(new Command("logs"), runtime),
27+
"build.logs",
28+
);
29+
30+
command
31+
.argument("<buildId>", "Build id from a git-push or Console deploy")
32+
.addOption(
33+
new Option(
34+
"--follow",
35+
"Keep the connection open and stream new logs as the build runs",
36+
),
37+
)
38+
.addOption(
39+
new Option("--cursor <cursor>", "Resume from a prior terminal cursor"),
40+
);
41+
addGlobalFlags(command);
42+
43+
command.action(async (buildId: string, options) => {
44+
const follow = (options as { follow?: boolean }).follow;
45+
const cursor = (options as { cursor?: string }).cursor;
46+
47+
await runStreamingCommand(
48+
runtime,
49+
"build.logs",
50+
options as Record<string, unknown>,
51+
(context) => runBuildLogs(context, buildId, { follow, cursor }),
52+
);
53+
});
54+
55+
return command;
56+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { requireComputeAuth } from "../lib/auth/guard";
2+
import { authRequiredError, CliError } from "../shell/errors";
3+
import { writeJsonEvent } from "../shell/output";
4+
import type { CommandContext } from "../shell/runtime";
5+
6+
/**
7+
* One line of `GET /v1/builds/{buildId}/logs` (the `BuildLogNdjsonLine` schema).
8+
* Build logs are a separate system from runtime `app logs`: this stream is keyed
9+
* by `Build.id` (a git-push / Console build), not a compute version.
10+
*/
11+
type BuildLogRecord =
12+
| {
13+
type: "log";
14+
text: string;
15+
level: "info" | "error";
16+
source: "runner" | "stdout" | "stderr";
17+
step?: string;
18+
cursor: string;
19+
}
20+
| {
21+
type: "terminal";
22+
kind: "end" | "error";
23+
code: string;
24+
message: string;
25+
retryable: boolean;
26+
cursor: string | null;
27+
};
28+
29+
export interface BuildLogsOptions {
30+
follow?: boolean;
31+
cursor?: string;
32+
}
33+
34+
export async function runBuildLogs(
35+
context: CommandContext,
36+
buildId: string,
37+
options: BuildLogsOptions = {},
38+
): Promise<void> {
39+
const client = await requireComputeAuth(
40+
context.runtime.env,
41+
context.runtime.signal,
42+
);
43+
if (!client) {
44+
throw authRequiredError(["prisma-cli auth login"]);
45+
}
46+
47+
if (!context.flags.json && !context.flags.quiet) {
48+
context.output.stderr.write(
49+
`build logs → Streaming logs for build ${buildId}\n\n`,
50+
);
51+
}
52+
53+
const { data, response } = await client.GET("/v1/builds/{buildId}/logs", {
54+
params: {
55+
path: { buildId },
56+
query: {
57+
...(options.follow ? { follow: "true" as const } : {}),
58+
...(options.cursor ? { cursor: options.cursor } : {}),
59+
},
60+
},
61+
parseAs: "stream",
62+
signal: context.runtime.signal,
63+
});
64+
65+
const body = data as ReadableStream<Uint8Array> | null | undefined;
66+
if (!response.ok || !body) {
67+
throw buildLogsRequestError(buildId, response.status);
68+
}
69+
70+
let sawError = false;
71+
await forEachNdjsonRecord<BuildLogRecord>(body, (record) => {
72+
if (record.type === "terminal" && record.kind === "error") {
73+
sawError = true;
74+
}
75+
writeBuildLogRecord(context, record);
76+
});
77+
78+
if (sawError) {
79+
process.exitCode = 1;
80+
}
81+
}
82+
83+
function writeBuildLogRecord(
84+
context: CommandContext,
85+
record: BuildLogRecord,
86+
): void {
87+
if (context.flags.json) {
88+
writeJsonEvent(context.output, {
89+
type: record.type,
90+
command: "build.logs",
91+
timestamp: new Date().toISOString(),
92+
data: record,
93+
});
94+
return;
95+
}
96+
97+
if (record.type === "log") {
98+
context.output.stdout.write(record.text);
99+
if (!record.text.endsWith("\n")) {
100+
context.output.stdout.write("\n");
101+
}
102+
return;
103+
}
104+
105+
// A terminal `end` is the normal stream close — nothing to print. A `no_logs`
106+
// end or any error terminal carries a message the user should see.
107+
if (record.code !== "end") {
108+
context.output.stderr.write(`${record.message}\n`);
109+
}
110+
}
111+
112+
/** Reads a newline-delimited JSON body line by line, parsing each into a record. */
113+
async function forEachNdjsonRecord<T>(
114+
body: ReadableStream<Uint8Array>,
115+
onRecord: (record: T) => void,
116+
): Promise<void> {
117+
const reader = body.getReader();
118+
const decoder = new TextDecoder();
119+
let buffer = "";
120+
121+
for (;;) {
122+
// biome-ignore lint/performance/noAwaitInLoops: a stream must be read sequentially, chunk by chunk.
123+
const { done, value } = await reader.read();
124+
if (value) {
125+
buffer += decoder.decode(value, { stream: true });
126+
}
127+
128+
let newlineIndex = buffer.indexOf("\n");
129+
while (newlineIndex !== -1) {
130+
const line = buffer.slice(0, newlineIndex).trim();
131+
buffer = buffer.slice(newlineIndex + 1);
132+
if (line) {
133+
onRecord(JSON.parse(line) as T);
134+
}
135+
newlineIndex = buffer.indexOf("\n");
136+
}
137+
138+
if (done) {
139+
const tail = buffer.trim();
140+
if (tail) {
141+
onRecord(JSON.parse(tail) as T);
142+
}
143+
return;
144+
}
145+
}
146+
}
147+
148+
function buildLogsRequestError(buildId: string, status: number): CliError {
149+
if (status === 404) {
150+
return new CliError({
151+
code: "BUILD_NOT_FOUND",
152+
domain: "app",
153+
summary: `Build ${buildId} was not found`,
154+
why: "The build does not exist, or your workspace does not have access to it.",
155+
fix: "Check the build id, or run prisma-cli auth login to switch to the workspace that owns it.",
156+
exitCode: 1,
157+
});
158+
}
159+
return new CliError({
160+
code: "BUILD_LOGS_FAILED",
161+
domain: "app",
162+
summary: `Failed to read logs for build ${buildId}`,
163+
why: `The Management API returned HTTP ${status}.`,
164+
fix: "Retry the command, or rerun with --trace for more detail.",
165+
exitCode: 1,
166+
});
167+
}

packages/cli/src/shell/command-meta.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,21 @@ const DESCRIPTORS: CommandDescriptor[] = [
177177
description: "View your Platform branches",
178178
examples: ["prisma-cli branch list"],
179179
},
180+
{
181+
id: "build",
182+
path: ["prisma", "build"],
183+
description: "Inspect builds",
184+
examples: ["prisma-cli build logs <build_id>"],
185+
},
186+
{
187+
id: "build.logs",
188+
path: ["prisma", "build", "logs"],
189+
description: "Stream the logs for a build",
190+
examples: [
191+
"prisma-cli build logs <build_id>",
192+
"prisma-cli build logs <build_id> --follow",
193+
],
194+
},
180195
{
181196
id: "database",
182197
path: ["prisma", "database"],

pnpm-lock.yaml

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)