diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index b0a90750..d0002ffe 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 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` Block until a remote resource reaches a terminal state. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 457f4cfc..7f0b9f7b 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 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,30 @@ Namespaces: | `SERVICE` | Deployed service management (`prisma service`) | | `SKILLS` | Agent-skill delivery (`prisma skills`) | +## 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 + +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, 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 + +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), 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 + +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 ### AUTH.CREDENTIAL_WORKSPACE_MISMATCH diff --git a/packages/cli/e2e/address.e2e.ts b/packages/cli/e2e/address.e2e.ts new file mode 100644 index 00000000..639b4abf --- /dev/null +++ b/packages/cli/e2e/address.e2e.ts @@ -0,0 +1,414 @@ +/** + * 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. + * + * 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"; + +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; + /** 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; +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 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", { + projectId: project?.id, + branchId, + logicalId: api, + displayName: scratchName("svc"), + }); + const productionService = await createService(production.id); + const previewService = await createService(preview.id); + 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 }; + await cli.run(["service", "version", "start", deploymentId], { cwd }); + await cli.run(["service", "version", "promote", deploymentId], { cwd }); + + fixture = { + project, + app, + environment, + productionDatabase, + previewDatabase, + productionDoomedDatabase, + previewDoomedDatabase, + productionService, + previewService: { ...previewService, deploymentId }, + bucket, + doomedBucket, + }; +}); + +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, doomedBucket } = requireFixture(); + const address = `@${environment}:${app}.uploads`; + const result = await run([ + "bucket", + "delete", + address, + "--confirm", + address, + ]); + const deleted = result.envelope.result as { readonly bucket: Row }; + + expect(deleted.bucket.id).toBe(doomedBucket.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"); + expect(result.envelope.error?.summary).toContain( + `database ${previewDatabase.id} in environment ${environment} (branch ${environment})`, + ); + 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, + previewDoomedDatabase, + productionDoomedDatabase, + } = requireFixture(); + const address = `@${environment}:${app}.doomed`; + const result = await run([ + "postgres", + "delete", + address, + "--confirm", + address, + ]); + const deleted = result.envelope.result as { readonly database: Row }; + expect(deleted.database.id).toBe(previewDoomedDatabase.id); + + const production = await run(["postgres", "show", `@${app}.doomed`]); + expect( + (production.envelope.result as { readonly database: Row }).database.id, + ).toBe(productionDoomedDatabase.id); + }); + + it("reports an address nothing is deployed at", async () => { + const { app, environment } = requireFixture(); + const address = `@${environment}:${app}.missing`; + 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/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", diff --git a/packages/cli/src/commands/bucket/context.ts b/packages/cli/src/commands/bucket/context.ts index cba961b9..68fcb74b 100644 --- a/packages/cli/src/commands/bucket/context.ts +++ b/packages/cli/src/commands/bucket/context.ts @@ -1,6 +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 { + type AddressMatch, + resolveBranchScopedSubject, +} from "../../lib/address/address"; import { type BucketProvider, createManagementBucketProvider, @@ -25,7 +29,8 @@ export const branchFlag = flag.string({ }); export const bucketPositional = positional.string({ - brief: "Bucket id", + brief: + "Bucket id, or its address: @shop.files (production) or @pr-42:shop.files (environment pr-42)", placeholder: "bucket-id", }); @@ -66,3 +71,19 @@ export function resolveBucketProviderOnly( ): BucketProvider { return createManagementBucketProvider(ctx.api); } + +/** 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<{ id: string; address: AddressMatch | undefined }> { + const subject = await resolveBranchScopedSubject( + ctx.api, + "bucket", + bucketRef, + ctx.signal, + ); + 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 cf14a382..67d327a5 100644 --- a/packages/cli/src/commands/bucket/delete.ts +++ b/packages/cli/src/commands/bucket/delete.ts @@ -6,15 +6,26 @@ import { type Presentations, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { + type AddressMatch, + describeEnvironment, +} from "../../lib/address/address"; import type { BucketDeleteResult } from "../../types/bucket"; import { bucketPositional, LIST_BUCKETS_COMMAND, + 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 { @@ -37,13 +48,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 +70,11 @@ export const bucketDeleteCommand = defineCommand({ ], }); } + const { id: bucketId, address } = await resolveBucket(ctx, bucketRef); - await ctx.prompt.consent(CONSENT_QUESTION, { token: bucketId }); + await ctx.prompt.consent(consentQuestion(bucketId, address), { + token: address?.text ?? 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..d100cccf 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, + resolveBucket, 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 { 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 bfc2df15..d79b0d07 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, + resolveBucket, + 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 { 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 7bacb399..a777b125 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, + resolveBucket, 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 { 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-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..9364799e 100644 --- a/packages/cli/src/commands/postgres/backup-restore.ts +++ b/packages/cli/src/commands/postgres/backup-restore.ts @@ -10,12 +10,22 @@ import { import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { resolveDatabase } from "../../controllers/database"; +import { + type AddressMatch, + describeEnvironment, +} from "../../lib/address/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( + 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( result: DatabaseRestoreResult, @@ -64,7 +74,8 @@ export const postgresBackupRestoreCommand = defineCommand({ args: { positionals: { database: positional.string({ - brief: "Target database id or name", + brief: + "Target database id or name, or its address: @shop.db (production) or @pr-42:shop.db (environment pr-42)", placeholder: "database", }), }, @@ -83,9 +94,9 @@ 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. 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 +122,33 @@ export const postgresBackupRestoreCommand = defineCommand({ ); } - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres backup restore"); - const database = await resolveDatabase( + const { provider, target, + projectId, + projectName, + branchName, + database, + address, + } = await resolvePostgresDatabase( + ctx, + args.flags, args.positionals.database, - args.flags.branch, - ctx.signal, + "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(database.id, address), { + token: address?.text ?? 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..e6c5ac1c 100644 --- a/packages/cli/src/commands/postgres/context.ts +++ b/packages/cli/src/commands/postgres/context.ts @@ -1,10 +1,16 @@ /** Workspace, project and provider for the `postgres *` commands. */ import { type CommandContext, flag, positional } from "@prisma/cli-engine"; +import { resolveDatabase } from "../../controllers/database"; +import { + type AddressMatch, + resolveBranchScopedSubject, +} from "../../lib/address/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 +28,8 @@ export const branchFlag = flag.string({ }); export const databasePositional = positional.string({ - brief: "Database id or name", + brief: + "Database id or name, or its address: @shop.db (production) or @pr-42:shop.db (environment pr-42)", placeholder: "database", }); @@ -56,6 +63,54 @@ 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; + /** Set when the database was named by an address. */ + readonly address: AddressMatch | 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 resolveBranchScopedSubject( + ctx.api, + "database", + databaseRef.trim(), + ctx.signal, + flags, + ); + 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, + address: subject.match, + }; +} + /** `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..a06407e8 100644 --- a/packages/cli/src/commands/postgres/delete.ts +++ b/packages/cli/src/commands/postgres/delete.ts @@ -6,17 +6,26 @@ import { type Presentations, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { resolveDatabase } from "../../controllers/database"; +import { + type AddressMatch, + describeEnvironment, +} from "../../lib/address/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( + 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 { return { @@ -47,24 +56,27 @@ 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; 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, address } = + await resolvePostgresDatabase( + ctx, + args.flags, + args.positionals.database, + "postgres delete", + ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: 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/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, 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 5d5ee51e..19c1babb 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -16,11 +16,21 @@ 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"; -const CONSENT_QUESTION = - "Deleting a project is permanent, destroys its databases, and stops its apps, so it requires the exact project 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 { return { @@ -54,30 +64,36 @@ export const projectDeleteCommand = defineCommand({ args: { positionals: { project: positional.string({ - brief: "Project id or name", + 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; 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 = await resolveProjectPositional( + ctx, + args.positionals.project, + ); const project = toProjectSummary( - resolveProjectForSetup( - args.positionals.project.trim(), - projects, - workspace, - ), + resolveProjectForSetup(subject?.ref ?? "", projects, workspace), ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); + await ctx.prompt.consent(consentQuestion(project, subject?.address), { + token: subject?.address ?? 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..68380f00 100644 --- a/packages/cli/src/commands/project/link.ts +++ b/packages/cli/src/commands/project/link.ts @@ -22,6 +22,7 @@ import { bindDirectoryToProject, listWorkspaceProjects, type ProjectCommandContext, + resolveProjectPositional, } from "./context"; import { setupPresentations } from "./presentation"; @@ -158,7 +159,7 @@ async function linkDirectoryToProject( ): Promise { const workspace = await resolveActiveWorkspace(ctx); const projects = await listWorkspaceProjects(ctx); - const ref = projectRef?.trim(); + const ref = (await resolveProjectPositional(ctx, projectRef))?.ref; return ref ? await bindDirectoryToProject( @@ -174,7 +175,8 @@ export const projectLinkCommand = defineCommand({ args: { positionals: { project: positional.optionalString({ - brief: "Project id or name", + 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 1d0304ba..e4c6e2ff 100644 --- a/packages/cli/src/commands/project/show.ts +++ b/packages/cli/src/commands/project/show.ts @@ -14,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; @@ -112,7 +116,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 address: @ and the app name, such as @shop (default: the project this directory is linked to)", placeholder: "id-or-name", }), }, @@ -121,7 +125,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 +137,9 @@ export const projectShowCommand = defineCommand({ const inspected = await inspectProjectBinding({ context: operationContext(ctx), workspace, - explicitProject: args.positionals.project, + 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 e8d254ff..84002a02 100644 --- a/packages/cli/src/commands/project/transfer.ts +++ b/packages/cli/src/commands/project/transfer.ts @@ -38,11 +38,18 @@ import { listWorkspaceProjects, operationContext, type ProjectCommandContext, + resolveProjectPositional, } 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( + 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 * formatter does not port. */ @@ -174,7 +181,8 @@ export const projectTransferCommand = defineCommand({ args: { positionals: { project: positional.string({ - brief: "Project id or name", + brief: + "Project id or name, or its address: @ and the app name, such as @shop", placeholder: "id-or-name", }), }, @@ -192,10 +200,9 @@ 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.", + "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 +247,17 @@ export const projectTransferCommand = defineCommand({ } const projects = await listWorkspaceProjects(ctx); + const subject = await resolveProjectPositional( + ctx, + args.positionals.project, + ); const project = toProjectSummary( - resolveProjectForSetup( - args.positionals.project.trim(), - projects, - workspace, - ), + resolveProjectForSetup(subject?.ref ?? "", projects, workspace), ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); + await ctx.prompt.consent(consentQuestion(project, subject?.address), { + token: subject?.address ?? project.id, + }); const recipient = await resolveRecipient(ctx, { toWorkspace, diff --git a/packages/cli/src/commands/service/delete.ts b/packages/cli/src/commands/service/delete.ts index cf2b53b3..0ff63898 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 { describeEnvironment } from "../../lib/address/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 }, @@ -43,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: 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/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..3e83c198 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -1,4 +1,8 @@ -import type { CommandContext } from "@prisma/cli-engine"; +import { type CommandContext, positional } from "@prisma/cli-engine"; +import { + type AddressMatch, + resolveBranchScopedSubject, +} from "../../lib/address/address"; import { type AppProvider, type AppRecord, @@ -44,6 +48,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 address: @shop.api (production) or @pr-42:shop.api (environment pr-42)", + placeholder: "service", +}); + export type ServiceContext = Pick< CommandContext, | "api" @@ -251,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 @@ -457,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. */ @@ -480,7 +494,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,15 +509,26 @@ export async function resolveServiceReadState( options.serviceName, options.commandName, ); - const state = await resolveServiceProjectState(ctx, options); + const subject = await resolveBranchScopedSubject( + ctx.api, + "service", + requested, + ctx.signal, + { project: options.projectRef, branch: options.branchName }, + ); + 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); - return { ...state, service }; + const service = matchRequestedService(subject.ref, services, state.projectId); + return { ...state, service, address: subject.match }; } export interface ResolvedServiceDomainTarget { 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 }, 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/address/address.ts b/packages/cli/src/lib/address/address.ts new file mode 100644 index 00000000..3ca33313 --- /dev/null +++ b/packages/cli/src/lib/address/address.ts @@ -0,0 +1,616 @@ +/** + * 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, + 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"; +import { listProjectBranches } from "../branch/list"; + +const PRODUCTION_ENVIRONMENT = "production"; +const WHITESPACE = /\s/; + +const DEPLOY_LIST_COMMAND = `${CLI_NAME} deploy list`; +const BRANCH_LIST_COMMAND = `${CLI_NAME} branch list`; + +/** The kinds that live on one Branch. A Project spans every Branch. */ +export type BranchScopedKind = "service" | "database" | "bucket"; + +const BRANCH_SCOPED_KINDS: readonly BranchScopedKind[] = [ + "service", + "database", + "bucket", +]; + +const KIND_GROUP: Readonly> = { + service: "service", + database: "postgres", + bucket: "bucket", +}; + +/** 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; + readonly address: string; + readonly appName: string; +} + +/** The service, database or bucket row an address subject names. */ +export interface AddressMatch { + readonly id: string; + /** 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 ScopeFlags { + readonly project?: string | undefined; + readonly branch?: string | undefined; +} + +/** 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 isAddress(subject: string): boolean { + return subject.startsWith("@"); +} + +export function parseAddress(subject: string): AddressSubject { + if (!isAddress(subject)) { + throw malformedAddressError(subject, "It does not start with '@'."); + } + if (WHITESPACE.test(subject)) { + throw malformedAddressError(subject, "It contains a space."); + } + 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 is empty."); + } + const segments = address.split("."); + if (segments.some((segment) => segment === "")) { + throw malformedAddressError( + subject, + `The address "${address}" has an empty part between dots.`, + ); + } + return { text: subject, environment, address, appName: segments[0] ?? "" }; +} + +/** 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 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<{ readonly id: string; readonly name: string }> { + const parsed = parseAddress(subject); + if (parsed.environment !== undefined) { + throw projectEnvironmentError(parsed); + } + if (parsed.address !== parsed.appName) { + throw projectGivenRowAddressError(parsed); + } + return findProject(api, parsed, signal); +} + +/** The service, database or bucket row an address subject names. */ +export async function resolveBranchScopedAddress( + api: Api, + kind: BranchScopedKind, + subject: string, + signal: AbortSignal, +): 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 listRows(api, kind, parsed, project.id, branch.id, signal); + + if (rows.length > 1) { + throw rowsAmbiguousError(parsed, kind, rows); + } + const [row] = rows; + if (row !== undefined) { + return { + id: row.id, + text: parsed.text, + environment: parsed.environment ?? PRODUCTION_ENVIRONMENT, + project, + branch, + }; + } + + 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 listRows( + api, + otherKind, + parsed, + project.id, + branch.id, + signal, + ); + if (others.length > 0) { + throw wrongKindError(parsed, kind, otherKind); + } + } + 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 resolveBranchScopedSubject( + api: Api, + kind: BranchScopedKind, + subject: string, + signal: AbortSignal, + flags: ScopeFlags = {}, +): Promise { + if (!isAddress(subject)) { + return { + ref: subject, + project: flags.project, + branch: flags.branch, + match: undefined, + }; + } + rejectScopeFlags(subject, flags); + const match = await resolveBranchScopedAddress(api, kind, subject, signal); + return { + ref: match.id, + project: match.project.id, + branch: match.branch.gitName, + match, + }; +} + +/** The Project id an address subject names, or the subject unchanged. */ +export async function resolveProjectSubject( + api: Api, + subject: string, + signal: AbortSignal, +): Promise { + if (!isAddress(subject)) { + return subject; + } + const project = await resolveProjectAddress(api, subject, signal); + return project.id; +} + +function rejectScopeFlags(subject: string, flags: ScopeFlags): void { + const given = [ + ...(flags.project !== undefined ? ["--project"] : []), + ...(flags.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, + subject: AddressSubject, + signal: AbortSignal, +): Promise { + const projects = await listAllPages( + "Failed to look up the app's Project", + (cursor) => + api.GET("/v1/projects", { + params: { query: { logicalId: subject.appName, cursor } }, + signal, + }), + ); + const matches = projects.filter( + (project) => project.logicalId === subject.appName, + ); + if (matches.length > 1) { + throw new CliStructuredError( + "ADDRESS.AMBIGUOUS", + `Several Projects are named "${subject.appName}"`, + { + 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(", ")}.`, + ), + ], + }, + ); + } + const [project] = matches; + if (project === undefined) { + throw new CliStructuredError( + "ADDRESS.NOT_FOUND", + `No Project for app "${subject.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.", + ), + runCommand(`${CLI_NAME} project list`), + ], + }, + ); + } + return { id: project.id, name: project.name }; +} + +/** 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, + subject: AddressSubject, + project: AddressMatch["project"], + signal: AbortSignal, +): 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) { + throw new CliStructuredError( + "ADDRESS.NOT_FOUND", + `No branch for environment "${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: subject.text, + environment, + projectId: project.id, + }, + nextActions: [ + 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, + isDefault: branch.isDefault, + }; +} + +/** 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: BranchScopedKind, + subject: AddressSubject, + projectId: string, + branchId: string, + signal: AbortSignal, +): Promise { + const filters = { projectId, logicalId: subject.address, branchId }; + const rows = await listAllPages( + `Failed to look up ${kind}s by address`, + (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 === 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, +): CliStructuredError { + return new CliStructuredError( + "ADDRESS.USAGE_ERROR", + `"${subject}" is not a valid address`, + { + 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( + "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(subject: AddressSubject): CliStructuredError { + const projectAddress = `@${subject.appName}`; + return new CliStructuredError( + "ADDRESS.USAGE_ERROR", + `A Project's address takes no environment: "${subject.text}"`, + { + 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 projectGivenRowAddressError( + subject: AddressSubject, +): CliStructuredError { + const projectAddress = `@${subject.appName}`; + return new CliStructuredError( + "ADDRESS.WRONG_KIND", + `"${subject.text}" names something inside app "${subject.appName}", not its 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 "${subject.text}".`, + ), + ], + }, + ); +} + +function addressNamesProjectError( + subject: AddressSubject, + kind: BranchScopedKind, +): CliStructuredError { + const projectCommand = `${CLI_NAME} project show @${subject.appName}`; + return new CliStructuredError( + "ADDRESS.WRONG_KIND", + `"${subject.text}" names the app's Project, not a ${kind}`, + { + 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), + ], + }, + ); +} + +function wrongKindError( + subject: AddressSubject, + expected: BranchScopedKind, + actual: BranchScopedKind, +): CliStructuredError { + return new CliStructuredError( + "ADDRESS.WRONG_KIND", + `"${subject.text}" is a ${actual}, not a ${expected}`, + { + why: `The address names a ${actual}. The ${KIND_GROUP[actual]} commands act on it.`, + meta: { address: subject.text, expected, actual }, + nextActions: [ + userChoice( + `Use a '${CLI_NAME} ${KIND_GROUP[actual]}' command for ${formatCommandArgument(subject.text)}.`, + ), + runCommand(`${CLI_NAME} ${KIND_GROUP[actual]} --help`), + ], + }, + ); +} + +function rowNotFoundError( + subject: AddressSubject, + kind: BranchScopedKind, + project: AddressMatch["project"], + branch: AddressMatch["branch"], +): CliStructuredError { + return new CliStructuredError( + "ADDRESS.NOT_FOUND", + `Nothing is deployed at "${subject.text}"`, + { + why: `No service, database or bucket on branch "${branch.gitName}" of Project "${project.name}" (${project.id}) has address "${subject.address}".`, + meta: { + address: subject.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 rowsAmbiguousError( + subject: AddressSubject, + kind: BranchScopedKind, + rows: readonly LogicalRow[], +): CliStructuredError { + return new CliStructuredError( + "ADDRESS.AMBIGUOUS", + `Several ${kind} rows have address "${subject.text}"`, + { + 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(", ")}.`, + ), + ], + }, + ); +} + +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 }; +} 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.", + }, + ], + }); +} diff --git a/packages/cli/tests/address.test.ts b/packages/cli/tests/address.test.ts new file mode 100644 index 00000000..388d7f92 --- /dev/null +++ b/packages/cli/tests/address.test.ts @@ -0,0 +1,678 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { describe, expect, it } from "vitest"; + +import { + type AddressMatch, + describeEnvironment, + isAddress, + parseAddress, + resolveBranchScopedAddress, + resolveBranchScopedSubject, + resolveProjectAddress, + resolveProjectSubject, +} from "../src/lib/address/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("parseAddress", () => { + it("reads an address with no environment as production", () => { + expect(parseAddress("@shop.api")).toEqual({ + text: "@shop.api", + environment: undefined, + address: "shop.api", + appName: "shop", + }); + }); + + it("splits the environment off at the first colon", () => { + expect(parseAddress("@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(parseAddress("@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(() => parseAddress(subject)).toThrow( + expect.objectContaining({ + code: "ADDRESS.USAGE_ERROR", + meta: { address: subject }, + }), + ); + }); + + it("tells an address from an id or name by its leading @", () => { + expect(isAddress("@shop.api")).toBe(true); + expect(isAddress("db_catalog_main")).toBe(false); + expect(isAddress("shop.api")).toBe(false); + }); +}); + +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("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("resolveBranchScopedAddress", () => { + it("finds the row on the default branch when the address names no environment", async () => { + const { api, calls } = fakeApi(); + + 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", isDefault: true }, + }); + 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 resolveBranchScopedAddress( + api, + "service", + "@production:shop.api", + signal, + ); + + expect(node.id).toBe("svc_api_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, asking the API for that branch only", async () => { + const { api, calls } = fakeApi(); + + const node = await resolveBranchScopedAddress( + api, + "service", + "@pr-42:shop.api", + signal, + ); + + expect(node.id).toBe("svc_api_pr42"); + 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( + resolveBranchScopedAddress(api, "database", "@shop.catalog", signal), + ).resolves.toMatchObject({ id: "db_catalog_main" }); + await expect( + resolveBranchScopedAddress(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( + resolveBranchScopedAddress(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( + resolveBranchScopedAddress(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( + resolveBranchScopedAddress(api, "service", "@shop.api", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.AMBIGUOUS", + 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 resolveBranchScopedAddress( + 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( + resolveBranchScopedAddress(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( + resolveBranchScopedAddress(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 resolveBranchScopedAddress( + 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( + resolveBranchScopedAddress(api, "database", "@pr-42:shop", signal), + ).rejects.toMatchObject({ + code: "ADDRESS.WRONG_KIND", + meta: { expected: "database", actual: "project" }, + }); + 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, + databases: [ + ...(FIXTURE.databases ?? []), + { + id: "db_catalog_copy", + name: "catalog-copy", + projectId: "proj_shop", + branchId: "br_main", + logicalId: "shop.catalog", + }, + ], + }); + + await expect( + resolveBranchScopedAddress(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( + resolveBranchScopedAddress(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( + resolveBranchScopedAddress(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.USAGE_ERROR", + meta: { address: "@pr-42:shop" }, + }); + await expect( + resolveProjectAddress(api, "@production:shop", signal), + ).rejects.toMatchObject({ code: "ADDRESS.USAGE_ERROR" }); + expect(calls).toEqual([]); + }); + + 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", actual: "service, database or bucket" }, + }); + }); + + 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("resolveBranchScopedSubject", () => { + it("hands over the row id, Project id and branch git name an address names", async () => { + const { api } = fakeApi(); + + await expect( + 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" }, + }); + }); + + it("passes an id or name through with the flags it came with", async () => { + const { api, calls } = fakeApi(); + + await expect( + resolveBranchScopedSubject(api, "database", "catalog", signal, { + project: "Shop", + branch: "pr-42", + }), + ).resolves.toEqual({ + ref: "catalog", + project: "Shop", + branch: "pr-42", + match: undefined, + }); + 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( + resolveBranchScopedSubject(api, "service", "@shop.api", signal, scope), + ).rejects.toMatchObject({ + code: "ADDRESS.USAGE_ERROR", + meta: { address: "@shop.api", flags }, + }); + expect(calls).toEqual([]); + }); +}); diff --git a/packages/cli/tests/bucket.test.ts b/packages/cli/tests/bucket.test.ts index 236d6526..2c1e33c6 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,130 @@ describe("prisma bucket key delete", () => { }); }); }); + +describe("bucket commands given an 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", + summary: expect.stringContaining( + "bucket bkt_files_main in production (branch main)", + ), + 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/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", 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..bff423f4 --- /dev/null +++ b/packages/cli/tests/helpers/fake-address-api.ts @@ -0,0 +1,96 @@ +/** + * The listings address resolution reads, answered the way the Management + * 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 FakeRow { + readonly id: string; + readonly name: string; + readonly projectId: string; + readonly branchId: string | null; + readonly logicalId: string | null; +} + +export interface FakeProjectRow { + readonly id: string; + readonly name: string; + readonly logicalId: string | null; + readonly workspace: { readonly id: string; readonly name: string }; +} + +export interface FakeBranchRow { + readonly id: string; + readonly gitName: string; + readonly isDefault: boolean; + readonly role: "production" | "preview"; +} + +export interface AddressFixture { + readonly projects: readonly FakeProjectRow[]; + readonly branches: Readonly>; + readonly services?: readonly FakeRow[]; + readonly databases?: readonly FakeRow[]; + readonly buckets?: readonly FakeRow[]; +} + +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 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]] ?? []; + 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/postgres.test.ts b/packages/cli/tests/postgres.test.ts index 2fc93b45..61aad413 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,219 @@ describe("prisma postgres connection delete", () => { }); }); }); + +describe("postgres commands given an 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", + 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"], + { + 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..3d2a0edd 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -2996,3 +2996,225 @@ describe("prisma project transfer", () => { }); }); }); + +describe("project commands given an address", () => { + const ADDRESSED_PROJECTS = [ + { ...API_PROJECTS[0], logicalId: "billing" }, + { ...API_PROJECTS[1], logicalId: null }, + ]; + + function addressedClient(deleted: string[] = [], posted: 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: {} }; + }, + post: (apiPath, init) => { + posted.push( + `${apiPath} ${(init as { params: { path: { id: string } } }).params.path.id}`, + ); + return { data: { 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("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", + "@billing.api", + "--confirm", + "@billing.api", + "--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("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"], + { 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..67e17450 --- /dev/null +++ b/packages/cli/tests/service-address.test.ts @@ -0,0 +1,230 @@ +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) }); + +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(recorded: Recorded = recorder()) { + const base = releaseRoutes(); + 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; + 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 an address", () => { + it("acts on the service on the branch the environment names", async () => { + const recorded = recorder(); + const harness = await makeServiceCli({ routes: addressRoutes(recorded) }); + + 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(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 recorded = recorder(); + const harness = await makeServiceCli({ routes: addressRoutes(recorded) }); + + 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(recorded.appListings).toEqual(["main"]); + }); + + it("takes the address, not the service name, as the delete consent token", async () => { + const recorded = recorder(); + const harness = await makeServiceCli({ routes: addressRoutes(recorded) }); + + 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", + }); + expect(recorded.deletes).toEqual([]); + + 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 }); + 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 () => { + 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 }, + }); + }); +}); 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",