From dc8460118032f3deaafa1712f72039f3f6111d1d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 4 Jun 2026 14:41:55 +0530 Subject: [PATCH] feat(cli): add verbose success diagnostics Signed-off-by: Aman Varshney --- packages/cli/package.json | 4 +- packages/cli/src/cli.ts | 5 +- packages/cli/src/controllers/app-env-file.ts | 5 + packages/cli/src/controllers/app-env.ts | 32 ++-- packages/cli/src/controllers/app.ts | 33 +++- packages/cli/src/controllers/branch.ts | 5 + packages/cli/src/lib/diagnostics.ts | 18 +++ packages/cli/src/lib/git/local-status.ts | 67 ++++++++ packages/cli/src/presenters/app-env.ts | 138 ++++++++++++++-- packages/cli/src/presenters/app.ts | 149 ++++++++++++++--- packages/cli/src/presenters/branch.ts | 17 +- packages/cli/src/presenters/project.ts | 16 +- .../cli/src/presenters/verbose-context.ts | 84 ++++++++++ packages/cli/src/shell/command-runner.ts | 32 +++- packages/cli/src/shell/diagnostics-output.ts | 63 ++++++++ packages/cli/src/shell/output.ts | 20 +++ packages/cli/src/shell/ui.ts | 45 ++++++ packages/cli/src/types/app-env.ts | 13 ++ packages/cli/src/types/app.ts | 32 ++++ packages/cli/src/types/branch.ts | 9 ++ packages/cli/src/types/diagnostics.ts | 12 ++ packages/cli/tests/app-controller.test.ts | 30 ++++ packages/cli/tests/app-env.test.ts | 19 +++ packages/cli/tests/app-presenter.test.ts | 94 ++++++++++- packages/cli/tests/branch-controller.test.ts | 19 +++ packages/cli/tests/branch.test.ts | 39 ++++- packages/cli/tests/command-runner.test.ts | 150 ++++++++++++++++++ packages/cli/tests/project.test.ts | 45 ++++++ packages/cli/tests/shell.test.ts | 11 ++ pnpm-lock.yaml | 22 +-- 30 files changed, 1159 insertions(+), 69 deletions(-) create mode 100644 packages/cli/src/lib/diagnostics.ts create mode 100644 packages/cli/src/lib/git/local-status.ts create mode 100644 packages/cli/src/presenters/verbose-context.ts create mode 100644 packages/cli/src/shell/diagnostics-output.ts create mode 100644 packages/cli/src/types/diagnostics.ts create mode 100644 packages/cli/tests/command-runner.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 49eefdc..b7953ee 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,9 +41,9 @@ }, "dependencies": { "@clack/prompts": "^1.5.0", - "@prisma/compute-sdk": "^0.20.0", + "@prisma/compute-sdk": "^0.21.0", "@prisma/credentials-store": "^7.8.0", - "@prisma/management-api-sdk": "^1.35.0", + "@prisma/management-api-sdk": "^1.37.0", "c12": "4.0.0-beta.5", "colorette": "^2.0.20", "commander": "^14.0.3", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 00ac5ba..f4824bc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,7 +13,7 @@ import { getCliName, getCliVersion } from "./lib/version"; import { attachCommandDescriptor } from "./shell/command-meta"; import { CliError } from "./shell/errors"; import { addCompactGlobalFlags } from "./shell/global-flags"; -import { writeHumanError, writeJsonError, writeJsonSuccess } from "./shell/output"; +import { formatUnexpectedError, writeHumanError, writeJsonError, writeJsonSuccess } from "./shell/output"; import { disposePromptState } from "./shell/prompt"; import { configureRuntimeCommand, createCommandContext, type CliRuntime } from "./shell/runtime"; import { createShellUi } from "./shell/ui"; @@ -49,8 +49,7 @@ export async function runCli(options: RunCliOptions = {}): Promise { return error.code === "commander.helpDisplayed" ? 0 : 2; } - const message = error instanceof Error ? error.stack ?? error.message : String(error); - runtime.stderr.write(`${message}\n`); + runtime.stderr.write(formatUnexpectedError(error, runtime.argv.includes("--trace"))); return 1; } finally { disposePromptState(runtime.stdin); diff --git a/packages/cli/src/controllers/app-env-file.ts b/packages/cli/src/controllers/app-env-file.ts index 00e455a..2b4128c 100644 --- a/packages/cli/src/controllers/app-env-file.ts +++ b/packages/cli/src/controllers/app-env-file.ts @@ -7,6 +7,7 @@ import type { CommandSuccess } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; import type { EnvAddResult, + EnvResolvedContext, EnvUpdateResult, EnvVariableMetadata, } from "../types/app-env"; @@ -29,6 +30,7 @@ export async function runEnvAddFile( resolved: ResolvedEnvFileScope, filePath: string, assignments: EnvFileAssignment[], + verboseContext: EnvResolvedContext, ): Promise> { const existing = await findVariablesByNaturalKey( client, @@ -96,6 +98,7 @@ export async function runEnvAddFile( command: "project.env.add", result: { projectId, + verboseContext, scope: resolved.descriptor, variables, file: { @@ -115,6 +118,7 @@ export async function runEnvUpdateFile( resolved: ResolvedEnvFileScope, filePath: string, assignments: EnvFileAssignment[], + verboseContext: EnvResolvedContext, ): Promise> { const existing = await findVariablesByNaturalKey( client, @@ -172,6 +176,7 @@ export async function runEnvUpdateFile( command: "project.env.update", result: { projectId, + verboseContext, scope: resolved.descriptor, variables, file: { diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 4383e66..5cc82ef 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -19,6 +19,7 @@ import type { EnvListTarget, EnvListResult, EnvRmResult, + EnvResolvedContext, EnvScopeDescriptor, EnvUpdateResult, } from "../types/app-env"; @@ -92,14 +93,14 @@ export async function runEnvAdd( } const input = await resolveEnvWriteInput(context, source, "add"); - const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env add"); + const { client, projectId, verboseContext } = await requireClientAndProject(context, flags.projectRef, "project env add"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: true, signal: context.runtime.signal, }); if (input.kind === "file") { - return runEnvAddFile(context, client, projectId, resolved, input.filePath, input.assignments); + return runEnvAddFile(context, client, projectId, resolved, input.filePath, input.assignments, verboseContext); } const existing = await findVariableByNaturalKey(client, projectId, input.key, resolved, context.runtime.signal); @@ -121,7 +122,6 @@ export async function runEnvAdd( const warnings = scope.kind === "branch" && !(await findVariableByNaturalKey(client, projectId, input.key, { - scope: { kind: "role", role: "preview" }, descriptor: { kind: "role", role: "preview" }, apiTarget: { class: "preview", branchId: null }, }, context.runtime.signal)) @@ -153,6 +153,7 @@ export async function runEnvAdd( command: "project.env.add", result: { projectId, + verboseContext, scope: resolved.descriptor, variable: toMetadata(data.data as RawEnvironmentVariable, resolved.descriptor), }, @@ -179,14 +180,14 @@ export async function runEnvUpdate( } const input = await resolveEnvWriteInput(context, source, "update"); - const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env update"); + const { client, projectId, verboseContext } = await requireClientAndProject(context, flags.projectRef, "project env update"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: false, signal: context.runtime.signal, }); if (input.kind === "file") { - return runEnvUpdateFile(context, client, projectId, resolved, input.filePath, input.assignments); + return runEnvUpdateFile(context, client, projectId, resolved, input.filePath, input.assignments, verboseContext); } const existing = await findVariableByNaturalKey(client, projectId, input.key, resolved, context.runtime.signal); @@ -221,6 +222,7 @@ export async function runEnvUpdate( command: "project.env.update", result: { projectId, + verboseContext, scope: resolved.descriptor, variable: toMetadata(data.data as RawEnvironmentVariable, resolved.descriptor), }, @@ -301,8 +303,8 @@ export async function runEnvList( ): Promise> { const explicit = resolveEnvScope(flags, { requireExplicit: false, command: "list" }); - const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env list"); - const resolved = await resolveListScopeToApi(client, projectId, explicit, { + const { client, projectId, verboseContext } = await requireClientAndProject(context, flags.projectRef, "project env list"); + const resolved = await resolveListScopeToApi(client, projectId, explicit ?? undefined, { cwd: context.runtime.cwd, signal: context.runtime.signal, }); @@ -318,6 +320,7 @@ export async function runEnvList( command: "project.env.list", result: { projectId, + verboseContext, scope: resolved.descriptor, target: resolved.target, variables: variables.map((row) => toMetadata(row, resolved.descriptor)), @@ -355,7 +358,7 @@ export async function runEnvRemove( ); } - const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env remove"); + const { client, projectId, verboseContext } = await requireClientAndProject(context, flags.projectRef, "project env remove"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: false, signal: context.runtime.signal, @@ -390,6 +393,7 @@ export async function runEnvRemove( command: "project.env.remove", result: { projectId, + verboseContext, scope: resolved.descriptor, key, }, @@ -402,7 +406,7 @@ async function requireClientAndProject( context: CommandContext, explicitProject: string | undefined, commandName: string, -): Promise<{ client: ManagementApiClient; projectId: string }> { +): Promise<{ client: ManagementApiClient; projectId: string; verboseContext: EnvResolvedContext }> { const authState = await requireAuthenticatedAuthState(context); const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { @@ -420,7 +424,15 @@ async function requireClientAndProject( commandName, }); - return { client, projectId: target.project.id }; + return { + client, + projectId: target.project.id, + verboseContext: { + workspace: authState.workspace, + project: target.project, + resolution: target.resolution, + }, + }; } async function resolveScopeToApi( diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index bd158d7..54dbd57 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -27,6 +27,7 @@ import type { AppOpenResult, AppPromoteResult, AppRemoveResult, + AppResolvedContext, AppRollbackResult, AppShowResult, AppRunResult, @@ -38,7 +39,7 @@ import type { ProjectResolution, ProjectSummary } from "../types/project"; import { requireComputeAuth } from "../lib/auth/guard"; import { readAuthState } from "../lib/auth/auth-ops"; import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; -import { parseEnvAssignments } from "../lib/app/env-vars"; +import { envVarNames, parseEnvAssignments } from "../lib/app/env-vars"; import { renderDeployOutputRows, renderDeploySettingsPreview } from "../lib/app/deploy-output"; import { DEFAULT_LOCAL_DEV_PORT, @@ -367,6 +368,18 @@ export async function runAppDeploy( name: deployResult.app.name, }, deployment: deployResult.deployment, + deploySettings: { + framework: { + key: framework.key, + buildType, + name: framework.displayName, + source: framework.annotation, + }, + entrypoint: entrypoint ?? null, + httpPort: runtime.port, + region: selectedApp.region ?? null, + envVars: envVarNames(envVars), + }, durationMs: deployDurationMs, localPin: localPinResult, }, @@ -393,6 +406,7 @@ export async function runAppListDeploys( command: "app.list-deploys", result: { projectId, + verboseContext: toAppVerboseContext(target), app: null, deployments: [], }, @@ -423,6 +437,7 @@ export async function runAppListDeploys( command: "app.list-deploys", result: { projectId, + verboseContext: toAppVerboseContext(target), app: { id: deploymentsResult.app.id, name: deploymentsResult.app.name, @@ -454,6 +469,7 @@ export async function runAppShow( command: "app.show", result: { projectId, + verboseContext: toAppVerboseContext(target), app: null, liveDeployment: null, liveUrl: null, @@ -489,6 +505,7 @@ export async function runAppShow( command: "app.show", result: { projectId, + verboseContext: toAppVerboseContext(target), app: { id: deploymentsResult.app.id, name: deploymentsResult.app.name, @@ -625,6 +642,7 @@ export async function runAppOpen( command: "app.open", result: { projectId, + verboseContext: toAppVerboseContext(target), app: { id: deploymentsResult.app.id, name: deploymentsResult.app.name, @@ -1094,6 +1112,7 @@ export async function runAppPromote( command: "app.promote", result: { projectId, + verboseContext: toAppVerboseContext(target), app: { id: deploymentsResult.app.id, name: deploymentsResult.app.name, @@ -1164,6 +1183,7 @@ export async function runAppRollback( command: "app.rollback", result: { projectId, + verboseContext: toAppVerboseContext(target), app: { id: deploymentsResult.app.id, name: deploymentsResult.app.name, @@ -1205,6 +1225,7 @@ export async function runAppRemove( command: "app.remove", result: { projectId, + verboseContext: toAppVerboseContext(target), app: { id: removedApp.id, name: removedApp.name, @@ -2493,11 +2514,21 @@ function toBranchKind(name: string): BranchKind { function toResultBranch(branch: ResolvedAppProjectContext["branch"]): AppDeployResult["branch"] { return { + id: branch.id, name: branch.name, kind: branch.kind, }; } +function toAppVerboseContext(target: ResolvedAppProjectContext): AppResolvedContext { + return { + workspace: target.workspace, + project: target.project, + branch: target.branch, + resolution: target.resolution, + }; +} + function toBranchDatabaseDeployBranch(branch: ResolvedAppProjectContext["branch"]): BranchDatabaseDeployBranch { if (!branch.id) { throw new Error(`Deploy branch "${branch.name}" was not resolved remotely.`); diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 5f98ba6..7e2dfde 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -68,6 +68,11 @@ async function listRealBranches(context: CommandContext): Promise { + const stateDir = resolveStateDir(context.runtime); + + return { + cwd: context.runtime.cwd, + stateFilePath: resolveLocalStateFilePath(stateDir), + git: await readLocalGitState(context.runtime.cwd, context.runtime.signal), + durationMs: options.durationMs, + }; +} diff --git a/packages/cli/src/lib/git/local-status.ts b/packages/cli/src/lib/git/local-status.ts new file mode 100644 index 0000000..2738d7c --- /dev/null +++ b/packages/cli/src/lib/git/local-status.ts @@ -0,0 +1,67 @@ +import { execFile } from "node:child_process"; + +import type { LocalGitState } from "../../types/diagnostics"; + +export async function readLocalGitState(cwd: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + + const insideWorkTree = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"], signal); + if (insideWorkTree?.trim() !== "true") { + return null; + } + + const [ref, sha, status] = await Promise.all([ + runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], signal), + runGit(cwd, ["rev-parse", "--short", "HEAD"], signal), + runGit(cwd, ["status", "--porcelain"], signal), + ]); + + return { + ref: cleanGitValue(ref), + sha: cleanGitValue(sha), + dirty: status === null ? null : status.trim().length > 0, + }; +} + +function runGit(cwd: string, args: string[], signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + + const child = execFile( + "git", + args, + { + cwd, + signal, + timeout: 2_000, + }, + (error, stdout) => { + if (signal.aborted) { + reject(error); + return; + } + + if (error) { + resolve(null); + return; + } + + resolve(stdout); + }, + ); + + child.on("error", (error) => { + if (signal.aborted) { + reject(error); + return; + } + + resolve(null); + }); + }); +} + +function cleanGitValue(value: string | null): string | null { + const cleaned = value?.trim(); + return cleaned ? cleaned : null; +} diff --git a/packages/cli/src/presenters/app-env.ts b/packages/cli/src/presenters/app-env.ts index deb3a89..aa018df 100644 --- a/packages/cli/src/presenters/app-env.ts +++ b/packages/cli/src/presenters/app-env.ts @@ -4,10 +4,13 @@ import type { EnvAddResult, EnvListResult, EnvRmResult, + EnvResolvedContext, EnvScopeDescriptor, EnvUpdateResult, } from "../types/app-env"; import { renderList, renderShow, serializeList } from "../output/patterns"; +import { renderVerboseBlock, type VerboseRow } from "../shell/ui"; +import { renderResolvedProjectContextBlock, stripVerboseContext } from "./verbose-context"; function scopeLabel(scope: EnvScopeDescriptor): string { if (scope.kind === "role") { @@ -33,13 +36,104 @@ function listTargetLabel(result: EnvListResult): string { return scopeLabel(result.scope); } +type EnvPresenterResult = EnvAddResult | EnvUpdateResult | EnvListResult | EnvRmResult; + +function renderEnvVerboseBlocks( + context: CommandContext, + result: EnvPresenterResult, +): string[] { + return [ + ...renderEnvResolvedContextBlock(context, result.verboseContext), + ...renderEnvTargetBlock(context, result), + ]; +} + +function renderEnvResolvedContextBlock( + context: CommandContext, + verboseContext: EnvResolvedContext | undefined, +): string[] { + return renderResolvedProjectContextBlock(context.ui, verboseContext); +} + +function renderEnvTargetBlock( + context: CommandContext, + result: EnvPresenterResult, +): string[] { + return renderVerboseBlock(context.ui, envTargetRows(result), { title: "Env target" }); +} + +function envTargetRows(result: EnvPresenterResult): VerboseRow[] { + return [ + { key: "project id", value: result.projectId, tone: "dim" }, + { key: "scope", value: scopeLabel(result.scope) }, + ...envListTargetRows(result), + ...envFileRows(result), + { + key: "keys", + value: formatKeyNames(envResultKeys(result)), + tone: envResultKeys(result).length > 0 ? "default" : "dim", + }, + ]; +} + +function envListTargetRows(result: EnvPresenterResult): VerboseRow[] { + if (!("target" in result)) { + return []; + } + + return [ + { key: "target source", value: result.target.source }, + { key: "env map", value: result.target.envMap }, + ...(result.target.branchName + ? [{ key: "branch", value: result.target.branchName }] + : []), + ...(result.target.branchId + ? [{ key: "branch id", value: result.target.branchId, tone: "dim" as const }] + : []), + ...(result.target.branchExists === false + ? [{ key: "branch state", value: "not created yet", tone: "warning" as const }] + : []), + ]; +} + +function envFileRows(result: EnvPresenterResult): VerboseRow[] { + if (!("file" in result) || !result.file) { + return []; + } + + return [ + { key: "file", value: result.file.path }, + { key: "file count", value: String(result.file.count) }, + ]; +} + +function envResultKeys(result: EnvPresenterResult): string[] { + if ("variables" in result && result.variables) { + return result.variables.map((variable) => variable.key).sort((left, right) => left.localeCompare(right)); + } + + if ("variable" in result && result.variable) { + return [result.variable.key]; + } + + if ("key" in result) { + return [result.key]; + } + + return []; +} + +function formatKeyNames(keys: string[]): string { + return keys.length > 0 ? keys.join(", ") : "none"; +} + export function renderEnvAdd( context: CommandContext, descriptor: CommandDescriptor, result: EnvAddResult, ): string[] { if (result.variables !== undefined) { - return renderList( + const lines = renderList( { title: "Setting new environment variables from file.", descriptor, @@ -57,9 +151,11 @@ export function renderEnvAdd( }, context.ui, ); + lines.push(...renderEnvVerboseBlocks(context, result)); + return lines; } - return renderShow( + const lines = renderShow( { title: "Setting a new environment variable.", descriptor, @@ -77,10 +173,12 @@ export function renderEnvAdd( }, context.ui, ); + lines.push(...renderEnvVerboseBlocks(context, result)); + return lines; } export function serializeEnvAdd(result: EnvAddResult) { - return result; + return stripVerboseContext(result); } export function renderEnvUpdate( @@ -89,7 +187,7 @@ export function renderEnvUpdate( result: EnvUpdateResult, ): string[] { if (result.variables !== undefined) { - return renderList( + const lines = renderList( { title: "Replacing environment variable values from file.", descriptor, @@ -107,9 +205,11 @@ export function renderEnvUpdate( }, context.ui, ); + lines.push(...renderEnvVerboseBlocks(context, result)); + return lines; } - return renderShow( + const lines = renderShow( { title: "Replacing the environment variable's value.", descriptor, @@ -127,10 +227,12 @@ export function renderEnvUpdate( }, context.ui, ); + lines.push(...renderEnvVerboseBlocks(context, result)); + return lines; } export function serializeEnvUpdate(result: EnvUpdateResult) { - return result; + return stripVerboseContext(result); } export function renderEnvList( @@ -138,7 +240,7 @@ export function renderEnvList( descriptor: CommandDescriptor, result: EnvListResult, ): string[] { - return renderList( + const lines = renderList( { title: "Listing environment variables for the selected scope.", descriptor, @@ -156,25 +258,29 @@ export function renderEnvList( }, context.ui, ); + lines.push(...renderEnvVerboseBlocks(context, result)); + return lines; } export function serializeEnvList(result: EnvListResult) { + const serializable = stripVerboseContext(result); + return { - projectId: result.projectId, - scope: result.scope, - target: result.target, + projectId: serializable.projectId, + scope: serializable.scope, + target: serializable.target, ...serializeList({ context: { - target: listTargetLabel(result), + target: listTargetLabel(serializable), }, - items: result.variables.map((variable) => ({ + items: serializable.variables.map((variable) => ({ noun: "variable", label: `${variable.key} (${variable.source})`, id: variable.id, status: variable.isManagedBySystem ? "default" : null, })), }), - variables: result.variables, + variables: serializable.variables, }; } @@ -183,7 +289,7 @@ export function renderEnvRm( descriptor: CommandDescriptor, result: EnvRmResult, ): string[] { - return renderShow( + const lines = renderShow( { title: "Removing the environment variable from the scope.", descriptor, @@ -195,8 +301,10 @@ export function renderEnvRm( }, context.ui, ); + lines.push(...renderEnvVerboseBlocks(context, result)); + return lines; } export function serializeEnvRm(result: EnvRmResult) { - return result; + return stripVerboseContext(result); } diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index 23a2beb..dc12b00 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -2,6 +2,7 @@ import type { CommandDescriptor } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import type { AppBuildResult, + AppDeploySettings, AppDeployResult, AppDomainAddResult, AppDomainRemoveResult, @@ -20,6 +21,8 @@ import type { import { renderList, renderShow, serializeList } from "../output/patterns"; import { renderDeployOutputRows } from "../lib/app/deploy-output"; import { formatDomainFailureFix } from "../lib/app/domain-guidance"; +import { renderVerboseBlock, type VerboseRow } from "../shell/ui"; +import { renderResolvedProjectContextBlock, stripVerboseContext } from "./verbose-context"; export function renderAppBuild( context: CommandContext, @@ -59,13 +62,20 @@ export function renderAppDeploy( ...renderDeployOutputRows(context.ui, [ { label: "Logs", value: "prisma-cli app logs" }, ]), + ...renderDeployResolvedContextBlock(context, result), + ...renderDeploySettingsBlock(context, result), ]; return lines; } export function serializeAppDeploy(result: AppDeployResult) { - const { localPin: _localPin, ...serialized } = result; - return serialized; + const { deploySettings: _deploySettings, localPin: _localPin, ...serialized } = result; + const { id: _branchId, ...branch } = serialized.branch; + + return { + ...serialized, + branch, + }; } function renderBranchDatabaseDeploySummary( @@ -113,12 +123,99 @@ function formatDuration(durationMs: number): string { return `${(durationMs / 1000).toFixed(1)}s`; } +function renderDeployResolvedContextBlock( + context: CommandContext, + result: AppDeployResult, +): string[] { + return renderResolvedProjectContextBlock(context.ui, { + workspace: result.workspace, + project: result.project, + resolution: result.resolution, + branch: { + id: result.branch.id, + name: result.branch.name, + kind: result.branch.kind, + }, + }, { + extraRows: [ + { key: "app", value: result.app.name }, + { key: "app id", value: result.app.id, tone: "dim" }, + { key: "deployment id", value: result.deployment.id, tone: "dim" }, + { key: "deployment status", value: result.deployment.status }, + ...(result.localPin ? [{ key: "local pin", value: result.localPin.path }] : []), + { key: "deploy duration", value: formatDuration(result.durationMs) }, + ], + }); +} + +function renderDeploySettingsBlock( + context: CommandContext, + result: AppDeployResult, +): string[] { + return renderVerboseBlock(context.ui, [ + ...deploySettingsRows(result.deploySettings), + ...branchDatabaseRows(result.branchDatabase), + ], { title: "Deploy settings" }); +} + +function deploySettingsRows(settings: AppDeploySettings): VerboseRow[] { + return [ + { key: "framework", value: `${settings.framework.name} (${settings.framework.buildType})` }, + { key: "framework source", value: settings.framework.source, tone: "dim" }, + { + key: "entrypoint", + value: settings.entrypoint ?? "derived from build output", + tone: settings.entrypoint ? "default" : "dim", + }, + { key: "http port", value: String(settings.httpPort) }, + { + key: "region", + value: settings.region ?? "existing app region", + tone: settings.region ? "default" : "dim", + }, + { + key: "env vars", + value: formatEnvVarNames(settings.envVars), + tone: settings.envVars.length > 0 ? "default" : "dim", + }, + ]; +} + +function branchDatabaseRows(branchDatabase: AppDeployResult["branchDatabase"]): VerboseRow[] { + if (!branchDatabase) { + return [{ key: "branch db", value: "not configured", tone: "dim" }]; + } + + return [ + { + key: "branch db", + value: branchDatabase.status === "created" + ? `created${branchDatabase.database ? ` (${branchDatabase.database.name})` : ""}` + : `skipped${branchDatabase.reason ? ` (${branchDatabase.reason})` : ""}`, + tone: branchDatabase.status === "created" ? "success" : "dim", + }, + ...(branchDatabase.envVars.length > 0 + ? [{ key: "branch db env", value: branchDatabase.envVars.join(", ") }] + : []), + ...(branchDatabase.schema + ? [{ + key: "branch db schema", + value: `${formatBranchDatabaseSchemaCommand(branchDatabase.schema.command)} (${branchDatabase.schema.source}, ${branchDatabase.schema.path})`, + }] + : []), + ]; +} + +function formatEnvVarNames(envVars: string[]): string { + return envVars.length > 0 ? envVars.join(", ") : "none"; +} + export function renderAppListDeploys( context: CommandContext, descriptor: CommandDescriptor, result: AppListDeploysResult, ): string[] { - return renderList( + const lines = renderList( { title: "Listing deployments for the selected app.", descriptor, @@ -136,12 +233,16 @@ export function renderAppListDeploys( }, context.ui, ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; } export function serializeAppListDeploys(result: AppListDeploysResult) { - if (!result.app) { + const { verboseContext: _verboseContext, ...serializable } = result; + + if (!serializable.app) { return { - projectId: result.projectId, + projectId: serializable.projectId, app: null, items: [], count: 0, @@ -149,13 +250,13 @@ export function serializeAppListDeploys(result: AppListDeploysResult) { } return { - projectId: result.projectId, - app: result.app, + projectId: serializable.projectId, + app: serializable.app, ...serializeList({ context: { - app: result.app.name, + app: serializable.app.name, }, - items: result.deployments.map((deployment) => ({ + items: serializable.deployments.map((deployment) => ({ noun: "deployment", label: deployment.id, id: deployment.id, @@ -170,7 +271,7 @@ export function renderAppShow( descriptor: CommandDescriptor, result: AppShowResult, ): string[] { - return renderShow( + const lines = renderShow( { title: "Showing the selected app state.", descriptor, @@ -196,10 +297,12 @@ export function renderAppShow( }, context.ui, ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; } export function serializeAppShow(result: AppShowResult) { - return result; + return stripVerboseContext(result); } export function renderAppShowDeploy( @@ -239,7 +342,7 @@ export function renderAppOpen( descriptor: CommandDescriptor, result: AppOpenResult, ): string[] { - return renderShow( + const lines = renderShow( { title: result.opened ? "Opening the live URL for the selected app." @@ -254,10 +357,12 @@ export function renderAppOpen( }, context.ui, ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; } export function serializeAppOpen(result: AppOpenResult) { - return result; + return stripVerboseContext(result); } export function renderAppDomainAdd( @@ -366,7 +471,7 @@ export function renderAppPromote( descriptor: CommandDescriptor, result: AppPromoteResult, ): string[] { - return renderShow( + const lines = renderShow( { title: "Switching the live deployment for the selected app.", descriptor, @@ -382,10 +487,12 @@ export function renderAppPromote( }, context.ui, ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; } export function serializeAppPromote(result: AppPromoteResult) { - return result; + return stripVerboseContext(result); } export function renderAppRollback( @@ -393,7 +500,7 @@ export function renderAppRollback( descriptor: CommandDescriptor, result: AppRollbackResult, ): string[] { - return renderShow( + const lines = renderShow( { title: "Restoring the selected app to an earlier deployment.", descriptor, @@ -412,10 +519,12 @@ export function renderAppRollback( }, context.ui, ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; } export function serializeAppRollback(result: AppRollbackResult) { - return result; + return stripVerboseContext(result); } export function renderAppRun( @@ -435,7 +544,7 @@ export function renderAppRemove( descriptor: CommandDescriptor, result: AppRemoveResult, ): string[] { - return renderShow( + const lines = renderShow( { title: "Removing the selected app.", descriptor, @@ -447,10 +556,12 @@ export function renderAppRemove( }, context.ui, ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; } export function serializeAppRemove(result: AppRemoveResult) { - return result; + return stripVerboseContext(result); } function toneForStatus(status: string): "success" | "warning" | "error" | "default" { diff --git a/packages/cli/src/presenters/branch.ts b/packages/cli/src/presenters/branch.ts index fb48e52..0533907 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -3,6 +3,7 @@ import { formatDescriptorLabel } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import { formatColumns } from "../shell/ui"; import type { BranchListResult } from "../types/branch"; +import { renderResolvedProjectContextBlock } from "./verbose-context"; export function renderBranchList( context: CommandContext, @@ -17,6 +18,7 @@ export function renderBranchList( if (result.branches.length === 0) { lines.push(`${rail} ${ui.dim("No branches found.")}`); + lines.push(...renderBranchResolvedContextBlock(context, result)); return lines; } @@ -30,13 +32,22 @@ export function renderBranchList( lines.push(`${rail} ${formatColumns([branch.name, branch.role, branch.envMap], widths)}`); } + lines.push(...renderBranchResolvedContextBlock(context, result)); return lines; } export function serializeBranchList(result: BranchListResult) { + const { verboseContext: _verboseContext, ...serializable } = result; return { - projectId: result.projectId, - projectName: result.projectName, - branches: result.branches, + projectId: serializable.projectId, + projectName: serializable.projectName, + branches: serializable.branches, }; } + +function renderBranchResolvedContextBlock( + context: CommandContext, + result: BranchListResult, +): string[] { + return renderResolvedProjectContextBlock(context.ui, result.verboseContext); +} diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index c721db7..56d5725 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -14,7 +14,8 @@ import type { ProjectShowResult, } from "../types/project"; import { renderMutate, renderShow, serializeList } from "../output/patterns"; -import { padDisplay, renderNextSteps, renderSummaryLine } from "../shell/ui"; +import { padDisplay, renderNextSteps, renderSummaryLine, renderVerboseBlock } from "../shell/ui"; +import { renderResolvedProjectContextBlock } from "./verbose-context"; export function renderProjectList( context: CommandContext, @@ -92,6 +93,13 @@ export function renderProjectShow( context.ui, ); + lines.push(...renderVerboseBlock(context.ui, [ + { key: "workspace", value: result.workspace.name }, + { key: "workspace id", value: result.workspace.id, tone: "dim" }, + { key: "project source", value: "unbound" }, + { key: "suggested name", value: `${result.suggestedProjectName} (${result.suggestedProjectNameSource})` }, + ], { title: "Resolved context" })); + lines.push(...renderNextSteps([ "Link an existing Project you choose: prisma-cli project link ", `Create a new Project: prisma-cli project create ${formatCommandArgument(result.suggestedProjectName)}`, @@ -195,6 +203,12 @@ function renderBoundProjectShow( lines.push(`${rail} ${ui.dim("→")} ${ui.link(result.project.url)}`); } + lines.push(...renderResolvedProjectContextBlock(context.ui, { + workspace: result.workspace, + project: result.project, + resolution: result.resolution, + })); + return lines; } diff --git a/packages/cli/src/presenters/verbose-context.ts b/packages/cli/src/presenters/verbose-context.ts new file mode 100644 index 0000000..7099d71 --- /dev/null +++ b/packages/cli/src/presenters/verbose-context.ts @@ -0,0 +1,84 @@ +import type { ShellUi, VerboseRow } from "../shell/ui"; +import { renderVerboseBlock } from "../shell/ui"; +import type { AuthWorkspace } from "../types/auth"; +import type { BranchKind } from "../types/branch"; +import type { ProjectResolution, ProjectSummary } from "../types/project"; + +export interface ResolvedProjectContext { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; + branch?: { + id: string | null; + name: string; + kind: BranchKind; + }; +} + +export function renderResolvedProjectContextBlock( + ui: ShellUi, + context: ResolvedProjectContext | undefined, + options: { title?: string; extraRows?: VerboseRow[] } = {}, +): string[] { + if (!context) { + return []; + } + + return renderVerboseBlock(ui, [ + ...projectResolutionRows(context), + ...(options.extraRows ?? []), + ], { title: options.title ?? "Resolved context" }); +} + +export function projectResolutionRows(context: ResolvedProjectContext): VerboseRow[] { + return [ + { key: "workspace", value: context.workspace.name }, + { key: "workspace id", value: context.workspace.id, tone: "dim" }, + { key: "project", value: context.project.name }, + { key: "project id", value: context.project.id, tone: "dim" }, + { key: "project source", value: formatProjectSource(context.resolution.projectSource) }, + ...(context.resolution.targetName + ? [{ key: "target name", value: formatTargetName(context.resolution) }] + : []), + ...(context.branch + ? [ + { key: "branch", value: `${context.branch.name} (${context.branch.kind})` }, + ...(context.branch.id + ? [{ key: "branch id", value: context.branch.id, tone: "dim" as const }] + : []), + ] + : []), + ]; +} + +export function stripVerboseContext( + result: T, +): Omit { + const { verboseContext: _verboseContext, ...serialized } = result; + return serialized; +} + +function formatProjectSource(source: ProjectResolution["projectSource"]): string { + switch (source) { + case "explicit": + return "--project"; + case "env": + return "environment"; + case "local-pin": + return ".prisma/local.json"; + case "platform-mapping": + return "platform mapping"; + case "created": + return "created"; + case "prompt": + return "prompt"; + case "unbound": + return "unbound"; + } +} + +function formatTargetName(resolution: ProjectResolution): string { + return resolution.targetNameSource + ? `${resolution.targetName} (${resolution.targetNameSource})` + : String(resolution.targetName); +} diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index b75c87f..40e7838 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -1,6 +1,8 @@ import { AuthError as SDKAuthError } from "@prisma/management-api-sdk"; import type { CommandDescriptor } from "./command-meta"; import { getCommandDescriptor } from "./command-meta"; +import { collectCommandDiagnostics } from "../lib/diagnostics"; +import { renderCommandDiagnostics } from "./diagnostics-output"; import { authRequiredError, CliError, commandCanceledError } from "./errors"; import { resolveGlobalFlags } from "./global-flags"; import type { CommandSuccess } from "./output"; @@ -48,6 +50,7 @@ export async function runCommand( const flags = resolveGlobalFlags(runtime.argv, options); const context = await createCommandContext(runtime, flags); const descriptor = getCommandDescriptor(commandName); + const startedAt = Date.now(); try { const success = await handler(context); @@ -64,7 +67,16 @@ export async function runCommand( return; } - writeHumanLines(context.output, presenter.renderHuman(context, descriptor, success.result)); + const rendered = presenter.renderHuman(context, descriptor, success.result); + const diagnostics = await renderBestEffortCommandDiagnostics(context, { + enabled: flags.verbose && rendered.length > 0, + durationMs: Date.now() - startedAt, + }); + + writeHumanLines(context.output, [ + ...rendered, + ...diagnostics, + ]); } catch (error) { const cliError = toCliError(error, runtime); if (cliError) { @@ -82,6 +94,24 @@ export async function runCommand( } } +async function renderBestEffortCommandDiagnostics( + context: Awaited>, + options: { enabled: boolean; durationMs: number }, +): Promise { + if (!options.enabled) { + return []; + } + + try { + return renderCommandDiagnostics( + context, + await collectCommandDiagnostics(context, { durationMs: options.durationMs }), + ); + } catch { + return []; + } +} + export async function runStreamingCommand( runtime: CliRuntime, commandName: string, diff --git a/packages/cli/src/shell/diagnostics-output.ts b/packages/cli/src/shell/diagnostics-output.ts new file mode 100644 index 0000000..7044522 --- /dev/null +++ b/packages/cli/src/shell/diagnostics-output.ts @@ -0,0 +1,63 @@ +import path from "node:path"; + +import type { CommandDiagnostics } from "../types/diagnostics"; +import type { CommandContext } from "./runtime"; +import { renderVerboseBlock, type VerboseRow } from "./ui"; + +export function renderCommandDiagnostics( + context: CommandContext, + diagnostics: CommandDiagnostics | undefined, + rows: VerboseRow[] = [], + options: { title?: string } = {}, +): string[] { + if (!diagnostics) { + return []; + } + + const { env } = context.runtime; + const git = diagnostics.git; + + return renderVerboseBlock(context.ui, [ + ...rows, + ...(diagnostics.durationMs === undefined + ? [] + : [{ key: "duration", value: formatDuration(diagnostics.durationMs) }]), + { key: "cwd", value: formatLocalPath(diagnostics.cwd, env) }, + { key: "state file", value: formatLocalPath(diagnostics.stateFilePath, env) }, + ...(git + ? [ + { key: "git ref", value: git.ref ?? "detached", tone: git.ref ? "default" as const : "dim" as const }, + { key: "git sha", value: git.sha ?? "unknown", tone: git.sha ? "default" as const : "dim" as const }, + { key: "git dirty", value: formatDirtyState(git.dirty), tone: git.dirty ? "warning" as const : "dim" as const }, + ] + : [{ key: "git", value: "not detected", tone: "dim" as const }]), + ], { title: options.title ?? "Local context" }); +} + +export function formatLocalPath(value: string, env: NodeJS.ProcessEnv): string { + const resolved = path.resolve(value); + const home = env.HOME ? path.resolve(env.HOME) : null; + + if (home && (resolved === home || resolved.startsWith(`${home}${path.sep}`))) { + const relative = path.relative(home, resolved); + return relative ? `~/${relative}` : "~"; + } + + return resolved; +} + +function formatDirtyState(dirty: boolean | null): string { + if (dirty === null) { + return "unknown"; + } + + return dirty ? "yes" : "no"; +} + +function formatDuration(durationMs: number): string { + if (durationMs < 1000) { + return `${durationMs}ms`; + } + + return `${(durationMs / 1000).toFixed(1)}s`; +} diff --git a/packages/cli/src/shell/output.ts b/packages/cli/src/shell/output.ts index 160afb3..0f7e06e 100644 --- a/packages/cli/src/shell/output.ts +++ b/packages/cli/src/shell/output.ts @@ -40,6 +40,26 @@ export function cliErrorToJson(error: CliError) { }; } +export function formatUnexpectedError(error: unknown, trace: boolean): string { + const debug = error instanceof Error + ? error.stack ?? error.message + : String(error); + + if (trace) { + return `${debug}\n`; + } + + const message = error instanceof Error && error.message + ? error.message + : String(error); + + return [ + `Unexpected CLI error: ${message}`, + "More: Re-run with --trace for deeper diagnostics", + "", + ].join("\n"); +} + export function writeJsonError(output: CliOutput, command: string, error: CliError): void { output.stdout.write( `${JSON.stringify( diff --git a/packages/cli/src/shell/ui.ts b/packages/cli/src/shell/ui.ts index 8afd246..c91e4e8 100644 --- a/packages/cli/src/shell/ui.ts +++ b/packages/cli/src/shell/ui.ts @@ -36,6 +36,13 @@ export interface FieldRow { tone?: "default" | "dim"; } +export interface VerboseRow { + key: string; + value: string; + tone?: "default" | "dim" | "success" | "warning" | "link"; + sensitive?: boolean; +} + const DEFAULT_WIDTH = 80; export function createShellUi(runtime: CliRuntime, flags: GlobalFlags): ShellUi { @@ -139,6 +146,22 @@ export function renderNextSteps(steps: string[]): string[] { ]; } +export function renderVerboseBlock(ui: ShellUi, rows: VerboseRow[], options: { title?: string } = {}): string[] { + if (!ui.verbose || rows.length === 0) { + return []; + } + + const title = options.title ?? "Details"; + const keyWidth = Math.max(...rows.map((row) => stringWidth(`${row.key}:`))); + const rail = ui.dim("│"); + + return [ + "", + `${ui.dim(title)}:`, + ...rows.map((row) => `${rail} ${ui.accent(padDisplay(`${row.key}:`, keyWidth))} ${formatVerboseValue(ui, row)}`), + ]; +} + export function formatColumns(columns: string[], widths: number[]): string { return columns.map((value, index) => padDisplay(value, widths[index])).join(" ").trimEnd(); } @@ -191,3 +214,25 @@ function formatHeaderValue(ui: ShellUi, row: HeaderRow): string { return value; } + +function formatVerboseValue(ui: ShellUi, row: VerboseRow): string { + const value = row.sensitive ? maskValue(row.value) : row.value; + + if (row.tone === "dim") { + return ui.dim(value); + } + + if (row.tone === "success") { + return ui.success(value); + } + + if (row.tone === "warning") { + return ui.warning(value); + } + + if (row.tone === "link") { + return ui.link(value); + } + + return value; +} diff --git a/packages/cli/src/types/app-env.ts b/packages/cli/src/types/app-env.ts index 689d998..77d2298 100644 --- a/packages/cli/src/types/app-env.ts +++ b/packages/cli/src/types/app-env.ts @@ -1,3 +1,12 @@ +import type { AuthWorkspace } from "./auth"; +import type { ProjectResolution, ProjectSummary } from "./project"; + +export interface EnvResolvedContext { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; +} + export type EnvScopeDescriptor = | { kind: "role"; role: "production" | "preview" } | { kind: "branch"; branchName: string; branchId: string } @@ -28,6 +37,7 @@ export interface EnvFileMetadata { export interface EnvSingleWriteResult { projectId: string; + verboseContext?: EnvResolvedContext; scope: EnvScopeDescriptor; variable: EnvVariableMetadata; variables?: never; @@ -36,6 +46,7 @@ export interface EnvSingleWriteResult { export interface EnvFileWriteResult { projectId: string; + verboseContext?: EnvResolvedContext; scope: EnvScopeDescriptor; variable?: never; variables: EnvVariableMetadata[]; @@ -48,6 +59,7 @@ export type EnvUpdateResult = EnvSingleWriteResult | EnvFileWriteResult; export interface EnvListResult { projectId: string; + verboseContext?: EnvResolvedContext; scope: EnvScopeDescriptor; target: EnvListTarget; variables: EnvVariableMetadata[]; @@ -55,6 +67,7 @@ export interface EnvListResult { export interface EnvRmResult { projectId: string; + verboseContext?: EnvResolvedContext; scope: EnvScopeDescriptor; key: string; } diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts index 5ce2942..d8b3588 100644 --- a/packages/cli/src/types/app.ts +++ b/packages/cli/src/types/app.ts @@ -15,10 +15,35 @@ export interface AppDeploymentSummary { live: boolean | null; } +export interface AppResolvedContext { + workspace: AuthWorkspace; + project: ProjectSummary; + branch: { + id: string | null; + name: string; + kind: BranchKind; + }; + resolution: ProjectResolution; +} + +export interface AppDeploySettings { + framework: { + key: string; + buildType: AppBuildResult["buildType"]; + name: string; + source: string; + }; + entrypoint: string | null; + httpPort: number; + region: string | null; + envVars: string[]; +} + export interface AppDeployResult { workspace: AuthWorkspace; project: ProjectSummary; branch: { + id: string | null; name: string; kind: BranchKind; }; @@ -43,6 +68,7 @@ export interface AppDeployResult { status: string; url: string | null; }; + deploySettings: AppDeploySettings; durationMs: number; localPin?: { path: string; @@ -52,12 +78,14 @@ export interface AppDeployResult { export interface AppListDeploysResult { projectId: string; + verboseContext?: AppResolvedContext; app: AppSummary | null; deployments: AppDeploymentSummary[]; } export interface AppShowResult { projectId: string; + verboseContext?: AppResolvedContext; app: AppSummary | null; liveDeployment: AppDeploymentSummary | null; liveUrl: string | null; @@ -77,6 +105,7 @@ export interface AppShowDeployResult { export interface AppOpenResult { projectId: string; + verboseContext?: AppResolvedContext; app: AppSummary; url: string; opened: boolean; @@ -91,12 +120,14 @@ export interface AppRunResult { export interface AppPromoteResult { projectId: string; + verboseContext?: AppResolvedContext; app: AppSummary; deployment: AppDeploymentSummary; } export interface AppRollbackResult { projectId: string; + verboseContext?: AppResolvedContext; app: AppSummary; deployment: AppDeploymentSummary; previousLiveDeploymentId: string | null; @@ -104,6 +135,7 @@ export interface AppRollbackResult { export interface AppRemoveResult { projectId: string; + verboseContext?: AppResolvedContext; app: AppSummary; removed: true; } diff --git a/packages/cli/src/types/branch.ts b/packages/cli/src/types/branch.ts index bcdf71e..408d317 100644 --- a/packages/cli/src/types/branch.ts +++ b/packages/cli/src/types/branch.ts @@ -1,4 +1,8 @@ +import type { AuthWorkspace } from "./auth"; +import type { ProjectResolution, ProjectSummary } from "./project"; + export type BranchRole = "preview" | "production"; +export type BranchKind = BranchRole; export interface BranchSummary { id: string; @@ -10,5 +14,10 @@ export interface BranchSummary { export interface BranchListResult { projectId: string; projectName: string; + verboseContext?: { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; + }; branches: BranchSummary[]; } diff --git a/packages/cli/src/types/diagnostics.ts b/packages/cli/src/types/diagnostics.ts new file mode 100644 index 0000000..bf8d2fc --- /dev/null +++ b/packages/cli/src/types/diagnostics.ts @@ -0,0 +1,12 @@ +export interface LocalGitState { + ref: string | null; + sha: string | null; + dirty: boolean | null; +} + +export interface CommandDiagnostics { + cwd: string; + stateFilePath: string; + git: LocalGitState | null; + durationMs?: number; +} diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index c9ad006..7007126 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -54,6 +54,29 @@ function withBranchDatabaseProviderDefaults>(p }; } +function expectedAppVerboseContext() { + return { + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + branch: { + id: null, + name: "main", + kind: "production", + }, + resolution: { + projectSource: "local-pin", + targetName: "Acme Dashboard", + targetNameSource: "local-pin", + }, + }; +} + function createDomain(overrides: Partial<{ id: string; hostname: string; @@ -256,6 +279,7 @@ describe("app controller", () => { }); expect(result.result.branch).toEqual({ + id: "branch_production", name: "production", kind: "preview", }); @@ -3154,6 +3178,7 @@ describe("app controller", () => { expect(result.result).toEqual({ projectId: "proj_123", + verboseContext: expectedAppVerboseContext(), app: null, liveDeployment: null, liveUrl: null, @@ -3231,6 +3256,7 @@ describe("app controller", () => { expect(result.result).toEqual({ projectId: "proj_123", + verboseContext: expectedAppVerboseContext(), app: { id: "app_1", name: "hello-world", @@ -3683,6 +3709,7 @@ describe("app controller", () => { expect(openUrl).not.toHaveBeenCalled(); expect(result.result).toEqual({ projectId: "proj_123", + verboseContext: expectedAppVerboseContext(), app: { id: "app_1", name: "hello-world", @@ -3885,6 +3912,7 @@ describe("app controller", () => { ); expect(result.result).toEqual({ projectId: "proj_123", + verboseContext: expectedAppVerboseContext(), app: { id: "app_1", name: "hello-world", @@ -4028,6 +4056,7 @@ describe("app controller", () => { ); expect(result.result).toEqual({ projectId: "proj_123", + verboseContext: expectedAppVerboseContext(), app: { id: "app_1", name: "hello-world", @@ -4592,6 +4621,7 @@ describe("app controller", () => { expect(removeApp).toHaveBeenCalledWith("app_1", { signal: context.runtime.signal }); expect(result.result).toEqual({ projectId: "proj_123", + verboseContext: expectedAppVerboseContext(), app: { id: "app_1", name: "hello-world", diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index 3de9f37..0e5e073 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -67,6 +67,24 @@ function expectNoApiCalls(client: MockClient) { expect(client.DELETE).not.toHaveBeenCalled(); } +function expectedEnvVerboseContext() { + return { + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + resolution: { + projectSource: "local-pin", + targetName: "Acme Dashboard", + targetNameSource: "local-pin", + }, + }; +} + async function writeLocalPin(cwd: string, projectId = "proj_123") { await mkdir(path.join(cwd, ".prisma"), { recursive: true }); await writeFile( @@ -1415,6 +1433,7 @@ describe("env remove", () => { ); expect(result.result).toEqual({ projectId: "proj_123", + verboseContext: expectedEnvVerboseContext(), scope: { kind: "role", role: "production" }, key: "STRIPE_KEY", }); diff --git a/packages/cli/tests/app-presenter.test.ts b/packages/cli/tests/app-presenter.test.ts index d0def53..33969d2 100644 --- a/packages/cli/tests/app-presenter.test.ts +++ b/packages/cli/tests/app-presenter.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; import { getCommandDescriptor } from "../src/shell/command-meta"; -import { renderAppDomainAdd, renderAppDomainRetry, renderAppDomainShow } from "../src/presenters/app"; -import type { AppDomainAddResult, AppDomainRetryResult, AppDomainShowResult, AppDomainSummary } from "../src/types/app"; +import { renderAppDeploy, renderAppDomainAdd, renderAppDomainRetry, renderAppDomainShow, serializeAppDeploy } from "../src/presenters/app"; +import type { + AppDeployResult, + AppDomainAddResult, + AppDomainRetryResult, + AppDomainShowResult, + AppDomainSummary, +} from "../src/types/app"; import { createTestCommandContext } from "./helpers"; function createDomain(overrides: Partial = {}): AppDomainSummary { @@ -40,6 +46,42 @@ function createTarget() { }; } +function createDeployResult(): AppDeployResult { + return { + workspace: { id: "wksp_123", name: "Prisma Team" }, + project: { id: "proj_123", name: "Billing API" }, + branch: { id: "br_main", name: "main", kind: "production" }, + resolution: { + projectSource: "local-pin", + targetName: "Billing API", + targetNameSource: "local-pin", + }, + app: { id: "app_123", name: "api" }, + deployment: { + id: "dep_123", + status: "running", + url: "https://api.prisma.build", + }, + deploySettings: { + framework: { + key: "hono", + buildType: "bun", + name: "Hono", + source: "detected from package.json", + }, + entrypoint: "src/index.ts", + httpPort: 8080, + region: "fra", + envVars: ["DATABASE_URL"], + }, + durationMs: 12_345, + localPin: { + path: ".prisma/local.json", + written: true, + }, + }; +} + describe("app domain presenters", () => { it("shows when DNS records were not provided by the platform", async () => { const { context } = await createTestCommandContext({}); @@ -126,3 +168,51 @@ describe("app domain presenters", () => { expect(lines).toContain("Add CNAME shop.acme.com -> switchboard.fra.prisma.build, then run prisma-cli app domain retry shop.acme.com."); }); }); + +describe("app deploy presenter", () => { + it("adds safe resolved context when verbose output is enabled", async () => { + const { context } = await createTestCommandContext({ + flags: { verbose: true }, + env: { ...process.env, HOME: "/Users/aman" }, + cwd: "/Users/aman/dev/app", + }); + const result = createDeployResult(); + + const lines = renderAppDeploy( + context, + getCommandDescriptor("app.deploy"), + result, + ).join("\n"); + + expect(lines).toContain("Resolved context:"); + expect(lines).toContain("workspace:"); + expect(lines).toContain("Prisma Team"); + expect(lines).toContain("project source:"); + expect(lines).toContain(".prisma/local.json"); + expect(lines).toContain("branch id:"); + expect(lines).toContain("br_main"); + expect(lines).toContain("deploy duration:"); + expect(lines).toContain("Deploy settings:"); + expect(lines).toContain("framework:"); + expect(lines).toContain("Hono (bun)"); + expect(lines).toContain("entrypoint:"); + expect(lines).toContain("src/index.ts"); + expect(lines).toContain("http port:"); + expect(lines).toContain("8080"); + expect(lines).toContain("env vars:"); + expect(lines).toContain("DATABASE_URL"); + expect(lines).not.toContain("postgresql://"); + }); + + it("keeps verbose-only deploy details out of JSON serialization", () => { + const json = JSON.parse(JSON.stringify(serializeAppDeploy(createDeployResult()))); + + expect(json).not.toHaveProperty("deploySettings"); + expect(json).not.toHaveProperty("localPin"); + expect(json.branch).toEqual({ + name: "main", + kind: "production", + }); + expect(json.branch).not.toHaveProperty("id"); + }); +}); diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index 087d86d..75dbcc0 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -70,6 +70,24 @@ async function writeLocalPin(cwd: string, projectId = "proj_123") { ); } +function expectedBranchVerboseContext() { + return { + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + resolution: { + projectSource: "local-pin", + targetName: "Acme Dashboard", + targetNameSource: "local-pin", + }, + }; +} + async function loadController(client: ReturnType) { vi.resetModules(); @@ -119,6 +137,7 @@ describe("branch controller", () => { result: { projectId: "proj_123", projectName: "Acme Dashboard", + verboseContext: expectedBranchVerboseContext(), branches: [ { id: "br_main", name: "main", role: "production", envMap: "production" }, { id: "br_feature", name: "feature/auth", role: "preview", envMap: "preview" }, diff --git a/packages/cli/tests/branch.test.ts b/packages/cli/tests/branch.test.ts index cfb8854..df333e0 100644 --- a/packages/cli/tests/branch.test.ts +++ b/packages/cli/tests/branch.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import stripAnsi from "strip-ansi"; -import { createTempCwd, executeCli } from "./helpers"; +import { getCommandDescriptor } from "../src/shell/command-meta"; +import { renderBranchList } from "../src/presenters/branch"; +import { createTempCwd, createTestCommandContext, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); @@ -63,6 +65,41 @@ describe("branch commands", () => { ); }); + it("renders resolved context for an empty verbose branch list", async () => { + const { context } = await createTestCommandContext({ + argv: ["branch", "list", "--verbose"], + flags: { verbose: true }, + }); + + const output = stripAnsi(renderBranchList( + context, + getCommandDescriptor("branch.list"), + { + projectId: "proj_empty", + projectName: "Empty Project", + branches: [], + verboseContext: { + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_empty", + name: "Empty Project", + }, + resolution: { + projectSource: "explicit", + }, + }, + }, + ).join("\n")); + + expect(output).toContain("No branches found."); + expect(output).toContain("Resolved context:"); + expect(output).toContain("workspace: Acme Inc"); + expect(output).toContain("project source: --project"); + }); + it("returns the direct branch list JSON shape", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/packages/cli/tests/command-runner.test.ts b/packages/cli/tests/command-runner.test.ts new file mode 100644 index 0000000..adf0ae7 --- /dev/null +++ b/packages/cli/tests/command-runner.test.ts @@ -0,0 +1,150 @@ +import { PassThrough, Writable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; + +import { runCommand } from "../src/shell/command-runner"; +import type { CliRuntime } from "../src/shell/runtime"; +import { createTempCwd } from "./helpers"; + +class CaptureStream extends Writable { + buffer = ""; + declare isTTY?: boolean; + declare columns?: number; + declare rows?: number; + + _write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.buffer += chunk.toString(); + callback(); + } +} + +class CaptureInput extends PassThrough { + declare isTTY?: boolean; + setRawMode(_value: boolean) { + return this; + } +} + +async function createRuntime(argv: string[]): Promise<{ + runtime: CliRuntime; + controller: AbortController; + stdout: CaptureStream; + stderr: CaptureStream; +}> { + const stdout = new CaptureStream(); + const stderr = new CaptureStream(); + stdout.isTTY = false; + stderr.isTTY = false; + stdout.columns = 80; + stderr.columns = 80; + stdout.rows = 24; + stderr.rows = 24; + + const stdin = new CaptureInput(); + stdin.isTTY = false; + stdin.end(); + const controller = new AbortController(); + + return { + runtime: { + argv, + cwd: await createTempCwd(), + env: { ...process.env }, + signal: controller.signal, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stderr as unknown as NodeJS.WriteStream, + }, + controller, + stdout, + stderr, + }; +} + +afterEach(() => { + process.exitCode = undefined; +}); + +describe("command runner success output", () => { + it("adds local diagnostics to successful verbose human output", async () => { + const { runtime, stderr } = await createRuntime(["project", "show", "--verbose"]); + + await runCommand( + runtime, + "project.show", + {}, + async () => ({ + command: "project.show", + result: { ok: true }, + warnings: [], + nextSteps: [], + }), + { + renderHuman: () => ["Project linked"], + }, + ); + + expect(process.exitCode).toBeUndefined(); + expect(stderr.buffer).toContain("Project linked"); + expect(stderr.buffer).toContain("Local context:"); + expect(stderr.buffer).toContain("duration:"); + expect(stderr.buffer).toContain("cwd:"); + expect(stderr.buffer).toContain("state file:"); + expect(stderr.buffer).toContain("git:"); + expect(stderr.buffer).not.toContain("DATABASE_URL"); + }); + + it("keeps successful verbose output when post-success diagnostics abort", async () => { + const { runtime, controller, stderr } = await createRuntime(["project", "show", "--verbose"]); + + await runCommand( + runtime, + "project.show", + {}, + async () => { + controller.abort(); + return { + command: "project.show", + result: { ok: true }, + warnings: [], + nextSteps: [], + }; + }, + { + renderHuman: () => ["Project linked"], + }, + ); + + expect(process.exitCode).toBeUndefined(); + expect(stderr.buffer).toContain("Project linked"); + expect(stderr.buffer).not.toContain("COMMAND_CANCELED"); + expect(stderr.buffer).not.toContain("Local context:"); + }); + + it("does not add local diagnostics to successful JSON output", async () => { + const { runtime, stdout, stderr } = await createRuntime(["project", "show", "--verbose", "--json"]); + + await runCommand( + runtime, + "project.show", + { verbose: true, json: true }, + async () => ({ + command: "project.show", + result: { ok: true }, + warnings: [], + nextSteps: [], + }), + { + renderHuman: () => ["Project linked"], + }, + ); + + expect(process.exitCode).toBeUndefined(); + expect(stderr.buffer).toBe(""); + expect(JSON.parse(stdout.buffer)).toMatchObject({ + ok: true, + command: "project.show", + result: { ok: true }, + }); + expect(stdout.buffer).not.toContain("Local context"); + }); +}); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index d344067..4cdde42 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -483,6 +483,51 @@ describe("project commands", () => { }); }); + it("adds safe local context to project show in verbose human mode", async () => { + const home = await createTempCwd(); + const cwd = path.join(home, "code", "apple"); + await mkdir(cwd, { recursive: true }); + const stateDir = path.join(cwd, ".state"); + const edithFixturePath = await createEdithOrangeFixture(cwd); + const env = { + ...process.env, + HOME: home, + }; + await writeLocalPin(cwd, { + workspaceId: "ws_123", + projectId: "proj_orange", + }); + await login(cwd, stateDir, edithFixturePath, env); + + const result = await executeCli({ + argv: ["project", "show", "--verbose"], + cwd, + env, + stateDir, + fixturePath: edithFixturePath, + isTTY: true, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(stderr).toContain("Resolved context:"); + expect(stderr).toContain("workspace:"); + expect(stderr).toContain("Edith"); + expect(stderr).toContain("workspace id:"); + expect(stderr).toContain("ws_123"); + expect(stderr).toContain("project id:"); + expect(stderr).toContain("proj_orange"); + expect(stderr).toContain("project source:"); + expect(stderr).toContain(".prisma/local.json"); + expect(stderr).toContain("Local context:"); + expect(stderr).toContain("duration:"); + expect(stderr).toContain("cwd:"); + expect(stderr).toContain("~/code/apple"); + expect(stderr).toContain("state file:"); + expect(stderr).toContain("~"); + }); + it("returns PROJECT_NOT_FOUND for an inaccessible explicit project", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/packages/cli/tests/shell.test.ts b/packages/cli/tests/shell.test.ts index 4cde6ec..25ab1f9 100644 --- a/packages/cli/tests/shell.test.ts +++ b/packages/cli/tests/shell.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import stripAnsi from "strip-ansi"; import { formatCommandArgument } from "../src/shell/command-arguments"; +import { formatUnexpectedError } from "../src/shell/output"; import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); @@ -16,6 +17,16 @@ describe("shell behavior", () => { expect(formatCommandArgument("$(rm -rf /)")).toBe("'$(rm -rf /)'"); }); + it("keeps unexpected error stacks behind --trace", () => { + const error = new Error("boom"); + error.stack = "Error: boom\n at explode"; + + expect(formatUnexpectedError(error, false)).toContain("Unexpected CLI error: boom"); + expect(formatUnexpectedError(error, false)).toContain("More: Re-run with --trace"); + expect(formatUnexpectedError(error, false)).not.toContain("at explode"); + expect(formatUnexpectedError(error, true)).toContain("at explode"); + }); + it("renders root help with workflow groups", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5f510c..b72a4c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,14 +27,14 @@ importers: specifier: ^1.5.0 version: 1.5.0 '@prisma/compute-sdk': - specifier: ^0.20.0 - version: 0.20.0(@prisma/management-api-sdk@1.35.0) + specifier: ^0.21.0 + version: 0.21.0(@prisma/management-api-sdk@1.37.0) '@prisma/credentials-store': specifier: ^7.8.0 version: 7.8.0 '@prisma/management-api-sdk': - specifier: ^1.35.0 - version: 1.35.0 + specifier: ^1.37.0 + version: 1.37.0 c12: specifier: 4.0.0-beta.5 version: 4.0.0-beta.5(dotenv@17.4.2)(jiti@2.7.0)(magicast@0.5.3) @@ -470,8 +470,8 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@prisma/compute-sdk@0.20.0': - resolution: {integrity: sha512-H82lNh117wAdbYyCfpRzy4ffU6cY7so3BU+iGEspTSPEzfmL/LTKCpFFckixShQrj0PbrFfkQK9+qnZJBM+wew==} + '@prisma/compute-sdk@0.21.0': + resolution: {integrity: sha512-l9nmeY7xvwF3UZuGgZpA1CNa0RKPqhNmiM7jGH6IYP5j6/GmOJAaFfs2kGmMcN0TuBnV4z0k9exYcOpuLbjj8Q==} engines: {node: '>=18.0.0'} peerDependencies: '@prisma/management-api-sdk': '>=1.23.0' @@ -479,8 +479,8 @@ packages: '@prisma/credentials-store@7.8.0': resolution: {integrity: sha512-T9yp5uYSowV2ZRkBeCZrqWFP4REUlxd5WEYgOFJDjZBRRV+zx3VFCBf0zJI7Z3/PYFF9o3+ZzLwokQ9nY5EbqA==} - '@prisma/management-api-sdk@1.35.0': - resolution: {integrity: sha512-ugUROU6SkKUhfjZ9LLV3vtryevPxKaqzet36m5ncD4ceI4PPoqNUPyFdhK9uWsdRgxR2peN7Nw2iUvZsc9aqBg==} + '@prisma/management-api-sdk@1.37.0': + resolution: {integrity: sha512-2YWe18YwsD84RwBqclNtC1gLqXIpxAsHWKwKgKR/mbeBA6PFJNAwLLNTOdLdlDd2Whnn+9dCRIcjmerosht7hw==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1641,9 +1641,9 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@prisma/compute-sdk@0.20.0(@prisma/management-api-sdk@1.35.0)': + '@prisma/compute-sdk@0.21.0(@prisma/management-api-sdk@1.37.0)': dependencies: - '@prisma/management-api-sdk': 1.35.0 + '@prisma/management-api-sdk': 1.37.0 better-result: 2.9.2 tar-stream: 3.2.0 tiny-invariant: 1.3.3 @@ -1659,7 +1659,7 @@ snapshots: dependencies: xdg-app-paths: 8.3.0 - '@prisma/management-api-sdk@1.35.0': + '@prisma/management-api-sdk@1.37.0': dependencies: openapi-fetch: 0.14.0