From 5134ca568e717733b5b72645477c8c50cd4ea02d Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Mon, 29 Jun 2026 10:45:48 +0200 Subject: [PATCH 1/5] feat(build): add `build logs ` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/build/index.ts | 56 ++++++++ packages/cli/src/controllers/build.ts | 167 +++++++++++++++++++++++ packages/cli/src/shell/command-meta.ts | 15 ++ pnpm-lock.yaml | 16 +-- 6 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/commands/build/index.ts create mode 100644 packages/cli/src/controllers/build.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index d314e53..8deffcd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,7 +46,7 @@ "@clack/prompts": "^1.5.0", "@prisma/compute-sdk": "0.31.0", "@prisma/credentials-store": "^7.8.0", - "@prisma/management-api-sdk": "^1.44.0", + "@prisma/management-api-sdk": "^1.45.0", "better-result": "^2.9.2", "colorette": "^2.0.20", "commander": "^14.0.3", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cfbe65a..d0c0b29 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,6 +6,7 @@ import { createAgentCommand } from "./commands/agent"; import { createAppCommand } from "./commands/app"; import { createAuthCommand } from "./commands/auth"; import { createBranchCommand } from "./commands/branch"; +import { createBuildCommand } from "./commands/build"; import { createDatabaseCommand } from "./commands/database"; import { createGitCommand } from "./commands/git"; import { createProjectCommand } from "./commands/project"; @@ -87,6 +88,7 @@ export function createProgram(runtime: CliRuntime): Command { program.addCommand(createProjectCommand(runtime)); program.addCommand(createGitCommand(runtime)); program.addCommand(createBranchCommand(runtime)); + program.addCommand(createBuildCommand(runtime)); program.addCommand(createDatabaseCommand(runtime)); program.addCommand(createAppCommand(runtime)); diff --git a/packages/cli/src/commands/build/index.ts b/packages/cli/src/commands/build/index.ts new file mode 100644 index 0000000..ced7b49 --- /dev/null +++ b/packages/cli/src/commands/build/index.ts @@ -0,0 +1,56 @@ +import { Command, Option } from "commander"; +import { runBuildLogs } from "../../controllers/build"; +import { attachCommandDescriptor } from "../../shell/command-meta"; +import { runStreamingCommand } from "../../shell/command-runner"; +import { + addCompactGlobalFlags, + addGlobalFlags, +} from "../../shell/global-flags"; +import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; + +export function createBuildCommand(runtime: CliRuntime): Command { + const build = attachCommandDescriptor( + configureRuntimeCommand(new Command("build"), runtime), + "build", + ); + + addCompactGlobalFlags(build); + + build.addCommand(createLogsCommand(runtime)); + + return build; +} + +function createLogsCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("logs"), runtime), + "build.logs", + ); + + command + .argument("", "Build id from a git-push or Console deploy") + .addOption( + new Option( + "--follow", + "Keep the connection open and stream new logs as the build runs", + ), + ) + .addOption( + new Option("--cursor ", "Resume from a prior terminal cursor"), + ); + addGlobalFlags(command); + + command.action(async (buildId: string, options) => { + const follow = (options as { follow?: boolean }).follow; + const cursor = (options as { cursor?: string }).cursor; + + await runStreamingCommand( + runtime, + "build.logs", + options as Record, + (context) => runBuildLogs(context, buildId, { follow, cursor }), + ); + }); + + return command; +} diff --git a/packages/cli/src/controllers/build.ts b/packages/cli/src/controllers/build.ts new file mode 100644 index 0000000..4e51434 --- /dev/null +++ b/packages/cli/src/controllers/build.ts @@ -0,0 +1,167 @@ +import { requireComputeAuth } from "../lib/auth/guard"; +import { authRequiredError, CliError } from "../shell/errors"; +import { writeJsonEvent } from "../shell/output"; +import type { CommandContext } from "../shell/runtime"; + +/** + * One line of `GET /v1/builds/{buildId}/logs` (the `BuildLogNdjsonLine` schema). + * Build logs are a separate system from runtime `app logs`: this stream is keyed + * by `Build.id` (a git-push / Console build), not a compute version. + */ +type BuildLogRecord = + | { + type: "log"; + text: string; + level: "info" | "error"; + source: "runner" | "stdout" | "stderr"; + step?: string; + cursor: string; + } + | { + type: "terminal"; + kind: "end" | "error"; + code: string; + message: string; + retryable: boolean; + cursor: string | null; + }; + +export interface BuildLogsOptions { + follow?: boolean; + cursor?: string; +} + +export async function runBuildLogs( + context: CommandContext, + buildId: string, + options: BuildLogsOptions = {}, +): Promise { + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(["prisma-cli auth login"]); + } + + if (!context.flags.json && !context.flags.quiet) { + context.output.stderr.write( + `build logs → Streaming logs for build ${buildId}\n\n`, + ); + } + + const { data, response } = await client.GET("/v1/builds/{buildId}/logs", { + params: { + path: { buildId }, + query: { + ...(options.follow ? { follow: "true" as const } : {}), + ...(options.cursor ? { cursor: options.cursor } : {}), + }, + }, + parseAs: "stream", + signal: context.runtime.signal, + }); + + const body = data as ReadableStream | null | undefined; + if (!response.ok || !body) { + throw buildLogsRequestError(buildId, response.status); + } + + let sawError = false; + await forEachNdjsonRecord(body, (record) => { + if (record.type === "terminal" && record.kind === "error") { + sawError = true; + } + writeBuildLogRecord(context, record); + }); + + if (sawError) { + process.exitCode = 1; + } +} + +function writeBuildLogRecord( + context: CommandContext, + record: BuildLogRecord, +): void { + if (context.flags.json) { + writeJsonEvent(context.output, { + type: record.type, + command: "build.logs", + timestamp: new Date().toISOString(), + data: record, + }); + return; + } + + if (record.type === "log") { + context.output.stdout.write(record.text); + if (!record.text.endsWith("\n")) { + context.output.stdout.write("\n"); + } + return; + } + + // A terminal `end` is the normal stream close — nothing to print. A `no_logs` + // end or any error terminal carries a message the user should see. + if (record.code !== "end") { + context.output.stderr.write(`${record.message}\n`); + } +} + +/** Reads a newline-delimited JSON body line by line, parsing each into a record. */ +async function forEachNdjsonRecord( + body: ReadableStream, + onRecord: (record: T) => void, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + for (;;) { + // biome-ignore lint/performance/noAwaitInLoops: a stream must be read sequentially, chunk by chunk. + const { done, value } = await reader.read(); + if (value) { + buffer += decoder.decode(value, { stream: true }); + } + + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (line) { + onRecord(JSON.parse(line) as T); + } + newlineIndex = buffer.indexOf("\n"); + } + + if (done) { + const tail = buffer.trim(); + if (tail) { + onRecord(JSON.parse(tail) as T); + } + return; + } + } +} + +function buildLogsRequestError(buildId: string, status: number): CliError { + if (status === 404) { + return new CliError({ + code: "BUILD_NOT_FOUND", + domain: "app", + summary: `Build ${buildId} was not found`, + why: "The build does not exist, or your workspace does not have access to it.", + fix: "Check the build id, or run prisma-cli auth login to switch to the workspace that owns it.", + exitCode: 1, + }); + } + return new CliError({ + code: "BUILD_LOGS_FAILED", + domain: "app", + summary: `Failed to read logs for build ${buildId}`, + why: `The Management API returned HTTP ${status}.`, + fix: "Retry the command, or rerun with --trace for more detail.", + exitCode: 1, + }); +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 9ca88a9..af091de 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -177,6 +177,21 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "View your Platform branches", examples: ["prisma-cli branch list"], }, + { + id: "build", + path: ["prisma", "build"], + description: "Inspect builds", + examples: ["prisma-cli build logs "], + }, + { + id: "build.logs", + path: ["prisma", "build", "logs"], + description: "Stream the logs for a build", + examples: [ + "prisma-cli build logs ", + "prisma-cli build logs --follow", + ], + }, { id: "database", path: ["prisma", "database"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17d30b6..9d35be5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,13 +25,13 @@ importers: version: 1.5.0 '@prisma/compute-sdk': specifier: 0.31.0 - version: 0.31.0(@prisma/management-api-sdk@1.44.0)(rollup@4.62.2) + version: 0.31.0(@prisma/management-api-sdk@1.45.0)(rollup@4.62.2) '@prisma/credentials-store': specifier: ^7.8.0 version: 7.8.0 '@prisma/management-api-sdk': - specifier: ^1.44.0 - version: 1.44.0 + specifier: ^1.45.0 + version: 1.45.0 better-result: specifier: ^2.9.2 version: 2.9.2 @@ -560,8 +560,8 @@ packages: '@prisma/credentials-store@7.8.0': resolution: {integrity: sha512-T9yp5uYSowV2ZRkBeCZrqWFP4REUlxd5WEYgOFJDjZBRRV+zx3VFCBf0zJI7Z3/PYFF9o3+ZzLwokQ9nY5EbqA==} - '@prisma/management-api-sdk@1.44.0': - resolution: {integrity: sha512-t15C1GjCiFLMk2vglU6syc8oO/1uhWcVHp9vljBw7oaMt3m7kpe82z7fgEZinDTnMCmfprxefXKcv1hILSOQsg==} + '@prisma/management-api-sdk@1.45.0': + resolution: {integrity: sha512-gwZrm1vQLlZrhkMN1wL8EZnWkDu8OHfO9ufGZkwQRljG/4HHtJmjZa1TAYpqaxIF5YCcNk10KBCq7lcGmOtOIQ==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1937,9 +1937,9 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@prisma/compute-sdk@0.31.0(@prisma/management-api-sdk@1.44.0)(rollup@4.62.2)': + '@prisma/compute-sdk@0.31.0(@prisma/management-api-sdk@1.45.0)(rollup@4.62.2)': dependencies: - '@prisma/management-api-sdk': 1.44.0 + '@prisma/management-api-sdk': 1.45.0 '@vercel/nft': 1.10.2(rollup@4.62.2) better-result: 2.9.2 jiti: 2.7.0 @@ -1961,7 +1961,7 @@ snapshots: dependencies: xdg-app-paths: 8.3.0 - '@prisma/management-api-sdk@1.44.0': + '@prisma/management-api-sdk@1.45.0': dependencies: openapi-fetch: 0.14.0 From 369e08aad11c06f1fd51494de476491afa11fb01 Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Mon, 29 Jun 2026 14:06:04 +0200 Subject: [PATCH 2/5] fix(build): suppress the wrapper success event in `build logs --json` runStreamingCommand appends a {type:"success"} envelope after the handler in --json mode, but `build logs` streams its own per-record JSON ending in a `terminal` record. That double-wrapped the output, and since runBuildLogs signals failure via process.exitCode rather than throwing, a terminal build error produced a trailing *success* event. Add an `emitJsonSuccessEvent` opt-out to runStreamingCommand; `build logs` uses it so the terminal record is the sole completion marker. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/build/index.ts | 3 +++ packages/cli/src/shell/command-runner.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/build/index.ts b/packages/cli/src/commands/build/index.ts index ced7b49..ae32a18 100644 --- a/packages/cli/src/commands/build/index.ts +++ b/packages/cli/src/commands/build/index.ts @@ -49,6 +49,9 @@ function createLogsCommand(runtime: CliRuntime): Command { "build.logs", options as Record, (context) => runBuildLogs(context, buildId, { follow, cursor }), + // build logs emits its own per-record JSON ending in a terminal record; + // suppress the redundant wrapper success event. + { emitJsonSuccessEvent: false }, ); }); diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index 14dc358..ac5f009 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -190,6 +190,7 @@ export async function runStreamingCommand( handler: ( context: Awaited>, ) => Promise, + streamOptions?: { emitJsonSuccessEvent?: boolean }, ): Promise { const flags = resolveGlobalFlags(runtime.argv, options); const context = await createCommandContext(runtime, flags); @@ -197,7 +198,11 @@ export async function runStreamingCommand( try { await handler(context); - if (flags.json) { + // Handlers that stream their own per-record JSON (e.g. `build logs`) end with + // a `terminal` record that conveys completion and any error, and signal + // failure via process.exitCode rather than throwing — so this wrapper success + // event is redundant and would mislabel a failed stream as succeeded. Opt out. + if (flags.json && (streamOptions?.emitJsonSuccessEvent ?? true)) { writeJsonEvent(context.output, { type: "success", command: commandName, From 113002688e58ac30bfa9c928b59407c5fb88d7ae Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Mon, 29 Jun 2026 21:09:35 +0200 Subject: [PATCH 3/5] fix(build): route stderr/error log records to stderr in human mode Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/controllers/build.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/controllers/build.ts b/packages/cli/src/controllers/build.ts index 4e51434..6648891 100644 --- a/packages/cli/src/controllers/build.ts +++ b/packages/cli/src/controllers/build.ts @@ -95,9 +95,13 @@ function writeBuildLogRecord( } if (record.type === "log") { - context.output.stdout.write(record.text); + const stream = + record.source === "stderr" || record.level === "error" + ? context.output.stderr + : context.output.stdout; + stream.write(record.text); if (!record.text.endsWith("\n")) { - context.output.stdout.write("\n"); + stream.write("\n"); } return; } From b6bd2ce29a01cfd02d5cba079c451b5475454e79 Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Tue, 30 Jun 2026 11:23:38 +0200 Subject: [PATCH 4/5] docs(build): document the build logs command in command-spec Adds a command-spec entry for prisma-cli build logs (purpose, behavior, --follow/--cursor/--json, auth + indistinguishable NOT_FOUND), addressing the request to document the new command. Co-Authored-By: Claude Opus 4.8 --- docs/product/command-spec.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index f288897..01d0b8a 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -17,6 +17,7 @@ The beta package includes these command groups: - `branch` - `database` (includes `database connection` subgroup) - `app` +- `build` (includes `build logs`) The beta package also includes one top-level utility command: @@ -1527,6 +1528,31 @@ prisma-cli app logs prisma-cli app logs --deployment dep_123 ``` +## `prisma-cli build logs --follow --cursor ` + +Purpose: + +- stream the build log for a specific build (from a git-push or a Console deploy) + +Behavior: + +- requires auth; authorizes against the workspace that owned the build when it ran (build logs can contain secrets, so access stays with that workspace) +- takes a build id — shown in the Console build view and in git-push output; this is a build id, distinct from a runtime deployment id (`prisma-cli app logs` streams a deployment's runtime logs; this streams a build's logs) +- streams the build's log records to stdout in human mode (each line carries its source — `runner` / `stdout` / `stderr` — and the build step); `stderr` and error-level records are written to stderr so stdout stays redirectable to a clean build log +- ends with a terminal line: `End of build log.`, or `No build logs are available for this build.` +- `--follow` keeps the connection open and streams new lines while an in-flight build runs; it ends on its own once the build reaches a terminal state +- `--cursor ` resumes from a prior terminal cursor +- `--json` emits one JSON event per record, ending in a terminal record (no trailing wrapper success event) +- returns an indistinguishable `NOT_FOUND` when the build does not exist or the caller's workspace does not own it + +Examples: + +```bash +prisma-cli build logs cmcz3v6ft0a1b2c3d +prisma-cli build logs cmcz3v6ft0a1b2c3d --follow +prisma-cli build logs cmcz3v6ft0a1b2c3d --json +``` + ## `prisma-cli app list-deploys [app] --app ` Purpose: From c5037af4d998db1b4692c48f2782a0446f210760 Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Tue, 30 Jun 2026 11:33:54 +0200 Subject: [PATCH 5/5] docs(build): correct terminal output + error code in build logs spec Addresses CodeRabbit: a clean end prints nothing (not 'End of build log.'); the 404 code is BUILD_NOT_FOUND, not NOT_FOUND. Co-Authored-By: Claude Opus 4.8 --- docs/product/command-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 01d0b8a..a8a457f 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -1539,11 +1539,11 @@ Behavior: - requires auth; authorizes against the workspace that owned the build when it ran (build logs can contain secrets, so access stays with that workspace) - takes a build id — shown in the Console build view and in git-push output; this is a build id, distinct from a runtime deployment id (`prisma-cli app logs` streams a deployment's runtime logs; this streams a build's logs) - streams the build's log records to stdout in human mode (each line carries its source — `runner` / `stdout` / `stderr` — and the build step); `stderr` and error-level records are written to stderr so stdout stays redirectable to a clean build log -- ends with a terminal line: `End of build log.`, or `No build logs are available for this build.` +- a clean end prints no trailing line; a build with no recorded logs prints `No build logs are available for this build.`, and read errors print their message (these terminal messages are written to stderr) - `--follow` keeps the connection open and streams new lines while an in-flight build runs; it ends on its own once the build reaches a terminal state - `--cursor ` resumes from a prior terminal cursor - `--json` emits one JSON event per record, ending in a terminal record (no trailing wrapper success event) -- returns an indistinguishable `NOT_FOUND` when the build does not exist or the caller's workspace does not own it +- returns an indistinguishable `BUILD_NOT_FOUND` when the build does not exist or the caller's workspace does not own it Examples: