From c7450f275526264e9eebd8471b6dc63271ea0e8f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 19:56:27 +0100 Subject: [PATCH 01/15] Add the node address parser and resolver Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/lib/address/node-address.ts | 591 +++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 packages/cli/src/lib/address/node-address.ts diff --git a/packages/cli/src/lib/address/node-address.ts b/packages/cli/src/lib/address/node-address.ts new file mode 100644 index 00000000..966312af --- /dev/null +++ b/packages/cli/src/lib/address/node-address.ts @@ -0,0 +1,591 @@ +/** + * Node addresses as command subjects: `@:
`, the name + * Prisma Composer gives every node it deploys (`@pr-42:shop.api`, + * `@shop.catalog`, `@shop`). The first `.`-separated segment of the + * address is the app, which is the Project's `logicalId`; the full + * address is the service, database or bucket row's `logicalId`. + */ +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; +import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { CLI_NAME } from "../../cli-name"; +import { formatCommandArgument } from "../../command-arguments"; + +const PRODUCTION_ENVIRONMENT = "production"; +const WHITESPACE = /\s/; + +const DEPLOY_LIST_COMMAND = `${CLI_NAME} deploy list`; +const BRANCH_LIST_COMMAND = `${CLI_NAME} branch list`; + +export type NodeKind = "service" | "database" | "bucket"; + +const NODE_KINDS: readonly NodeKind[] = ["service", "database", "bucket"]; + +/** The command group that acts on each kind. */ +const KIND_GROUP: Readonly> = { + service: "service", + database: "postgres", + bucket: "bucket", +}; + +export interface NodeAddress { + /** The subject exactly as typed, `@` included. */ + readonly text: string; + /** The environment before `:`; undefined when the subject names none. */ + readonly environment: string | undefined; + readonly address: string; + readonly appName: string; +} + +export interface AddressedProject { + readonly id: string; + readonly name: string; +} + +export interface AddressedBranch { + readonly id: string; + readonly gitName: string; +} + +export interface AddressedNode { + readonly id: string; + readonly project: AddressedProject; + readonly branch: AddressedBranch; +} + +/** The `--project` and `--branch` values a command was given. */ +export interface SubjectScope { + readonly project?: string | undefined; + readonly branch?: string | undefined; +} + +/** What the command acts on: the subject and scope flags as typed, or, + * for an address, the row id with the Project id and Branch git name + * the address names. */ +export interface ScopedSubject { + readonly ref: string; + readonly project: string | undefined; + readonly branch: string | undefined; +} + +type Api = Pick; + +export function isNodeAddress(subject: string): boolean { + return subject.startsWith("@"); +} + +export function parseNodeAddress(subject: string): NodeAddress { + if (!isNodeAddress(subject)) { + throw malformedAddressError(subject, "An address starts with '@'."); + } + if (WHITESPACE.test(subject)) { + throw malformedAddressError(subject, "An address contains no spaces."); + } + const body = subject.slice(1); + const colon = body.indexOf(":"); + const environment = colon === -1 ? undefined : body.slice(0, colon); + const address = colon === -1 ? body : body.slice(colon + 1); + if (environment === "") { + throw malformedAddressError( + subject, + "The environment before ':' is empty. Leave out the ':' to name production.", + ); + } + if (address === "") { + throw malformedAddressError(subject, "The address after '@' is empty."); + } + const segments = address.split("."); + if (segments.some((segment) => segment === "")) { + throw malformedAddressError( + subject, + `"${address}" has an empty part between dots.`, + ); + } + return { text: subject, environment, address, appName: segments[0] ?? "" }; +} + +/** A destructive command's consent token is the subject as typed when + * it is an address; otherwise the command's own token. */ +export function consentToken( + subject: string | undefined, + fallback: string, +): string { + return subject !== undefined && isNodeAddress(subject) ? subject : fallback; +} + +/** The Project an address names. It must be the app name alone, with no + * environment: a Project spans every environment, so `@pr-42:shop` + * must never reach a command that would act on all of them. */ +export async function resolveProjectAddress( + api: Api, + subject: string, + signal: AbortSignal, +): Promise { + const parsed = parseNodeAddress(subject); + if (parsed.environment !== undefined) { + throw projectEnvironmentError(parsed); + } + if (parsed.address !== parsed.appName) { + throw projectNodeAddressError(parsed); + } + return findProject(api, parsed, signal); +} + +/** The service, database or bucket row an address names. */ +export async function resolveNodeAddress( + api: Api, + kind: NodeKind, + subject: string, + signal: AbortSignal, +): Promise { + const parsed = parseNodeAddress(subject); + if (parsed.address === parsed.appName) { + throw addressNamesProjectError(parsed, kind); + } + const project = await findProject(api, parsed, signal); + const branch = await findBranch(api, parsed, project, signal); + const rows = await listNodes(api, kind, parsed, project, branch, signal); + + if (rows.length > 1) { + throw ambiguousError(parsed, kind, rows); + } + const [row] = rows; + if (row !== undefined) { + return { id: row.id, project, branch }; + } + + for (const otherKind of NODE_KINDS.filter((other) => other !== kind)) { + // biome-ignore lint/performance/noAwaitInLoops: the first kind that holds the address answers; there is no reason to ask about the next one. + const others = await listNodes( + api, + otherKind, + parsed, + project, + branch, + signal, + ); + if (others.length > 0) { + throw wrongKindError(parsed, kind, otherKind); + } + } + throw nodeNotFoundError(parsed, kind, project, branch); +} + +/** The hand-over: a subject that is an address becomes the row id, the + * Project id and the Branch git name it names, exactly what the user + * could have typed instead. Any other subject passes through with the + * flags it came with. */ +export async function resolveScopedSubject( + api: Api, + kind: NodeKind, + subject: string, + scope: SubjectScope, + signal: AbortSignal, +): Promise { + if (!isNodeAddress(subject)) { + return { ref: subject, project: scope.project, branch: scope.branch }; + } + rejectScopeFlags(subject, scope); + const node = await resolveNodeAddress(api, kind, subject, signal); + return { + ref: node.id, + project: node.project.id, + branch: node.branch.gitName, + }; +} + +/** The Project id an address names, or the subject unchanged. */ +export async function resolveProjectSubject( + api: Api, + subject: string, + signal: AbortSignal, +): Promise { + if (!isNodeAddress(subject)) { + return subject; + } + const project = await resolveProjectAddress(api, subject, signal); + return project.id; +} + +function rejectScopeFlags(subject: string, scope: SubjectScope): void { + const given = [ + ...(scope.project !== undefined ? ["--project"] : []), + ...(scope.branch !== undefined ? ["--branch"] : []), + ]; + if (given.length === 0) { + return; + } + throw new CliStructuredError( + "ADDRESS.USAGE_ERROR", + `${given.join(" and ")} cannot be combined with an address`, + { + why: `"${subject}" already names its Project and Branch, so ${given.join(" and ")} would name them a second time.`, + meta: { address: subject, flags: given }, + nextActions: [ + userChoice( + `Drop ${given.join(" and ")}, or pass an id or name instead of the address.`, + ), + ], + }, + ); +} + +interface Page { + readonly data: readonly T[]; + readonly pagination: { + readonly hasMore: boolean; + readonly nextCursor: string | null; + }; +} + +interface ApiErrorBody { + readonly error?: { + readonly code?: string; + readonly message?: string; + readonly hint?: string; + }; +} + +interface PageResponse { + readonly data?: Page | undefined; + readonly error?: ApiErrorBody | undefined; + readonly response: Response; +} + +async function listAllPages( + summary: string, + fetchPage: (cursor: string | undefined) => Promise>, +): Promise { + const rows: T[] = []; + let cursor: string | undefined; + do { + // biome-ignore lint/performance/noAwaitInLoops: each page is fetched with the cursor the previous page returned. + const { data, error, response } = await fetchPage(cursor); + if (error || !data) { + throw addressApiError(summary, response, error); + } + rows.push(...data.data); + cursor = data.pagination.hasMore + ? (data.pagination.nextCursor ?? undefined) + : undefined; + } while (cursor); + return rows; +} + +interface LogicalRow { + readonly id: string; + readonly name: string; + readonly logicalId: string | null; +} + +async function findProject( + api: Api, + address: NodeAddress, + signal: AbortSignal, +): Promise { + const projects = await listAllPages( + "Failed to look up the app's Project", + (cursor) => + api.GET("/v1/projects", { + params: { query: { logicalId: address.appName, cursor } }, + signal, + }), + ); + const matches = projects.filter( + (project) => project.logicalId === address.appName, + ); + if (matches.length > 1) { + throw new CliStructuredError( + "ADDRESS.AMBIGUOUS", + `Several Projects are named "${address.appName}"`, + { + why: `"${address.text}" names app "${address.appName}", and more than one Project in this workspace has it.`, + meta: { + address: address.text, + projectIds: matches.map((project) => project.id), + }, + nextActions: [ + userChoice( + `Pass a Project id instead of the address: ${matches.map((project) => project.id).join(", ")}.`, + ), + ], + }, + ); + } + const [project] = matches; + if (project === undefined) { + throw new CliStructuredError( + "ADDRESS.NOT_FOUND", + `No Project for app "${address.appName}"`, + { + why: `"${address.text}" names app "${address.appName}", and no Project in this workspace was deployed for it.`, + meta: { address: address.text, app: address.appName }, + nextActions: [ + userChoice( + "Check the app name, or that you are signed in to the workspace the app was deployed to.", + ), + runCommand(`${CLI_NAME} project list`), + ], + }, + ); + } + return { id: project.id, name: project.name }; +} + +interface BranchRow { + readonly id: string; + readonly gitName: string; + readonly isDefault: boolean; +} + +async function findBranch( + api: Api, + address: NodeAddress, + project: AddressedProject, + signal: AbortSignal, +): Promise { + const branches = await listAllPages( + "Failed to list the Project's branches", + (cursor) => + api.GET("/v1/projects/{projectId}/branches", { + params: { path: { projectId: project.id }, query: { cursor } }, + signal, + }), + ); + const environment = address.environment ?? PRODUCTION_ENVIRONMENT; + const branch = + environment === PRODUCTION_ENVIRONMENT + ? branches.find((candidate) => candidate.isDefault) + : branches.find((candidate) => candidate.gitName === environment); + if (branch === undefined) { + const listCommand = `${BRANCH_LIST_COMMAND} --project ${project.id}`; + throw new CliStructuredError( + "ADDRESS.NOT_FOUND", + `No branch for environment "${environment}"`, + { + why: + environment === PRODUCTION_ENVIRONMENT + ? `Project "${project.name}" (${project.id}) has no default branch, which is where production lives.` + : `Project "${project.name}" (${project.id}) has no branch whose git name is "${environment}".`, + meta: { + address: address.text, + environment, + projectId: project.id, + }, + nextActions: [ + userChoice("Use an environment from the Project's branch list."), + runCommand(listCommand), + ], + }, + ); + } + return { id: branch.id, gitName: branch.gitName }; +} + +async function listNodes( + api: Api, + kind: NodeKind, + address: NodeAddress, + project: AddressedProject, + branch: AddressedBranch, + signal: AbortSignal, +): Promise { + const filters = { + projectId: project.id, + logicalId: address.address, + branchId: branch.id, + }; + const summary = `Failed to look up ${KIND_GROUP[kind]} rows by address`; + const rows = await listAllPages( + summary, + (cursor) => { + const init = { params: { query: { ...filters, cursor } }, signal }; + switch (kind) { + case "service": + return api.GET("/v1/services", init); + case "database": + return api.GET("/v1/databases", init); + case "bucket": + return api.GET("/v1/buckets", init); + } + }, + ); + return rows.filter( + (row) => row.logicalId === address.address && row.branchId === branch.id, + ); +} + +function malformedAddressError( + subject: string, + why: string, +): CliStructuredError { + return new CliStructuredError( + "ADDRESS.USAGE_ERROR", + `"${subject}" is not a valid address`, + { + why: `${why} An address is @
for production, or @:
, such as @shop.api or @pr-42:shop.api.`, + meta: { address: subject }, + nextActions: [ + userChoice( + "Fix the address, or pass the id or name instead. Addresses that were deployed are listed by the command below, run in the app's directory.", + ), + runCommand(DEPLOY_LIST_COMMAND), + ], + }, + ); +} + +function projectEnvironmentError(address: NodeAddress): CliStructuredError { + const projectAddress = `@${address.appName}`; + return new CliStructuredError( + "ADDRESS.WRONG_KIND", + `"${address.text}" names one environment, not a Project`, + { + why: `A Project spans every environment of app "${address.appName}", so its address carries none. Write ${projectAddress}.`, + meta: { address: address.text, expected: "project" }, + nextActions: [userChoice(`Pass ${projectAddress} to name the Project.`)], + }, + ); +} + +function projectNodeAddressError(address: NodeAddress): CliStructuredError { + const projectAddress = `@${address.appName}`; + return new CliStructuredError( + "ADDRESS.WRONG_KIND", + `"${address.text}" names a node inside app "${address.appName}", not its Project`, + { + why: `A Project's address is the app name alone, ${projectAddress}. "${address.address}" names a service, database or bucket inside it.`, + meta: { address: address.text, expected: "project" }, + nextActions: [ + userChoice( + `Pass ${projectAddress} to name the Project, or use a service, postgres or bucket command for "${address.text}".`, + ), + ], + }, + ); +} + +function addressNamesProjectError( + address: NodeAddress, + kind: NodeKind, +): CliStructuredError { + const projectCommand = `${CLI_NAME} project show @${address.appName}`; + return new CliStructuredError( + "ADDRESS.WRONG_KIND", + `"${address.text}" names the app's Project, not a ${kind}`, + { + why: `The app name alone, "${address.appName}", is the Project's address. A ${kind}'s address continues past it, such as @${address.appName}..`, + meta: { address: address.text, expected: kind, actual: "project" }, + nextActions: [ + userChoice(`Name the ${kind} inside the app, or inspect the Project.`), + runCommand(projectCommand), + ], + }, + ); +} + +function wrongKindError( + address: NodeAddress, + expected: NodeKind, + actual: NodeKind, +): CliStructuredError { + const command = `${CLI_NAME} ${KIND_GROUP[actual]} --help`; + return new CliStructuredError( + "ADDRESS.WRONG_KIND", + `"${address.text}" is a ${actual}, not a ${expected}`, + { + why: `The address names a ${actual}. The ${KIND_GROUP[actual]} commands act on it.`, + meta: { address: address.text, expected, actual }, + nextActions: [ + userChoice( + `Use a '${CLI_NAME} ${KIND_GROUP[actual]}' command for ${formatCommandArgument(address.text)}.`, + ), + runCommand(command), + ], + }, + ); +} + +function nodeNotFoundError( + address: NodeAddress, + kind: NodeKind, + project: AddressedProject, + branch: AddressedBranch, +): CliStructuredError { + return new CliStructuredError( + "ADDRESS.NOT_FOUND", + `Nothing is deployed at "${address.text}"`, + { + why: `No service, database or bucket on branch "${branch.gitName}" of Project "${project.name}" (${project.id}) has address "${address.address}".`, + meta: { + address: address.text, + expected: kind, + projectId: project.id, + branchId: branch.id, + }, + nextActions: [ + userChoice( + "Check the address and environment against what the app deployed; the command below lists them, run in the app's directory.", + ), + runCommand(DEPLOY_LIST_COMMAND), + ], + }, + ); +} + +function ambiguousError( + address: NodeAddress, + kind: NodeKind, + rows: readonly LogicalRow[], +): CliStructuredError { + return new CliStructuredError( + "ADDRESS.AMBIGUOUS", + `Several ${kind} rows have address "${address.text}"`, + { + why: `More than one ${kind} on the branch carries address "${address.address}", so the address does not pick one.`, + meta: { + address: address.text, + matches: rows.map((row) => ({ id: row.id, name: row.name })), + }, + nextActions: [ + userChoice( + `Pass one of the ids instead: ${rows.map((row) => row.id).join(", ")}.`, + ), + ], + }, + ); +} + +function addressApiError( + summary: string, + response: Response, + error: ApiErrorBody | undefined, +): CliStructuredError { + const status = response.status; + const apiCode = error?.error?.code; + return new CliStructuredError("ADDRESS.API_ERROR", summary, { + why: + error?.error?.message ?? + `The Management API returned status ${status || "unknown"}.`, + meta: { + ...(status ? { status } : {}), + ...(apiCode !== undefined ? { apiCode } : {}), + }, + nextActions: [ + userChoice( + error?.error?.hint ?? + "Re-run with --log-level verbose for the underlying API response details.", + ), + ], + }); +} + +function userChoice(label: string): NextAction { + return { kind: "user-choice", label }; +} + +function runCommand(command: string): NextAction { + return { kind: "run-command", label: command, command }; +} From affac83efcea3adea9fc0f91521d258b30117416 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 19:56:27 +0100 Subject: [PATCH 02/15] Accept a node address as the service subject Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/service/delete.ts | 19 +++++++------ packages/cli/src/commands/service/logs.ts | 9 +++--- packages/cli/src/commands/service/open.ts | 8 ++---- packages/cli/src/commands/service/show.ts | 8 ++---- packages/cli/src/commands/service/target.ts | 28 ++++++++++++++++--- .../cli/src/commands/service/version-list.ts | 8 ++---- .../src/commands/service/version-rollback.ts | 8 ++---- 7 files changed, 51 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/commands/service/delete.ts b/packages/cli/src/commands/service/delete.ts index cf2b53b3..c81491f6 100644 --- a/packages/cli/src/commands/service/delete.ts +++ b/packages/cli/src/commands/service/delete.ts @@ -1,19 +1,25 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; +import { consentToken } from "../../lib/address/node-address"; import { deleteFailedError, userCancelledError } from "./errors"; import { deletePresentations } from "./presentation"; import { destroyProgressReporter } from "./release"; import type { ServiceDeleteResult } from "./results"; -import { resolveServiceReadState, toServiceSummary } from "./target"; +import { + resolveServiceReadState, + servicePositional, + toServiceSummary, +} from "./target"; export const serviceDeleteCommand = defineCommand({ help: { summary: "Delete a service from one branch", description: - "Removes the service from the targeted Branch only: its versions stop serving and its URL goes away. The same service on other branches is untouched. Pass the confirmation token with --confirm to run non-interactively.", + "Removes the service from the targeted Branch only: its versions stop serving and its URL goes away. The same service on other branches is untouched. The confirmation token is the service name, or the address when you pass one; pass it with --confirm to run non-interactively.", examples: [ "service delete my-service", "service delete my-service --confirm my-service", + "service delete @pr-42:shop.api --confirm @pr-42:shop.api", ], }, args: { @@ -28,10 +34,7 @@ export const serviceDeleteCommand = defineCommand({ }), }, positionals: { - service: positional.optionalString({ - brief: "Service id or name", - placeholder: "service", - }), + service: servicePositional, }, }, needs: { credentials: true }, @@ -45,7 +48,7 @@ export const serviceDeleteCommand = defineCommand({ const granted = await ctx.prompt.consent( `Delete Service "${state.service.name}" and every version it owns?`, - { token: state.service.name }, + { token: consentToken(args.positionals.service, state.service.name) }, ); // A token consent resolves to true or throws (mismatch, or the // engine's consent-required error), so this guard only fires if that diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index f0ec6d7b..426ed864 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -1,5 +1,5 @@ import type { CommandContext } from "@prisma/cli-engine"; -import { defineSessionCommand, flag, positional } from "@prisma/cli-engine"; +import { defineSessionCommand, flag } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import type { AppProvider, AppRecord } from "../../lib/app/app-provider"; import { forEachNdjsonRecord } from "../../lib/ndjson"; @@ -19,6 +19,7 @@ import { resolveCurrentLiveVersionId, resolveServiceReadState, resolveVersionSubject, + servicePositional, } from "./target"; const TRAILING_NEWLINE = /\n$/; @@ -387,15 +388,13 @@ export const serviceLogsCommand = defineSessionCommand({ "service logs my-service", "service logs my-service --tail 500", "service logs my-service --follow", + "service logs @pr-42:shop.api", "service logs --version-id cpv_123 --from-start", ], }, args: { positionals: { - service: positional.optionalString({ - brief: "Service id or name", - placeholder: "service", - }), + service: servicePositional, }, flags: { project: flag.string({ diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index d2141d9d..3b7ba14c 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, @@ -12,6 +12,7 @@ import { applyLiveVersionHint, resolveCurrentLiveVersionId, resolveServiceReadState, + servicePositional, sortVersionsNewestFirst, toServiceSummary, } from "./target"; @@ -36,10 +37,7 @@ export const serviceOpenCommand = defineCommand({ }), }, positionals: { - service: positional.optionalString({ - brief: "Service id or name", - placeholder: "service", - }), + service: servicePositional, }, }, needs: { credentials: true }, diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index 0be6e2f2..e7834370 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, runCommandAction } from "./errors"; import { showPresentations } from "./presentation"; @@ -7,6 +7,7 @@ import { applyLiveVersionHint, resolveCurrentLiveVersionId, resolveServiceReadState, + servicePositional, sortVersionsNewestFirst, toServiceSummary, } from "./target"; @@ -33,10 +34,7 @@ export const serviceShowCommand = defineCommand({ }), }, positionals: { - service: positional.optionalString({ - brief: "Service id or name", - placeholder: "service", - }), + service: servicePositional, }, }, needs: { credentials: true }, diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index c00c5952..dbdb1ed7 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -1,4 +1,5 @@ -import type { CommandContext } from "@prisma/cli-engine"; +import { type CommandContext, positional } from "@prisma/cli-engine"; +import { resolveScopedSubject } from "../../lib/address/node-address"; import { type AppProvider, type AppRecord, @@ -44,6 +45,13 @@ import type { const TRAILING_DOT = /\.$/; const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +/** The subject of every command that acts on one existing service. */ +export const servicePositional = positional.optionalString({ + brief: + "Service id or name, or its deploy address: @shop.api (default branch) or @pr-42:shop.api (branch pr-42)", + placeholder: "service", +}); + export type ServiceContext = Pick< CommandContext, | "api" @@ -480,7 +488,8 @@ async function resolveServiceProjectState( /** The shared read flow for every command that acts on an existing * service: project + branch resolution, service listing, and the - * parameter-only service match. */ + * parameter-only service match. A service address stands in for the + * service id, `--project` and `--branch` together. */ export async function resolveServiceReadState( ctx: ServiceContext, options: { @@ -494,14 +503,25 @@ export async function resolveServiceReadState( options.serviceName, options.commandName, ); - const state = await resolveServiceProjectState(ctx, options); + const subject = await resolveScopedSubject( + ctx.api, + "service", + requested, + { project: options.projectRef, branch: options.branchName }, + ctx.signal, + ); + const state = await resolveServiceProjectState(ctx, { + commandName: options.commandName, + ...(subject.project !== undefined ? { projectRef: subject.project } : {}), + ...(subject.branch !== undefined ? { branchName: subject.branch } : {}), + }); const services = await listServices( ctx, state.provider, state.projectId, state.target.branch.name, ); - const service = matchRequestedService(requested, services, state.projectId); + const service = matchRequestedService(subject.ref, services, state.projectId); return { ...state, service }; } diff --git a/packages/cli/src/commands/service/version-list.ts b/packages/cli/src/commands/service/version-list.ts index 71c133f6..5674127f 100644 --- a/packages/cli/src/commands/service/version-list.ts +++ b/packages/cli/src/commands/service/version-list.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError } from "./errors"; import { versionListPresentations } from "./presentation"; @@ -7,6 +7,7 @@ import { applyLiveVersionHint, resolveCurrentLiveVersionId, resolveServiceReadState, + servicePositional, sortVersionsNewestFirst, toServiceSummary, } from "./target"; @@ -33,10 +34,7 @@ export const serviceVersionListCommand = defineCommand({ }), }, positionals: { - service: positional.optionalString({ - brief: "Service id or name", - placeholder: "service", - }), + service: servicePositional, }, }, needs: { credentials: true }, diff --git a/packages/cli/src/commands/service/version-rollback.ts b/packages/cli/src/commands/service/version-rollback.ts index 1e3abbb0..8e43f1fc 100644 --- a/packages/cli/src/commands/service/version-rollback.ts +++ b/packages/cli/src/commands/service/version-rollback.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; import { @@ -16,6 +16,7 @@ import type { ServiceRollbackResult } from "./results"; import { resolveCurrentLiveVersionId, resolveServiceReadState, + servicePositional, toServiceSummary, } from "./target"; @@ -47,10 +48,7 @@ export const serviceVersionRollbackCommand = defineCommand({ }), }, positionals: { - service: positional.optionalString({ - brief: "Service id or name", - placeholder: "service", - }), + service: servicePositional, }, }, needs: { credentials: true }, From cb642d90617e8893974fa7bd54d1ec50aa153713 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 19:57:50 +0100 Subject: [PATCH 03/15] Accept a node address as the postgres database subject Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/commands/postgres/backup-list.ts | 20 ++++---- .../src/commands/postgres/backup-restore.ts | 35 +++++++------- .../commands/postgres/connection-create.ts | 19 ++------ .../src/commands/postgres/connection-list.ts | 19 ++++---- packages/cli/src/commands/postgres/context.ts | 47 ++++++++++++++++++- packages/cli/src/commands/postgres/delete.ts | 36 +++++++------- packages/cli/src/commands/postgres/show.ts | 20 ++++---- packages/cli/src/commands/postgres/usage.ts | 19 ++++---- 8 files changed, 124 insertions(+), 91 deletions(-) diff --git a/packages/cli/src/commands/postgres/backup-list.ts b/packages/cli/src/commands/postgres/backup-list.ts index b46f4e80..eb2c2bce 100644 --- a/packages/cli/src/commands/postgres/backup-list.ts +++ b/packages/cli/src/commands/postgres/backup-list.ts @@ -6,14 +6,14 @@ import { type Presentations, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { parseBackupLimit, resolveDatabase } from "../../controllers/database"; +import { parseBackupLimit } from "../../controllers/database"; import { serializeDatabaseBackupList } from "../../presenters/database"; import type { DatabaseBackupListResult } from "../../types/database"; import { branchFlag, databasePositional, projectFlag, - resolvePostgresContext, + resolvePostgresDatabase, } from "./context"; import { backupRows, backupStdoutRows } from "./presentation"; @@ -90,15 +90,13 @@ export const postgresBackupListCommand = defineCommand({ needs: { credentials: true }, handler: async (args, ctx) => { const limit = parseBackupLimit(args.flags.limit); - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres backup list"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); + const { provider, projectId, projectName, database } = + await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres backup list", + ); const backups = await provider.listBackups(database.id, { limit, signal: ctx.signal, diff --git a/packages/cli/src/commands/postgres/backup-restore.ts b/packages/cli/src/commands/postgres/backup-restore.ts index bf4d5b99..554213be 100644 --- a/packages/cli/src/commands/postgres/backup-restore.ts +++ b/packages/cli/src/commands/postgres/backup-restore.ts @@ -10,12 +10,14 @@ import { import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { resolveDatabase } from "../../controllers/database"; +import { consentToken, isNodeAddress } from "../../lib/address/node-address"; import type { DatabaseRestoreResult } from "../../types/database"; -import { branchFlag, projectFlag, resolvePostgresContext } from "./context"; +import { branchFlag, projectFlag, resolvePostgresDatabase } from "./context"; import type { FieldRow } from "./presentation"; -const CONSENT_QUESTION = - "Restoring immediately and irreversibly overwrites all data in the target database, so it requires the exact target database id."; +function consentQuestion(subject: string): string { + return `Restoring immediately and irreversibly overwrites all data in the target database, so it requires the exact target database ${isNodeAddress(subject) ? "address" : "id"}.`; +} function restorePresentations( result: DatabaseRestoreResult, @@ -64,7 +66,8 @@ export const postgresBackupRestoreCommand = defineCommand({ args: { positionals: { database: positional.string({ - brief: "Target database id or name", + brief: + "Target database id or name, or its deploy address: @shop.db (default branch) or @pr-42:shop.db (branch pr-42)", placeholder: "database", }), }, @@ -85,7 +88,7 @@ export const postgresBackupRestoreCommand = defineCommand({ help: { summary: "Restore a database from a backup after exact id confirmation", description: - "Replaces the target database's current state with the backup's contents; anything written since that backup is lost, so the exact target database id is the consent token. To restore another database's backup into this one, name where the backup came from with --source-database.", + "Replaces the target database's current state with the backup's contents; anything written since that backup is lost, so the exact target database id is the consent token (or the address, when you pass one). To restore another database's backup into this one, name where the backup came from with --source-database.", examples: [ "postgres backup restore db_123 --backup bkp_456 --confirm db_123", ], @@ -111,26 +114,26 @@ export const postgresBackupRestoreCommand = defineCommand({ ); } - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres backup restore"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); + const { provider, target, projectId, projectName, branchName, database } = + await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres backup restore", + ); const sourceDatabase = args.flags.sourceDatabase ? await resolveDatabase( provider, target, args.flags.sourceDatabase, - args.flags.branch, + branchName, ctx.signal, ) : database; - await ctx.prompt.consent(CONSENT_QUESTION, { token: database.id }); + await ctx.prompt.consent(consentQuestion(args.positionals.database), { + token: consentToken(args.positionals.database, database.id), + }); const restored = await provider.restoreDatabase({ targetDatabaseId: database.id, diff --git a/packages/cli/src/commands/postgres/connection-create.ts b/packages/cli/src/commands/postgres/connection-create.ts index 03fccbb0..27d39e28 100644 --- a/packages/cli/src/commands/postgres/connection-create.ts +++ b/packages/cli/src/commands/postgres/connection-create.ts @@ -1,16 +1,13 @@ /** The `postgres connection create` command. */ import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { - defaultConnectionName, - resolveDatabase, -} from "../../controllers/database"; +import { defaultConnectionName } from "../../controllers/database"; import type { DatabaseConnectionCreateResult } from "../../types/database"; import { branchFlag, databasePositional, projectFlag, - resolvePostgresContext, + resolvePostgresDatabase, } from "./context"; import { postgresTargetLabel, secretBlocks } from "./presentation"; @@ -39,19 +36,13 @@ export const postgresConnectionCreateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const { provider, target, projectId, projectName } = - await resolvePostgresContext( + const { provider, projectId, projectName, database } = + await resolvePostgresDatabase( ctx, args.flags, + args.positionals.database, "postgres connection create", ); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); const created = await provider.createConnection({ databaseId: database.id, name: args.flags.name?.trim() || defaultConnectionName(), diff --git a/packages/cli/src/commands/postgres/connection-list.ts b/packages/cli/src/commands/postgres/connection-list.ts index 68bd03db..896ed002 100644 --- a/packages/cli/src/commands/postgres/connection-list.ts +++ b/packages/cli/src/commands/postgres/connection-list.ts @@ -5,14 +5,13 @@ import { type Presentations, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { resolveDatabase } from "../../controllers/database"; import { serializeDatabaseConnectionList } from "../../presenters/database"; import type { DatabaseConnectionListResult } from "../../types/database"; import { branchFlag, databasePositional, projectFlag, - resolvePostgresContext, + resolvePostgresDatabase, } from "./context"; const TITLE = "Listing database connection metadata."; @@ -86,15 +85,13 @@ export const postgresConnectionListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres connection list"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); + const { provider, projectId, projectName, database } = + await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres connection list", + ); const connections = await provider.listConnections(database.id, { signal: ctx.signal, }); diff --git a/packages/cli/src/commands/postgres/context.ts b/packages/cli/src/commands/postgres/context.ts index 0d671db5..a04b892f 100644 --- a/packages/cli/src/commands/postgres/context.ts +++ b/packages/cli/src/commands/postgres/context.ts @@ -1,10 +1,13 @@ /** Workspace, project and provider for the `postgres *` commands. */ import { type CommandContext, flag, positional } from "@prisma/cli-engine"; +import { resolveDatabase } from "../../controllers/database"; +import { resolveScopedSubject } from "../../lib/address/node-address"; import { createManagementDatabaseProvider, type DatabaseProvider, } from "../../lib/database/provider"; import type { ResolvedProjectTarget } from "../../lib/project/resolution"; +import type { DatabaseSummary } from "../../types/database"; import { resolvePinnedProject } from "../project/context"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; @@ -22,7 +25,8 @@ export const branchFlag = flag.string({ }); export const databasePositional = positional.string({ - brief: "Database id or name", + brief: + "Database id or name, or its deploy address: @shop.db (default branch) or @pr-42:shop.db (branch pr-42)", placeholder: "database", }); @@ -56,6 +60,47 @@ export async function resolvePostgresContext( }; } +export interface PostgresDatabaseContext extends PostgresContext { + readonly database: DatabaseSummary; + /** The branch git name the database was looked up on, from --branch + * or the address. */ + readonly branchName: string | undefined; +} + +/** The project, then the database the subject names. A database + * address stands in for the database id, `--project` and `--branch` + * together. */ +export async function resolvePostgresDatabase( + ctx: PostgresCommandContext, + flags: { project?: string; branch?: string }, + databaseRef: string, + commandName: string, +): Promise { + const subject = await resolveScopedSubject( + ctx.api, + "database", + databaseRef, + flags, + ctx.signal, + ); + const context = await resolvePostgresContext( + ctx, + { + ...(subject.project !== undefined ? { project: subject.project } : {}), + ...(subject.branch !== undefined ? { branch: subject.branch } : {}), + }, + commandName, + ); + const database = await resolveDatabase( + context.provider, + context.target, + subject.ref, + subject.branch, + ctx.signal, + ); + return { ...context, database, branchName: subject.branch }; +} + /** `connection rotate` and `connection delete` address a connection * directly: no workspace requirement and no project resolution, so * the workspace is only a plan-limit lookup hint. */ diff --git a/packages/cli/src/commands/postgres/delete.ts b/packages/cli/src/commands/postgres/delete.ts index c86d4088..5a963b47 100644 --- a/packages/cli/src/commands/postgres/delete.ts +++ b/packages/cli/src/commands/postgres/delete.ts @@ -6,17 +6,18 @@ import { type Presentations, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { resolveDatabase } from "../../controllers/database"; +import { consentToken, isNodeAddress } from "../../lib/address/node-address"; import type { DatabaseDeleteResult } from "../../types/database"; import { branchFlag, databasePositional, projectFlag, - resolvePostgresContext, + resolvePostgresDatabase, } from "./context"; -const CONSENT_QUESTION = - "Deleting this database is destructive and requires the exact id."; +function consentQuestion(subject: string): string { + return `Deleting this database is destructive and requires the exact ${isNodeAddress(subject) ? "address" : "id"}.`; +} function deletePresentations(result: DatabaseDeleteResult): Presentations { return { @@ -49,22 +50,25 @@ export const postgresDeleteCommand = defineCommand({ help: { summary: "Delete a database after exact id confirmation", description: - "Deletion is permanent and destroys the data. The exact database id is the consent token; pass it with --confirm to run non-interactively.", - examples: ["postgres delete db_123 --confirm db_123"], + "Deletion is permanent and destroys the data. The exact database id is the consent token, or the address when you pass one; pass it with --confirm to run non-interactively.", + examples: [ + "postgres delete db_123 --confirm db_123", + "postgres delete @pr-42:shop.db --confirm @pr-42:shop.db", + ], }, needs: { credentials: true }, handler: async (args, ctx) => { - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres delete"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); + const { provider, projectId, projectName, database } = + await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres delete", + ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: database.id }); + await ctx.prompt.consent(consentQuestion(args.positionals.database), { + token: consentToken(args.positionals.database, database.id), + }); await provider.removeDatabase(database.id, { signal: ctx.signal }); diff --git a/packages/cli/src/commands/postgres/show.ts b/packages/cli/src/commands/postgres/show.ts index 564f5478..1268fa5d 100644 --- a/packages/cli/src/commands/postgres/show.ts +++ b/packages/cli/src/commands/postgres/show.ts @@ -5,14 +5,13 @@ import { type Presentations, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { resolveDatabase } from "../../controllers/database"; import { fieldLines } from "../../output/data-lines"; import type { DatabaseShowResult } from "../../types/database"; import { branchFlag, databasePositional, projectFlag, - resolvePostgresContext, + resolvePostgresDatabase, } from "./context"; import { branchLabel, @@ -75,19 +74,18 @@ export const postgresShowCommand = defineCommand({ examples: [ "postgres show db_123", "postgres show acme-preview --branch preview --json", + "postgres show @shop.db", ], }, needs: { credentials: true }, handler: async (args, ctx) => { - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres show"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); + const { provider, projectId, projectName, database } = + await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres show", + ); const connections = await provider.listConnections(database.id, { signal: ctx.signal, }); diff --git a/packages/cli/src/commands/postgres/usage.ts b/packages/cli/src/commands/postgres/usage.ts index 3c4e1de7..71298a25 100644 --- a/packages/cli/src/commands/postgres/usage.ts +++ b/packages/cli/src/commands/postgres/usage.ts @@ -8,7 +8,6 @@ import { import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { parseUsageDate, - resolveDatabase, USAGE_PERIOD_EXAMPLE_COMMAND, } from "../../controllers/database"; import { fieldLines } from "../../output/data-lines"; @@ -17,7 +16,7 @@ import { branchFlag, databasePositional, projectFlag, - resolvePostgresContext, + resolvePostgresDatabase, } from "./context"; import { type FieldRow, @@ -127,15 +126,13 @@ export const postgresUsageCommand = defineCommand({ ); } - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres usage"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); + const { provider, projectId, projectName, database } = + await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres usage", + ); const usage = await provider.getUsage(database.id, { from, to, From f7d64bdbbd234f20df6c31a21ca320c6563983d5 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 19:58:13 +0100 Subject: [PATCH 04/15] Accept a node address as the bucket subject Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/bucket/context.ts | 20 ++++++++++++++++++- packages/cli/src/commands/bucket/delete.ts | 18 ++++++++++++----- .../cli/src/commands/bucket/key-create.ts | 6 ++++-- .../cli/src/commands/bucket/key-delete.ts | 11 +++++++--- packages/cli/src/commands/bucket/key-list.ts | 12 ++++++++--- 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/bucket/context.ts b/packages/cli/src/commands/bucket/context.ts index cba961b9..a8a72ee6 100644 --- a/packages/cli/src/commands/bucket/context.ts +++ b/packages/cli/src/commands/bucket/context.ts @@ -1,6 +1,7 @@ /** Workspace, project and provider for the `bucket *` commands. */ import { type CommandContext, flag, positional } from "@prisma/cli-engine"; import { CLI_NAME } from "../../cli-name"; +import { resolveScopedSubject } from "../../lib/address/node-address"; import { type BucketProvider, createManagementBucketProvider, @@ -25,7 +26,8 @@ export const branchFlag = flag.string({ }); export const bucketPositional = positional.string({ - brief: "Bucket id", + brief: + "Bucket id, or its deploy address: @shop.files (default branch) or @pr-42:shop.files (branch pr-42)", placeholder: "bucket-id", }); @@ -66,3 +68,19 @@ export function resolveBucketProviderOnly( ): BucketProvider { return createManagementBucketProvider(ctx.api); } + +/** The bucket id the subject names: the id as typed, or the id of the + * bucket a bucket address names. */ +export async function resolveBucketId( + ctx: BucketCommandContext, + bucketRef: string, +): Promise { + const subject = await resolveScopedSubject( + ctx.api, + "bucket", + bucketRef, + {}, + ctx.signal, + ); + return subject.ref; +} diff --git a/packages/cli/src/commands/bucket/delete.ts b/packages/cli/src/commands/bucket/delete.ts index cf14a382..6213bee8 100644 --- a/packages/cli/src/commands/bucket/delete.ts +++ b/packages/cli/src/commands/bucket/delete.ts @@ -6,10 +6,12 @@ import { type Presentations, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { consentToken } from "../../lib/address/node-address"; import type { BucketDeleteResult } from "../../types/bucket"; import { bucketPositional, LIST_BUCKETS_COMMAND, + resolveBucketId, resolveBucketProviderOnly, } from "./context"; @@ -37,13 +39,16 @@ export const bucketDeleteCommand = defineCommand({ help: { summary: "Delete a bucket and all its access keys", description: - "Deletion is permanent: the stored objects are destroyed and every access key stops working. The exact bucket id is the consent token; pass it with --confirm to run non-interactively.", - examples: ["bucket delete bkt_123 --confirm bkt_123"], + "Deletion is permanent: the stored objects are destroyed and every access key stops working. The exact bucket id is the consent token, or the address when you pass one; pass it with --confirm to run non-interactively.", + examples: [ + "bucket delete bkt_123 --confirm bkt_123", + "bucket delete @pr-42:shop.files --confirm @pr-42:shop.files", + ], }, needs: { credentials: true }, handler: async (args, ctx) => { - const bucketId = args.positionals.bucketId.trim(); - if (!bucketId) { + const bucketRef = args.positionals.bucketId.trim(); + if (!bucketRef) { throw new CliStructuredError("BUCKET.USAGE_ERROR", "Bucket id required", { why: "Bucket deletion needs a bucket id.", nextActions: [ @@ -56,8 +61,11 @@ export const bucketDeleteCommand = defineCommand({ ], }); } + const bucketId = await resolveBucketId(ctx, bucketRef); - await ctx.prompt.consent(CONSENT_QUESTION, { token: bucketId }); + await ctx.prompt.consent(CONSENT_QUESTION, { + token: consentToken(bucketRef, bucketId), + }); await resolveBucketProviderOnly(ctx).deleteBucket(bucketId, { signal: ctx.signal, diff --git a/packages/cli/src/commands/bucket/key-create.ts b/packages/cli/src/commands/bucket/key-create.ts index 31ac0554..277be03e 100644 --- a/packages/cli/src/commands/bucket/key-create.ts +++ b/packages/cli/src/commands/bucket/key-create.ts @@ -10,6 +10,7 @@ import type { BucketKeyCreateResult } from "../../types/bucket"; import { bucketPositional, LIST_BUCKETS_COMMAND, + resolveBucketId, resolveBucketProviderOnly, } from "./context"; @@ -83,8 +84,8 @@ export const bucketKeyCreateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const bucketId = args.positionals.bucketId.trim(); - if (!bucketId) { + const bucketRef = args.positionals.bucketId.trim(); + if (!bucketRef) { throw new CliStructuredError("BUCKET.USAGE_ERROR", "Bucket id required", { why: "Bucket key creation needs a bucket id.", nextActions: [ @@ -97,6 +98,7 @@ export const bucketKeyCreateCommand = defineCommand({ ], }); } + const bucketId = await resolveBucketId(ctx, bucketRef); const created = await resolveBucketProviderOnly(ctx).createKey({ bucketId, diff --git a/packages/cli/src/commands/bucket/key-delete.ts b/packages/cli/src/commands/bucket/key-delete.ts index bfc2df15..a83b11cb 100644 --- a/packages/cli/src/commands/bucket/key-delete.ts +++ b/packages/cli/src/commands/bucket/key-delete.ts @@ -9,7 +9,11 @@ import { import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import type { BucketKeyDeleteResult } from "../../types/bucket"; -import { bucketPositional, resolveBucketProviderOnly } from "./context"; +import { + bucketPositional, + resolveBucketId, + resolveBucketProviderOnly, +} from "./context"; function deletePresentations(result: BucketKeyDeleteResult): Presentations { return { @@ -39,9 +43,9 @@ export const bucketKeyDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const bucketId = args.positionals.bucketId.trim(); + const bucketRef = args.positionals.bucketId.trim(); const keyId = args.positionals.keyId.trim(); - if (!bucketId || !keyId) { + if (!bucketRef || !keyId) { const listKeysCommand = `${CLI_NAME} bucket key list `; throw new CliStructuredError( "BUCKET.USAGE_ERROR", @@ -59,6 +63,7 @@ export const bucketKeyDeleteCommand = defineCommand({ }, ); } + const bucketId = await resolveBucketId(ctx, bucketRef); await resolveBucketProviderOnly(ctx).deleteKey(bucketId, keyId, { signal: ctx.signal, diff --git a/packages/cli/src/commands/bucket/key-list.ts b/packages/cli/src/commands/bucket/key-list.ts index 7bacb399..d76b4f2c 100644 --- a/packages/cli/src/commands/bucket/key-list.ts +++ b/packages/cli/src/commands/bucket/key-list.ts @@ -10,6 +10,7 @@ import type { BucketKeyListResult } from "../../types/bucket"; import { bucketPositional, LIST_BUCKETS_COMMAND, + resolveBucketId, resolveBucketProviderOnly, } from "./context"; import { bucketKeyRows } from "./presentation"; @@ -50,12 +51,16 @@ export const bucketKeyListCommand = defineCommand({ summary: "List access keys for a bucket", description: "Shows each key's id, name, and role. Key secrets print once, at 'bucket key create'; listing never reveals them.", - examples: ["bucket key list bkt_123", "bucket key list bkt_123 --json"], + examples: [ + "bucket key list bkt_123", + "bucket key list bkt_123 --json", + "bucket key list @shop.files", + ], }, needs: { credentials: true }, handler: async (args, ctx) => { - const bucketId = args.positionals.bucketId.trim(); - if (!bucketId) { + const bucketRef = args.positionals.bucketId.trim(); + if (!bucketRef) { throw new CliStructuredError("BUCKET.USAGE_ERROR", "Bucket id required", { why: "Bucket key listing needs a bucket id.", nextActions: [ @@ -68,6 +73,7 @@ export const bucketKeyListCommand = defineCommand({ ], }); } + const bucketId = await resolveBucketId(ctx, bucketRef); const keys = await resolveBucketProviderOnly(ctx).listKeys(bucketId, { signal: ctx.signal, From 18ba99d2ca39bd1c1b2120ca10d27d818e975a92 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 19:58:44 +0100 Subject: [PATCH 05/15] Accept a Project address on project show, delete, transfer and link Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/project/delete.ts | 35 +++++++++++++------ packages/cli/src/commands/project/link.ts | 8 +++-- packages/cli/src/commands/project/show.ts | 18 ++++++++-- packages/cli/src/commands/project/transfer.ts | 30 ++++++++++------ 4 files changed, 65 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index 5d5ee51e..b2028e64 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -9,6 +9,11 @@ import { import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; import { cleanupLocalPinForProject } from "../../controllers/project"; +import { + consentToken, + isNodeAddress, + resolveProjectSubject, +} from "../../lib/address/node-address"; import { createManagementProjectProvider } from "../../lib/project/provider"; import { resolveProjectForSetup, @@ -19,8 +24,9 @@ import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { listWorkspaceProjects, operationContext } from "./context"; import { localPinDiagnostics } from "./presentation"; -const CONSENT_QUESTION = - "Deleting a project is permanent, destroys its databases, and stops its apps, so it requires the exact project id."; +function consentQuestion(subject: string): string { + return `Deleting a project is permanent, destroys its databases, and stops its apps, so it requires the exact project ${isNodeAddress(subject) ? "address" : "id"}.`; +} function deletePresentations(result: ProjectDeleteResult): Presentations { return { @@ -54,7 +60,7 @@ export const projectDeleteCommand = defineCommand({ args: { positionals: { project: positional.string({ - brief: "Project id or name", + brief: "Project id or name, or its deploy address such as @shop", placeholder: "id-or-name", }), }, @@ -62,22 +68,29 @@ export const projectDeleteCommand = defineCommand({ help: { summary: "Delete a Project permanently after exact id confirmation", description: - "Deletion is permanent: it destroys the project's databases and stops its deployed services. Because of that, the command asks for the exact project id as a consent token; pass it with --confirm to run non-interactively.", - examples: ["project delete proj_123 --confirm proj_123"], + "Deletion is permanent: it destroys the project's databases and stops its deployed services. Because of that, the command asks for the exact project id as a consent token, or the address when you pass one; pass it with --confirm to run non-interactively.", + examples: [ + "project delete proj_123 --confirm proj_123", + "project delete @shop --confirm @shop", + ], }, needs: { credentials: true }, handler: async (args, ctx) => { const workspace = await resolveActiveWorkspace(ctx); const projects = await listWorkspaceProjects(ctx); + const subject = args.positionals.project.trim(); + const projectRef = await resolveProjectSubject( + ctx.api, + subject, + ctx.signal, + ); const project = toProjectSummary( - resolveProjectForSetup( - args.positionals.project.trim(), - projects, - workspace, - ), + resolveProjectForSetup(projectRef, projects, workspace), ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); + await ctx.prompt.consent(consentQuestion(subject), { + token: consentToken(subject, project.id), + }); await createManagementProjectProvider(ctx.api).removeProject({ projectId: project.id, diff --git a/packages/cli/src/commands/project/link.ts b/packages/cli/src/commands/project/link.ts index e9c10bc3..50de77f7 100644 --- a/packages/cli/src/commands/project/link.ts +++ b/packages/cli/src/commands/project/link.ts @@ -2,6 +2,7 @@ import { defineCommand, positional } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { formatCommandArgument } from "../../command-arguments"; +import { resolveProjectSubject } from "../../lib/address/node-address"; import { createAppProvider } from "../../lib/app/app-provider"; import { inferTargetName, @@ -158,7 +159,10 @@ async function linkDirectoryToProject( ): Promise { const workspace = await resolveActiveWorkspace(ctx); const projects = await listWorkspaceProjects(ctx); - const ref = projectRef?.trim(); + const trimmed = projectRef?.trim(); + const ref = trimmed + ? await resolveProjectSubject(ctx.api, trimmed, ctx.signal) + : undefined; return ref ? await bindDirectoryToProject( @@ -174,7 +178,7 @@ export const projectLinkCommand = defineCommand({ args: { positionals: { project: positional.optionalString({ - brief: "Project id or name", + brief: "Project id or name, or its deploy address such as @shop", placeholder: "id-or-name", }), }, diff --git a/packages/cli/src/commands/project/show.ts b/packages/cli/src/commands/project/show.ts index 1d0304ba..593039f2 100644 --- a/packages/cli/src/commands/project/show.ts +++ b/packages/cli/src/commands/project/show.ts @@ -5,6 +5,7 @@ import { positional, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; +import { resolveProjectSubject } from "../../lib/address/node-address"; import { shortenHomePath } from "../../lib/fs/home-path"; import { buildProjectSetupNextActions, @@ -112,7 +113,7 @@ export const projectShowCommand = defineCommand({ positionals: { project: positional.optionalString({ brief: - "Project id or name (default: the project this directory is linked to)", + "Project id or name, or its deploy address such as @shop (default: the project this directory is linked to)", placeholder: "id-or-name", }), }, @@ -121,7 +122,11 @@ export const projectShowCommand = defineCommand({ summary: "Show which Project this directory is linked to", description: "Reports which Project this directory is linked to, or that it is not linked, with next steps. Pass an id or name to inspect any project without changing the local link.", - examples: ["project show", "project show proj_123 --json"], + examples: [ + "project show", + "project show proj_123 --json", + "project show @shop", + ], }, needs: { credentials: true }, handler: async (args, ctx) => { @@ -129,7 +134,14 @@ export const projectShowCommand = defineCommand({ const inspected = await inspectProjectBinding({ context: operationContext(ctx), workspace, - explicitProject: args.positionals.project, + explicitProject: + args.positionals.project === undefined + ? undefined + : await resolveProjectSubject( + ctx.api, + args.positionals.project, + ctx.signal, + ), listProjects: () => listWorkspaceProjects(ctx), commandName: "project show", }); diff --git a/packages/cli/src/commands/project/transfer.ts b/packages/cli/src/commands/project/transfer.ts index e8d254ff..7915c14b 100644 --- a/packages/cli/src/commands/project/transfer.ts +++ b/packages/cli/src/commands/project/transfer.ts @@ -26,6 +26,11 @@ import { transferRecipientRequiredError, transferRecipientUnavailableError, } from "../../controllers/project"; +import { + consentToken, + isNodeAddress, + resolveProjectSubject, +} from "../../lib/address/node-address"; import type { PrismaCliPackageCommandFormatter } from "../../lib/agent/cli-command"; import { createManagementProjectProvider } from "../../lib/project/provider"; import { @@ -41,8 +46,9 @@ import { } from "./context"; import { localPinDiagnostics } from "./presentation"; -const CONSENT_QUESTION = - "Transferring moves the project to another workspace and this workspace loses access, so it requires the exact project id."; +function consentQuestion(subject: string): string { + return `Transferring moves the project to another workspace and this workspace loses access, so it requires the exact project ${isNodeAddress(subject) ? "address" : "id"}.`; +} /** This CLI's command strings are `${CLI_NAME} …`; the legacy package-runner * formatter does not port. */ @@ -174,7 +180,7 @@ export const projectTransferCommand = defineCommand({ args: { positionals: { project: positional.string({ - brief: "Project id or name", + brief: "Project id or name, or its deploy address such as @shop", placeholder: "id-or-name", }), }, @@ -195,7 +201,7 @@ export const projectTransferCommand = defineCommand({ summary: "Transfer a Project to another workspace after exact id confirmation", description: - "Moves a project, with everything in it, out of the current workspace. Name the receiving workspace with --to-workspace when you are logged in to it too, or pass --recipient-token when someone else owns it. The exact project id is the consent token.", + "Moves a project, with everything in it, out of the current workspace. Name the receiving workspace with --to-workspace when you are logged in to it too, or pass --recipient-token when someone else owns it. The exact project id is the consent token, or the address when you pass one.", examples: [ 'project transfer proj_123 --to-workspace "Prisma Labs" --confirm proj_123', "project transfer proj_123 --recipient-token --confirm proj_123", @@ -240,15 +246,19 @@ export const projectTransferCommand = defineCommand({ } const projects = await listWorkspaceProjects(ctx); + const subject = args.positionals.project.trim(); + const projectRef = await resolveProjectSubject( + ctx.api, + subject, + ctx.signal, + ); const project = toProjectSummary( - resolveProjectForSetup( - args.positionals.project.trim(), - projects, - workspace, - ), + resolveProjectForSetup(projectRef, projects, workspace), ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); + await ctx.prompt.consent(consentQuestion(subject), { + token: consentToken(subject, project.id), + }); const recipient = await resolveRecipient(ctx, { toWorkspace, From d0d5bcbd444bbc1ace18aeca122a8974279fc89a Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 19:59:04 +0100 Subject: [PATCH 06/15] Register the ADDRESS error codes Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 23 +++++++++++++++++++++++ scripts/list-error-codes.mjs | 1 + 2 files changed, 24 insertions(+) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 457f4cfc..9cad4974 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -14,6 +14,7 @@ Namespaces: | Namespace | Covers | | --- | --- | +| `ADDRESS` | Naming a Project, service, database or bucket by its deploy address (`@:
`) instead of its id | | `AUTH` | Workspace authentication and sessions (`prisma auth`, credential resolution) | | `BRANCH` | Branch listing (`prisma branch`) | | `BUCKET` | Bucket and bucket-key management (`prisma bucket`) | @@ -27,6 +28,28 @@ Namespaces: | `SERVICE` | Deployed service management (`prisma service`) | | `SKILLS` | Agent-skill delivery (`prisma skills`) | +## ADDRESS + +### ADDRESS.AMBIGUOUS + +A deploy address matched more than one row: more than one Project in the workspace carries the app name as its logical id, or more than one service, database or bucket on the addressed branch carries the full address. The nextActions list the matching ids to pass instead. Meta: `address`, plus `projectIds` or `matches` (id and name of each match). + +### ADDRESS.API_ERROR + +A Management API request made to look up an address (listing Projects, branches, services, databases or buckets by logical id) failed. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. Meta: `status`, `apiCode` (the API's own error code, when the response supplied one). + +### ADDRESS.NOT_FOUND + +A deploy address named nothing: no Project in the workspace has the app name as its logical id, the Project has no branch for the environment (no default branch for production, or no branch with that git name), or nothing on that branch has the address. The nextActions point at `prisma project list`, `prisma branch list`, or `prisma deploy list`. Meta: `address`, plus `app`, `environment`, `projectId`, `branchId` or `expected` as they apply. + +### ADDRESS.USAGE_ERROR + +A subject starting with `@` is not a valid address (an empty environment before `:`, an empty address, an empty part between dots, or whitespace), or an address was combined with `--project` or `--branch`, which the address already names. Meta: `address`, plus `flags` for the flag conflict. + +### ADDRESS.WRONG_KIND + +A deploy address names a different kind of thing than the command acts on: a service, postgres or bucket command was given the bare app name (which names the Project) or the address of another kind of row, or a project command was given an address with an environment or with more than the app name. The nextActions name the command group that acts on it. Meta: `address`, `expected`, and `actual` when known. + ## AUTH ### AUTH.CREDENTIAL_WORKSPACE_MISMATCH diff --git a/scripts/list-error-codes.mjs b/scripts/list-error-codes.mjs index 5c5794fd..8cfc7bc9 100644 --- a/scripts/list-error-codes.mjs +++ b/scripts/list-error-codes.mjs @@ -26,6 +26,7 @@ import { argv, exit, stderr, stdout } from "node:process"; import { fileURLToPath } from "node:url"; const NAMESPACES = [ + "ADDRESS", "AUTH", "BRANCH", "BUCKET", From 275a3cf5a2e5ce7464651d6078d7f31e29542a5b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:00:41 +0100 Subject: [PATCH 07/15] Test address parsing and resolution Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/tests/helpers/fake-address-api.ts | 91 +++ packages/cli/tests/node-address.test.ts | 588 ++++++++++++++++++ 2 files changed, 679 insertions(+) create mode 100644 packages/cli/tests/helpers/fake-address-api.ts create mode 100644 packages/cli/tests/node-address.test.ts diff --git a/packages/cli/tests/helpers/fake-address-api.ts b/packages/cli/tests/helpers/fake-address-api.ts new file mode 100644 index 00000000..0b7f6892 --- /dev/null +++ b/packages/cli/tests/helpers/fake-address-api.ts @@ -0,0 +1,91 @@ +/** + * The listings address resolution reads, answered the way the Management + * API filters them: `/v1/projects` by `logicalId`, a Project's branches, + * and `/v1/services`, `/v1/databases`, `/v1/buckets` by `projectId`, + * `logicalId` and `branchId`. + */ + +export interface AddressedRow { + readonly id: string; + readonly name: string; + readonly projectId: string; + readonly branchId: string | null; + readonly logicalId: string | null; +} + +export interface AddressedProjectRow { + readonly id: string; + readonly name: string; + readonly logicalId: string | null; + readonly workspace: { readonly id: string; readonly name: string }; +} + +export interface AddressedBranchRow { + readonly id: string; + readonly gitName: string; + readonly isDefault: boolean; + readonly role: "production" | "preview"; +} + +export interface AddressFixture { + readonly projects: readonly AddressedProjectRow[]; + readonly branches: Readonly>; + readonly services?: readonly AddressedRow[]; + readonly databases?: readonly AddressedRow[]; + readonly buckets?: readonly AddressedRow[]; +} + +export interface Page { + readonly data: readonly T[]; + readonly pagination: { readonly hasMore: false; readonly nextCursor: null }; +} + +function page(data: readonly T[]): Page { + return { data, pagination: { hasMore: false, nextCursor: null } }; +} + +const ROW_PATHS = { + "/v1/services": "services", + "/v1/databases": "databases", + "/v1/buckets": "buckets", +} as const; + +/** The page the API would answer `GET ` with, or undefined for a + * path address resolution does not read. */ +export function addressListing( + fixture: AddressFixture, + path: string, + params: { + readonly path?: Record; + readonly query?: Record; + } = {}, +): Page | undefined { + const query = params.query ?? {}; + if (path === "/v1/projects") { + return page( + query.logicalId === undefined + ? fixture.projects + : fixture.projects.filter( + (project) => project.logicalId === query.logicalId, + ), + ); + } + if (path === "/v1/projects/{projectId}/branches") { + const projectId = params.path?.projectId ?? ""; + return page(fixture.branches[projectId] ?? []); + } + if (path in ROW_PATHS) { + const rows = fixture[ROW_PATHS[path as keyof typeof ROW_PATHS]] ?? []; + return page( + rows.filter( + (row) => + (query.projectId === undefined || + row.projectId === query.projectId) && + (query.logicalId === undefined || + row.logicalId === query.logicalId) && + (query.branchId === undefined || row.branchId === query.branchId), + ), + ); + } + return undefined; +} diff --git a/packages/cli/tests/node-address.test.ts b/packages/cli/tests/node-address.test.ts new file mode 100644 index 00000000..8d56dbdf --- /dev/null +++ b/packages/cli/tests/node-address.test.ts @@ -0,0 +1,588 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { describe, expect, it } from "vitest"; + +import { + consentToken, + isNodeAddress, + parseNodeAddress, + resolveNodeAddress, + resolveProjectAddress, + resolveProjectSubject, + resolveScopedSubject, +} from "../src/lib/address/node-address"; +import { + type AddressFixture, + addressListing, +} from "./helpers/fake-address-api"; + +const WORKSPACE = { id: "wksp_cmjs0z06102rz2mgzk5zqj495", name: "Acme Inc" }; + +const FIXTURE: AddressFixture = { + projects: [ + { id: "proj_shop", name: "Shop", logicalId: "shop", workspace: WORKSPACE }, + { id: "proj_blog", name: "Blog", logicalId: null, workspace: WORKSPACE }, + ], + branches: { + proj_shop: [ + { + id: "br_main", + gitName: "main", + isDefault: true, + role: "production", + }, + { + id: "br_pr42", + gitName: "pr-42", + isDefault: false, + role: "preview", + }, + { + id: "br_named_production", + gitName: "production", + isDefault: false, + role: "preview", + }, + ], + }, + services: [ + { + id: "svc_api_main", + name: "api", + projectId: "proj_shop", + branchId: "br_main", + logicalId: "shop.api", + }, + { + id: "svc_api_pr42", + name: "api", + projectId: "proj_shop", + branchId: "br_pr42", + logicalId: "shop.api", + }, + { + id: "svc_api_named_production", + name: "api", + projectId: "proj_shop", + branchId: "br_named_production", + logicalId: "shop.api", + }, + ], + databases: [ + { + id: "db_catalog_main", + name: "catalog", + projectId: "proj_shop", + branchId: "br_main", + logicalId: "shop.catalog", + }, + ], + buckets: [ + { + id: "bkt_files_pr42", + name: "files", + projectId: "proj_shop", + branchId: "br_pr42", + logicalId: "shop.files", + }, + ], +}; + +interface Call { + readonly path: string; + readonly query: Record; +} + +function fakeApi( + fixture: AddressFixture = FIXTURE, + override?: (path: string, query: Record) => unknown, +): { api: ManagementApiClient; calls: Call[] } { + const calls: Call[] = []; + const GET = async ( + path: string, + init: { + params?: { + path?: Record; + query?: Record; + }; + } = {}, + ) => { + const query = init.params?.query ?? {}; + calls.push({ path, query }); + const overridden = override?.(path, query); + if (overridden !== undefined) { + return overridden; + } + const data = addressListing(fixture, path, init.params); + return data === undefined + ? { + error: { error: { message: `no route ${path}` } }, + response: new Response(null, { status: 404 }), + } + : { data, response: new Response(null, { status: 200 }) }; + }; + return { api: { GET } as unknown as ManagementApiClient, calls }; +} + +const signal = new AbortController().signal; + +describe("parseNodeAddress", () => { + it("reads an address with no environment as production", () => { + expect(parseNodeAddress("@shop.api")).toEqual({ + text: "@shop.api", + environment: undefined, + address: "shop.api", + appName: "shop", + }); + }); + + it("splits the environment off at the first colon", () => { + expect(parseNodeAddress("@pr-42:shop.api.v2")).toEqual({ + text: "@pr-42:shop.api.v2", + environment: "pr-42", + address: "shop.api.v2", + appName: "shop", + }); + }); + + it("reads the bare app name as the address of the app", () => { + expect(parseNodeAddress("@shop")).toMatchObject({ + address: "shop", + appName: "shop", + }); + }); + + it.each([ + ["@", "empty address"], + ["@:shop.api", "empty environment"], + ["@pr-42:", "empty address after the environment"], + ["@shop .api", "whitespace"], + ["@shop..api", "an empty part between dots"], + ["@.api", "an empty app name"], + ["@shop.", "a trailing dot"], + ])("refuses %s (%s) as a usage error", (subject) => { + expect(() => parseNodeAddress(subject)).toThrow( + expect.objectContaining({ + code: "ADDRESS.USAGE_ERROR", + meta: { address: subject }, + }), + ); + }); + + it("tells an address from an id or name by its leading @", () => { + expect(isNodeAddress("@shop.api")).toBe(true); + expect(isNodeAddress("db_catalog_main")).toBe(false); + expect(isNodeAddress("shop.api")).toBe(false); + }); +}); + +describe("consentToken", () => { + it("is the address as typed when the subject is an address", () => { + expect(consentToken("@pr-42:shop.catalog", "db_catalog_pr42")).toBe( + "@pr-42:shop.catalog", + ); + }); + + it("is the command's own token for an id or name", () => { + expect(consentToken("catalog", "db_catalog_main")).toBe("db_catalog_main"); + expect(consentToken(undefined, "db_catalog_main")).toBe("db_catalog_main"); + }); +}); + +describe("resolveNodeAddress", () => { + it("finds the row on the default branch when the address names no environment", async () => { + const { api, calls } = fakeApi(); + + const node = await resolveNodeAddress(api, "service", "@shop.api", signal); + + expect(node).toEqual({ + id: "svc_api_main", + project: { id: "proj_shop", name: "Shop" }, + branch: { id: "br_main", gitName: "main" }, + }); + expect(calls).toEqual([ + { path: "/v1/projects", query: { logicalId: "shop" } }, + { path: "/v1/projects/{projectId}/branches", query: {} }, + { + path: "/v1/services", + query: { + projectId: "proj_shop", + logicalId: "shop.api", + branchId: "br_main", + }, + }, + ]); + }); + + it("maps the production environment to the default branch, not a branch named production", async () => { + const { api } = fakeApi(); + + const node = await resolveNodeAddress( + api, + "service", + "@production:shop.api", + signal, + ); + + expect(node.id).toBe("svc_api_main"); + expect(node.branch).toEqual({ id: "br_main", gitName: "main" }); + }); + + it("finds the row on the branch whose git name is the environment", async () => { + const { api } = fakeApi(); + + const node = await resolveNodeAddress( + api, + "service", + "@pr-42:shop.api", + signal, + ); + + expect(node.id).toBe("svc_api_pr42"); + expect(node.branch).toEqual({ id: "br_pr42", gitName: "pr-42" }); + }); + + it("resolves databases and buckets from their own listings", async () => { + const { api } = fakeApi(); + + await expect( + resolveNodeAddress(api, "database", "@shop.catalog", signal), + ).resolves.toMatchObject({ id: "db_catalog_main" }); + await expect( + resolveNodeAddress(api, "bucket", "@pr-42:shop.files", signal), + ).resolves.toMatchObject({ id: "bkt_files_pr42" }); + }); + + it("reports an app with no Project as not found", async () => { + const { api } = fakeApi(); + + await expect( + resolveNodeAddress(api, "service", "@cart.api", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.NOT_FOUND", + meta: { address: "@cart.api", app: "cart" }, + }); + }); + + it("compares the app name to the Project's logical id exactly", async () => { + // An API that ignored the filter would hand back every Project; only + // an exact logical id may match. + const { api } = fakeApi(FIXTURE, (path) => + path === "/v1/projects" + ? { + data: { + data: [ + { + id: "proj_shop2", + name: "Shop 2", + logicalId: "shop-2", + workspace: WORKSPACE, + }, + { + id: "proj_shop_upper", + name: "SHOP", + logicalId: "Shop", + workspace: WORKSPACE, + }, + ], + pagination: { hasMore: false, nextCursor: null }, + }, + response: new Response(null, { status: 200 }), + } + : undefined, + ); + + await expect( + resolveNodeAddress(api, "service", "@shop.api", signal), + ).rejects.toMatchObject({ code: "ADDRESS.NOT_FOUND" }); + }); + + it("reports several Projects for one app as ambiguous", async () => { + const { api } = fakeApi({ + ...FIXTURE, + projects: [ + ...FIXTURE.projects, + { + id: "proj_shop_copy", + name: "Shop copy", + logicalId: "shop", + workspace: WORKSPACE, + }, + ], + }); + + await expect( + resolveNodeAddress(api, "service", "@shop.api", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.AMBIGUOUS", + meta: { projectIds: ["proj_shop", "proj_shop_copy"] }, + }); + }); + + it("reports an environment with no branch as not found and points at branch list", async () => { + const { api } = fakeApi(); + + const error = await resolveNodeAddress( + api, + "service", + "@pr-7:shop.api", + signal, + ).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: "ADDRESS.NOT_FOUND", + meta: { environment: "pr-7", projectId: "proj_shop" }, + nextActions: expect.arrayContaining([ + expect.objectContaining({ + command: "prisma branch list --project proj_shop", + }), + ]), + }); + }); + + it("reports a Project with no default branch as not found for production", async () => { + const { api } = fakeApi({ + ...FIXTURE, + branches: { + proj_shop: [ + { + id: "br_pr42", + gitName: "pr-42", + isDefault: false, + role: "preview", + }, + ], + }, + }); + + await expect( + resolveNodeAddress(api, "service", "@shop.api", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.NOT_FOUND", + meta: { environment: "production" }, + }); + }); + + it("reports an address nothing carries as not found after checking every kind", async () => { + const { api, calls } = fakeApi(); + + await expect( + resolveNodeAddress(api, "service", "@shop.web", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.NOT_FOUND", + meta: { + address: "@shop.web", + expected: "service", + projectId: "proj_shop", + branchId: "br_main", + }, + }); + expect(calls.map((call) => call.path)).toEqual([ + "/v1/projects", + "/v1/projects/{projectId}/branches", + "/v1/services", + "/v1/databases", + "/v1/buckets", + ]); + }); + + it("reports an address that names a row of another kind as the wrong kind", async () => { + const { api } = fakeApi(); + + const error = await resolveNodeAddress( + api, + "service", + "@shop.catalog", + signal, + ).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: "ADDRESS.WRONG_KIND", + meta: { expected: "service", actual: "database" }, + nextActions: expect.arrayContaining([ + expect.objectContaining({ command: "prisma postgres --help" }), + ]), + }); + }); + + it("reports the bare app name as the Project's address before asking the API anything", async () => { + const { api, calls } = fakeApi(); + + await expect( + resolveNodeAddress(api, "database", "@pr-42:shop", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.WRONG_KIND", + meta: { expected: "database", actual: "project" }, + }); + expect(calls).toEqual([]); + }); + + it("reports several rows with the address on one branch as ambiguous", async () => { + const { api } = fakeApi({ + ...FIXTURE, + databases: [ + ...(FIXTURE.databases ?? []), + { + id: "db_catalog_copy", + name: "catalog-copy", + projectId: "proj_shop", + branchId: "br_main", + logicalId: "shop.catalog", + }, + ], + }); + + await expect( + resolveNodeAddress(api, "database", "@shop.catalog", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.AMBIGUOUS", + meta: { + matches: [ + { id: "db_catalog_main", name: "catalog" }, + { id: "db_catalog_copy", name: "catalog-copy" }, + ], + }, + }); + }); + + it("follows the cursor through every page of a listing", async () => { + const { api, calls } = fakeApi(FIXTURE, (path, query) => { + if (path !== "/v1/projects") { + return undefined; + } + const first = query.cursor === undefined; + return { + data: { + data: first ? [FIXTURE.projects[1]] : [FIXTURE.projects[0]], + pagination: { + hasMore: first, + nextCursor: first ? "cursor_2" : null, + }, + }, + response: new Response(null, { status: 200 }), + }; + }); + + await expect( + resolveNodeAddress(api, "service", "@shop.api", signal), + ).resolves.toMatchObject({ id: "svc_api_main" }); + expect( + calls.filter((call) => call.path === "/v1/projects").map((c) => c.query), + ).toEqual([ + { logicalId: "shop", cursor: undefined }, + { logicalId: "shop", cursor: "cursor_2" }, + ]); + }); + + it("raises a refused listing as an API error with the API's code and status", async () => { + const { api } = fakeApi(FIXTURE, (path) => + path === "/v1/projects" + ? { + error: { error: { code: "forbidden", message: "No access" } }, + response: new Response(null, { status: 403 }), + } + : undefined, + ); + + await expect( + resolveNodeAddress(api, "service", "@shop.api", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.API_ERROR", + why: "No access", + meta: { status: 403, apiCode: "forbidden" }, + }); + }); +}); + +describe("resolveProjectAddress", () => { + it("finds the Project whose logical id is the app name", async () => { + const { api } = fakeApi(); + + await expect(resolveProjectAddress(api, "@shop", signal)).resolves.toEqual({ + id: "proj_shop", + name: "Shop", + }); + }); + + it("refuses an address with an environment, since a Project spans every environment", async () => { + const { api, calls } = fakeApi(); + + await expect( + resolveProjectAddress(api, "@pr-42:shop", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.WRONG_KIND", + meta: { expected: "project" }, + }); + await expect( + resolveProjectAddress(api, "@production:shop", signal), + ).rejects.toMatchObject({ code: "ADDRESS.WRONG_KIND" }); + expect(calls).toEqual([]); + }); + + it("refuses the address of a node inside the app", async () => { + const { api } = fakeApi(); + + await expect( + resolveProjectAddress(api, "@shop.api", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.WRONG_KIND", + meta: { expected: "project" }, + }); + }); + + it("passes an id or name through untouched", async () => { + const { api, calls } = fakeApi(); + + await expect(resolveProjectSubject(api, "Shop", signal)).resolves.toBe( + "Shop", + ); + await expect(resolveProjectSubject(api, "@shop", signal)).resolves.toBe( + "proj_shop", + ); + expect(calls).toHaveLength(1); + }); +}); + +describe("resolveScopedSubject", () => { + it("hands over the row id, Project id and branch git name an address names", async () => { + const { api } = fakeApi(); + + await expect( + resolveScopedSubject(api, "bucket", "@pr-42:shop.files", {}, signal), + ).resolves.toEqual({ + ref: "bkt_files_pr42", + project: "proj_shop", + branch: "pr-42", + }); + }); + + it("passes an id or name through with the flags it came with", async () => { + const { api, calls } = fakeApi(); + + await expect( + resolveScopedSubject( + api, + "database", + "catalog", + { project: "Shop", branch: "pr-42" }, + signal, + ), + ).resolves.toEqual({ ref: "catalog", project: "Shop", branch: "pr-42" }); + expect(calls).toEqual([]); + }); + + it.each([ + [{ project: "proj_shop" }, ["--project"]], + [{ branch: "main" }, ["--branch"]], + [{ project: "proj_shop", branch: "main" }, ["--project", "--branch"]], + ])("refuses an address combined with %o", async (scope, flags) => { + const { api, calls } = fakeApi(); + + await expect( + resolveScopedSubject(api, "service", "@shop.api", scope, signal), + ).rejects.toMatchObject({ + code: "ADDRESS.USAGE_ERROR", + meta: { address: "@shop.api", flags }, + }); + expect(calls).toEqual([]); + }); +}); From 5b1c4671ec813b031d101e91001a3ddc9421f3f2 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:03:03 +0100 Subject: [PATCH 08/15] Test address subjects on service, postgres, bucket and project commands Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/tests/bucket.test.ts | 128 +++++++++++++ packages/cli/tests/postgres.test.ts | 197 +++++++++++++++++++++ packages/cli/tests/project.test.ts | 128 +++++++++++++ packages/cli/tests/service-address.test.ts | 181 +++++++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 packages/cli/tests/service-address.test.ts diff --git a/packages/cli/tests/bucket.test.ts b/packages/cli/tests/bucket.test.ts index 236d6526..e2f62a9a 100644 --- a/packages/cli/tests/bucket.test.ts +++ b/packages/cli/tests/bucket.test.ts @@ -11,6 +11,10 @@ import { bucketKeyCreateCommand } from "../src/commands/bucket/key-create"; import { bucketKeyDeleteCommand } from "../src/commands/bucket/key-delete"; import { bucketKeyListCommand } from "../src/commands/bucket/key-list"; import { bucketListCommand } from "../src/commands/bucket/list"; +import { + type AddressFixture, + addressListing, +} from "./helpers/fake-address-api"; const ACME_SESSION = { workspaceId: "ws_1", @@ -976,3 +980,127 @@ describe("prisma bucket key delete", () => { }); }); }); + +describe("bucket commands given a deploy address", () => { + const ADDRESSES: AddressFixture = { + projects: [{ ...PROJECTS[0], logicalId: "billing" }], + branches: { + proj_1: [ + { + id: "br_main", + gitName: "main", + isDefault: true, + role: "production", + }, + { id: "br_pr42", gitName: "pr-42", isDefault: false, role: "preview" }, + ], + }, + buckets: [ + { + id: "bkt_files_main", + name: "files", + projectId: "proj_1", + branchId: "br_main", + logicalId: "billing.files", + }, + { + id: "bkt_files_pr42", + name: "files", + projectId: "proj_1", + branchId: "br_pr42", + logicalId: "billing.files", + }, + ], + }; + + function addressClient(calls: Call[]) { + const listing = (call: Call) => ({ + data: addressListing(ADDRESSES, call.path, call.init.params), + }); + return bucketClient({ + calls, + routes: { + "GET /v1/projects": listing, + "GET /v1/projects/{projectId}/branches": listing, + "GET /v1/buckets": listing, + "GET /v1/services": listing, + "GET /v1/databases": listing, + }, + }); + } + + it("deletes the bucket the address names, with the address as the consent token", async () => { + const calls: Call[] = []; + const result = await makeCli(addressClient(calls)).run( + [ + "bucket", + "delete", + "@pr-42:billing.files", + "--confirm", + "@pr-42:billing.files", + "--json", + ], + {}, + ); + + expect(result.exitCode).toBe(0); + expect( + calls + .filter((call) => call.method === "DELETE") + .map((call) => call.init.params?.path?.bucketId), + ).toEqual(["bkt_files_pr42"]); + }); + + it("refuses the bucket id as consent when the subject was an address", async () => { + const calls: Call[] = []; + const result = await makeCli(addressClient(calls)).run( + [ + "bucket", + "delete", + "@billing.files", + "--confirm", + "bkt_files_main", + "--json", + ], + {}, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "CLI.CONSENT_REQUIRED", + meta: { consentToken: "@billing.files" }, + }, + }); + expect(calls.some((call) => call.method === "DELETE")).toBe(false); + }); + + it("lists the keys of the default branch's bucket for an address with no environment", async () => { + const calls: Call[] = []; + const result = await makeCli(addressClient(calls)).run( + ["bucket", "key", "list", "@billing.files", "--json"], + {}, + ); + + expect(result.exitCode).toBe(0); + expect( + calls + .filter((call) => call.path === "/v1/buckets/{bucketId}/keys") + .map((call) => call.init.params?.path?.bucketId), + ).toEqual(["bkt_files_main"]); + }); + + it("reports an address nothing was deployed at as not found", async () => { + const result = await makeCli(addressClient([])).run( + ["bucket", "key", "create", "@billing.uploads", "--json"], + {}, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { code: "ADDRESS.NOT_FOUND", meta: { expected: "bucket" } }, + }); + }); +}); diff --git a/packages/cli/tests/postgres.test.ts b/packages/cli/tests/postgres.test.ts index 2fc93b45..2145e618 100644 --- a/packages/cli/tests/postgres.test.ts +++ b/packages/cli/tests/postgres.test.ts @@ -16,6 +16,10 @@ import { postgresDeleteCommand } from "../src/commands/postgres/delete"; import { postgresListCommand } from "../src/commands/postgres/list"; import { postgresShowCommand } from "../src/commands/postgres/show"; import { postgresUsageCommand } from "../src/commands/postgres/usage"; +import { + type AddressFixture, + addressListing, +} from "./helpers/fake-address-api"; const ACME_SESSION = { workspaceId: "ws_1", @@ -2648,3 +2652,196 @@ describe("prisma postgres connection delete", () => { }); }); }); + +describe("postgres commands given a deploy address", () => { + const CATALOG_PREVIEW = { + id: "db_catalog_pr42", + name: "catalog", + projectId: "proj_1", + branchGitName: "pr-42", + branchId: "br_pr42", + logicalId: "shop.catalog", + region: "us-east-1", + status: "ready", + isDefault: false, + }; + const CATALOG_MAIN = { + ...CATALOG_PREVIEW, + id: "db_catalog_main", + branchGitName: "main", + branchId: "br_main", + }; + const ADDRESSES: AddressFixture = { + projects: [{ ...PROJECTS[0], logicalId: "shop" }], + branches: { + proj_1: [ + { + id: "br_main", + gitName: "main", + isDefault: true, + role: "production", + }, + { id: "br_pr42", gitName: "pr-42", isDefault: false, role: "preview" }, + ], + }, + databases: [CATALOG_MAIN, CATALOG_PREVIEW], + services: [ + { + id: "svc_api_main", + name: "api", + projectId: "proj_1", + branchId: "br_main", + logicalId: "shop.api", + }, + ], + }; + + function addressClient(calls: Call[] = []) { + const listing = (call: Call) => ({ + data: addressListing(ADDRESSES, call.path, call.init.params), + }); + return postgresClient({ + calls, + databases: [CATALOG_MAIN, CATALOG_PREVIEW], + routes: { + "GET /v1/projects": listing, + "GET /v1/projects/{projectId}/branches": listing, + "GET /v1/services": listing, + "GET /v1/buckets": listing, + "GET /v1/databases": (call) => { + const query = call.init.params?.query ?? {}; + if (query.logicalId !== undefined) { + return listing(call); + } + return { + data: { + data: [CATALOG_MAIN, CATALOG_PREVIEW].filter( + (database) => + query.branchGitName === undefined || + database.branchGitName === query.branchGitName, + ), + pagination: { hasMore: false, nextCursor: null }, + }, + }; + }, + }, + }); + } + + it("deletes the database the address names, with the address as the consent token", async () => { + const calls: Call[] = []; + const result = await makeCli(addressClient(calls)).run( + [ + "postgres", + "delete", + "@pr-42:shop.catalog", + "--confirm", + "@pr-42:shop.catalog", + "--json", + ], + { cwd: await unpinnedCwd() }, + ); + + expect(result.exitCode).toBe(0); + expect( + calls.find( + (call) => + call.path === "/v1/databases" && + call.init.params?.query?.branchGitName, + )?.init.params?.query, + ).toMatchObject({ projectId: "proj_1", branchGitName: "pr-42" }); + expect( + calls + .filter((call) => call.method === "DELETE") + .map((call) => call.init.params?.path?.databaseId), + ).toEqual(["db_catalog_pr42"]); + }); + + it("refuses the database id as consent when the subject was an address", async () => { + const calls: Call[] = []; + const result = await makeCli(addressClient(calls)).run( + [ + "postgres", + "delete", + "@pr-42:shop.catalog", + "--confirm", + "db_catalog_pr42", + "--json", + ], + { cwd: await unpinnedCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "CLI.CONSENT_REQUIRED", + meta: { consentToken: "@pr-42:shop.catalog" }, + }, + }); + expect(calls.some((call) => call.method === "DELETE")).toBe(false); + }); + + it("asks for the address, not the id, at the prompt", async () => { + const result = await makeCli(addressClient()).run( + ["postgres", "delete", "@shop.catalog"], + { + cwd: await unpinnedCwd(), + answers: ["@shop.catalog"], + isTty: { stdin: true, stdout: true }, + }, + ); + + expect(result.exitCode).toBe(0); + }); + + it("shows the database on the default branch when the address names no environment", async () => { + const result = await makeCli(addressClient()).run( + ["postgres", "show", "@shop.catalog", "--json"], + { cwd: await unpinnedCwd() }, + ); + + expect(result.exitCode).toBe(0); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: true, + result: { projectId: "proj_1", database: { id: "db_catalog_main" } }, + }); + }); + + it("refuses --project or --branch alongside an address", async () => { + for (const flags of [ + ["--project", "proj_1"], + ["--branch", "pr-42"], + ]) { + const calls: Call[] = []; + // biome-ignore lint/performance/noAwaitInLoops: each run is its own case. + const result = await makeCli(addressClient(calls)).run( + ["postgres", "show", "@shop.catalog", ...flags, "--json"], + { cwd: await pinnedCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { code: "ADDRESS.USAGE_ERROR" }, + }); + expect(calls).toEqual([]); + } + }); + + it("names the right command group when the address is a service", async () => { + const result = await makeCli(addressClient()).run( + ["postgres", "show", "@shop.api", "--json"], + { cwd: await unpinnedCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "ADDRESS.WRONG_KIND", + meta: { expected: "database", actual: "service" }, + }, + }); + }); +}); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index d40fd1e1..5b417677 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -2996,3 +2996,131 @@ describe("prisma project transfer", () => { }); }); }); + +describe("project commands given a deploy address", () => { + const ADDRESSED_PROJECTS = [ + { ...API_PROJECTS[0], logicalId: "billing" }, + { ...API_PROJECTS[1], logicalId: null }, + ]; + + function addressedClient(deleted: string[] = []) { + return fakeClient({ + get: (apiPath, init) => { + if (apiPath !== "/v1/projects") { + return undefined; + } + const logicalId = ( + init as { params?: { query?: { logicalId?: string } } } | undefined + )?.params?.query?.logicalId; + return { + data: { + data: + logicalId === undefined + ? ADDRESSED_PROJECTS + : ADDRESSED_PROJECTS.filter( + (project) => project.logicalId === logicalId, + ), + pagination: { hasMore: false, nextCursor: null }, + }, + }; + }, + del: (_apiPath, init) => { + deleted.push( + (init as { params: { path: { id: string } } }).params.path.id, + ); + return { data: {} }; + }, + }); + } + + it("deletes the Project the app name names, with the address as the consent token", async () => { + const deleted: string[] = []; + const result = await makeCli(addressedClient(deleted)).run( + ["project", "delete", "@billing", "--confirm", "@billing"], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toMatchObject({ + project: { id: "proj_1", name: "Billing" }, + }); + expect(deleted).toEqual(["proj_1"]); + }); + + it("refuses the project id as consent when the subject was an address", async () => { + const deleted: string[] = []; + const result = await makeCli(addressedClient(deleted)).run( + ["project", "delete", "@billing", "--confirm", "proj_1", "--json"], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "CLI.CONSENT_REQUIRED", + meta: { consentToken: "@billing" }, + }, + }); + expect(deleted).toEqual([]); + }); + + it.each([ + "@pr-42:billing", + "@billing.api", + ])("refuses %s, which names less than the whole Project", async (address) => { + const deleted: string[] = []; + const result = await makeCli(addressedClient(deleted)).run( + ["project", "delete", address, "--confirm", address, "--json"], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { code: "ADDRESS.WRONG_KIND", meta: { expected: "project" } }, + }); + expect(deleted).toEqual([]); + }); + + it("shows the Project an address names", async () => { + const result = await makeCli(addressedClient()).run( + ["project", "show", "@billing", "--json"], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(0); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: true, + result: { project: { id: "proj_1", name: "Billing" } }, + }); + }); + + it("links this directory to the Project an address names", async () => { + const cwd = await tempCwd(); + const result = await makeCli(addressedClient()).run( + ["project", "link", "@billing"], + { cwd }, + ); + + expect(result.exitCode).toBe(0); + expect( + JSON.parse( + await readFile(path.join(cwd, ".prisma", "local.json"), "utf8"), + ), + ).toMatchObject({ projectId: "proj_1" }); + }); + + it("reports an app no Project was deployed for as not found", async () => { + const result = await makeCli(addressedClient()).run( + ["project", "show", "@storefront", "--json"], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { code: "ADDRESS.NOT_FOUND", meta: { app: "storefront" } }, + }); + }); +}); diff --git a/packages/cli/tests/service-address.test.ts b/packages/cli/tests/service-address.test.ts new file mode 100644 index 00000000..94f89c29 --- /dev/null +++ b/packages/cli/tests/service-address.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + type AddressFixture, + addressListing, +} from "./helpers/fake-address-api"; +import { + makeServiceCli, + PROJECT, + page, + type RouteHandler, + releaseRoutes, + SERVICE, +} from "./service-testkit"; + +const PREVIEW_SERVICE = { ...SERVICE, id: "svc_api_pr42", branchId: "br_pr42" }; + +const ADDRESSES: AddressFixture = { + projects: [{ ...PROJECT, logicalId: "shop" }], + branches: { + proj_1: [ + { id: "br_1", gitName: "main", isDefault: true, role: "production" }, + { id: "br_pr42", gitName: "pr-42", isDefault: false, role: "preview" }, + ], + }, + services: [ + { + id: "svc_1", + name: "hello-world", + projectId: "proj_1", + branchId: "br_1", + logicalId: "shop.api", + }, + { + id: "svc_api_pr42", + name: "hello-world", + projectId: "proj_1", + branchId: "br_pr42", + logicalId: "shop.api", + }, + ], + databases: [ + { + id: "db_catalog_main", + name: "catalog", + projectId: "proj_1", + branchId: "br_1", + logicalId: "shop.catalog", + }, + ], +}; + +const listing = + (path: string): RouteHandler => + (init) => ({ data: addressListing(ADDRESSES, path, init.params) }); + +/** The release routes, with every listing the address lookup reads and + * a `/v1/apps` listing that filters by branch git name. */ +function addressRoutes(appListings: string[] = []) { + return releaseRoutes({ + "GET /v1/projects": listing("/v1/projects"), + "GET /v1/projects/{projectId}/branches": listing( + "/v1/projects/{projectId}/branches", + ), + "GET /v1/services": listing("/v1/services"), + "GET /v1/databases": listing("/v1/databases"), + "GET /v1/buckets": listing("/v1/buckets"), + "GET /v1/apps": (init) => { + const branch = init.params?.query?.branchGitName as string; + appListings.push(branch); + return { + data: page(branch === "pr-42" ? [PREVIEW_SERVICE] : [SERVICE]), + }; + }, + }); +} + +describe("service commands given a deploy address", () => { + it("acts on the service on the branch the environment names", async () => { + const appListings: string[] = []; + const harness = await makeServiceCli({ + routes: addressRoutes(appListings), + }); + + const result = await harness.cli.run( + ["service", "version", "list", "@pr-42:shop.api", "--json"], + { cwd: harness.cwd, env: harness.env }, + ); + + expect(result.exitCode).toBe(0); + expect(appListings).toEqual(["pr-42"]); + }); + + it("acts on the default branch's service when the address names no environment", async () => { + const appListings: string[] = []; + const harness = await makeServiceCli({ + routes: addressRoutes(appListings), + }); + + const result = await harness.cli.run(["service", "show", "@shop.api"], { + cwd: harness.cwd, + env: harness.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toMatchObject({ + projectId: "proj_1", + service: { id: "svc_1" }, + }); + expect(appListings).toEqual(["main"]); + }); + + it("takes the address, not the service name, as the delete consent token", async () => { + const harness = await makeServiceCli({ routes: addressRoutes() }); + + const byName = await harness.cli.run( + [ + "service", + "delete", + "@pr-42:shop.api", + "--confirm", + "hello-world", + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + const frame = byName.json.at(-1); + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("CLI.CONSENT_REQUIRED"); + expect(frame.envelope.error.meta).toMatchObject({ + consentToken: "@pr-42:shop.api", + }); + + const byAddress = await harness.cli.run( + ["service", "delete", "@pr-42:shop.api", "--confirm", "@pr-42:shop.api"], + { cwd: harness.cwd, env: harness.env }, + ); + expect(byAddress.exitCode).toBe(0); + expect(byAddress.presented?.data).toMatchObject({ deleted: true }); + }); + + it("refuses --branch alongside an address", async () => { + const harness = await makeServiceCli({ routes: addressRoutes() }); + + const result = await harness.cli.run( + ["service", "logs", "@shop.api", "--branch", "main", "--json"], + { cwd: harness.cwd, env: harness.env }, + ); + + expect(result.exitCode).toBe(2); + const frame = result.json.at(-1); + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("ADDRESS.USAGE_ERROR"); + }); + + it.each([ + ["@shop", "project"], + ["@shop.catalog", "database"], + ])("refuses %s as a service, naming it a %s", async (address, actual) => { + const harness = await makeServiceCli({ routes: addressRoutes() }); + + const result = await harness.cli.run( + ["service", "show", address, "--json"], + { cwd: harness.cwd, env: harness.env }, + ); + + expect(result.exitCode).toBe(2); + const frame = result.json.at(-1); + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error).toMatchObject({ + code: "ADDRESS.WRONG_KIND", + meta: { expected: "service", actual }, + }); + }); +}); From 1511effff414ae0938668caa32948c011df57e30 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:03:10 +0100 Subject: [PATCH 09/15] Name the kind, not the command group, in the address lookup error Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/lib/address/node-address.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/address/node-address.ts b/packages/cli/src/lib/address/node-address.ts index 966312af..f17be592 100644 --- a/packages/cli/src/lib/address/node-address.ts +++ b/packages/cli/src/lib/address/node-address.ts @@ -397,7 +397,7 @@ async function listNodes( logicalId: address.address, branchId: branch.id, }; - const summary = `Failed to look up ${KIND_GROUP[kind]} rows by address`; + const summary = `Failed to look up ${kind}s by address`; const rows = await listAllPages( summary, (cursor) => { From 8ae30d538d86c2ade2b56592856db354c5ea0b36 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:12:28 +0100 Subject: [PATCH 10/15] Test address subjects against the real API A scratch Project with a logicalId, a default and a preview branch, and rows sharing an address on each, built through the management API. Covers service logs and show, project show, bucket key list and delete, postgres show and delete, consent by address, and the wrong-kind and not-found errors. service logs leaves the coverage backlog. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/e2e/address.e2e.ts | 385 ++++++++++++++++++++++++ packages/cli/e2e/deployed-service.ts | 4 +- packages/cli/tests/e2e-coverage.test.ts | 8 - 3 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 packages/cli/e2e/address.e2e.ts diff --git a/packages/cli/e2e/address.e2e.ts b/packages/cli/e2e/address.e2e.ts new file mode 100644 index 00000000..e2f75a09 --- /dev/null +++ b/packages/cli/e2e/address.e2e.ts @@ -0,0 +1,385 @@ +/** + * Node addresses as subjects, against the real API: `@:
` + * names a row by the Project's and the row's `logicalId` and the Branch's + * git name, the way Composer deploys them. + * + * The CLI cannot set a `logicalId`, so this file builds what Composer + * would through the management API: a Project whose `logicalId` is the + * app name, a default and a preview Branch, and on each a database with + * the same address. The preview Branch also carries a deployed service + * and a bucket; the default Branch an undeployed service with the same + * address as the deployed one. Every pair that shares an address is + * there to prove the environment picks the Branch. + */ +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { + apiBaseUrl, + createDeployment, + deleteDeployment, + serviceToken, +} from "./deployed-service"; +import { isScratchName, scratchDatabaseName, scratchName } from "./harness"; +import { removeScratchProject } from "./scratch"; +import { describeCommand, describeCommands, session } from "./suite"; + +const READY_TIMEOUT_MS = 120_000; +const READY_POLL_MS = 3000; + +async function managementApi( + method: "GET" | "POST", + path: string, + body?: unknown, +): Promise { + const response = await fetch(`${apiBaseUrl()}${path}`, { + method, + headers: { + authorization: `Bearer ${serviceToken()}`, + "content-type": "application/json", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) { + throw new Error( + `${method} ${path}: HTTP ${response.status} ${await response.text()}`, + ); + } + return ((await response.json()) as { data: T }).data; +} + +interface Row { + readonly id: string; + readonly name: string; +} + +interface Branch { + readonly id: string; + readonly gitName: string; + readonly isDefault: boolean; +} + +async function defaultBranch(projectId: string): Promise { + const branches = await managementApi( + "GET", + `/v1/projects/${projectId}/branches`, + ); + const existing = branches.find((branch) => branch.isDefault); + if (existing !== undefined) { + return existing; + } + // The first Branch a Project gets is its default, whatever is asked. + return managementApi("POST", `/v1/projects/${projectId}/branches`, { + gitName: "main", + }); +} + +async function createReadyDatabase( + projectId: string, + branchId: string, + logicalId: string, +): Promise { + const created = await managementApi( + "POST", + "/v1/databases", + { projectId, branchId, logicalId, name: scratchDatabaseName("addr") }, + ); + const deadline = Date.now() + READY_TIMEOUT_MS; + let status = created.status; + while (status !== "ready") { + if (Date.now() > deadline) { + throw new Error(`database ${created.id} is still ${status}`); + } + // biome-ignore lint/performance/noAwaitInLoops: polling one database until it is ready. + await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS)); + status = ( + await managementApi<{ status: string }>( + "GET", + `/v1/databases/${created.id}`, + ) + ).status; + } + return created; +} + +interface Fixture { + readonly project: Row & { readonly cwd: string }; + readonly app: string; + readonly environment: string; + readonly productionDatabase: Row; + readonly previewDatabase: Row; + readonly productionService: Row; + readonly previewService: Row & { readonly deploymentId: string }; + readonly bucket: Row; +} + +let fixture: Fixture | undefined; +let project: (Row & { cwd: string }) | undefined; +let deployment: { id: string; serviceName: string } | undefined; + +function requireFixture(): Fixture { + if (fixture === undefined) { + throw new Error("the address fixture did not finish setting up"); + } + return fixture; +} + +beforeAll(async () => { + const cli = await session(); + const cwd = await cli.workdir(); + const app = scratchName("addr"); + const created = await managementApi("POST", "/v1/projects", { + name: app, + logicalId: app, + createDatabase: false, + }); + project = { ...created, cwd }; + + const production = await defaultBranch(project.id); + const environment = scratchName("pr"); + const preview = await managementApi( + "POST", + `/v1/projects/${project.id}/branches`, + { gitName: environment }, + ); + + const catalog = `${app}.catalog`; + const api = `${app}.api`; + const [productionDatabase, previewDatabase] = await Promise.all([ + createReadyDatabase(project.id, production.id, catalog), + createReadyDatabase(project.id, preview.id, catalog), + ]); + const createService = (branchId: string) => + managementApi("POST", "/v1/services", { + projectId: project?.id, + branchId, + logicalId: api, + displayName: scratchName("svc"), + }); + const productionService = await createService(production.id); + const previewService = await createService(preview.id); + const bucket = await managementApi("POST", "/v1/buckets", { + projectId: project.id, + branchId: preview.id, + logicalId: `${app}.files`, + name: scratchName("bkt"), + }); + + const deploymentId = await createDeployment(previewService.id); + deployment = { id: deploymentId, serviceName: previewService.name }; + await cli.run(["service", "version", "start", deploymentId], { cwd }); + await cli.run(["service", "version", "promote", deploymentId], { cwd }); + + fixture = { + project, + app, + environment, + productionDatabase, + previewDatabase, + productionService, + previewService: { ...previewService, deploymentId }, + bucket, + }; +}); + +afterAll(async () => { + const cli = await session(); + if (deployment !== undefined) { + await deleteDeployment( + { + run: (args, options) => + cli.run(args, { cwd: project?.cwd, ...options }), + }, + deployment, + ); + } + if (project === undefined) { + return; + } + if (!isScratchName(project.name)) { + throw new Error( + `refusing to remove "${project.name}": not an e2e scratch project`, + ); + } + await removeScratchProject(cli, project); +}); + +async function run(args: readonly string[], expectOk = true) { + const cli = await session(); + return cli.run(args, { cwd: requireFixture().project.cwd, expectOk }); +} + +interface OutputFrame { + readonly kind: string; + readonly channel?: string; + readonly line?: string; +} + +function outputLines(stdout: string, channel: string): string[] { + return stdout + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line) as OutputFrame) + .filter((frame) => frame.kind === "output" && frame.channel === channel) + .map((frame) => frame.line ?? ""); +} + +describeCommand("service logs", () => { + it("reads the live version of the service an environment address names", async () => { + const { app, environment, previewService } = requireFixture(); + const result = await run(["service", "logs", `@${environment}:${app}.api`]); + + const diagnostics = outputLines(result.stdout, "diagnostic"); + expect(diagnostics).toContain(`service: ${previewService.name}`); + expect(diagnostics).toContain(`version: ${previewService.deploymentId}`); + }); +}); + +describeCommands(["service show", "project show"], () => { + it("resolves an address with no environment to the default branch", async () => { + const { app, project: scratch, productionService } = requireFixture(); + const result = await run(["service", "show", `@${app}.api`]); + const shown = result.envelope.result as { + readonly projectId: string; + readonly service: Row; + readonly liveVersion: unknown; + }; + + expect(shown.projectId).toBe(scratch.id); + expect(shown.service.id).toBe(productionService.id); + expect(shown.liveVersion).toBeNull(); + }); + + it("resolves the same address in a named environment to that branch's row", async () => { + const { app, environment, previewService } = requireFixture(); + const result = await run(["service", "show", `@${environment}:${app}.api`]); + const shown = result.envelope.result as { + readonly service: Row; + readonly liveVersion: { readonly id: string } | null; + }; + + expect(shown.service.id).toBe(previewService.id); + expect(shown.liveVersion?.id).toBe(previewService.deploymentId); + }); + + it("resolves the app name alone to the Project", async () => { + const { app, project: scratch } = requireFixture(); + const result = await run(["project", "show", `@${app}`]); + const shown = result.envelope.result as { readonly project: Row }; + + expect(shown.project.id).toBe(scratch.id); + expect(shown.project.name).toBe(app); + }); +}); + +describeCommands(["bucket key list", "bucket delete"], () => { + it("lists the keys of the bucket an address names", async () => { + const { app, environment, bucket } = requireFixture(); + const result = await run([ + "bucket", + "key", + "list", + `@${environment}:${app}.files`, + ]); + const listed = result.envelope.result as { readonly bucketId: string }; + + expect(listed.bucketId).toBe(bucket.id); + }); + + it("says which kind an address is when the command expects another", async () => { + const { app, environment } = requireFixture(); + const address = `@${environment}:${app}.files`; + const result = await run(["postgres", "show", address], false); + + expect(result.envelope.error?.code).toBe("ADDRESS.WRONG_KIND"); + expect(result.envelope.error?.meta).toMatchObject({ + address, + expected: "database", + actual: "bucket", + }); + }); + + it("deletes the bucket an address names, with the address as consent", async () => { + const { app, environment, bucket } = requireFixture(); + const address = `@${environment}:${app}.files`; + const result = await run([ + "bucket", + "delete", + address, + "--confirm", + address, + ]); + const deleted = result.envelope.result as { readonly bucket: Row }; + + expect(deleted.bucket.id).toBe(bucket.id); + }); +}); + +describeCommands(["postgres show", "postgres delete"], () => { + it("shows the database an environment address names", async () => { + const { app, environment, previewDatabase } = requireFixture(); + const result = await run([ + "postgres", + "show", + `@${environment}:${app}.catalog`, + ]); + const shown = result.envelope.result as { readonly database: Row }; + + expect(shown.database.id).toBe(previewDatabase.id); + }); + + it("refuses the row id as consent when the subject is an address", async () => { + const { app, environment, previewDatabase } = requireFixture(); + const result = await run( + [ + "postgres", + "delete", + `@${environment}:${app}.catalog`, + "--confirm", + previewDatabase.id, + ], + false, + ); + + expect(result.envelope.error?.code).toBe("CLI.CONSENT_REQUIRED"); + const after = await run([ + "postgres", + "show", + `@${environment}:${app}.catalog`, + ]); + expect( + (after.envelope.result as { readonly database: Row }).database.id, + ).toBe(previewDatabase.id); + }); + + it("deletes only the named environment's database", async () => { + const { app, environment, previewDatabase, productionDatabase } = + requireFixture(); + const address = `@${environment}:${app}.catalog`; + const result = await run([ + "postgres", + "delete", + address, + "--confirm", + address, + ]); + const deleted = result.envelope.result as { readonly database: Row }; + expect(deleted.database.id).toBe(previewDatabase.id); + + const production = await run(["postgres", "show", `@${app}.catalog`]); + expect( + (production.envelope.result as { readonly database: Row }).database.id, + ).toBe(productionDatabase.id); + }); + + it("reports an address nothing is deployed at", async () => { + const { app, environment } = requireFixture(); + const address = `@${environment}:${app}.catalog`; + const result = await run(["postgres", "show", address], false); + + expect(result.envelope.error?.code).toBe("ADDRESS.NOT_FOUND"); + expect(result.envelope.error?.meta).toMatchObject({ + address, + expected: "database", + }); + }); +}); diff --git a/packages/cli/e2e/deployed-service.ts b/packages/cli/e2e/deployed-service.ts index 1ab51701..2e5de9c9 100644 --- a/packages/cli/e2e/deployed-service.ts +++ b/packages/cli/e2e/deployed-service.ts @@ -23,13 +23,13 @@ type Runner = { run: (args: readonly string[], options?: RunOptions) => Promise; }; -function apiBaseUrl(): string { +export function apiBaseUrl(): string { return ( process.env.PRISMA_MANAGEMENT_API_URL?.trim() || "https://api.prisma.io" ); } -function serviceToken(): string { +export function serviceToken(): string { const credentials = e2eCredentials(); if (credentials === null) { throw new Error("no e2e credentials; this fixture should not have run"); diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index 879f48e6..71dbb08d 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -138,17 +138,9 @@ const EXCLUSIONS: Readonly> = { * verification failed: ensure the hostname CNAMEs to * switchboard.ewr.prisma.build." No fixture inside this repo can satisfy * that; it needs a domain the test account owns and a DNS record. - * - * - * `service logs` arrived while this was being written, excluded because - * only Composer's deploy produced a deployment to read logs from. That - * is no longer true, so it is owed rather than excused — it needs a - * deployment that has actually served traffic, which is a little more - * than the fixture does today. */ const AWAITING_COVERAGE: readonly string[] = [ "service version rollback", - "service logs", "service domain add", "service domain show", "service domain delete", From a6dddff81e530713fbb7aeb7303664b8161d46fe Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:12:47 +0100 Subject: [PATCH 11/15] Document the address form of a command subject Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/product/command-principles.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index b0a90750..0a34415d 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -117,6 +117,8 @@ Resolve a service version and show or stream its logs. A command that operates on a subject resource takes that resource's identifier as its first positional argument (`service show my-api`, `postgres delete db_123`, `service version promote cpv_123`) — the established convention across CLIs. Flags never name the subject; they scope or qualify it (`--project`, `--branch`, `--role`). The argument primarily targets the stable platform id, with the display name as a secondary fallback: an id match always wins, and a resource named like another resource's id can never shadow it. Environment variables never target a subject. When the subject's identifier is globally unique — a service version id, a bucket id — the id alone is the complete target, and the command asks for no redundant parent scope. +A Project, service, database or bucket can also be named by the address Prisma Composer deployed it at: `@:
`, such as `@pr-42:shop.api`. The address is the row's `logicalId`, and its first `.`-separated part is the app, which is the Project's `logicalId`. The environment is the git name of the Project's Branch. Leaving it out, or writing `production`, means the Project's default Branch, so `@shop.api` is production. A Project's address is the app name alone, with no environment (`@shop`). With an address, a destructive command's `--confirm` takes the address as typed (`postgres delete @pr-42:shop.db --confirm @pr-42:shop.db`). `--project` and `--branch` cannot be combined with an address, because the address already names both. + ### `wait` Block until a remote resource reaches a terminal state. From 07d104adb161ca562555e7aa72ca1ceea070c12b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:21:34 +0100 Subject: [PATCH 12/15] List a Project's branches in one place branch list and the service read path each had their own cursor loop over /v1/projects/{projectId}/branches. Both now use one listing that can also ask the API for a single git name. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/controllers/branch.ts | 73 +------------------ packages/cli/src/lib/app/read-branch.ts | 31 ++------ packages/cli/src/lib/branch/list.ts | 95 +++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 97 deletions(-) create mode 100644 packages/cli/src/lib/branch/list.ts diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index eefa143d..679d15b2 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,7 +1,5 @@ -// biome-ignore-all lint/performance/noAwaitInLoops: Branch pagination requests must run sequentially. - -import { CliStructuredError } from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { listProjectBranches } from "../lib/branch/list"; import type { BranchRole, BranchSummary } from "../types/branch"; export interface RawBranchRecord { @@ -32,36 +30,7 @@ export async function listBranches( projectId: string, signal: AbortSignal, ): Promise { - const collected: RawBranchRecord[] = []; - let cursor: string | undefined; - - // eslint-disable-next-line no-constant-condition - while (true) { - const query: Record = {}; - if (cursor !== undefined) { - query.cursor = cursor; - } - - const { data, error, response } = await client.GET( - "/v1/projects/{projectId}/branches", - { - params: { path: { projectId }, query }, - signal, - }, - ); - if (error || !data) { - throw branchApiError("Failed to list branches", response, error); - } - - collected.push(...(data.data as RawBranchRecord[])); - - if (!data.pagination.hasMore || !data.pagination.nextCursor) { - break; - } - cursor = data.pagination.nextCursor; - } - - return collected; + return await listProjectBranches(client, { projectId, signal }); } export function toBranchSummary(branch: RawBranchRecord): BranchSummary { @@ -72,41 +41,3 @@ export function toBranchSummary(branch: RawBranchRecord): BranchSummary { envMap: branch.role, }; } - -interface ApiErrorBody { - error?: { - code?: string; - message?: string; - hint?: string; - }; -} - -function branchApiError( - summary: string, - response: Response | undefined, - error: ApiErrorBody | undefined, -): CliStructuredError { - const status = response?.status ?? 0; - const apiCode = error?.error?.code; - return new CliStructuredError("BRANCH.API_ERROR", summary, { - why: - error?.error?.message ?? - `The Management API returned status ${status || "unknown"}.`, - ...(status || apiCode !== undefined - ? { - meta: { - ...(status ? { status } : {}), - ...(apiCode !== undefined ? { apiCode } : {}), - }, - } - : {}), - nextActions: [ - { - kind: "user-choice", - label: - error?.error?.hint ?? - "Re-run with --log-level verbose for the underlying API response details.", - }, - ], - }); -} diff --git a/packages/cli/src/lib/app/read-branch.ts b/packages/cli/src/lib/app/read-branch.ts index 918142e1..8836fbe9 100644 --- a/packages/cli/src/lib/app/read-branch.ts +++ b/packages/cli/src/lib/app/read-branch.ts @@ -1,6 +1,7 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; import type { BranchKind } from "../../types/branch"; +import { listProjectBranches } from "../branch/list"; export interface ReadBranch { id: string; @@ -17,32 +18,10 @@ export async function resolveReadBranch( client: ManagementApiClient, options: { projectId: string; branchName: string; signal?: AbortSignal }, ): Promise { - const branches: Array<{ - id: string; - gitName: string; - isDefault: boolean; - role: BranchKind; - }> = []; - let cursor: string | undefined; - - do { - // biome-ignore lint/performance/noAwaitInLoops: each page is fetched with the cursor the previous page returned, so the requests cannot be issued until the one before them has answered. - const result = await client.GET("/v1/projects/{projectId}/branches", { - params: { path: { projectId: options.projectId }, query: { cursor } }, - signal: options.signal, - }); - if (result.error || !result.data) { - throw new Error( - `Failed to list branches for project ${options.projectId}: ${JSON.stringify(result.error)}`, - ); - } - - branches.push(...result.data.data); - cursor = result.data.pagination.hasMore - ? (result.data.pagination.nextCursor ?? undefined) - : undefined; - } while (cursor); - + const branches = await listProjectBranches(client, { + projectId: options.projectId, + signal: options.signal, + }); const chosen = branches.find((branch) => branch.gitName === options.branchName) ?? branches.find((branch) => branch.isDefault) ?? diff --git a/packages/cli/src/lib/branch/list.ts b/packages/cli/src/lib/branch/list.ts new file mode 100644 index 00000000..5443c7e6 --- /dev/null +++ b/packages/cli/src/lib/branch/list.ts @@ -0,0 +1,95 @@ +import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import type { BranchRole } from "../../types/branch"; + +export interface BranchRecord { + readonly id: string; + readonly gitName: string; + readonly isDefault: boolean; + readonly role: BranchRole; +} + +/** Every live Branch of a Project, following the cursor through each + * page. `gitName` asks the API for the one Branch with that git name. */ +export async function listProjectBranches( + client: Pick, + options: { + readonly projectId: string; + readonly gitName?: string | undefined; + readonly signal?: AbortSignal | undefined; + }, +): Promise { + const collected: BranchRecord[] = []; + let cursor: string | undefined; + do { + // biome-ignore lint/performance/noAwaitInLoops: each page is fetched with the cursor the previous page returned. + const { data, error, response } = await client.GET( + "/v1/projects/{projectId}/branches", + { + params: { + path: { projectId: options.projectId }, + query: { + ...(options.gitName !== undefined + ? { gitName: options.gitName } + : {}), + ...(cursor !== undefined ? { cursor } : {}), + }, + }, + signal: options.signal, + }, + ); + if (error || !data) { + throw branchApiError("Failed to list branches", response, error); + } + collected.push( + ...data.data.map((branch) => ({ + id: branch.id, + gitName: branch.gitName, + isDefault: branch.isDefault, + role: branch.role, + })), + ); + cursor = data.pagination.hasMore + ? (data.pagination.nextCursor ?? undefined) + : undefined; + } while (cursor); + return collected; +} + +interface ApiErrorBody { + error?: { + code?: string; + message?: string; + hint?: string; + }; +} + +function branchApiError( + summary: string, + response: Response | undefined, + error: ApiErrorBody | undefined, +): CliStructuredError { + const status = response?.status ?? 0; + const apiCode = error?.error?.code; + return new CliStructuredError("BRANCH.API_ERROR", summary, { + why: + error?.error?.message ?? + `The Management API returned status ${status || "unknown"}.`, + ...(status || apiCode !== undefined + ? { + meta: { + ...(status ? { status } : {}), + ...(apiCode !== undefined ? { apiCode } : {}), + }, + } + : {}), + nextActions: [ + { + kind: "user-choice", + label: + error?.error?.hint ?? + "Re-run with --log-level verbose for the underlying API response details.", + }, + ], + }); +} From ba56fba24114c063b77c9c28c9f53a46d687948e Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:27:17 +0100 Subject: [PATCH 13/15] Show the environment and row before a destructive command acts on an address postgres delete @shop.db used to ask only for 'the exact address', so a user could delete production without ever seeing the word. A destructive command given an address now names the row id, the environment (saying production plainly) and the branch in its consent question: Deleting database db_123 in production (branch main) is destructive and requires the exact address. Alongside: - The resolver moves to lib/address/address.ts with names for what they hold: AddressSubject for the typed @env:address string, AddressMatch for the one result, BranchScopedKind for service/database/bucket, and resolveBranchScopedSubject beside resolveProjectSubject. ScopeFlags is optional, so bucket no longer passes {}. - A named environment asks the API for that branch by gitName, through the shared branch listing. - Every group trims the subject before reading it as an address, and the consent token is the trimmed address. - The project commands share resolveProjectPositional. - @env:app on a project command is ADDRESS.USAGE_ERROR; every ADDRESS.AMBIGUOUS carries matches: [{id, name}], and every ADDRESS.WRONG_KIND carries expected and actual. - Tests assert which service row was deleted or listed, that the client-side logicalId/branchId check holds when the API ignores its filters, and that project delete and transfer refuse @env:app at the command level. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/bucket/context.ts | 21 +- packages/cli/src/commands/bucket/delete.ts | 23 +- .../cli/src/commands/bucket/key-create.ts | 4 +- .../cli/src/commands/bucket/key-delete.ts | 4 +- packages/cli/src/commands/bucket/key-list.ts | 4 +- .../src/commands/postgres/backup-restore.ts | 43 +- packages/cli/src/commands/postgres/context.ts | 22 +- packages/cli/src/commands/postgres/delete.ts | 22 +- packages/cli/src/commands/project/context.ts | 19 + packages/cli/src/commands/project/delete.ts | 39 +- packages/cli/src/commands/project/link.ts | 10 +- packages/cli/src/commands/project/show.ts | 20 +- packages/cli/src/commands/project/transfer.ts | 35 +- packages/cli/src/commands/service/delete.ts | 9 +- packages/cli/src/commands/service/target.ts | 20 +- .../address/{node-address.ts => address.ts} | 383 ++++++++++-------- .../{node-address.test.ts => address.test.ts} | 214 +++++++--- packages/cli/tests/bucket.test.ts | 5 +- .../cli/tests/helpers/fake-address-api.ts | 27 +- packages/cli/tests/postgres.test.ts | 25 +- packages/cli/tests/project.test.ts | 108 ++++- packages/cli/tests/service-address.test.ts | 79 +++- 22 files changed, 748 insertions(+), 388 deletions(-) rename packages/cli/src/lib/address/{node-address.ts => address.ts} (53%) rename packages/cli/tests/{node-address.test.ts => address.test.ts} (71%) diff --git a/packages/cli/src/commands/bucket/context.ts b/packages/cli/src/commands/bucket/context.ts index a8a72ee6..68fcb74b 100644 --- a/packages/cli/src/commands/bucket/context.ts +++ b/packages/cli/src/commands/bucket/context.ts @@ -1,7 +1,10 @@ /** Workspace, project and provider for the `bucket *` commands. */ import { type CommandContext, flag, positional } from "@prisma/cli-engine"; import { CLI_NAME } from "../../cli-name"; -import { resolveScopedSubject } from "../../lib/address/node-address"; +import { + type AddressMatch, + resolveBranchScopedSubject, +} from "../../lib/address/address"; import { type BucketProvider, createManagementBucketProvider, @@ -27,7 +30,7 @@ export const branchFlag = flag.string({ export const bucketPositional = positional.string({ brief: - "Bucket id, or its deploy address: @shop.files (default branch) or @pr-42:shop.files (branch pr-42)", + "Bucket id, or its address: @shop.files (production) or @pr-42:shop.files (environment pr-42)", placeholder: "bucket-id", }); @@ -69,18 +72,18 @@ export function resolveBucketProviderOnly( return createManagementBucketProvider(ctx.api); } -/** The bucket id the subject names: the id as typed, or the id of the - * bucket a bucket address names. */ -export async function resolveBucketId( +/** The bucket the subject names: the id as typed, or the bucket an + * address names. Bucket ids are globally unique, so the Project and + * Branch an address names are not needed past the lookup. */ +export async function resolveBucket( ctx: BucketCommandContext, bucketRef: string, -): Promise { - const subject = await resolveScopedSubject( +): Promise<{ id: string; address: AddressMatch | undefined }> { + const subject = await resolveBranchScopedSubject( ctx.api, "bucket", bucketRef, - {}, ctx.signal, ); - return subject.ref; + return { id: subject.ref, address: subject.match }; } diff --git a/packages/cli/src/commands/bucket/delete.ts b/packages/cli/src/commands/bucket/delete.ts index 6213bee8..67d327a5 100644 --- a/packages/cli/src/commands/bucket/delete.ts +++ b/packages/cli/src/commands/bucket/delete.ts @@ -6,17 +6,26 @@ import { type Presentations, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; -import { consentToken } from "../../lib/address/node-address"; +import { + type AddressMatch, + describeEnvironment, +} from "../../lib/address/address"; import type { BucketDeleteResult } from "../../types/bucket"; import { bucketPositional, LIST_BUCKETS_COMMAND, - resolveBucketId, + resolveBucket, resolveBucketProviderOnly, } from "./context"; -const CONSENT_QUESTION = - "Deleting this bucket permanently removes all objects and access keys."; +function consentQuestion( + bucketId: string, + address: AddressMatch | undefined, +): string { + return address + ? `Deleting bucket ${bucketId} ${describeEnvironment(address)} permanently removes all objects and access keys.` + : "Deleting this bucket permanently removes all objects and access keys."; +} function deletePresentations(result: BucketDeleteResult): Presentations { return { @@ -61,10 +70,10 @@ export const bucketDeleteCommand = defineCommand({ ], }); } - const bucketId = await resolveBucketId(ctx, bucketRef); + const { id: bucketId, address } = await resolveBucket(ctx, bucketRef); - await ctx.prompt.consent(CONSENT_QUESTION, { - token: consentToken(bucketRef, bucketId), + await ctx.prompt.consent(consentQuestion(bucketId, address), { + token: address?.text ?? bucketId, }); await resolveBucketProviderOnly(ctx).deleteBucket(bucketId, { diff --git a/packages/cli/src/commands/bucket/key-create.ts b/packages/cli/src/commands/bucket/key-create.ts index 277be03e..d100cccf 100644 --- a/packages/cli/src/commands/bucket/key-create.ts +++ b/packages/cli/src/commands/bucket/key-create.ts @@ -10,7 +10,7 @@ import type { BucketKeyCreateResult } from "../../types/bucket"; import { bucketPositional, LIST_BUCKETS_COMMAND, - resolveBucketId, + resolveBucket, resolveBucketProviderOnly, } from "./context"; @@ -98,7 +98,7 @@ export const bucketKeyCreateCommand = defineCommand({ ], }); } - const bucketId = await resolveBucketId(ctx, bucketRef); + const { id: bucketId } = await resolveBucket(ctx, bucketRef); const created = await resolveBucketProviderOnly(ctx).createKey({ bucketId, diff --git a/packages/cli/src/commands/bucket/key-delete.ts b/packages/cli/src/commands/bucket/key-delete.ts index a83b11cb..d79b0d07 100644 --- a/packages/cli/src/commands/bucket/key-delete.ts +++ b/packages/cli/src/commands/bucket/key-delete.ts @@ -11,7 +11,7 @@ import { CLI_NAME } from "../../cli-name"; import type { BucketKeyDeleteResult } from "../../types/bucket"; import { bucketPositional, - resolveBucketId, + resolveBucket, resolveBucketProviderOnly, } from "./context"; @@ -63,7 +63,7 @@ export const bucketKeyDeleteCommand = defineCommand({ }, ); } - const bucketId = await resolveBucketId(ctx, bucketRef); + const { id: bucketId } = await resolveBucket(ctx, bucketRef); await resolveBucketProviderOnly(ctx).deleteKey(bucketId, keyId, { signal: ctx.signal, diff --git a/packages/cli/src/commands/bucket/key-list.ts b/packages/cli/src/commands/bucket/key-list.ts index d76b4f2c..a777b125 100644 --- a/packages/cli/src/commands/bucket/key-list.ts +++ b/packages/cli/src/commands/bucket/key-list.ts @@ -10,7 +10,7 @@ import type { BucketKeyListResult } from "../../types/bucket"; import { bucketPositional, LIST_BUCKETS_COMMAND, - resolveBucketId, + resolveBucket, resolveBucketProviderOnly, } from "./context"; import { bucketKeyRows } from "./presentation"; @@ -73,7 +73,7 @@ export const bucketKeyListCommand = defineCommand({ ], }); } - const bucketId = await resolveBucketId(ctx, bucketRef); + const { id: bucketId } = await resolveBucket(ctx, bucketRef); const keys = await resolveBucketProviderOnly(ctx).listKeys(bucketId, { signal: ctx.signal, diff --git a/packages/cli/src/commands/postgres/backup-restore.ts b/packages/cli/src/commands/postgres/backup-restore.ts index 554213be..9364799e 100644 --- a/packages/cli/src/commands/postgres/backup-restore.ts +++ b/packages/cli/src/commands/postgres/backup-restore.ts @@ -10,13 +10,21 @@ import { import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { resolveDatabase } from "../../controllers/database"; -import { consentToken, isNodeAddress } from "../../lib/address/node-address"; +import { + type AddressMatch, + describeEnvironment, +} from "../../lib/address/address"; import type { DatabaseRestoreResult } from "../../types/database"; import { branchFlag, projectFlag, resolvePostgresDatabase } from "./context"; import type { FieldRow } from "./presentation"; -function consentQuestion(subject: string): string { - return `Restoring immediately and irreversibly overwrites all data in the target database, so it requires the exact target database ${isNodeAddress(subject) ? "address" : "id"}.`; +function consentQuestion( + databaseId: string, + address: AddressMatch | undefined, +): string { + return address + ? `Restoring immediately and irreversibly overwrites all data in database ${databaseId} ${describeEnvironment(address)}, so it requires the exact target database address.` + : "Restoring immediately and irreversibly overwrites all data in the target database, so it requires the exact target database id."; } function restorePresentations( @@ -67,7 +75,7 @@ export const postgresBackupRestoreCommand = defineCommand({ positionals: { database: positional.string({ brief: - "Target database id or name, or its deploy address: @shop.db (default branch) or @pr-42:shop.db (branch pr-42)", + "Target database id or name, or its address: @shop.db (production) or @pr-42:shop.db (environment pr-42)", placeholder: "database", }), }, @@ -86,7 +94,7 @@ export const postgresBackupRestoreCommand = defineCommand({ }, }, help: { - summary: "Restore a database from a backup after exact id confirmation", + summary: "Restore a database from a backup after exact confirmation", description: "Replaces the target database's current state with the backup's contents; anything written since that backup is lost, so the exact target database id is the consent token (or the address, when you pass one). To restore another database's backup into this one, name where the backup came from with --source-database.", examples: [ @@ -114,13 +122,20 @@ export const postgresBackupRestoreCommand = defineCommand({ ); } - const { provider, target, projectId, projectName, branchName, database } = - await resolvePostgresDatabase( - ctx, - args.flags, - args.positionals.database, - "postgres backup restore", - ); + const { + provider, + target, + projectId, + projectName, + branchName, + database, + address, + } = await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres backup restore", + ); const sourceDatabase = args.flags.sourceDatabase ? await resolveDatabase( provider, @@ -131,8 +146,8 @@ export const postgresBackupRestoreCommand = defineCommand({ ) : database; - await ctx.prompt.consent(consentQuestion(args.positionals.database), { - token: consentToken(args.positionals.database, database.id), + await ctx.prompt.consent(consentQuestion(database.id, address), { + token: address?.text ?? database.id, }); const restored = await provider.restoreDatabase({ diff --git a/packages/cli/src/commands/postgres/context.ts b/packages/cli/src/commands/postgres/context.ts index a04b892f..e6c5ac1c 100644 --- a/packages/cli/src/commands/postgres/context.ts +++ b/packages/cli/src/commands/postgres/context.ts @@ -1,7 +1,10 @@ /** Workspace, project and provider for the `postgres *` commands. */ import { type CommandContext, flag, positional } from "@prisma/cli-engine"; import { resolveDatabase } from "../../controllers/database"; -import { resolveScopedSubject } from "../../lib/address/node-address"; +import { + type AddressMatch, + resolveBranchScopedSubject, +} from "../../lib/address/address"; import { createManagementDatabaseProvider, type DatabaseProvider, @@ -26,7 +29,7 @@ export const branchFlag = flag.string({ export const databasePositional = positional.string({ brief: - "Database id or name, or its deploy address: @shop.db (default branch) or @pr-42:shop.db (branch pr-42)", + "Database id or name, or its address: @shop.db (production) or @pr-42:shop.db (environment pr-42)", placeholder: "database", }); @@ -65,6 +68,8 @@ export interface PostgresDatabaseContext extends PostgresContext { /** The branch git name the database was looked up on, from --branch * or the address. */ readonly branchName: string | undefined; + /** Set when the database was named by an address. */ + readonly address: AddressMatch | undefined; } /** The project, then the database the subject names. A database @@ -76,12 +81,12 @@ export async function resolvePostgresDatabase( databaseRef: string, commandName: string, ): Promise { - const subject = await resolveScopedSubject( + const subject = await resolveBranchScopedSubject( ctx.api, "database", - databaseRef, - flags, + databaseRef.trim(), ctx.signal, + flags, ); const context = await resolvePostgresContext( ctx, @@ -98,7 +103,12 @@ export async function resolvePostgresDatabase( subject.branch, ctx.signal, ); - return { ...context, database, branchName: subject.branch }; + return { + ...context, + database, + branchName: subject.branch, + address: subject.match, + }; } /** `connection rotate` and `connection delete` address a connection diff --git a/packages/cli/src/commands/postgres/delete.ts b/packages/cli/src/commands/postgres/delete.ts index 5a963b47..a06407e8 100644 --- a/packages/cli/src/commands/postgres/delete.ts +++ b/packages/cli/src/commands/postgres/delete.ts @@ -6,7 +6,10 @@ import { type Presentations, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { consentToken, isNodeAddress } from "../../lib/address/node-address"; +import { + type AddressMatch, + describeEnvironment, +} from "../../lib/address/address"; import type { DatabaseDeleteResult } from "../../types/database"; import { branchFlag, @@ -15,8 +18,13 @@ import { resolvePostgresDatabase, } from "./context"; -function consentQuestion(subject: string): string { - return `Deleting this database is destructive and requires the exact ${isNodeAddress(subject) ? "address" : "id"}.`; +function consentQuestion( + databaseId: string, + address: AddressMatch | undefined, +): string { + return address + ? `Deleting database ${databaseId} ${describeEnvironment(address)} is destructive and requires the exact address.` + : "Deleting this database is destructive and requires the exact id."; } function deletePresentations(result: DatabaseDeleteResult): Presentations { @@ -48,7 +56,7 @@ export const postgresDeleteCommand = defineCommand({ flags: { project: projectFlag, branch: branchFlag }, }, help: { - summary: "Delete a database after exact id confirmation", + summary: "Delete a database after exact confirmation", description: "Deletion is permanent and destroys the data. The exact database id is the consent token, or the address when you pass one; pass it with --confirm to run non-interactively.", examples: [ @@ -58,7 +66,7 @@ export const postgresDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const { provider, projectId, projectName, database } = + const { provider, projectId, projectName, database, address } = await resolvePostgresDatabase( ctx, args.flags, @@ -66,8 +74,8 @@ export const postgresDeleteCommand = defineCommand({ "postgres delete", ); - await ctx.prompt.consent(consentQuestion(args.positionals.database), { - token: consentToken(args.positionals.database, database.id), + await ctx.prompt.consent(consentQuestion(database.id, address), { + token: address?.text ?? database.id, }); await provider.removeDatabase(database.id, { signal: ctx.signal }); diff --git a/packages/cli/src/commands/project/context.ts b/packages/cli/src/commands/project/context.ts index 8b387d3c..c8837781 100644 --- a/packages/cli/src/commands/project/context.ts +++ b/packages/cli/src/commands/project/context.ts @@ -8,6 +8,7 @@ import path from "node:path"; import type { CommandContext } from "@prisma/cli-engine"; import type { CommandContext as ControllerCommandContext } from "../../controllers/context"; import { listRealWorkspaceProjects } from "../../controllers/project"; +import { isAddress, resolveProjectSubject } from "../../lib/address/address"; import { ensureLocalResolutionPinGitignore, LOCAL_RESOLUTION_PIN_RELATIVE_PATH, @@ -72,6 +73,24 @@ export function listWorkspaceProjects( return listRealWorkspaceProjects(ctx.api, ctx.signal); } +/** The Project a command's positional names, trimmed: the id or name as + * typed, or the id of the Project an address names. `address` keeps the + * address as typed for the consent token. Undefined when the positional + * is absent or blank. */ +export async function resolveProjectPositional( + ctx: ProjectCommandContext, + positional: string | undefined, +): Promise<{ ref: string; address: string | undefined } | undefined> { + const subject = positional?.trim(); + if (!subject) { + return undefined; + } + return { + ref: await resolveProjectSubject(ctx.api, subject, ctx.signal), + address: isAddress(subject) ? subject : undefined, + }; +} + /** Explicit `--project`, else the `.prisma/local.json` pin, else the * setup-required error. An absent `commandName` makes that error read * "this command" and drops its retry step — `branch list`'s legacy diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index b2028e64..19c1babb 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -9,11 +9,6 @@ import { import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; import { cleanupLocalPinForProject } from "../../controllers/project"; -import { - consentToken, - isNodeAddress, - resolveProjectSubject, -} from "../../lib/address/node-address"; import { createManagementProjectProvider } from "../../lib/project/provider"; import { resolveProjectForSetup, @@ -21,11 +16,20 @@ import { } from "../../lib/project/setup"; import type { ProjectDeleteResult } from "../../types/project"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; -import { listWorkspaceProjects, operationContext } from "./context"; +import { + listWorkspaceProjects, + operationContext, + resolveProjectPositional, +} from "./context"; import { localPinDiagnostics } from "./presentation"; -function consentQuestion(subject: string): string { - return `Deleting a project is permanent, destroys its databases, and stops its apps, so it requires the exact project ${isNodeAddress(subject) ? "address" : "id"}.`; +function consentQuestion( + project: { id: string; name: string }, + address: string | undefined, +): string { + return address + ? `Deleting Project "${project.name}" (${project.id}) is permanent, destroys its databases on every branch, and stops its apps, so it requires the exact project address.` + : "Deleting a project is permanent, destroys its databases, and stops its apps, so it requires the exact project id."; } function deletePresentations(result: ProjectDeleteResult): Presentations { @@ -60,13 +64,14 @@ export const projectDeleteCommand = defineCommand({ args: { positionals: { project: positional.string({ - brief: "Project id or name, or its deploy address such as @shop", + brief: + "Project id or name, or its address: @ and the app name, such as @shop", placeholder: "id-or-name", }), }, }, help: { - summary: "Delete a Project permanently after exact id confirmation", + summary: "Delete a Project permanently after exact confirmation", description: "Deletion is permanent: it destroys the project's databases and stops its deployed services. Because of that, the command asks for the exact project id as a consent token, or the address when you pass one; pass it with --confirm to run non-interactively.", examples: [ @@ -78,18 +83,16 @@ export const projectDeleteCommand = defineCommand({ handler: async (args, ctx) => { const workspace = await resolveActiveWorkspace(ctx); const projects = await listWorkspaceProjects(ctx); - const subject = args.positionals.project.trim(); - const projectRef = await resolveProjectSubject( - ctx.api, - subject, - ctx.signal, + const subject = await resolveProjectPositional( + ctx, + args.positionals.project, ); const project = toProjectSummary( - resolveProjectForSetup(projectRef, projects, workspace), + resolveProjectForSetup(subject?.ref ?? "", projects, workspace), ); - await ctx.prompt.consent(consentQuestion(subject), { - token: consentToken(subject, project.id), + await ctx.prompt.consent(consentQuestion(project, subject?.address), { + token: subject?.address ?? project.id, }); await createManagementProjectProvider(ctx.api).removeProject({ diff --git a/packages/cli/src/commands/project/link.ts b/packages/cli/src/commands/project/link.ts index 50de77f7..68380f00 100644 --- a/packages/cli/src/commands/project/link.ts +++ b/packages/cli/src/commands/project/link.ts @@ -2,7 +2,6 @@ import { defineCommand, positional } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { formatCommandArgument } from "../../command-arguments"; -import { resolveProjectSubject } from "../../lib/address/node-address"; import { createAppProvider } from "../../lib/app/app-provider"; import { inferTargetName, @@ -23,6 +22,7 @@ import { bindDirectoryToProject, listWorkspaceProjects, type ProjectCommandContext, + resolveProjectPositional, } from "./context"; import { setupPresentations } from "./presentation"; @@ -159,10 +159,7 @@ async function linkDirectoryToProject( ): Promise { const workspace = await resolveActiveWorkspace(ctx); const projects = await listWorkspaceProjects(ctx); - const trimmed = projectRef?.trim(); - const ref = trimmed - ? await resolveProjectSubject(ctx.api, trimmed, ctx.signal) - : undefined; + const ref = (await resolveProjectPositional(ctx, projectRef))?.ref; return ref ? await bindDirectoryToProject( @@ -178,7 +175,8 @@ export const projectLinkCommand = defineCommand({ args: { positionals: { project: positional.optionalString({ - brief: "Project id or name, or its deploy address such as @shop", + brief: + "Project id or name, or its address: @ and the app name, such as @shop", placeholder: "id-or-name", }), }, diff --git a/packages/cli/src/commands/project/show.ts b/packages/cli/src/commands/project/show.ts index 593039f2..e4c6e2ff 100644 --- a/packages/cli/src/commands/project/show.ts +++ b/packages/cli/src/commands/project/show.ts @@ -5,7 +5,6 @@ import { positional, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { resolveProjectSubject } from "../../lib/address/node-address"; import { shortenHomePath } from "../../lib/fs/home-path"; import { buildProjectSetupNextActions, @@ -15,7 +14,11 @@ import { import { fieldLines } from "../../output/data-lines"; import type { ProjectShowResult } from "../../types/project"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; -import { listWorkspaceProjects, operationContext } from "./context"; +import { + listWorkspaceProjects, + operationContext, + resolveProjectPositional, +} from "./context"; interface FieldRow { readonly label: string; @@ -113,7 +116,7 @@ export const projectShowCommand = defineCommand({ positionals: { project: positional.optionalString({ brief: - "Project id or name, or its deploy address such as @shop (default: the project this directory is linked to)", + "Project id or name, or its address: @ and the app name, such as @shop (default: the project this directory is linked to)", placeholder: "id-or-name", }), }, @@ -134,14 +137,9 @@ export const projectShowCommand = defineCommand({ const inspected = await inspectProjectBinding({ context: operationContext(ctx), workspace, - explicitProject: - args.positionals.project === undefined - ? undefined - : await resolveProjectSubject( - ctx.api, - args.positionals.project, - ctx.signal, - ), + explicitProject: ( + await resolveProjectPositional(ctx, args.positionals.project) + )?.ref, listProjects: () => listWorkspaceProjects(ctx), commandName: "project show", }); diff --git a/packages/cli/src/commands/project/transfer.ts b/packages/cli/src/commands/project/transfer.ts index 7915c14b..84002a02 100644 --- a/packages/cli/src/commands/project/transfer.ts +++ b/packages/cli/src/commands/project/transfer.ts @@ -26,11 +26,6 @@ import { transferRecipientRequiredError, transferRecipientUnavailableError, } from "../../controllers/project"; -import { - consentToken, - isNodeAddress, - resolveProjectSubject, -} from "../../lib/address/node-address"; import type { PrismaCliPackageCommandFormatter } from "../../lib/agent/cli-command"; import { createManagementProjectProvider } from "../../lib/project/provider"; import { @@ -43,11 +38,17 @@ import { listWorkspaceProjects, operationContext, type ProjectCommandContext, + resolveProjectPositional, } from "./context"; import { localPinDiagnostics } from "./presentation"; -function consentQuestion(subject: string): string { - return `Transferring moves the project to another workspace and this workspace loses access, so it requires the exact project ${isNodeAddress(subject) ? "address" : "id"}.`; +function consentQuestion( + project: { id: string; name: string }, + address: string | undefined, +): string { + return address + ? `Transferring moves Project "${project.name}" (${project.id}) to another workspace and this workspace loses access, so it requires the exact project address.` + : "Transferring moves the project to another workspace and this workspace loses access, so it requires the exact project id."; } /** This CLI's command strings are `${CLI_NAME} …`; the legacy package-runner @@ -180,7 +181,8 @@ export const projectTransferCommand = defineCommand({ args: { positionals: { project: positional.string({ - brief: "Project id or name, or its deploy address such as @shop", + brief: + "Project id or name, or its address: @ and the app name, such as @shop", placeholder: "id-or-name", }), }, @@ -198,8 +200,7 @@ export const projectTransferCommand = defineCommand({ }, }, help: { - summary: - "Transfer a Project to another workspace after exact id confirmation", + summary: "Transfer a Project to another workspace after exact confirmation", description: "Moves a project, with everything in it, out of the current workspace. Name the receiving workspace with --to-workspace when you are logged in to it too, or pass --recipient-token when someone else owns it. The exact project id is the consent token, or the address when you pass one.", examples: [ @@ -246,18 +247,16 @@ export const projectTransferCommand = defineCommand({ } const projects = await listWorkspaceProjects(ctx); - const subject = args.positionals.project.trim(); - const projectRef = await resolveProjectSubject( - ctx.api, - subject, - ctx.signal, + const subject = await resolveProjectPositional( + ctx, + args.positionals.project, ); const project = toProjectSummary( - resolveProjectForSetup(projectRef, projects, workspace), + resolveProjectForSetup(subject?.ref ?? "", projects, workspace), ); - await ctx.prompt.consent(consentQuestion(subject), { - token: consentToken(subject, project.id), + await ctx.prompt.consent(consentQuestion(project, subject?.address), { + token: subject?.address ?? project.id, }); const recipient = await resolveRecipient(ctx, { diff --git a/packages/cli/src/commands/service/delete.ts b/packages/cli/src/commands/service/delete.ts index c81491f6..0ff63898 100644 --- a/packages/cli/src/commands/service/delete.ts +++ b/packages/cli/src/commands/service/delete.ts @@ -1,6 +1,6 @@ import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { consentToken } from "../../lib/address/node-address"; +import { describeEnvironment } from "../../lib/address/address"; import { deleteFailedError, userCancelledError } from "./errors"; import { deletePresentations } from "./presentation"; import { destroyProgressReporter } from "./release"; @@ -46,9 +46,12 @@ export const serviceDeleteCommand = defineCommand({ commandName: "service delete", }); + const where = state.address + ? ` (${state.service.id}) ${describeEnvironment(state.address)}` + : ""; const granted = await ctx.prompt.consent( - `Delete Service "${state.service.name}" and every version it owns?`, - { token: consentToken(args.positionals.service, state.service.name) }, + `Delete Service "${state.service.name}"${where} and every version it owns?`, + { token: state.address?.text ?? state.service.name }, ); // A token consent resolves to true or throws (mismatch, or the // engine's consent-required error), so this guard only fires if that diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index dbdb1ed7..3e83c198 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -1,5 +1,8 @@ import { type CommandContext, positional } from "@prisma/cli-engine"; -import { resolveScopedSubject } from "../../lib/address/node-address"; +import { + type AddressMatch, + resolveBranchScopedSubject, +} from "../../lib/address/address"; import { type AppProvider, type AppRecord, @@ -48,7 +51,7 @@ const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; /** The subject of every command that acts on one existing service. */ export const servicePositional = positional.optionalString({ brief: - "Service id or name, or its deploy address: @shop.api (default branch) or @pr-42:shop.api (branch pr-42)", + "Service id or name, or its address: @shop.api (production) or @pr-42:shop.api (environment pr-42)", placeholder: "service", }); @@ -259,10 +262,11 @@ export function requireServiceArgument( serviceRef: string | undefined, commandName: string, ): string { - if (!serviceRef) { + const trimmed = serviceRef?.trim(); + if (!trimmed) { throw serviceTargetRequiredError(commandName); } - return serviceRef; + return trimmed; } /** Matches the service argument against the branch's services: the @@ -465,6 +469,8 @@ interface ServiceProjectState { export interface ServiceReadState extends ServiceProjectState { service: AppRecord; + /** Set when the service was named by an address. */ + address: AddressMatch | undefined; } /** Project + branch resolution, before the service match. */ @@ -503,12 +509,12 @@ export async function resolveServiceReadState( options.serviceName, options.commandName, ); - const subject = await resolveScopedSubject( + const subject = await resolveBranchScopedSubject( ctx.api, "service", requested, - { project: options.projectRef, branch: options.branchName }, ctx.signal, + { project: options.projectRef, branch: options.branchName }, ); const state = await resolveServiceProjectState(ctx, { commandName: options.commandName, @@ -522,7 +528,7 @@ export async function resolveServiceReadState( state.target.branch.name, ); const service = matchRequestedService(subject.ref, services, state.projectId); - return { ...state, service }; + return { ...state, service, address: subject.match }; } export interface ResolvedServiceDomainTarget { diff --git a/packages/cli/src/lib/address/node-address.ts b/packages/cli/src/lib/address/address.ts similarity index 53% rename from packages/cli/src/lib/address/node-address.ts rename to packages/cli/src/lib/address/address.ts index f17be592..3ca33313 100644 --- a/packages/cli/src/lib/address/node-address.ts +++ b/packages/cli/src/lib/address/address.ts @@ -1,9 +1,10 @@ /** - * Node addresses as command subjects: `@:
`, the name - * Prisma Composer gives every node it deploys (`@pr-42:shop.api`, - * `@shop.catalog`, `@shop`). The first `.`-separated segment of the - * address is the app, which is the Project's `logicalId`; the full - * address is the service, database or bucket row's `logicalId`. + * Addresses as command subjects. Prisma Composer names everything it + * deploys with an address such as `shop.api`, whose first `.`-separated + * part is the app. A subject names one by `@`, an optional environment + * and `:`, then the address: `@pr-42:shop.api`, `@shop.catalog`, + * `@shop`. The app is the Project's `logicalId`; the full address is the + * service, database or bucket row's `logicalId`. */ import { CliStructuredError, @@ -12,6 +13,7 @@ import { import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { CLI_NAME } from "../../cli-name"; import { formatCommandArgument } from "../../command-arguments"; +import { listProjectBranches } from "../branch/list"; const PRODUCTION_ENVIRONMENT = "production"; const WHITESPACE = /\s/; @@ -19,19 +21,25 @@ const WHITESPACE = /\s/; const DEPLOY_LIST_COMMAND = `${CLI_NAME} deploy list`; const BRANCH_LIST_COMMAND = `${CLI_NAME} branch list`; -export type NodeKind = "service" | "database" | "bucket"; +/** The kinds that live on one Branch. A Project spans every Branch. */ +export type BranchScopedKind = "service" | "database" | "bucket"; -const NODE_KINDS: readonly NodeKind[] = ["service", "database", "bucket"]; +const BRANCH_SCOPED_KINDS: readonly BranchScopedKind[] = [ + "service", + "database", + "bucket", +]; -/** The command group that acts on each kind. */ -const KIND_GROUP: Readonly> = { +const KIND_GROUP: Readonly> = { service: "service", database: "postgres", bucket: "bucket", }; -export interface NodeAddress { - /** The subject exactly as typed, `@` included. */ +/** A subject written as `@`, an optional environment and `:`, then an + * address. */ +export interface AddressSubject { + /** The subject as typed, `@` included. */ readonly text: string; /** The environment before `:`; undefined when the subject names none. */ readonly environment: string | undefined; @@ -39,49 +47,49 @@ export interface NodeAddress { readonly appName: string; } -export interface AddressedProject { +/** The service, database or bucket row an address subject names. */ +export interface AddressMatch { readonly id: string; - readonly name: string; -} - -export interface AddressedBranch { - readonly id: string; - readonly gitName: string; -} - -export interface AddressedNode { - readonly id: string; - readonly project: AddressedProject; - readonly branch: AddressedBranch; + /** The subject as typed, `@` included. */ + readonly text: string; + /** The environment named, `production` when none was. */ + readonly environment: string; + readonly project: { readonly id: string; readonly name: string }; + readonly branch: { + readonly id: string; + readonly gitName: string; + readonly isDefault: boolean; + }; } /** The `--project` and `--branch` values a command was given. */ -export interface SubjectScope { +export interface ScopeFlags { readonly project?: string | undefined; readonly branch?: string | undefined; } -/** What the command acts on: the subject and scope flags as typed, or, - * for an address, the row id with the Project id and Branch git name - * the address names. */ -export interface ScopedSubject { +/** What a command acts on: the subject and scope flags as typed, or, for + * an address, the row id with the Project id and Branch git name the + * address names, plus the match itself. */ +export interface ResolvedSubject { readonly ref: string; readonly project: string | undefined; readonly branch: string | undefined; + readonly match: AddressMatch | undefined; } type Api = Pick; -export function isNodeAddress(subject: string): boolean { +export function isAddress(subject: string): boolean { return subject.startsWith("@"); } -export function parseNodeAddress(subject: string): NodeAddress { - if (!isNodeAddress(subject)) { - throw malformedAddressError(subject, "An address starts with '@'."); +export function parseAddress(subject: string): AddressSubject { + if (!isAddress(subject)) { + throw malformedAddressError(subject, "It does not start with '@'."); } if (WHITESPACE.test(subject)) { - throw malformedAddressError(subject, "An address contains no spaces."); + throw malformedAddressError(subject, "It contains a space."); } const body = subject.slice(1); const colon = body.indexOf(":"); @@ -94,125 +102,143 @@ export function parseNodeAddress(subject: string): NodeAddress { ); } if (address === "") { - throw malformedAddressError(subject, "The address after '@' is empty."); + throw malformedAddressError(subject, "The address is empty."); } const segments = address.split("."); if (segments.some((segment) => segment === "")) { throw malformedAddressError( subject, - `"${address}" has an empty part between dots.`, + `The address "${address}" has an empty part between dots.`, ); } return { text: subject, environment, address, appName: segments[0] ?? "" }; } -/** A destructive command's consent token is the subject as typed when - * it is an address; otherwise the command's own token. */ -export function consentToken( - subject: string | undefined, - fallback: string, -): string { - return subject !== undefined && isNodeAddress(subject) ? subject : fallback; +/** Where a match lives, for a consent question: `in production (branch + * main)` or `in environment pr-42 (branch pr-42)`. */ +export function describeEnvironment(match: AddressMatch): string { + const branch = `(branch ${match.branch.gitName})`; + if (match.environment === PRODUCTION_ENVIRONMENT) { + return `in production ${branch}`; + } + if (match.branch.isDefault) { + return `in environment ${match.environment}, which is production ${branch}`; + } + return `in environment ${match.environment} ${branch}`; } -/** The Project an address names. It must be the app name alone, with no - * environment: a Project spans every environment, so `@pr-42:shop` - * must never reach a command that would act on all of them. */ +/** The Project an address subject names. It must be the app name alone, + * with no environment: a Project spans every environment, so + * `@pr-42:shop` must never reach a command that acts on all of them. */ export async function resolveProjectAddress( api: Api, subject: string, signal: AbortSignal, -): Promise { - const parsed = parseNodeAddress(subject); +): Promise<{ readonly id: string; readonly name: string }> { + const parsed = parseAddress(subject); if (parsed.environment !== undefined) { throw projectEnvironmentError(parsed); } if (parsed.address !== parsed.appName) { - throw projectNodeAddressError(parsed); + throw projectGivenRowAddressError(parsed); } return findProject(api, parsed, signal); } -/** The service, database or bucket row an address names. */ -export async function resolveNodeAddress( +/** The service, database or bucket row an address subject names. */ +export async function resolveBranchScopedAddress( api: Api, - kind: NodeKind, + kind: BranchScopedKind, subject: string, signal: AbortSignal, -): Promise { - const parsed = parseNodeAddress(subject); +): Promise { + const parsed = parseAddress(subject); if (parsed.address === parsed.appName) { throw addressNamesProjectError(parsed, kind); } const project = await findProject(api, parsed, signal); const branch = await findBranch(api, parsed, project, signal); - const rows = await listNodes(api, kind, parsed, project, branch, signal); + const rows = await listRows(api, kind, parsed, project.id, branch.id, signal); if (rows.length > 1) { - throw ambiguousError(parsed, kind, rows); + throw rowsAmbiguousError(parsed, kind, rows); } const [row] = rows; if (row !== undefined) { - return { id: row.id, project, branch }; + return { + id: row.id, + text: parsed.text, + environment: parsed.environment ?? PRODUCTION_ENVIRONMENT, + project, + branch, + }; } - for (const otherKind of NODE_KINDS.filter((other) => other !== kind)) { + for (const otherKind of BRANCH_SCOPED_KINDS.filter( + (other) => other !== kind, + )) { // biome-ignore lint/performance/noAwaitInLoops: the first kind that holds the address answers; there is no reason to ask about the next one. - const others = await listNodes( + const others = await listRows( api, otherKind, parsed, - project, - branch, + project.id, + branch.id, signal, ); if (others.length > 0) { throw wrongKindError(parsed, kind, otherKind); } } - throw nodeNotFoundError(parsed, kind, project, branch); + throw rowNotFoundError(parsed, kind, project, branch); } /** The hand-over: a subject that is an address becomes the row id, the * Project id and the Branch git name it names, exactly what the user * could have typed instead. Any other subject passes through with the * flags it came with. */ -export async function resolveScopedSubject( +export async function resolveBranchScopedSubject( api: Api, - kind: NodeKind, + kind: BranchScopedKind, subject: string, - scope: SubjectScope, signal: AbortSignal, -): Promise { - if (!isNodeAddress(subject)) { - return { ref: subject, project: scope.project, branch: scope.branch }; + flags: ScopeFlags = {}, +): Promise { + if (!isAddress(subject)) { + return { + ref: subject, + project: flags.project, + branch: flags.branch, + match: undefined, + }; } - rejectScopeFlags(subject, scope); - const node = await resolveNodeAddress(api, kind, subject, signal); + rejectScopeFlags(subject, flags); + const match = await resolveBranchScopedAddress(api, kind, subject, signal); return { - ref: node.id, - project: node.project.id, - branch: node.branch.gitName, + ref: match.id, + project: match.project.id, + branch: match.branch.gitName, + match, }; } -/** The Project id an address names, or the subject unchanged. */ +/** The Project id an address subject names, or the subject unchanged. */ export async function resolveProjectSubject( api: Api, subject: string, signal: AbortSignal, ): Promise { - if (!isNodeAddress(subject)) { + if (!isAddress(subject)) { return subject; } const project = await resolveProjectAddress(api, subject, signal); return project.id; } -function rejectScopeFlags(subject: string, scope: SubjectScope): void { +function rejectScopeFlags(subject: string, flags: ScopeFlags): void { const given = [ - ...(scope.project !== undefined ? ["--project"] : []), - ...(scope.branch !== undefined ? ["--branch"] : []), + ...(flags.project !== undefined ? ["--project"] : []), + ...(flags.branch !== undefined ? ["--branch"] : []), ]; if (given.length === 0) { return; @@ -282,30 +308,27 @@ interface LogicalRow { async function findProject( api: Api, - address: NodeAddress, + subject: AddressSubject, signal: AbortSignal, -): Promise { +): Promise { const projects = await listAllPages( "Failed to look up the app's Project", (cursor) => api.GET("/v1/projects", { - params: { query: { logicalId: address.appName, cursor } }, + params: { query: { logicalId: subject.appName, cursor } }, signal, }), ); const matches = projects.filter( - (project) => project.logicalId === address.appName, + (project) => project.logicalId === subject.appName, ); if (matches.length > 1) { throw new CliStructuredError( "ADDRESS.AMBIGUOUS", - `Several Projects are named "${address.appName}"`, + `Several Projects are named "${subject.appName}"`, { - why: `"${address.text}" names app "${address.appName}", and more than one Project in this workspace has it.`, - meta: { - address: address.text, - projectIds: matches.map((project) => project.id), - }, + why: `"${subject.text}" names app "${subject.appName}", and more than one Project in this workspace has it.`, + meta: { address: subject.text, matches: idsAndNames(matches) }, nextActions: [ userChoice( `Pass a Project id instead of the address: ${matches.map((project) => project.id).join(", ")}.`, @@ -318,10 +341,10 @@ async function findProject( if (project === undefined) { throw new CliStructuredError( "ADDRESS.NOT_FOUND", - `No Project for app "${address.appName}"`, + `No Project for app "${subject.appName}"`, { - why: `"${address.text}" names app "${address.appName}", and no Project in this workspace was deployed for it.`, - meta: { address: address.text, app: address.appName }, + why: `"${subject.text}" names app "${subject.appName}", and no Project in this workspace was deployed for it.`, + meta: { address: subject.text, app: subject.appName }, nextActions: [ userChoice( "Check the app name, or that you are signed in to the workspace the app was deployed to.", @@ -334,72 +357,66 @@ async function findProject( return { id: project.id, name: project.name }; } -interface BranchRow { - readonly id: string; - readonly gitName: string; - readonly isDefault: boolean; -} - +/** No environment, or `production`, is the Project's default Branch; + * any other environment is the Branch with that git name. */ async function findBranch( api: Api, - address: NodeAddress, - project: AddressedProject, + subject: AddressSubject, + project: AddressMatch["project"], signal: AbortSignal, -): Promise { - const branches = await listAllPages( - "Failed to list the Project's branches", - (cursor) => - api.GET("/v1/projects/{projectId}/branches", { - params: { path: { projectId: project.id }, query: { cursor } }, - signal, - }), - ); - const environment = address.environment ?? PRODUCTION_ENVIRONMENT; - const branch = - environment === PRODUCTION_ENVIRONMENT - ? branches.find((candidate) => candidate.isDefault) - : branches.find((candidate) => candidate.gitName === environment); +): Promise { + const environment = subject.environment ?? PRODUCTION_ENVIRONMENT; + const production = environment === PRODUCTION_ENVIRONMENT; + const branches = await listProjectBranches(api, { + projectId: project.id, + gitName: production ? undefined : environment, + signal, + }); + const branch = production + ? branches.find((candidate) => candidate.isDefault) + : branches.find((candidate) => candidate.gitName === environment); if (branch === undefined) { - const listCommand = `${BRANCH_LIST_COMMAND} --project ${project.id}`; throw new CliStructuredError( "ADDRESS.NOT_FOUND", `No branch for environment "${environment}"`, { - why: - environment === PRODUCTION_ENVIRONMENT - ? `Project "${project.name}" (${project.id}) has no default branch, which is where production lives.` - : `Project "${project.name}" (${project.id}) has no branch whose git name is "${environment}".`, + why: production + ? `Project "${project.name}" (${project.id}) has no default branch. Production is the default branch.` + : `Project "${project.name}" (${project.id}) has no branch whose git name is "${environment}".`, meta: { - address: address.text, + address: subject.text, environment, projectId: project.id, }, nextActions: [ - userChoice("Use an environment from the Project's branch list."), - runCommand(listCommand), + userChoice( + "Use an environment from the Project's branch list: a branch's git name, or production for the default branch.", + ), + runCommand(`${BRANCH_LIST_COMMAND} --project ${project.id}`), ], }, ); } - return { id: branch.id, gitName: branch.gitName }; + return { + id: branch.id, + gitName: branch.gitName, + isDefault: branch.isDefault, + }; } -async function listNodes( +/** The API filters by `logicalId` and `branchId`; the rows are checked + * again here so an API that ignored a filter cannot widen the match. */ +async function listRows( api: Api, - kind: NodeKind, - address: NodeAddress, - project: AddressedProject, - branch: AddressedBranch, + kind: BranchScopedKind, + subject: AddressSubject, + projectId: string, + branchId: string, signal: AbortSignal, ): Promise { - const filters = { - projectId: project.id, - logicalId: address.address, - branchId: branch.id, - }; - const summary = `Failed to look up ${kind}s by address`; + const filters = { projectId, logicalId: subject.address, branchId }; const rows = await listAllPages( - summary, + `Failed to look up ${kind}s by address`, (cursor) => { const init = { params: { query: { ...filters, cursor } }, signal }; switch (kind) { @@ -413,10 +430,16 @@ async function listNodes( }, ); return rows.filter( - (row) => row.logicalId === address.address && row.branchId === branch.id, + (row) => row.logicalId === subject.address && row.branchId === branchId, ); } +function idsAndNames( + rows: readonly LogicalRow[], +): { id: string; name: string }[] { + return rows.map((row) => ({ id: row.id, name: row.name })); +} + function malformedAddressError( subject: string, why: string, @@ -425,7 +448,7 @@ function malformedAddressError( "ADDRESS.USAGE_ERROR", `"${subject}" is not a valid address`, { - why: `${why} An address is @
for production, or @:
, such as @shop.api or @pr-42:shop.api.`, + why: `${why} Name a deployed resource with @, then an optional environment and ':', then its address, such as @shop.api for production or @pr-42:shop.api.`, meta: { address: subject }, nextActions: [ userChoice( @@ -437,30 +460,36 @@ function malformedAddressError( ); } -function projectEnvironmentError(address: NodeAddress): CliStructuredError { - const projectAddress = `@${address.appName}`; +function projectEnvironmentError(subject: AddressSubject): CliStructuredError { + const projectAddress = `@${subject.appName}`; return new CliStructuredError( - "ADDRESS.WRONG_KIND", - `"${address.text}" names one environment, not a Project`, + "ADDRESS.USAGE_ERROR", + `A Project's address takes no environment: "${subject.text}"`, { - why: `A Project spans every environment of app "${address.appName}", so its address carries none. Write ${projectAddress}.`, - meta: { address: address.text, expected: "project" }, + why: `A Project spans every environment of app "${subject.appName}", so naming one environment would still act on all of them. Write ${projectAddress}.`, + meta: { address: subject.text }, nextActions: [userChoice(`Pass ${projectAddress} to name the Project.`)], }, ); } -function projectNodeAddressError(address: NodeAddress): CliStructuredError { - const projectAddress = `@${address.appName}`; +function projectGivenRowAddressError( + subject: AddressSubject, +): CliStructuredError { + const projectAddress = `@${subject.appName}`; return new CliStructuredError( "ADDRESS.WRONG_KIND", - `"${address.text}" names a node inside app "${address.appName}", not its Project`, + `"${subject.text}" names something inside app "${subject.appName}", not its Project`, { - why: `A Project's address is the app name alone, ${projectAddress}. "${address.address}" names a service, database or bucket inside it.`, - meta: { address: address.text, expected: "project" }, + why: `A Project's address is the app name alone, ${projectAddress}. "${subject.address}" names a service, database or bucket inside it.`, + meta: { + address: subject.text, + expected: "project", + actual: "service, database or bucket", + }, nextActions: [ userChoice( - `Pass ${projectAddress} to name the Project, or use a service, postgres or bucket command for "${address.text}".`, + `Pass ${projectAddress} to name the Project, or use a service, postgres or bucket command for "${subject.text}".`, ), ], }, @@ -468,16 +497,16 @@ function projectNodeAddressError(address: NodeAddress): CliStructuredError { } function addressNamesProjectError( - address: NodeAddress, - kind: NodeKind, + subject: AddressSubject, + kind: BranchScopedKind, ): CliStructuredError { - const projectCommand = `${CLI_NAME} project show @${address.appName}`; + const projectCommand = `${CLI_NAME} project show @${subject.appName}`; return new CliStructuredError( "ADDRESS.WRONG_KIND", - `"${address.text}" names the app's Project, not a ${kind}`, + `"${subject.text}" names the app's Project, not a ${kind}`, { - why: `The app name alone, "${address.appName}", is the Project's address. A ${kind}'s address continues past it, such as @${address.appName}..`, - meta: { address: address.text, expected: kind, actual: "project" }, + why: `The app name alone, "${subject.appName}", is the Project's address. A ${kind}'s address continues past it, such as @${subject.appName}..`, + meta: { address: subject.text, expected: kind, actual: "project" }, nextActions: [ userChoice(`Name the ${kind} inside the app, or inspect the Project.`), runCommand(projectCommand), @@ -487,40 +516,39 @@ function addressNamesProjectError( } function wrongKindError( - address: NodeAddress, - expected: NodeKind, - actual: NodeKind, + subject: AddressSubject, + expected: BranchScopedKind, + actual: BranchScopedKind, ): CliStructuredError { - const command = `${CLI_NAME} ${KIND_GROUP[actual]} --help`; return new CliStructuredError( "ADDRESS.WRONG_KIND", - `"${address.text}" is a ${actual}, not a ${expected}`, + `"${subject.text}" is a ${actual}, not a ${expected}`, { why: `The address names a ${actual}. The ${KIND_GROUP[actual]} commands act on it.`, - meta: { address: address.text, expected, actual }, + meta: { address: subject.text, expected, actual }, nextActions: [ userChoice( - `Use a '${CLI_NAME} ${KIND_GROUP[actual]}' command for ${formatCommandArgument(address.text)}.`, + `Use a '${CLI_NAME} ${KIND_GROUP[actual]}' command for ${formatCommandArgument(subject.text)}.`, ), - runCommand(command), + runCommand(`${CLI_NAME} ${KIND_GROUP[actual]} --help`), ], }, ); } -function nodeNotFoundError( - address: NodeAddress, - kind: NodeKind, - project: AddressedProject, - branch: AddressedBranch, +function rowNotFoundError( + subject: AddressSubject, + kind: BranchScopedKind, + project: AddressMatch["project"], + branch: AddressMatch["branch"], ): CliStructuredError { return new CliStructuredError( "ADDRESS.NOT_FOUND", - `Nothing is deployed at "${address.text}"`, + `Nothing is deployed at "${subject.text}"`, { - why: `No service, database or bucket on branch "${branch.gitName}" of Project "${project.name}" (${project.id}) has address "${address.address}".`, + why: `No service, database or bucket on branch "${branch.gitName}" of Project "${project.name}" (${project.id}) has address "${subject.address}".`, meta: { - address: address.text, + address: subject.text, expected: kind, projectId: project.id, branchId: branch.id, @@ -535,20 +563,17 @@ function nodeNotFoundError( ); } -function ambiguousError( - address: NodeAddress, - kind: NodeKind, +function rowsAmbiguousError( + subject: AddressSubject, + kind: BranchScopedKind, rows: readonly LogicalRow[], ): CliStructuredError { return new CliStructuredError( "ADDRESS.AMBIGUOUS", - `Several ${kind} rows have address "${address.text}"`, + `Several ${kind} rows have address "${subject.text}"`, { - why: `More than one ${kind} on the branch carries address "${address.address}", so the address does not pick one.`, - meta: { - address: address.text, - matches: rows.map((row) => ({ id: row.id, name: row.name })), - }, + why: `More than one ${kind} on the branch carries address "${subject.address}", so the address does not pick one.`, + meta: { address: subject.text, matches: idsAndNames(rows) }, nextActions: [ userChoice( `Pass one of the ids instead: ${rows.map((row) => row.id).join(", ")}.`, diff --git a/packages/cli/tests/node-address.test.ts b/packages/cli/tests/address.test.ts similarity index 71% rename from packages/cli/tests/node-address.test.ts rename to packages/cli/tests/address.test.ts index 8d56dbdf..388d7f92 100644 --- a/packages/cli/tests/node-address.test.ts +++ b/packages/cli/tests/address.test.ts @@ -2,14 +2,15 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { describe, expect, it } from "vitest"; import { - consentToken, - isNodeAddress, - parseNodeAddress, - resolveNodeAddress, + type AddressMatch, + describeEnvironment, + isAddress, + parseAddress, + resolveBranchScopedAddress, + resolveBranchScopedSubject, resolveProjectAddress, resolveProjectSubject, - resolveScopedSubject, -} from "../src/lib/address/node-address"; +} from "../src/lib/address/address"; import { type AddressFixture, addressListing, @@ -125,9 +126,9 @@ function fakeApi( const signal = new AbortController().signal; -describe("parseNodeAddress", () => { +describe("parseAddress", () => { it("reads an address with no environment as production", () => { - expect(parseNodeAddress("@shop.api")).toEqual({ + expect(parseAddress("@shop.api")).toEqual({ text: "@shop.api", environment: undefined, address: "shop.api", @@ -136,7 +137,7 @@ describe("parseNodeAddress", () => { }); it("splits the environment off at the first colon", () => { - expect(parseNodeAddress("@pr-42:shop.api.v2")).toEqual({ + expect(parseAddress("@pr-42:shop.api.v2")).toEqual({ text: "@pr-42:shop.api.v2", environment: "pr-42", address: "shop.api.v2", @@ -145,7 +146,7 @@ describe("parseNodeAddress", () => { }); it("reads the bare app name as the address of the app", () => { - expect(parseNodeAddress("@shop")).toMatchObject({ + expect(parseAddress("@shop")).toMatchObject({ address: "shop", appName: "shop", }); @@ -160,7 +161,7 @@ describe("parseNodeAddress", () => { ["@.api", "an empty app name"], ["@shop.", "a trailing dot"], ])("refuses %s (%s) as a usage error", (subject) => { - expect(() => parseNodeAddress(subject)).toThrow( + expect(() => parseAddress(subject)).toThrow( expect.objectContaining({ code: "ADDRESS.USAGE_ERROR", meta: { address: subject }, @@ -169,35 +170,70 @@ describe("parseNodeAddress", () => { }); it("tells an address from an id or name by its leading @", () => { - expect(isNodeAddress("@shop.api")).toBe(true); - expect(isNodeAddress("db_catalog_main")).toBe(false); - expect(isNodeAddress("shop.api")).toBe(false); + expect(isAddress("@shop.api")).toBe(true); + expect(isAddress("db_catalog_main")).toBe(false); + expect(isAddress("shop.api")).toBe(false); }); }); -describe("consentToken", () => { - it("is the address as typed when the subject is an address", () => { - expect(consentToken("@pr-42:shop.catalog", "db_catalog_pr42")).toBe( - "@pr-42:shop.catalog", - ); +describe("describeEnvironment", () => { + const match = ( + environment: string, + branch: AddressMatch["branch"], + ): AddressMatch => ({ + id: "db_catalog", + text: "@shop.catalog", + environment, + project: { id: "proj_shop", name: "Shop" }, + branch, + }); + + it("says production when the address names no environment", () => { + expect( + describeEnvironment( + match("production", { + id: "br_main", + gitName: "main", + isDefault: true, + }), + ), + ).toBe("in production (branch main)"); + }); + + it("names the environment and its branch otherwise", () => { + expect( + describeEnvironment( + match("pr-42", { id: "br_pr42", gitName: "pr-42", isDefault: false }), + ), + ).toBe("in environment pr-42 (branch pr-42)"); }); - it("is the command's own token for an id or name", () => { - expect(consentToken("catalog", "db_catalog_main")).toBe("db_catalog_main"); - expect(consentToken(undefined, "db_catalog_main")).toBe("db_catalog_main"); + it("says production when the named environment is the default branch", () => { + expect( + describeEnvironment( + match("main", { id: "br_main", gitName: "main", isDefault: true }), + ), + ).toBe("in environment main, which is production (branch main)"); }); }); -describe("resolveNodeAddress", () => { +describe("resolveBranchScopedAddress", () => { it("finds the row on the default branch when the address names no environment", async () => { const { api, calls } = fakeApi(); - const node = await resolveNodeAddress(api, "service", "@shop.api", signal); + const node = await resolveBranchScopedAddress( + api, + "service", + "@shop.api", + signal, + ); expect(node).toEqual({ id: "svc_api_main", + text: "@shop.api", + environment: "production", project: { id: "proj_shop", name: "Shop" }, - branch: { id: "br_main", gitName: "main" }, + branch: { id: "br_main", gitName: "main", isDefault: true }, }); expect(calls).toEqual([ { path: "/v1/projects", query: { logicalId: "shop" } }, @@ -216,7 +252,7 @@ describe("resolveNodeAddress", () => { it("maps the production environment to the default branch, not a branch named production", async () => { const { api } = fakeApi(); - const node = await resolveNodeAddress( + const node = await resolveBranchScopedAddress( api, "service", "@production:shop.api", @@ -224,13 +260,17 @@ describe("resolveNodeAddress", () => { ); expect(node.id).toBe("svc_api_main"); - expect(node.branch).toEqual({ id: "br_main", gitName: "main" }); + expect(node.branch).toEqual({ + id: "br_main", + gitName: "main", + isDefault: true, + }); }); - it("finds the row on the branch whose git name is the environment", async () => { - const { api } = fakeApi(); + it("finds the row on the branch whose git name is the environment, asking the API for that branch only", async () => { + const { api, calls } = fakeApi(); - const node = await resolveNodeAddress( + const node = await resolveBranchScopedAddress( api, "service", "@pr-42:shop.api", @@ -238,17 +278,25 @@ describe("resolveNodeAddress", () => { ); expect(node.id).toBe("svc_api_pr42"); - expect(node.branch).toEqual({ id: "br_pr42", gitName: "pr-42" }); + expect(node.branch).toEqual({ + id: "br_pr42", + gitName: "pr-42", + isDefault: false, + }); + expect( + calls.find((call) => call.path === "/v1/projects/{projectId}/branches") + ?.query, + ).toEqual({ gitName: "pr-42" }); }); it("resolves databases and buckets from their own listings", async () => { const { api } = fakeApi(); await expect( - resolveNodeAddress(api, "database", "@shop.catalog", signal), + resolveBranchScopedAddress(api, "database", "@shop.catalog", signal), ).resolves.toMatchObject({ id: "db_catalog_main" }); await expect( - resolveNodeAddress(api, "bucket", "@pr-42:shop.files", signal), + resolveBranchScopedAddress(api, "bucket", "@pr-42:shop.files", signal), ).resolves.toMatchObject({ id: "bkt_files_pr42" }); }); @@ -256,7 +304,7 @@ describe("resolveNodeAddress", () => { const { api } = fakeApi(); await expect( - resolveNodeAddress(api, "service", "@cart.api", signal), + resolveBranchScopedAddress(api, "service", "@cart.api", signal), ).rejects.toMatchObject({ code: "ADDRESS.NOT_FOUND", meta: { address: "@cart.api", app: "cart" }, @@ -292,7 +340,7 @@ describe("resolveNodeAddress", () => { ); await expect( - resolveNodeAddress(api, "service", "@shop.api", signal), + resolveBranchScopedAddress(api, "service", "@shop.api", signal), ).rejects.toMatchObject({ code: "ADDRESS.NOT_FOUND" }); }); @@ -311,17 +359,22 @@ describe("resolveNodeAddress", () => { }); await expect( - resolveNodeAddress(api, "service", "@shop.api", signal), + resolveBranchScopedAddress(api, "service", "@shop.api", signal), ).rejects.toMatchObject({ code: "ADDRESS.AMBIGUOUS", - meta: { projectIds: ["proj_shop", "proj_shop_copy"] }, + meta: { + matches: [ + { id: "proj_shop", name: "Shop" }, + { id: "proj_shop_copy", name: "Shop copy" }, + ], + }, }); }); it("reports an environment with no branch as not found and points at branch list", async () => { const { api } = fakeApi(); - const error = await resolveNodeAddress( + const error = await resolveBranchScopedAddress( api, "service", "@pr-7:shop.api", @@ -355,7 +408,7 @@ describe("resolveNodeAddress", () => { }); await expect( - resolveNodeAddress(api, "service", "@shop.api", signal), + resolveBranchScopedAddress(api, "service", "@shop.api", signal), ).rejects.toMatchObject({ code: "ADDRESS.NOT_FOUND", meta: { environment: "production" }, @@ -366,7 +419,7 @@ describe("resolveNodeAddress", () => { const { api, calls } = fakeApi(); await expect( - resolveNodeAddress(api, "service", "@shop.web", signal), + resolveBranchScopedAddress(api, "service", "@shop.web", signal), ).rejects.toMatchObject({ code: "ADDRESS.NOT_FOUND", meta: { @@ -388,7 +441,7 @@ describe("resolveNodeAddress", () => { it("reports an address that names a row of another kind as the wrong kind", async () => { const { api } = fakeApi(); - const error = await resolveNodeAddress( + const error = await resolveBranchScopedAddress( api, "service", "@shop.catalog", @@ -408,7 +461,7 @@ describe("resolveNodeAddress", () => { const { api, calls } = fakeApi(); await expect( - resolveNodeAddress(api, "database", "@pr-42:shop", signal), + resolveBranchScopedAddress(api, "database", "@pr-42:shop", signal), ).rejects.toMatchObject({ code: "ADDRESS.WRONG_KIND", meta: { expected: "database", actual: "project" }, @@ -416,6 +469,40 @@ describe("resolveNodeAddress", () => { expect(calls).toEqual([]); }); + it("compares a row's address and branch exactly, even when the API ignores the filters", async () => { + const { api } = fakeApi(FIXTURE, (path) => + path === "/v1/databases" + ? { + data: { + data: [ + { + id: "db_catalog_pr42", + name: "catalog", + projectId: "proj_shop", + branchId: "br_pr42", + logicalId: "shop.catalog", + }, + { + id: "db_catalog_v2", + name: "catalog-v2", + projectId: "proj_shop", + branchId: "br_main", + logicalId: "shop.catalog.v2", + }, + ...(FIXTURE.databases ?? []), + ], + pagination: { hasMore: false, nextCursor: null }, + }, + response: new Response(null, { status: 200 }), + } + : undefined, + ); + + await expect( + resolveBranchScopedAddress(api, "database", "@shop.catalog", signal), + ).resolves.toMatchObject({ id: "db_catalog_main" }); + }); + it("reports several rows with the address on one branch as ambiguous", async () => { const { api } = fakeApi({ ...FIXTURE, @@ -432,7 +519,7 @@ describe("resolveNodeAddress", () => { }); await expect( - resolveNodeAddress(api, "database", "@shop.catalog", signal), + resolveBranchScopedAddress(api, "database", "@shop.catalog", signal), ).rejects.toMatchObject({ code: "ADDRESS.AMBIGUOUS", meta: { @@ -463,7 +550,7 @@ describe("resolveNodeAddress", () => { }); await expect( - resolveNodeAddress(api, "service", "@shop.api", signal), + resolveBranchScopedAddress(api, "service", "@shop.api", signal), ).resolves.toMatchObject({ id: "svc_api_main" }); expect( calls.filter((call) => call.path === "/v1/projects").map((c) => c.query), @@ -484,7 +571,7 @@ describe("resolveNodeAddress", () => { ); await expect( - resolveNodeAddress(api, "service", "@shop.api", signal), + resolveBranchScopedAddress(api, "service", "@shop.api", signal), ).rejects.toMatchObject({ code: "ADDRESS.API_ERROR", why: "No access", @@ -509,23 +596,23 @@ describe("resolveProjectAddress", () => { await expect( resolveProjectAddress(api, "@pr-42:shop", signal), ).rejects.toMatchObject({ - code: "ADDRESS.WRONG_KIND", - meta: { expected: "project" }, + code: "ADDRESS.USAGE_ERROR", + meta: { address: "@pr-42:shop" }, }); await expect( resolveProjectAddress(api, "@production:shop", signal), - ).rejects.toMatchObject({ code: "ADDRESS.WRONG_KIND" }); + ).rejects.toMatchObject({ code: "ADDRESS.USAGE_ERROR" }); expect(calls).toEqual([]); }); - it("refuses the address of a node inside the app", async () => { + it("refuses the address of something inside the app", async () => { const { api } = fakeApi(); await expect( resolveProjectAddress(api, "@shop.api", signal), ).rejects.toMatchObject({ code: "ADDRESS.WRONG_KIND", - meta: { expected: "project" }, + meta: { expected: "project", actual: "service, database or bucket" }, }); }); @@ -542,16 +629,17 @@ describe("resolveProjectAddress", () => { }); }); -describe("resolveScopedSubject", () => { +describe("resolveBranchScopedSubject", () => { it("hands over the row id, Project id and branch git name an address names", async () => { const { api } = fakeApi(); await expect( - resolveScopedSubject(api, "bucket", "@pr-42:shop.files", {}, signal), - ).resolves.toEqual({ + resolveBranchScopedSubject(api, "bucket", "@pr-42:shop.files", signal), + ).resolves.toMatchObject({ ref: "bkt_files_pr42", project: "proj_shop", branch: "pr-42", + match: { id: "bkt_files_pr42", text: "@pr-42:shop.files" }, }); }); @@ -559,14 +647,16 @@ describe("resolveScopedSubject", () => { const { api, calls } = fakeApi(); await expect( - resolveScopedSubject( - api, - "database", - "catalog", - { project: "Shop", branch: "pr-42" }, - signal, - ), - ).resolves.toEqual({ ref: "catalog", project: "Shop", branch: "pr-42" }); + resolveBranchScopedSubject(api, "database", "catalog", signal, { + project: "Shop", + branch: "pr-42", + }), + ).resolves.toEqual({ + ref: "catalog", + project: "Shop", + branch: "pr-42", + match: undefined, + }); expect(calls).toEqual([]); }); @@ -578,7 +668,7 @@ describe("resolveScopedSubject", () => { const { api, calls } = fakeApi(); await expect( - resolveScopedSubject(api, "service", "@shop.api", scope, signal), + resolveBranchScopedSubject(api, "service", "@shop.api", signal, scope), ).rejects.toMatchObject({ code: "ADDRESS.USAGE_ERROR", meta: { address: "@shop.api", flags }, diff --git a/packages/cli/tests/bucket.test.ts b/packages/cli/tests/bucket.test.ts index e2f62a9a..2c1e33c6 100644 --- a/packages/cli/tests/bucket.test.ts +++ b/packages/cli/tests/bucket.test.ts @@ -981,7 +981,7 @@ describe("prisma bucket key delete", () => { }); }); -describe("bucket commands given a deploy address", () => { +describe("bucket commands given an address", () => { const ADDRESSES: AddressFixture = { projects: [{ ...PROJECTS[0], logicalId: "billing" }], branches: { @@ -1070,6 +1070,9 @@ describe("bucket commands given a deploy address", () => { ok: false, error: { code: "CLI.CONSENT_REQUIRED", + summary: expect.stringContaining( + "bucket bkt_files_main in production (branch main)", + ), meta: { consentToken: "@billing.files" }, }, }); diff --git a/packages/cli/tests/helpers/fake-address-api.ts b/packages/cli/tests/helpers/fake-address-api.ts index 0b7f6892..bff423f4 100644 --- a/packages/cli/tests/helpers/fake-address-api.ts +++ b/packages/cli/tests/helpers/fake-address-api.ts @@ -1,11 +1,12 @@ /** * The listings address resolution reads, answered the way the Management - * API filters them: `/v1/projects` by `logicalId`, a Project's branches, + * API filters them: `/v1/projects` by `logicalId`, a Project's branches + * by `gitName`, * and `/v1/services`, `/v1/databases`, `/v1/buckets` by `projectId`, * `logicalId` and `branchId`. */ -export interface AddressedRow { +export interface FakeRow { readonly id: string; readonly name: string; readonly projectId: string; @@ -13,14 +14,14 @@ export interface AddressedRow { readonly logicalId: string | null; } -export interface AddressedProjectRow { +export interface FakeProjectRow { readonly id: string; readonly name: string; readonly logicalId: string | null; readonly workspace: { readonly id: string; readonly name: string }; } -export interface AddressedBranchRow { +export interface FakeBranchRow { readonly id: string; readonly gitName: string; readonly isDefault: boolean; @@ -28,11 +29,11 @@ export interface AddressedBranchRow { } export interface AddressFixture { - readonly projects: readonly AddressedProjectRow[]; - readonly branches: Readonly>; - readonly services?: readonly AddressedRow[]; - readonly databases?: readonly AddressedRow[]; - readonly buckets?: readonly AddressedRow[]; + readonly projects: readonly FakeProjectRow[]; + readonly branches: Readonly>; + readonly services?: readonly FakeRow[]; + readonly databases?: readonly FakeRow[]; + readonly buckets?: readonly FakeRow[]; } export interface Page { @@ -71,8 +72,12 @@ export function addressListing( ); } if (path === "/v1/projects/{projectId}/branches") { - const projectId = params.path?.projectId ?? ""; - return page(fixture.branches[projectId] ?? []); + const branches = fixture.branches[params.path?.projectId ?? ""] ?? []; + return page( + query.gitName === undefined + ? branches + : branches.filter((branch) => branch.gitName === query.gitName), + ); } if (path in ROW_PATHS) { const rows = fixture[ROW_PATHS[path as keyof typeof ROW_PATHS]] ?? []; diff --git a/packages/cli/tests/postgres.test.ts b/packages/cli/tests/postgres.test.ts index 2145e618..61aad413 100644 --- a/packages/cli/tests/postgres.test.ts +++ b/packages/cli/tests/postgres.test.ts @@ -2653,7 +2653,7 @@ describe("prisma postgres connection delete", () => { }); }); -describe("postgres commands given a deploy address", () => { +describe("postgres commands given an address", () => { const CATALOG_PREVIEW = { id: "db_catalog_pr42", name: "catalog", @@ -2776,12 +2776,35 @@ describe("postgres commands given a deploy address", () => { ok: false, error: { code: "CLI.CONSENT_REQUIRED", + summary: expect.stringContaining( + "database db_catalog_pr42 in environment pr-42 (branch pr-42)", + ), meta: { consentToken: "@pr-42:shop.catalog" }, }, }); expect(calls.some((call) => call.method === "DELETE")).toBe(false); }); + it("says production in the consent question, and trims the subject before reading it as an address", async () => { + const calls: Call[] = []; + const result = await makeCli(addressClient(calls)).run( + ["postgres", "delete", " @shop.catalog ", "--json"], + { cwd: await unpinnedCwd() }, + ); + + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "CLI.CONSENT_REQUIRED", + summary: expect.stringContaining( + "database db_catalog_main in production (branch main)", + ), + meta: { consentToken: "@shop.catalog" }, + }, + }); + expect(calls.some((call) => call.method === "DELETE")).toBe(false); + }); + it("asks for the address, not the id, at the prompt", async () => { const result = await makeCli(addressClient()).run( ["postgres", "delete", "@shop.catalog"], diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 5b417677..3d2a0edd 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -2997,13 +2997,13 @@ describe("prisma project transfer", () => { }); }); -describe("project commands given a deploy address", () => { +describe("project commands given an address", () => { const ADDRESSED_PROJECTS = [ { ...API_PROJECTS[0], logicalId: "billing" }, { ...API_PROJECTS[1], logicalId: null }, ]; - function addressedClient(deleted: string[] = []) { + function addressedClient(deleted: string[] = [], posted: string[] = []) { return fakeClient({ get: (apiPath, init) => { if (apiPath !== "/v1/projects") { @@ -3030,6 +3030,12 @@ describe("project commands given a deploy address", () => { ); return { data: {} }; }, + post: (apiPath, init) => { + posted.push( + `${apiPath} ${(init as { params: { path: { id: string } } }).params.path.id}`, + ); + return { data: { data: {} } }; + }, }); } @@ -3065,13 +3071,42 @@ describe("project commands given a deploy address", () => { expect(deleted).toEqual([]); }); - it.each([ - "@pr-42:billing", - "@billing.api", - ])("refuses %s, which names less than the whole Project", async (address) => { + it("refuses an environment on a Project's address and deletes nothing", async () => { + const deleted: string[] = []; + const result = await makeCli(addressedClient(deleted)).run( + [ + "project", + "delete", + "@pr-42:billing", + "--confirm", + "@pr-42:billing", + "--json", + ], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "ADDRESS.USAGE_ERROR", + meta: { address: "@pr-42:billing" }, + }, + }); + expect(deleted).toEqual([]); + }); + + it("refuses the address of something inside the app and deletes nothing", async () => { const deleted: string[] = []; const result = await makeCli(addressedClient(deleted)).run( - ["project", "delete", address, "--confirm", address, "--json"], + [ + "project", + "delete", + "@billing.api", + "--confirm", + "@billing.api", + "--json", + ], { cwd: await tempCwd() }, ); @@ -3083,6 +3118,65 @@ describe("project commands given a deploy address", () => { expect(deleted).toEqual([]); }); + it("names the Project in the consent question when given an address", async () => { + const result = await makeCli(addressedClient()).run( + ["project", "delete", " @billing ", "--json"], + { cwd: await tempCwd() }, + ); + + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "CLI.CONSENT_REQUIRED", + summary: expect.stringContaining('Project "Billing" (proj_1)'), + meta: { consentToken: "@billing" }, + }, + }); + }); + + it("transfers the Project an address names, with the address as the consent token", async () => { + const posted: string[] = []; + const result = await makeCli(addressedClient([], posted)).run( + [ + "project", + "transfer", + "@billing", + "--recipient-token", + "tok_recipient", + "--confirm", + "@billing", + ], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(0); + expect(posted).toEqual(["/v1/projects/{id}/transfer proj_1"]); + }); + + it("refuses an environment on a transfer's address and transfers nothing", async () => { + const posted: string[] = []; + const result = await makeCli(addressedClient([], posted)).run( + [ + "project", + "transfer", + "@pr-42:billing", + "--recipient-token", + "tok_recipient", + "--confirm", + "@pr-42:billing", + "--json", + ], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { code: "ADDRESS.USAGE_ERROR" }, + }); + expect(posted).toEqual([]); + }); + it("shows the Project an address names", async () => { const result = await makeCli(addressedClient()).run( ["project", "show", "@billing", "--json"], diff --git a/packages/cli/tests/service-address.test.ts b/packages/cli/tests/service-address.test.ts index 94f89c29..67e17450 100644 --- a/packages/cli/tests/service-address.test.ts +++ b/packages/cli/tests/service-address.test.ts @@ -54,9 +54,23 @@ const listing = (path: string): RouteHandler => (init) => ({ data: addressListing(ADDRESSES, path, init.params) }); +interface Recorded { + /** The branch git name each `/v1/apps` listing asked for. */ + readonly appListings: string[]; + /** The service id each version listing read. */ + readonly versionListings: string[]; + /** The service id each delete targeted. */ + readonly deletes: string[]; +} + +function recorder(): Recorded { + return { appListings: [], versionListings: [], deletes: [] }; +} + /** The release routes, with every listing the address lookup reads and * a `/v1/apps` listing that filters by branch git name. */ -function addressRoutes(appListings: string[] = []) { +function addressRoutes(recorded: Recorded = recorder()) { + const base = releaseRoutes(); return releaseRoutes({ "GET /v1/projects": listing("/v1/projects"), "GET /v1/projects/{projectId}/branches": listing( @@ -67,20 +81,30 @@ function addressRoutes(appListings: string[] = []) { "GET /v1/buckets": listing("/v1/buckets"), "GET /v1/apps": (init) => { const branch = init.params?.query?.branchGitName as string; - appListings.push(branch); + recorded.appListings.push(branch); return { data: page(branch === "pr-42" ? [PREVIEW_SERVICE] : [SERVICE]), }; }, + "GET /v1/apps/{appId}/deployments": (init) => { + recorded.versionListings.push(init.params?.path?.appId ?? ""); + return ( + base["GET /v1/apps/{appId}/deployments"]?.(init) ?? { + data: page([]), + } + ); + }, + "DELETE /v1/apps/{appId}": (init) => { + recorded.deletes.push(init.params?.path?.appId ?? ""); + return { data: { data: {} } }; + }, }); } -describe("service commands given a deploy address", () => { +describe("service commands given an address", () => { it("acts on the service on the branch the environment names", async () => { - const appListings: string[] = []; - const harness = await makeServiceCli({ - routes: addressRoutes(appListings), - }); + const recorded = recorder(); + const harness = await makeServiceCli({ routes: addressRoutes(recorded) }); const result = await harness.cli.run( ["service", "version", "list", "@pr-42:shop.api", "--json"], @@ -88,14 +112,13 @@ describe("service commands given a deploy address", () => { ); expect(result.exitCode).toBe(0); - expect(appListings).toEqual(["pr-42"]); + expect(recorded.appListings).toEqual(["pr-42"]); + expect(recorded.versionListings).toEqual(["svc_api_pr42"]); }); it("acts on the default branch's service when the address names no environment", async () => { - const appListings: string[] = []; - const harness = await makeServiceCli({ - routes: addressRoutes(appListings), - }); + const recorded = recorder(); + const harness = await makeServiceCli({ routes: addressRoutes(recorded) }); const result = await harness.cli.run(["service", "show", "@shop.api"], { cwd: harness.cwd, @@ -107,11 +130,12 @@ describe("service commands given a deploy address", () => { projectId: "proj_1", service: { id: "svc_1" }, }); - expect(appListings).toEqual(["main"]); + expect(recorded.appListings).toEqual(["main"]); }); it("takes the address, not the service name, as the delete consent token", async () => { - const harness = await makeServiceCli({ routes: addressRoutes() }); + const recorded = recorder(); + const harness = await makeServiceCli({ routes: addressRoutes(recorded) }); const byName = await harness.cli.run( [ @@ -132,13 +156,38 @@ describe("service commands given a deploy address", () => { expect(frame.envelope.error.meta).toMatchObject({ consentToken: "@pr-42:shop.api", }); + expect(recorded.deletes).toEqual([]); const byAddress = await harness.cli.run( - ["service", "delete", "@pr-42:shop.api", "--confirm", "@pr-42:shop.api"], + [ + "service", + "delete", + " @pr-42:shop.api ", + "--confirm", + "@pr-42:shop.api", + ], { cwd: harness.cwd, env: harness.env }, ); expect(byAddress.exitCode).toBe(0); expect(byAddress.presented?.data).toMatchObject({ deleted: true }); + expect(recorded.deletes).toEqual(["svc_api_pr42"]); + }); + + it("names the environment, branch and service id in the delete consent question", async () => { + const harness = await makeServiceCli({ routes: addressRoutes() }); + + const result = await harness.cli.run( + ["service", "delete", "@shop.api", "--json"], + { cwd: harness.cwd, env: harness.env }, + ); + + const frame = result.json.at(-1); + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.summary).toContain( + '"hello-world" (svc_1) in production (branch main)', + ); }); it("refuses --branch alongside an address", async () => { From b14a028b394664c5a2c8facc9633bd318e46c752 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:29:01 +0100 Subject: [PATCH 14/15] Define the address and its environment where help and docs use them The service, postgres, bucket and project group cards now say how to name a resource by its address, what the address is (the name the app gave it, such as shop.db) and what the part before ':' is (the environment the app was deployed to: its branch, or production when left out). The postgres card no longer uses 'address' as a verb. command-principles.md states that production means the Project's default Branch, not the production-role Branch or a Branch named production. The error reference drops 'deploy address' and says that an ADDRESS code can replace a group's own not-found code. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/product/command-principles.md | 2 +- docs/reference/error-reference.md | 14 ++++++++------ packages/cli/src/cli.ts | 10 +++++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 0a34415d..d0002ffe 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -117,7 +117,7 @@ Resolve a service version and show or stream its logs. A command that operates on a subject resource takes that resource's identifier as its first positional argument (`service show my-api`, `postgres delete db_123`, `service version promote cpv_123`) — the established convention across CLIs. Flags never name the subject; they scope or qualify it (`--project`, `--branch`, `--role`). The argument primarily targets the stable platform id, with the display name as a secondary fallback: an id match always wins, and a resource named like another resource's id can never shadow it. Environment variables never target a subject. When the subject's identifier is globally unique — a service version id, a bucket id — the id alone is the complete target, and the command asks for no redundant parent scope. -A Project, service, database or bucket can also be named by the address Prisma Composer deployed it at: `@:
`, such as `@pr-42:shop.api`. The address is the row's `logicalId`, and its first `.`-separated part is the app, which is the Project's `logicalId`. The environment is the git name of the Project's Branch. Leaving it out, or writing `production`, means the Project's default Branch, so `@shop.api` is production. A Project's address is the app name alone, with no environment (`@shop`). With an address, a destructive command's `--confirm` takes the address as typed (`postgres delete @pr-42:shop.db --confirm @pr-42:shop.db`). `--project` and `--branch` cannot be combined with an address, because the address already names both. +A Project, service, database or bucket that Prisma Composer deployed can also be named by its address: the name the app gave it, such as `shop.api`. The address is the row's `logicalId`, and its first `.`-separated part is the app, which is the Project's `logicalId`. On the command line the subject is `@`, then an optional environment and `:`, then the address: `@pr-42:shop.api`. The environment is the one the app was deployed to: the git name of one of the Project's Branches. Leaving it out, or writing `production`, means the Project's default Branch (the Branch with `isDefault` set), so `@shop.api` is production. This is the default Branch, not the Branch whose role is `production`, and not a Branch whose git name is `production`; a non-default Branch with that git name cannot be named by address. A Project's address is the app name alone, with no environment (`@shop`), because a Project spans every Branch. With an address, a destructive command's `--confirm` takes the address as typed (`postgres delete @pr-42:shop.db --confirm @pr-42:shop.db`), and its question names the row id, the environment and the Branch, saying plainly when that is production. `--project` and `--branch` cannot be combined with an address, because the address already names both. ### `wait` diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 9cad4974..7f0b9f7b 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -14,7 +14,7 @@ Namespaces: | Namespace | Covers | | --- | --- | -| `ADDRESS` | Naming a Project, service, database or bucket by its deploy address (`@:
`) instead of its id | +| `ADDRESS` | Naming a Project, service, database or bucket by its address (`@:
`) instead of its id | | `AUTH` | Workspace authentication and sessions (`prisma auth`, credential resolution) | | `BRANCH` | Branch listing (`prisma branch`) | | `BUCKET` | Bucket and bucket-key management (`prisma bucket`) | @@ -30,25 +30,27 @@ Namespaces: ## ADDRESS +These codes cover a subject written as `@`, an optional environment and `:`, then an address: the name the app gave a Project, service, database or bucket, such as `shop.api`. The environment is the one the app was deployed to: a Branch's git name, or production (the default Branch) when left out. Any project, service, postgres or bucket command given an address can fail with these codes before its own lookup runs, so an `ADDRESS` code can replace the group's own not-found code: a script that handles a missing database on `postgres delete` also needs to handle `ADDRESS.NOT_FOUND`. + ### ADDRESS.AMBIGUOUS -A deploy address matched more than one row: more than one Project in the workspace carries the app name as its logical id, or more than one service, database or bucket on the addressed branch carries the full address. The nextActions list the matching ids to pass instead. Meta: `address`, plus `projectIds` or `matches` (id and name of each match). +An address matched more than one row: more than one Project in the workspace carries the app name as its logical id, or more than one service, database or bucket on the environment's branch carries the full address. The nextActions list the matching ids to pass instead. Meta: `address`, `matches` (the `id` and `name` of each match). ### ADDRESS.API_ERROR -A Management API request made to look up an address (listing Projects, branches, services, databases or buckets by logical id) failed. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. Meta: `status`, `apiCode` (the API's own error code, when the response supplied one). +A Management API request made to look up an address (listing Projects, services, databases or buckets by logical id) failed. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. A failed branch listing raises `BRANCH.API_ERROR` instead. Meta: `status`, `apiCode` (the API's own error code, when the response supplied one). ### ADDRESS.NOT_FOUND -A deploy address named nothing: no Project in the workspace has the app name as its logical id, the Project has no branch for the environment (no default branch for production, or no branch with that git name), or nothing on that branch has the address. The nextActions point at `prisma project list`, `prisma branch list`, or `prisma deploy list`. Meta: `address`, plus `app`, `environment`, `projectId`, `branchId` or `expected` as they apply. +An address named nothing: no Project in the workspace has the app name as its logical id, the Project has no branch for the environment (no default branch for production, or no branch with that git name), or nothing on that branch has the address. The nextActions point at `prisma project list`, `prisma branch list`, or `prisma deploy list`. Meta: `address`, plus `app`, `environment`, `projectId`, `branchId` or `expected` as they apply. ### ADDRESS.USAGE_ERROR -A subject starting with `@` is not a valid address (an empty environment before `:`, an empty address, an empty part between dots, or whitespace), or an address was combined with `--project` or `--branch`, which the address already names. Meta: `address`, plus `flags` for the flag conflict. +A subject starting with `@` is not a valid address (an empty environment before `:`, an empty address, an empty part between dots, or whitespace), an address was combined with `--project` or `--branch`, which the address already names, or a project command was given an address with an environment, which a Project's address never has. Meta: `address`, plus `flags` for the flag conflict. ### ADDRESS.WRONG_KIND -A deploy address names a different kind of thing than the command acts on: a service, postgres or bucket command was given the bare app name (which names the Project) or the address of another kind of row, or a project command was given an address with an environment or with more than the app name. The nextActions name the command group that acts on it. Meta: `address`, `expected`, and `actual` when known. +An address names a different kind of thing than the command acts on: a service, postgres or bucket command was given the bare app name (which names the Project) or the address of another kind of row, or a project command was given an address longer than the app name. The nextActions name the command group that acts on it. Meta: `address`, `expected`, `actual` (`project`, `service`, `database`, `bucket`, or `service, database or bucket` when a project command is given a longer address and the kind was not looked up). ## AUTH diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ff21795d..54c1ef68 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -181,7 +181,7 @@ export const cliGroups: Readonly< brief: "Manage Prisma Platform projects. CRUD, link a directory, transfer ownership, manage environment variables", description: - "A Project groups one product or codebase. It is the child of a workspace and the parent of Branches: isolated environments, one per Git branch, each holding its own services, databases, and buckets. 'Linking' connects a local directory to a Project: the link is stored locally, and every project-scoped command run in that directory targets the linked project unless --project names another. 'create' links automatically; 'link' points a directory at an existing Project; 'show' reports the link.", + "A Project groups one product or codebase. It is the child of a workspace and the parent of Branches: isolated environments, one per Git branch, each holding its own services, databases, and buckets. 'Linking' connects a local directory to a Project: the link is stored locally, and every project-scoped command run in that directory targets the linked project unless --project names another. 'create' links automatically; 'link' points a directory at an existing Project; 'show' reports the link. Name a Project by its id, its name, or its address: @ and the app name, such as @shop. A Project spans every branch, so its address takes no environment.", workflow: [ { run: "project create my-app", @@ -208,7 +208,7 @@ export const cliGroups: Readonly< brief: "Manage Prisma Postgres databases. CRUD, usage metrics, backups, and connection credentials", description: - "Databases are branch-bound: each belongs to a Branch, the isolated environment for one Git branch of a project, so commands take --branch to target one. Address a database by its id (db_...) or name. Connection URLs are secrets that print exactly once, at create or rotate; nothing shows them again. 'backup' restores platform-taken backups, and 'connection' manages per-consumer credentials.", + "Databases are branch-bound: each belongs to a Branch, the isolated environment for one Git branch of a project, so commands take --branch to target one. Name a database by its id (db_...), its name, or its address (@shop.db, @pr-42:shop.db). The address is the name the app gave it, such as shop.db; the part before ':' is the environment the app was deployed to: its branch, or production when left out. Connection URLs are secrets that print exactly once, at create or rotate; nothing shows them again. 'backup' restores platform-taken backups, and 'connection' manages per-consumer credentials.", workflow: [ { run: "postgres create app-db", @@ -228,7 +228,7 @@ export const cliGroups: Readonly< brief: "Inspect and restore database backups. The platform takes them automatically", description: - "The platform takes backups automatically; there is no backup-create command. Restore replaces a database's current state with a backup's contents after exact id confirmation.", + "The platform takes backups automatically; there is no backup-create command. Restore replaces a database's current state with a backup's contents after exact confirmation of the database's id, or its address when you name it by one.", }, "postgres connection": { brief: @@ -240,7 +240,7 @@ export const cliGroups: Readonly< brief: "Manage S3-compatible object-store buckets for a project. CRUD operations and access keys", description: - "A bucket is blob storage for files and uploads, bound to one Branch of a project and reachable through the standard S3 API. Access goes through S3-compatible keys minted per consumer with 'bucket key create', which prints credentials (endpoint, key id, secret) exactly once.", + "A bucket is blob storage for files and uploads, bound to one Branch of a project and reachable through the standard S3 API. Access goes through S3-compatible keys minted per consumer with 'bucket key create', which prints credentials (endpoint, key id, secret) exactly once. Name a bucket by its id or its address (@shop.files, @pr-42:shop.files). The address is the name the app gave it, such as shop.files; the part before ':' is the environment the app was deployed to: its branch, or production when left out.", workflow: [ { run: "bucket create --name uploads", @@ -274,7 +274,7 @@ export const cliGroups: Readonly< brief: "Manage deployed services. Logs, versions, promote and rollback releases, custom domains", description: - "A service is one HTTP application (a frontend or a backend) deployed on a Branch. Every deploy produces an immutable service version; at most one serves traffic at a time. The 'version' subcommands move which one that is: promote releases a preview build into production, rollback returns production to a previous version without rebuilding. 'logs' reads and streams output, and 'domain' attaches hostnames you own.", + "A service is one HTTP application (a frontend or a backend) deployed on a Branch. Every deploy produces an immutable service version; at most one serves traffic at a time. The 'version' subcommands move which one that is: promote releases a preview build into production, rollback returns production to a previous version without rebuilding. 'logs' reads and streams output, and 'domain' attaches hostnames you own. Name a service by its id, its name, or its address (@shop.api, @pr-42:shop.api). The address is the name the app gave it, such as shop.api; the part before ':' is the environment the app was deployed to: its branch, or production when left out.", workflow: [ { run: "service logs my-api --follow", From 53efc9a01486cfae1b0419b4ffb1fc32d265f585 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 27 Oct 2026 20:32:10 +0100 Subject: [PATCH 15/15] Let the real-API address tests run in any order The not-found test used the address the delete test had just emptied, and the bucket delete removed the bucket other tests read. The delete tests now act on their own database pair and bucket, the not-found test uses an address nothing was ever deployed at, and the consent test checks that the question names the environment and branch. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/e2e/address.e2e.ts | 65 ++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/packages/cli/e2e/address.e2e.ts b/packages/cli/e2e/address.e2e.ts index e2f75a09..639b4abf 100644 --- a/packages/cli/e2e/address.e2e.ts +++ b/packages/cli/e2e/address.e2e.ts @@ -1,5 +1,5 @@ /** - * Node addresses as subjects, against the real API: `@:
` + * Addresses as subjects, against the real API: `@:
` * names a row by the Project's and the row's `logicalId` and the Branch's * git name, the way Composer deploys them. * @@ -10,6 +10,9 @@ * and a bucket; the default Branch an undeployed service with the same * address as the deployed one. Every pair that shares an address is * there to prove the environment picks the Branch. + * + * The delete tests act on rows no other test reads (a second database + * pair and a second bucket), so the tests pass in any order. */ import { afterAll, beforeAll, expect, it } from "vitest"; @@ -107,9 +110,14 @@ interface Fixture { readonly environment: string; readonly productionDatabase: Row; readonly previewDatabase: Row; + /** A database pair the delete test removes one of. */ + readonly productionDoomedDatabase: Row; + readonly previewDoomedDatabase: Row; readonly productionService: Row; readonly previewService: Row & { readonly deploymentId: string }; readonly bucket: Row; + /** A bucket the delete test removes. */ + readonly doomedBucket: Row; } let fixture: Fixture | undefined; @@ -144,9 +152,17 @@ beforeAll(async () => { const catalog = `${app}.catalog`; const api = `${app}.api`; - const [productionDatabase, previewDatabase] = await Promise.all([ + const doomed = `${app}.doomed`; + const [ + productionDatabase, + previewDatabase, + productionDoomedDatabase, + previewDoomedDatabase, + ] = await Promise.all([ createReadyDatabase(project.id, production.id, catalog), createReadyDatabase(project.id, preview.id, catalog), + createReadyDatabase(project.id, production.id, doomed), + createReadyDatabase(project.id, preview.id, doomed), ]); const createService = (branchId: string) => managementApi("POST", "/v1/services", { @@ -157,12 +173,15 @@ beforeAll(async () => { }); const productionService = await createService(production.id); const previewService = await createService(preview.id); - const bucket = await managementApi("POST", "/v1/buckets", { - projectId: project.id, - branchId: preview.id, - logicalId: `${app}.files`, - name: scratchName("bkt"), - }); + const createBucket = (logicalId: string) => + managementApi("POST", "/v1/buckets", { + projectId: project?.id, + branchId: preview.id, + logicalId, + name: scratchName("bkt"), + }); + const bucket = await createBucket(`${app}.files`); + const doomedBucket = await createBucket(`${app}.uploads`); const deploymentId = await createDeployment(previewService.id); deployment = { id: deploymentId, serviceName: previewService.name }; @@ -175,9 +194,12 @@ beforeAll(async () => { environment, productionDatabase, previewDatabase, + productionDoomedDatabase, + previewDoomedDatabase, productionService, previewService: { ...previewService, deploymentId }, bucket, + doomedBucket, }; }); @@ -299,8 +321,8 @@ describeCommands(["bucket key list", "bucket delete"], () => { }); it("deletes the bucket an address names, with the address as consent", async () => { - const { app, environment, bucket } = requireFixture(); - const address = `@${environment}:${app}.files`; + const { app, environment, doomedBucket } = requireFixture(); + const address = `@${environment}:${app}.uploads`; const result = await run([ "bucket", "delete", @@ -310,7 +332,7 @@ describeCommands(["bucket key list", "bucket delete"], () => { ]); const deleted = result.envelope.result as { readonly bucket: Row }; - expect(deleted.bucket.id).toBe(bucket.id); + expect(deleted.bucket.id).toBe(doomedBucket.id); }); }); @@ -341,6 +363,9 @@ describeCommands(["postgres show", "postgres delete"], () => { ); expect(result.envelope.error?.code).toBe("CLI.CONSENT_REQUIRED"); + expect(result.envelope.error?.summary).toContain( + `database ${previewDatabase.id} in environment ${environment} (branch ${environment})`, + ); const after = await run([ "postgres", "show", @@ -352,9 +377,13 @@ describeCommands(["postgres show", "postgres delete"], () => { }); it("deletes only the named environment's database", async () => { - const { app, environment, previewDatabase, productionDatabase } = - requireFixture(); - const address = `@${environment}:${app}.catalog`; + const { + app, + environment, + previewDoomedDatabase, + productionDoomedDatabase, + } = requireFixture(); + const address = `@${environment}:${app}.doomed`; const result = await run([ "postgres", "delete", @@ -363,17 +392,17 @@ describeCommands(["postgres show", "postgres delete"], () => { address, ]); const deleted = result.envelope.result as { readonly database: Row }; - expect(deleted.database.id).toBe(previewDatabase.id); + expect(deleted.database.id).toBe(previewDoomedDatabase.id); - const production = await run(["postgres", "show", `@${app}.catalog`]); + const production = await run(["postgres", "show", `@${app}.doomed`]); expect( (production.envelope.result as { readonly database: Row }).database.id, - ).toBe(productionDatabase.id); + ).toBe(productionDoomedDatabase.id); }); it("reports an address nothing is deployed at", async () => { const { app, environment } = requireFixture(); - const address = `@${environment}:${app}.catalog`; + const address = `@${environment}:${app}.missing`; const result = await run(["postgres", "show", address], false); expect(result.envelope.error?.code).toBe("ADDRESS.NOT_FOUND");