Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));

Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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("<buildId>", "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 <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<string, unknown>,
(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 },
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

return command;
}
171 changes: 171 additions & 0 deletions packages/cli/src/controllers/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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<void> {
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<Uint8Array> | null | undefined;
if (!response.ok || !body) {
throw buildLogsRequestError(buildId, response.status);
}

let sawError = false;
await forEachNdjsonRecord<BuildLogRecord>(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") {
const stream =
record.source === "stderr" || record.level === "error"
? context.output.stderr
: context.output.stdout;
stream.write(record.text);
if (!record.text.endsWith("\n")) {
stream.write("\n");
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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<T>(
body: ReadableStream<Uint8Array>,
onRecord: (record: T) => void,
): Promise<void> {
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,
});
}
15 changes: 15 additions & 0 deletions packages/cli/src/shell/command-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <build_id>"],
},
{
id: "build.logs",
path: ["prisma", "build", "logs"],
description: "Stream the logs for a build",
examples: [
"prisma-cli build logs <build_id>",
"prisma-cli build logs <build_id> --follow",
],
},
{
id: "database",
path: ["prisma", "database"],
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/shell/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,19 @@ export async function runStreamingCommand(
handler: (
context: Awaited<ReturnType<typeof createCommandContext>>,
) => Promise<void>,
streamOptions?: { emitJsonSuccessEvent?: boolean },
): Promise<void> {
const flags = resolveGlobalFlags(runtime.argv, options);
const context = await createCommandContext(runtime, flags);

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,
Expand Down
Loading