diff --git a/README.md b/README.md index dd68e43d..c9385e6d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ The CLI groups commands by developer workflow: - `project` - `git` - `branch` +- `database` +- `bucket` - `app` The canonical command shape is: diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index d06b5b82..c660901e 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -39,7 +39,7 @@ The long-term command surface grows through workflow groups such as: - `app` - `git` -The preview implements only `agent`, `auth`, `project`, `git`, `branch`, `database`, and `app`. +The preview implements only `agent`, `auth`, `project`, `git`, `branch`, `database`, `bucket`, and `app`. ## Stable Nouns @@ -50,6 +50,7 @@ The CLI should keep the meaning of these nouns stable: - `branch` - `schema` - `database` +- `bucket` - `app` - `deployment` - `domain` diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 82d1faf9..94b375ca 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -16,6 +16,7 @@ The beta package includes these command groups: - `git` - `branch` - `database` (includes `database connection` subgroup) +- `bucket` (includes `bucket key` subgroup) - `app` - `build` (includes `build logs`) @@ -1249,6 +1250,255 @@ Examples: prisma-cli database connection remove conn_123 --confirm conn_123 ``` +## `prisma-cli bucket` + +Manage Tigris-backed object-store buckets for a project. `bucket` is a +top-level group, parallel to `database`, because buckets are branch-scoped +platform resources with their own key-management subgroup. + +`bucket key` is nested under `bucket` because access keys exist only in the +context of one bucket. There is no `bucket key show` command because the secret +access key is a one-time-view secret: the platform returns it only from +`bucket key create`. + +### `prisma-cli bucket list --project --branch ` + +Purpose: + +- list object-store buckets for the resolved project + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- lists bucket metadata only: name, id, status, branch id, and created timestamp +- `--branch ` narrows the list to buckets attached to that branch +- does not create, delete, or mutate remote state +- uses the standard list JSON envelope with bucket metadata + +In `--json`, `result` uses this shape: + +```json +{ + "context": { "project": "Acme Dashboard" }, + "items": [ + { "name": "acme-preview-store", "id": "bkt_123", "status": "ready" } + ], + "count": 1, + "projectId": "proj_123", + "branchName": null, + "buckets": [ + { + "id": "bkt_123", + "name": "acme-preview-store", + "status": "ready", + "branchId": "br_123", + "createdAt": "2026-06-01T00:00:00.000Z" + } + ] +} +``` + +Examples: + +```bash +prisma-cli bucket list +prisma-cli bucket list --branch preview +prisma-cli bucket list --json +``` + +### `prisma-cli bucket create --name --project --branch ` + +Purpose: + +- create an object-store bucket in the resolved project + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- `--name ` sets the bucket display name; when omitted, the platform assigns an auto-generated name +- `--branch ` scopes the bucket to that branch; when omitted, the bucket is unscoped and visible across all branches +- the bucket is created with `ready` status when provisioning succeeds +- fails with `BRANCH_NOT_FOUND` when `--branch` names a branch that does not exist in the resolved project + +In `--json`, `result` uses this shape: + +```json +{ + "projectId": "proj_123", + "projectName": "Acme Dashboard", + "bucket": { + "id": "bkt_123", + "name": "acme-preview-store", + "status": "ready", + "branchId": "br_123", + "createdAt": "2026-06-01T00:00:00.000Z" + } +} +``` + +Examples: + +```bash +prisma-cli bucket create +prisma-cli bucket create --name my-store +prisma-cli bucket create --branch preview --json +``` + +### `prisma-cli bucket delete ` + +Purpose: + +- delete a bucket and all its access keys + +Behavior: + +- requires auth +- treats `` as the bucket id to delete +- removes the bucket and all its access keys through the Management API +- fails with `BUCKET_NOT_FOUND` when the bucket id does not exist +- deletion requires the bucket to be empty; deleting a bucket that still + contains objects currently fails with an internal Management API error + rather than a conflict error + +In `--json`, `result` uses this shape: + +```json +{ + "bucket": { "id": "bkt_123" } +} +``` + +Examples: + +```bash +prisma-cli bucket delete bkt_123 +prisma-cli bucket delete bkt_123 --json +``` + +### `prisma-cli bucket key list ` + +Purpose: + +- list access keys for a bucket + +Behavior: + +- requires auth +- lists key metadata only: name, id, role, value hint, and created timestamp +- never prints or returns the secret access key +- fails with `BUCKET_NOT_FOUND` when the bucket id does not exist + +In `--json`, `result` uses this shape: + +```json +{ + "context": { "bucket": "bkt_123" }, + "items": [ + { "name": "primary", "id": "bkey_123", "status": null } + ], + "count": 1, + "bucketId": "bkt_123", + "keys": [ + { + "id": "bkey_123", + "name": "primary", + "role": "read_write", + "valueHint": "AKIABKT123...", + "createdAt": "2026-06-01T00:00:00.000Z" + } + ] +} +``` + +Examples: + +```bash +prisma-cli bucket key list bkt_123 +prisma-cli bucket key list bkt_123 --json +``` + +### `prisma-cli bucket key create --role --name ` + +Purpose: + +- create an access key for a bucket and print its one-time credentials as an S3-compatible env block + +Behavior: + +- requires auth +- `--role ` sets the access role; defaults to `read_write`; Commander validates the choice at parse time — any other value is rejected as a usage error before the action runs +- `--name ` sets the key display name; when omitted, the platform assigns an auto-generated name +- the secret access key is a one-time-view secret: it is returned only from this command and cannot be retrieved again +- in default human mode, stderr shows a short creation summary with a "shown once" warning; stdout contains exactly four lines — the S3-compatible env block: + ``` + S3_ENDPOINT= + S3_ACCESS_KEY_ID= + S3_SECRET_ACCESS_KEY= + S3_BUCKET= + ``` +- human stderr does not repeat, label, or wrap the credential values +- `--quiet` suppresses successful stderr output and leaves stdout as exactly the four-line env block +- in `--json`, `result` contains the full credential set exactly once, including `secretAccessKey` +- fails with `BUCKET_NOT_FOUND` when the bucket id does not exist +- fails with `BUCKET_KEY_SECRET_MISSING` when the Management API response does not include the one-time credential payload + +In `--json`, `result` uses this shape: + +```json +{ + "bucketId": "bkt_123", + "key": { + "id": "bkey_456", + "name": "ci-key", + "role": "read", + "valueHint": "AKIABKT456...", + "createdAt": "2026-06-09T00:00:00.000Z" + }, + "secretAccessKey": "", + "accessKeyId": "", + "endpoint": "https://fly.storage.tigris.dev", + "bucketName": "acme-preview-store" +} +``` + +Examples: + +```bash +prisma-cli bucket key create bkt_123 +prisma-cli bucket key create bkt_123 --role read +prisma-cli bucket key create bkt_123 --name ci-key --role read_write +prisma-cli bucket key create bkt_123 --json +``` + +### `prisma-cli bucket key delete ` + +Purpose: + +- revoke and delete a bucket access key + +Behavior: + +- requires auth +- treats `` and `` as the bucket id and key id to target +- revokes and removes the access key through the Management API +- fails with `BUCKET_NOT_FOUND` when the bucket id does not exist +- fails with `BUCKET_KEY_NOT_FOUND` when the key id does not exist for the bucket + +In `--json`, `result` uses this shape: + +```json +{ + "key": { "id": "bkey_123" } +} +``` + +Examples: + +```bash +prisma-cli bucket key delete bkt_123 bkey_123 +prisma-cli bucket key delete bkt_123 bkey_123 --json +``` + ## `prisma-cli app build [app] --entry --build-type ` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 438a8c94..c9140791 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -143,7 +143,7 @@ Rules: - `ok` is always `false` - `command` is always present - `error.code` is stable and machine-readable -- `error.domain` is a stable logical area such as `cli`, `agent`, `auth`, `project`, `branch`, `app`, or `database` +- `error.domain` is a stable logical area such as `cli`, `agent`, `auth`, `project`, `branch`, `app`, `database`, or `bucket` - `error.severity` is stable and machine-readable - `error.summary` is the short human-readable headline - `error.why` explains the immediate cause when known @@ -228,6 +228,10 @@ These codes are the minimum stable set for the MVP: - `DATABASE_BACKUPS_UNSUPPORTED` - `DATABASE_BACKUP_NOT_FOUND` - `DATABASE_RESTORE_CONFLICT` +- `BUCKET_NOT_FOUND` +- `BUCKET_KEY_NOT_FOUND` +- `BUCKET_KEY_SECRET_MISSING` +- `BRANCH_NOT_FOUND` - `RUN_FAILED` - `DEPLOY_FAILED` - `VERSION_UNAVAILABLE` @@ -305,6 +309,10 @@ Recommended meanings: - `DATABASE_BACKUPS_UNSUPPORTED`: the platform does not manage backups for the database, for example remote/BYO databases - `DATABASE_BACKUP_NOT_FOUND`: requested backup id does not exist for the resolved source database - `DATABASE_RESTORE_CONFLICT`: restore target database is provisioning or already recovering +- `BUCKET_NOT_FOUND`: requested bucket id does not exist or is not accessible +- `BUCKET_KEY_NOT_FOUND`: requested key id does not exist for the resolved bucket +- `BUCKET_KEY_SECRET_MISSING`: bucket key creation succeeded but the API response did not include the one-time credential payload +- `BRANCH_NOT_FOUND`: the branch name passed to a bucket command does not exist in the resolved project - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully - `DEPLOY_FAILED`: deployment or post-build health failed - `VERSION_UNAVAILABLE`: CLI could not read its own bundled package metadata to report a version (defensive; not expected in normal installs) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 09c56d34..721343de 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -115,6 +115,12 @@ Current MVP commands map to patterns like this: | `database connection list` | `list` | | `database connection create` | compact mutate stderr + raw secret stdout + JSON envelope | | `database connection remove` | `mutate` | +| `bucket list` | `list` | +| `bucket create` | `mutate` | +| `bucket delete` | `mutate` | +| `bucket key list` | `list` | +| `bucket key create` | compact mutate stderr + S3 env-block stdout + JSON envelope | +| `bucket key delete` | `mutate` | No current MVP command uses `verify` or `inspect`, but new commands must still choose one existing pattern rather than inventing a new one casually. diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index c2474292..890f6cca 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -11,7 +11,7 @@ deployment, database, and environment variables. The Prisma CLI resolves through one hierarchy: ```text -workspace -> project -> branch -> { app, database } +workspace -> project -> branch -> { app, database, bucket } ``` The preview implementation exposes only part of this hierarchy, but current @@ -155,6 +155,43 @@ Rules: The `env` word is reserved for environment-variable ergonomics. The current top-level target-context group is `branch`, not `env`. +### Object Store and Bucket + +`bucket` is a branch-scoped object-store resource backed by Tigris. + +A bucket is created inside a project and may be scoped to a branch. Access to +its contents is controlled by access keys. Each access key has a role (`read` +or `read_write`) and is issued as a one-time secret: the secret access key is +returned only when the key is created and cannot be retrieved again. + +The `bucket key` subgroup manages access keys for a bucket. Its secret-reveal +behavior mirrors `database connection create`: the one-time credential is +written to stdout as a four-line S3-compatible env block, and human metadata +goes to stderr. + +Rules: + +- `bucket` is the canonical public group; Public Beta does not add public + `storage` or `object-store` aliases +- `bucket create` provisions the bucket; `--branch ` scopes it to + that branch +- `bucket key create` returns the secret access key exactly once, as an env + block on stdout: `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, + `S3_BUCKET` +- `bucket list` and `bucket key list` never print or return secret values +- successful bucket deletion also revokes all of the bucket's access keys; + deletion requires the bucket to be empty and currently fails with an + internal Management API error when it is not + +The beta package exposes: + +- `bucket list` +- `bucket create` +- `bucket delete ` +- `bucket key list ` +- `bucket key create ` +- `bucket key delete ` + ### Schema and Database `schema` stays a local code artifact. `database` stays a branch-bound remote @@ -217,10 +254,12 @@ workspace -> project project -> branch branch -> app* branch -> database* +branch -> bucket* app -> deployment* +bucket -> bucket_key* ``` -Long-term, branch is where app and database relationships meet. +Long-term, branch is where app, database, and bucket relationships meet. ## Invariants diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 50985293..35bf5224 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -18,7 +18,9 @@ output, and implementation. | Deployment | One build-and-release instance of an app. | [Resource model](../product/resource-model.md) | | Source revision | Code state a deployment was built from. | [Resource model](../product/resource-model.md) | | Schema | Local data model in the codebase. Out of scope for the current beta package. | [Resource model](../product/resource-model.md) | -| Database | Branch-bound data store. Out of scope for the current beta package. | [Resource model](../product/resource-model.md) | +| Database | Branch-bound Prisma Postgres data store managed by the `database` command group. | [Resource model](../product/resource-model.md) | +| Bucket | Branch-scoped Tigris object-store resource managed by the `bucket` command group. | [Resource model](../product/resource-model.md) | +| Bucket key | One-time-credential access key for a bucket, with role `read` or `read_write`. | [Resource model](../product/resource-model.md) | | Command group | First command segment after `prisma`, such as `auth` or `app`. | [Command spec](../product/command-spec.md) | | Action | Operation inside a command group, such as `deploy` or `whoami`. | [Command spec](../product/command-spec.md) | | Structured output | Explicit `--json` output intended for automation. | [Output conventions](../product/output-conventions.md) | diff --git a/packages/cli/README.md b/packages/cli/README.md index 15c23985..3a66740a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -76,6 +76,8 @@ The beta package exposes `prisma-cli` so it can coexist with the existing | `project` | List projects, show the resolved project, and manage project environment variables. | | `git` | Connect or disconnect a project from a GitHub repository. | | `branch` | List Prisma branches for the resolved project. | +| `database` | Create, inspect, and remove Prisma Postgres databases and their connection strings. | +| `bucket` | Create, list, and delete Tigris object-store buckets and their access keys. | | `app` | Build, run, deploy, inspect, open, stream logs, promote, roll back, and remove apps. | Common examples: diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index 6749e8b6..4d56507a 100644 --- a/packages/cli/fixtures/mock-api.json +++ b/packages/cli/fixtures/mock-api.json @@ -230,5 +230,33 @@ }, "generatedAt": "2026-07-01T00:00:00.000Z" } + ], + "buckets": [ + { + "id": "bkt_123", + "projectId": "proj_123", + "branchId": "br_123", + "name": "acme-preview-store", + "status": "ready", + "createdAt": "2026-06-01T00:00:00.000Z" + }, + { + "id": "bkt_456", + "projectId": "proj_123", + "branchId": "br_456", + "name": "acme-production-store", + "status": "ready", + "createdAt": "2026-06-02T00:00:00.000Z" + } + ], + "bucketKeys": [ + { + "id": "bkey_123", + "bucketId": "bkt_123", + "name": "primary", + "role": "read_write", + "valueHint": "AKIABKT123...", + "createdAt": "2026-06-01T00:00:00.000Z" + } ] } diff --git a/packages/cli/package.json b/packages/cli/package.json index 12bdc11c..ec677a43 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,7 +46,7 @@ "@clack/prompts": "^1.5.0", "@prisma/compute-sdk": "0.34.0", "@prisma/credentials-store": "^7.8.0", - "@prisma/management-api-sdk": "^1.46.0", + "@prisma/management-api-sdk": "^1.50.0", "better-result": "^2.9.2", "colorette": "^2.0.20", "commander": "^14.0.3", diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 8b43bc7e..0d0bd357 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -88,6 +88,28 @@ interface DatabaseUsageRecord { generatedAt: string; } +interface BucketRecord { + id: string; + projectId: string; + branchId: string | null; + name: string; + status: string; + createdAt: string; +} + +interface BucketKeyRecord { + id: string; + bucketId: string; + name: string; + role: "read" | "read_write"; + valueHint: string; + createdAt: string; + secretAccessKey?: string; + accessKeyId?: string; + endpoint?: string; + bucketName?: string; +} + interface MockApiData { providers: ProviderRecord[]; users: UserRecord[]; @@ -101,6 +123,8 @@ interface MockApiData { databaseBackups?: DatabaseBackupRecord[]; databaseUsage?: DatabaseUsageRecord[]; databaseBackupRetentionDays?: number; + buckets?: BucketRecord[]; + bucketKeys?: BucketKeyRecord[]; } export class MockApi { @@ -523,10 +547,145 @@ export class MockApi { connection.connectionString = connectionString; return { connection, connectionString }; } + + listBucketsForProject( + projectId: string, + branchName?: string, + ): BucketRecord[] { + return (this.data.buckets ?? []).filter( + (bucket) => + bucket.projectId === projectId && + (!branchName || + this.data.branches.find( + (branch) => + branch.id === bucket.branchId && branch.name === branchName, + )), + ); + } + + getBucket(bucketId: string): BucketRecord | undefined { + return (this.data.buckets ?? []).find((bucket) => bucket.id === bucketId); + } + + createBucket(input: { + projectId: string; + name?: string; + branchGitName?: string; + }): BucketRecord | undefined { + this.data.buckets ??= []; + + let branchId: string | null = null; + if (input.branchGitName) { + const branch = this.data.branches.find( + (b) => + b.projectId === input.projectId && b.name === input.branchGitName, + ); + if (!branch) { + return undefined; + } + branchId = branch.id; + } + + const bucket: BucketRecord = { + id: `bkt_${this.data.buckets.length + 1_000}`, + projectId: input.projectId, + branchId, + name: input.name ?? `bucket-${this.data.buckets.length + 1_000}`, + status: "ready", + createdAt: "2026-06-09T00:00:00.000Z", + }; + + this.data.buckets.push(bucket); + return bucket; + } + + deleteBucket(bucketId: string): BucketRecord | undefined { + this.data.buckets ??= []; + this.data.bucketKeys ??= []; + const bucket = this.getBucket(bucketId); + if (!bucket) { + return undefined; + } + + this.data.buckets = this.data.buckets.filter( + (candidate) => candidate.id !== bucketId, + ); + this.data.bucketKeys = this.data.bucketKeys.filter( + (key) => key.bucketId !== bucketId, + ); + return bucket; + } + + listBucketKeys(bucketId: string): BucketKeyRecord[] { + return (this.data.bucketKeys ?? []).filter( + (key) => key.bucketId === bucketId, + ); + } + + createBucketKey(input: { + bucketId: string; + name?: string; + role: "read" | "read_write"; + }): + | { + key: BucketKeyRecord; + secretAccessKey: string; + accessKeyId: string; + endpoint: string; + bucketName: string; + } + | undefined { + const bucket = this.getBucket(input.bucketId); + if (!bucket) { + return undefined; + } + + this.data.bucketKeys ??= []; + const secretAccessKey = `secret-${input.bucketId}-${this.data.bucketKeys.length + 1}`; + const accessKeyId = `AKIA${input.bucketId.toUpperCase().replace(/_/g, "")}${this.data.bucketKeys.length + 1}`; + const endpoint = `https://fly.storage.tigris.dev`; + const bucketName = bucket.name; + + const key: BucketKeyRecord = { + id: `bkey_${this.data.bucketKeys.length + 1_000}`, + bucketId: input.bucketId, + name: input.name ?? `key-${this.data.bucketKeys.length + 1_000}`, + role: input.role, + valueHint: `${accessKeyId.slice(0, 8)}...`, + createdAt: "2026-06-09T00:00:00.000Z", + secretAccessKey, + accessKeyId, + endpoint, + bucketName, + }; + + this.data.bucketKeys.push(key); + return { key, secretAccessKey, accessKeyId, endpoint, bucketName }; + } + + deleteBucketKey( + bucketId: string, + keyId: string, + ): BucketKeyRecord | undefined { + this.data.bucketKeys ??= []; + const key = (this.data.bucketKeys ?? []).find( + (candidate) => candidate.id === keyId && candidate.bucketId === bucketId, + ); + if (!key) { + return undefined; + } + + this.data.bucketKeys = this.data.bucketKeys.filter( + (candidate) => candidate.id !== keyId, + ); + return key; + } } export type { BranchRecord, + BucketKeyRecord, + BucketRecord, DatabaseConnectionRecord, DatabaseRecord, DeploymentRecord, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a13937d0..d1c1a6b2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,6 +6,7 @@ import { createAgentCommand } from "./commands/agent"; import { createAppCommand } from "./commands/app"; import { createAuthCommand } from "./commands/auth"; import { createBranchCommand } from "./commands/branch"; +import { createBucketCommand } from "./commands/bucket"; import { createBuildCommand } from "./commands/build"; import { createDatabaseCommand } from "./commands/database"; import { createFeedbackCommand } from "./commands/feedback"; @@ -113,6 +114,7 @@ export function createProgram(runtime: CliRuntime): Command { program.addCommand(createBranchCommand(runtime)); program.addCommand(createBuildCommand(runtime)); program.addCommand(createDatabaseCommand(runtime)); + program.addCommand(createBucketCommand(runtime)); program.addCommand(createAppCommand(runtime)); return program; diff --git a/packages/cli/src/commands/bucket/index.ts b/packages/cli/src/commands/bucket/index.ts new file mode 100644 index 00000000..c22eac36 --- /dev/null +++ b/packages/cli/src/commands/bucket/index.ts @@ -0,0 +1,264 @@ +import { Command, Option } from "commander"; + +import { + runBucketCreate, + runBucketDelete, + runBucketKeyCreate, + runBucketKeyDelete, + runBucketKeyList, + runBucketList, +} from "../../controllers/bucket"; +import { + renderBucketCreate, + renderBucketDelete, + renderBucketKeyCreate, + renderBucketKeyCreateStdout, + renderBucketKeyDelete, + renderBucketKeyList, + renderBucketList, + serializeBucketCreate, + serializeBucketDelete, + serializeBucketKeyCreate, + serializeBucketKeyDelete, + serializeBucketKeyList, + serializeBucketList, +} from "../../presenters/bucket"; +import { attachCommandDescriptor } from "../../shell/command-meta"; +import { runCommand } from "../../shell/command-runner"; +import { + addCompactGlobalFlags, + addGlobalFlags, +} from "../../shell/global-flags"; +import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; +import type { + BucketCreateResult, + BucketDeleteResult, + BucketKeyCreateResult, + BucketKeyDeleteResult, + BucketKeyListResult, + BucketListResult, +} from "../../types/bucket"; + +export function createBucketCommand(runtime: CliRuntime): Command { + const bucket = attachCommandDescriptor( + configureRuntimeCommand(new Command("bucket"), runtime), + "bucket", + ); + + addCompactGlobalFlags(bucket); + + bucket.addCommand(createBucketListCommand(runtime)); + bucket.addCommand(createBucketCreateCommand(runtime)); + bucket.addCommand(createBucketDeleteCommand(runtime)); + bucket.addCommand(createBucketKeyCommand(runtime)); + + return bucket; +} + +function addProjectAndBranchOptions(command: Command): Command { + return command + .addOption(new Option("--project ", "Project id or name")) + .addOption(new Option("--branch ", "Branch git name")); +} + +function createBucketListCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("list"), runtime), + "bucket.list", + ); + + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "bucket.list", + options as Record, + (context) => runBucketList(context, { projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => + renderBucketList(context, descriptor, result), + renderJson: (result) => serializeBucketList(result), + }, + ); + }); + + return command; +} + +function createBucketCreateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("create"), runtime), + "bucket.create", + ); + + command.addOption( + new Option( + "--name ", + "Bucket display name (auto-generated if omitted)", + ), + ); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const name = (options as { name?: string }).name; + + await runCommand( + runtime, + "bucket.create", + options as Record, + (context) => runBucketCreate(context, { projectRef, branchName, name }), + { + renderHuman: (context, descriptor, result) => + renderBucketCreate(context, descriptor, result), + renderJson: (result) => serializeBucketCreate(result), + }, + ); + }); + + return command; +} + +function createBucketDeleteCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("delete"), runtime), + "bucket.delete", + ); + + command.argument("", "Bucket id"); + addGlobalFlags(command); + + command.action(async (bucketId: string, options) => { + await runCommand( + runtime, + "bucket.delete", + options as Record, + (context) => runBucketDelete(context, bucketId), + { + renderHuman: (context, descriptor, result) => + renderBucketDelete(context, descriptor, result), + renderJson: (result) => serializeBucketDelete(result), + }, + ); + }); + + return command; +} + +function createBucketKeyCommand(runtime: CliRuntime): Command { + const key = attachCommandDescriptor( + configureRuntimeCommand(new Command("key"), runtime), + "bucket.key", + ); + + addCompactGlobalFlags(key); + + key.addCommand(createBucketKeyListCommand(runtime)); + key.addCommand(createBucketKeyCreateCommand(runtime)); + key.addCommand(createBucketKeyDeleteCommand(runtime)); + + return key; +} + +function createBucketKeyListCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("list"), runtime), + "bucket.key.list", + ); + + command.argument("", "Bucket id"); + addGlobalFlags(command); + + command.action(async (bucketId: string, options) => { + await runCommand( + runtime, + "bucket.key.list", + options as Record, + (context) => runBucketKeyList(context, bucketId), + { + renderHuman: (context, descriptor, result) => + renderBucketKeyList(context, descriptor, result), + renderJson: (result) => serializeBucketKeyList(result), + }, + ); + }); + + return command; +} + +function createBucketKeyCreateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("create"), runtime), + "bucket.key.create", + ); + + command + .argument("", "Bucket id") + .addOption( + new Option("--role ", "Access role (default: read_write)").choices([ + "read", + "read_write", + ]), + ) + .addOption( + new Option( + "--name ", + "Key display name (auto-generated if omitted)", + ), + ); + addGlobalFlags(command); + + command.action(async (bucketId: string, options) => { + const role = (options as { role?: string }).role; + const name = (options as { name?: string }).name; + + await runCommand( + runtime, + "bucket.key.create", + options as Record, + (context) => runBucketKeyCreate(context, bucketId, { role, name }), + { + renderStdout: (context, descriptor, result) => + renderBucketKeyCreateStdout(context, descriptor, result), + renderHuman: (context, descriptor, result) => + renderBucketKeyCreate(context, descriptor, result), + renderJson: (result) => serializeBucketKeyCreate(result), + }, + ); + }); + + return command; +} + +function createBucketKeyDeleteCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("delete"), runtime), + "bucket.key.delete", + ); + + command.argument("", "Bucket id").argument("", "Key id"); + addGlobalFlags(command); + + command.action(async (bucketId: string, keyId: string, options) => { + await runCommand( + runtime, + "bucket.key.delete", + options as Record, + (context) => runBucketKeyDelete(context, bucketId, keyId), + { + renderHuman: (context, descriptor, result) => + renderBucketKeyDelete(context, descriptor, result), + renderJson: (result) => serializeBucketKeyDelete(result), + }, + ); + }); + + return command; +} diff --git a/packages/cli/src/controllers/bucket.ts b/packages/cli/src/controllers/bucket.ts new file mode 100644 index 00000000..743cd37d --- /dev/null +++ b/packages/cli/src/controllers/bucket.ts @@ -0,0 +1,432 @@ +import { requireComputeAuth } from "../lib/auth/guard"; +import { + type BucketProvider, + createManagementBucketProvider, + normalizeBucket, + normalizeKey, +} from "../lib/bucket/provider"; +import { + projectResolutionErrorToCliError, + type ResolvedProjectTarget, + resolveProjectTarget, +} from "../lib/project/resolution"; +import { + authRequiredError, + CliError, + workspaceRequiredError, +} from "../shell/errors"; +import type { CommandSuccess } from "../shell/output"; +import type { CommandContext } from "../shell/runtime"; +import type { + BucketCreateResult, + BucketDeleteResult, + BucketKeyCreateResult, + BucketKeyDeleteResult, + BucketKeyListResult, + BucketListResult, +} from "../types/bucket"; +import { requireAuthenticatedAuthState } from "./auth"; +import { + listFixtureWorkspaceProjects, + listRealWorkspaceProjects, +} from "./project"; + +interface BucketCommandFlags { + projectRef?: string; + branchName?: string; +} + +interface BucketCreateFlags extends BucketCommandFlags { + name?: string; +} + +interface BucketKeyCreateFlags { + role?: string; + name?: string; +} + +interface ResolvedBucketContext { + provider: BucketProvider; + target: ResolvedProjectTarget; +} + +function isRealMode(context: CommandContext): boolean { + return ( + !context.runtime.fixturePath && + !context.runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH + ); +} + +export async function runBucketList( + context: CommandContext, + flags: BucketCommandFlags, +): Promise> { + const { provider, target } = await requireBucketContext( + context, + flags, + "bucket list", + ); + const buckets = await provider.listBuckets({ + projectId: target.project.id, + branchName: flags.branchName, + signal: context.runtime.signal, + }); + + return { + command: "bucket.list", + result: { + projectId: target.project.id, + projectName: target.project.name, + branchName: flags.branchName ?? null, + verboseContext: target, + buckets, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runBucketCreate( + context: CommandContext, + flags: BucketCreateFlags, +): Promise> { + const { provider, target } = await requireBucketContext( + context, + flags, + "bucket create", + ); + const bucket = await provider.createBucket({ + projectId: target.project.id, + name: flags.name?.trim() || undefined, + branchGitName: flags.branchName, + signal: context.runtime.signal, + }); + + return { + command: "bucket.create", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + bucket, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runBucketDelete( + context: CommandContext, + bucketId: string, +): Promise> { + const id = bucketId.trim(); + if (!id) { + throw new CliError({ + code: "USAGE_ERROR", + domain: "bucket", + summary: "Bucket id required", + why: "Bucket deletion needs a bucket id.", + fix: "Pass the bucket id to delete.", + exitCode: 2, + nextSteps: ["prisma-cli bucket list"], + }); + } + + const provider = await requireBucketProviderOnly(context); + await provider.deleteBucket(id, { signal: context.runtime.signal }); + + return { + command: "bucket.delete", + result: { + bucket: { id }, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runBucketKeyList( + context: CommandContext, + bucketId: string, +): Promise> { + const id = bucketId.trim(); + if (!id) { + throw new CliError({ + code: "USAGE_ERROR", + domain: "bucket", + summary: "Bucket id required", + why: "Bucket key listing needs a bucket id.", + fix: "Pass the bucket id.", + exitCode: 2, + nextSteps: ["prisma-cli bucket list"], + }); + } + + const provider = await requireBucketProviderOnly(context); + const keys = await provider.listKeys(id, { signal: context.runtime.signal }); + + return { + command: "bucket.key.list", + result: { + bucketId: id, + keys, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runBucketKeyCreate( + context: CommandContext, + bucketId: string, + flags: BucketKeyCreateFlags, +): Promise> { + const id = bucketId.trim(); + if (!id) { + throw new CliError({ + code: "USAGE_ERROR", + domain: "bucket", + summary: "Bucket id required", + why: "Bucket key creation needs a bucket id.", + fix: "Pass the bucket id.", + exitCode: 2, + nextSteps: ["prisma-cli bucket list"], + }); + } + + const role = resolveKeyRole(flags.role); + const provider = await requireBucketProviderOnly(context); + const created = await provider.createKey({ + bucketId: id, + name: flags.name?.trim() || undefined, + role, + signal: context.runtime.signal, + }); + + return { + command: "bucket.key.create", + result: { + bucketId: id, + key: created.key, + secretAccessKey: created.secretAccessKey, + accessKeyId: created.accessKeyId, + endpoint: created.endpoint, + bucketName: created.bucketName, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runBucketKeyDelete( + context: CommandContext, + bucketId: string, + keyId: string, +): Promise> { + const bktId = bucketId.trim(); + const kId = keyId.trim(); + if (!bktId || !kId) { + throw new CliError({ + code: "USAGE_ERROR", + domain: "bucket", + summary: "Bucket id and key id required", + why: "Bucket key deletion needs both a bucket id and a key id.", + fix: "Pass the bucket id and key id.", + exitCode: 2, + nextSteps: ["prisma-cli bucket key list "], + }); + } + + const provider = await requireBucketProviderOnly(context); + await provider.deleteKey(bktId, kId, { signal: context.runtime.signal }); + + return { + command: "bucket.key.delete", + result: { + key: { id: kId }, + }, + warnings: [], + nextSteps: [], + }; +} + +async function resolveBucketProvider( + context: CommandContext, +): Promise { + if (isRealMode(context)) { + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(); + } + return createManagementBucketProvider(client); + } + return createFixtureBucketProvider(context); +} + +async function requireBucketContext( + context: CommandContext, + flags: BucketCommandFlags, + commandName: string, +): Promise { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + if (isRealMode(context)) { + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(); + } + + const targetResult = await resolveProjectTarget({ + context, + workspace, + explicitProject: flags.projectRef, + listProjects: () => + listRealWorkspaceProjects(client, workspace, context.runtime.signal), + commandName, + }); + if (targetResult.isErr()) { + throw projectResolutionErrorToCliError(targetResult.error); + } + + return { + provider: createManagementBucketProvider(client), + target: targetResult.value, + }; + } + + const targetResult = await resolveProjectTarget({ + context, + workspace, + explicitProject: flags.projectRef, + listProjects: async () => listFixtureWorkspaceProjects(context, workspace), + commandName, + }); + if (targetResult.isErr()) { + throw projectResolutionErrorToCliError(targetResult.error); + } + + return { + provider: createFixtureBucketProvider(context), + target: targetResult.value, + }; +} + +async function requireBucketProviderOnly( + context: CommandContext, +): Promise { + await requireAuthenticatedAuthState(context); + return resolveBucketProvider(context); +} + +function createFixtureBucketProvider(context: CommandContext): BucketProvider { + return { + async listBuckets(options) { + return context.api + .listBucketsForProject(options.projectId, options.branchName) + .map((bucket) => normalizeBucket(bucket)); + }, + + async createBucket(options) { + const created = context.api.createBucket({ + projectId: options.projectId, + name: options.name, + branchGitName: options.branchGitName, + }); + if (!created) { + throw branchNotFoundError(options.branchGitName ?? ""); + } + return normalizeBucket(created); + }, + + async deleteBucket(bucketId) { + const removed = context.api.deleteBucket(bucketId); + if (!removed) { + throw bucketNotFoundError(bucketId); + } + }, + + async listKeys(bucketId) { + if (!context.api.getBucket(bucketId)) { + throw bucketNotFoundError(bucketId); + } + return context.api + .listBucketKeys(bucketId) + .map((key) => normalizeKey(key)); + }, + + async createKey(options) { + const created = context.api.createBucketKey({ + bucketId: options.bucketId, + name: options.name, + role: options.role, + }); + if (!created) { + throw bucketNotFoundError(options.bucketId); + } + return { + key: normalizeKey(created.key), + secretAccessKey: created.secretAccessKey, + accessKeyId: created.accessKeyId, + endpoint: created.endpoint, + bucketName: created.bucketName, + }; + }, + + async deleteKey(bucketId, keyId) { + const removed = context.api.deleteBucketKey(bucketId, keyId); + if (!removed) { + throw keyNotFoundError(keyId, bucketId); + } + }, + }; +} + +function resolveKeyRole(role: string | undefined): "read" | "read_write" { + return role === "read" ? "read" : "read_write"; +} + +function branchNotFoundError(branchGitName: string): CliError { + return new CliError({ + code: "BRANCH_NOT_FOUND", + domain: "bucket", + summary: "Branch not found", + why: `No branch matched "${branchGitName}" in the resolved project.`, + fix: "Pass a branch git name from prisma-cli branch list.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); +} + +function bucketNotFoundError(bucketId: string): CliError { + return new CliError({ + code: "BUCKET_NOT_FOUND", + domain: "bucket", + summary: "Bucket not found", + why: `No bucket matched "${bucketId}".`, + fix: "Pass a bucket id from prisma-cli bucket list.", + exitCode: 1, + nextSteps: ["prisma-cli bucket list"], + }); +} + +function keyNotFoundError(keyId: string, bucketId: string): CliError { + return new CliError({ + code: "BUCKET_KEY_NOT_FOUND", + domain: "bucket", + summary: "Bucket key not found", + why: `No key matched "${keyId}" for bucket "${bucketId}".`, + fix: "Pass a key id from prisma-cli bucket key list .", + exitCode: 1, + nextSteps: [`prisma-cli bucket key list ${bucketId}`], + }); +} diff --git a/packages/cli/src/lib/bucket/provider.ts b/packages/cli/src/lib/bucket/provider.ts new file mode 100644 index 00000000..741996c7 --- /dev/null +++ b/packages/cli/src/lib/bucket/provider.ts @@ -0,0 +1,309 @@ +// biome-ignore-all lint/performance/noAwaitInLoops: Bucket pagination requests must run sequentially. +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import { CliError } from "../../shell/errors"; +import type { BucketKeySummary, BucketSummary } from "../../types/bucket"; + +export interface BucketCreateInput { + projectId: string; + name?: string; + branchGitName?: string; + signal?: AbortSignal; +} + +export interface BucketKeyCreateInput { + bucketId: string; + name?: string; + role: "read" | "read_write"; + signal?: AbortSignal; +} + +export interface BucketKeyCreateRecord { + key: BucketKeySummary; + secretAccessKey: string; + accessKeyId: string; + endpoint: string; + bucketName: string; +} + +export interface BucketProvider { + listBuckets(options: { + projectId: string; + branchName?: string; + signal?: AbortSignal; + }): Promise; + createBucket(options: BucketCreateInput): Promise; + deleteBucket( + bucketId: string, + options?: { signal?: AbortSignal }, + ): Promise; + listKeys( + bucketId: string, + options?: { signal?: AbortSignal }, + ): Promise; + createKey(options: BucketKeyCreateInput): Promise; + deleteKey( + bucketId: string, + keyId: string, + options?: { signal?: AbortSignal }, + ): Promise; +} + +interface RawApiErrorBody { + error?: { + code?: string; + message?: string; + hint?: string; + }; +} + +interface RawBucketRecord { + id: string; + name: string; + status: string; + branchId: string | null; + createdAt: string; + project?: { + id: string; + } | null; +} + +interface RawBucketKeyRecord { + id: string; + name: string; + role: "read" | "read_write"; + valueHint: string; + createdAt: string; + secretAccessKey?: string; + accessKeyId?: string; + endpoint?: string; + bucketName?: string; +} + +export function createManagementBucketProvider( + client: ManagementApiClient, +): BucketProvider { + return { + async listBuckets(options) { + const buckets: RawBucketRecord[] = []; + let cursor: string | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await client.GET("/v1/buckets", { + params: { + query: { + projectId: options.projectId, + branchGitName: options.branchName, + cursor, + }, + }, + signal: options.signal, + }); + if (result.error || !result.data) { + throw bucketApiError( + "Failed to list buckets", + result.response, + result.error as RawApiErrorBody | undefined, + ); + } + + const data = result.data as { + data: RawBucketRecord[]; + pagination: { hasMore: boolean; nextCursor: string | null }; + }; + buckets.push(...data.data); + + if (!data.pagination.hasMore || !data.pagination.nextCursor) { + break; + } + cursor = data.pagination.nextCursor; + } + + return buckets.map(normalizeBucket); + }, + + async createBucket(options) { + const result = await client.POST("/v1/buckets", { + body: { + projectId: options.projectId, + ...(options.name ? { name: options.name } : {}), + ...(options.branchGitName + ? { branchGitName: options.branchGitName } + : {}), + }, + signal: options.signal, + }); + if (result.error || !result.data) { + throw bucketApiError( + "Failed to create bucket", + result.response, + result.error as RawApiErrorBody | undefined, + ); + } + + const data = result.data as { data: RawBucketRecord }; + return normalizeBucket(data.data); + }, + + async deleteBucket(bucketId, options) { + const result = await client.DELETE("/v1/buckets/{bucketId}", { + params: { + path: { bucketId }, + }, + signal: options?.signal, + }); + if (result.error) { + throw bucketApiError( + "Failed to delete bucket", + result.response, + result.error as RawApiErrorBody | undefined, + ); + } + }, + + async listKeys(bucketId, options) { + const keys: RawBucketKeyRecord[] = []; + let cursor: string | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await client.GET("/v1/buckets/{bucketId}/keys", { + params: { + path: { bucketId }, + query: { cursor }, + }, + signal: options?.signal, + }); + if (result.error || !result.data) { + throw bucketApiError( + "Failed to list bucket keys", + result.response, + result.error as RawApiErrorBody | undefined, + ); + } + + const data = result.data as { + data: RawBucketKeyRecord[]; + pagination: { hasMore: boolean; nextCursor: string | null }; + }; + keys.push(...data.data); + + if (!data.pagination.hasMore || !data.pagination.nextCursor) { + break; + } + cursor = data.pagination.nextCursor; + } + + return keys.map(normalizeKey); + }, + + async createKey(options) { + const result = await client.POST("/v1/buckets/{bucketId}/keys", { + params: { + path: { bucketId: options.bucketId }, + }, + body: { + role: options.role, + ...(options.name ? { name: options.name } : {}), + }, + signal: options.signal, + }); + if (result.error || !result.data) { + throw bucketApiError( + "Failed to create bucket key", + result.response, + result.error as RawApiErrorBody | undefined, + ); + } + + const data = result.data as { data: RawBucketKeyRecord }; + const raw = data.data; + + const secretAccessKey = raw.secretAccessKey; + const accessKeyId = raw.accessKeyId; + const endpoint = raw.endpoint; + const bucketName = raw.bucketName; + + if (!secretAccessKey || !accessKeyId || !endpoint || !bucketName) { + throw new CliError({ + code: "BUCKET_KEY_SECRET_MISSING", + domain: "bucket", + summary: "Created bucket key did not return credentials", + why: "Bucket key credentials are one-time-view secrets, but the Management API did not include them in this create response.", + fix: "Create another bucket key and store the returned credentials immediately.", + exitCode: 1, + nextSteps: [`prisma-cli bucket key create ${options.bucketId}`], + }); + } + + return { + key: normalizeKey(raw), + secretAccessKey, + accessKeyId, + endpoint, + bucketName, + }; + }, + + async deleteKey(bucketId, keyId, options) { + const result = await client.DELETE( + "/v1/buckets/{bucketId}/keys/{keyId}", + { + params: { + path: { bucketId, keyId }, + }, + signal: options?.signal, + }, + ); + if (result.error) { + throw bucketApiError( + "Failed to delete bucket key", + result.response, + result.error as RawApiErrorBody | undefined, + ); + } + }, + }; +} + +export function normalizeBucket(raw: RawBucketRecord): BucketSummary { + return { + id: raw.id, + name: raw.name, + status: raw.status, + branchId: raw.branchId, + createdAt: raw.createdAt, + }; +} + +export function normalizeKey(raw: RawBucketKeyRecord): BucketKeySummary { + return { + id: raw.id, + name: raw.name, + role: raw.role, + valueHint: raw.valueHint, + createdAt: raw.createdAt, + }; +} + +function bucketApiError( + summary: string, + response: Response | undefined, + error: RawApiErrorBody | undefined, +): CliError { + const status = response?.status ?? 0; + return new CliError({ + code: error?.error?.code ?? "BUCKET_API_ERROR", + domain: "bucket", + summary, + why: + error?.error?.message ?? + `The Management API returned status ${status || "unknown"}.`, + fix: + error?.error?.hint ?? + "Re-run with --trace for the underlying API response details.", + exitCode: 1, + nextSteps: [], + }); +} diff --git a/packages/cli/src/presenters/bucket.ts b/packages/cli/src/presenters/bucket.ts new file mode 100644 index 00000000..17fc4ecd --- /dev/null +++ b/packages/cli/src/presenters/bucket.ts @@ -0,0 +1,254 @@ +import { renderMutate, serializeList } from "../output/patterns"; +import type { CommandDescriptor } from "../shell/command-meta"; +import { formatDescriptorLabel } from "../shell/command-meta"; +import type { CommandContext } from "../shell/runtime"; +import { formatColumns, renderSummaryLine } from "../shell/ui"; +import type { + BucketCreateResult, + BucketDeleteResult, + BucketKeyCreateResult, + BucketKeyDeleteResult, + BucketKeyListResult, + BucketListResult, +} from "../types/bucket"; +import { + renderResolvedProjectContextBlock, + stripVerboseContext, +} from "./verbose-context"; + +export function renderBucketList( + context: CommandContext, + descriptor: CommandDescriptor, + result: BucketListResult, +): string[] { + const ui = context.ui; + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing object-store buckets for the resolved project.")}`, + "", + ]; + const rail = ui.dim("│"); + lines.push(`${rail} ${ui.accent("project:")} ${result.projectName}`); + if (result.branchName) { + lines.push(`${rail} ${ui.accent("branch:")} ${result.branchName}`); + } + lines.push(rail); + + if (result.buckets.length === 0) { + lines.push(`${rail} ${ui.dim("No buckets found.")}`); + lines.push( + ...renderResolvedProjectContextBlock(context.ui, result.verboseContext), + ); + return lines; + } + + const rows = result.buckets.map((bucket) => [ + bucket.name, + bucket.id, + bucket.status, + bucket.branchId ?? "unscoped", + bucket.createdAt, + ]); + const widths = [ + Math.max("Name".length, ...rows.map((row) => row[0].length)), + Math.max("Id".length, ...rows.map((row) => row[1].length)), + Math.max("Status".length, ...rows.map((row) => row[2].length)), + Math.max("Branch".length, ...rows.map((row) => row[3].length)), + Math.max("Created".length, ...rows.map((row) => row[4].length)), + ]; + + lines.push( + `${rail} ${ui.accent(formatColumns(["Name", "Id", "Status", "Branch", "Created"], widths))}`, + ); + for (const row of rows) { + lines.push(`${rail} ${formatColumns(row, widths)}`); + } + + lines.push( + ...renderResolvedProjectContextBlock(context.ui, result.verboseContext), + ); + return lines; +} + +export function serializeBucketList(result: BucketListResult) { + return { + context: { + project: result.projectName, + ...(result.branchName ? { branch: result.branchName } : {}), + }, + items: result.buckets.map((bucket) => ({ + name: bucket.name, + id: bucket.id, + status: bucket.status, + })), + count: result.buckets.length, + projectId: result.projectId, + branchName: result.branchName, + buckets: result.buckets, + }; +} + +export function renderBucketCreate( + context: CommandContext, + _descriptor: CommandDescriptor, + result: BucketCreateResult, +): string[] { + const ui = context.ui; + return [ + "Creating bucket...", + renderSummaryLine( + ui, + "success", + `Created bucket "${result.bucket.name}" in ${formatBucketTarget(result.projectName, result.bucket.branchId)}.`, + ), + ]; +} + +export function serializeBucketCreate(result: BucketCreateResult) { + return stripVerboseContext(result); +} + +export function renderBucketDelete( + context: CommandContext, + descriptor: CommandDescriptor, + result: BucketDeleteResult, +): string[] { + return renderMutate( + { + title: "Deleting object-store bucket.", + descriptor, + context: [{ key: "bucket", value: result.bucket.id, tone: "dim" }], + operationDescription: "Deleting bucket", + operationCount: 1, + details: ["Bucket and all its access keys were removed."], + }, + context.ui, + ); +} + +export function serializeBucketDelete(result: BucketDeleteResult) { + return { bucket: result.bucket }; +} + +export function renderBucketKeyList( + context: CommandContext, + descriptor: CommandDescriptor, + result: BucketKeyListResult, +): string[] { + const ui = context.ui; + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing access keys for bucket.")}`, + "", + ]; + const rail = ui.dim("│"); + lines.push(`${rail} ${ui.accent("bucket:")} ${result.bucketId}`); + lines.push(rail); + + if (result.keys.length === 0) { + lines.push(`${rail} ${ui.dim("No keys found.")}`); + return lines; + } + + const rows = result.keys.map((key) => [ + key.name, + key.id, + key.role, + key.valueHint, + key.createdAt, + ]); + const widths = [ + Math.max("Name".length, ...rows.map((row) => row[0].length)), + Math.max("Id".length, ...rows.map((row) => row[1].length)), + Math.max("Role".length, ...rows.map((row) => row[2].length)), + Math.max("Hint".length, ...rows.map((row) => row[3].length)), + Math.max("Created".length, ...rows.map((row) => row[4].length)), + ]; + + lines.push( + `${rail} ${ui.accent(formatColumns(["Name", "Id", "Role", "Hint", "Created"], widths))}`, + ); + for (const row of rows) { + lines.push(`${rail} ${formatColumns(row, widths)}`); + } + + return lines; +} + +export function serializeBucketKeyList(result: BucketKeyListResult) { + return { + ...serializeList({ + context: { bucket: result.bucketId }, + items: result.keys.map((key) => ({ + noun: "key", + label: key.name, + id: key.id, + status: null, + })), + }), + bucketId: result.bucketId, + keys: result.keys, + }; +} + +export function renderBucketKeyCreateStdout( + _context: CommandContext, + _descriptor: CommandDescriptor, + result: BucketKeyCreateResult, +): string[] { + return [ + `S3_ENDPOINT=${result.endpoint}`, + `S3_ACCESS_KEY_ID=${result.accessKeyId}`, + `S3_SECRET_ACCESS_KEY=${result.secretAccessKey}`, + `S3_BUCKET=${result.bucketName}`, + ]; +} + +export function renderBucketKeyCreate( + context: CommandContext, + _descriptor: CommandDescriptor, + result: BucketKeyCreateResult, +): string[] { + const ui = context.ui; + return [ + "Creating bucket key...", + renderSummaryLine( + ui, + "success", + `Created key "${result.key.name}" for bucket "${result.bucketName}".`, + ), + " The credentials below are shown once — copy them now.", + " Set these environment variables to use this bucket:", + ]; +} + +export function serializeBucketKeyCreate(result: BucketKeyCreateResult) { + return result; +} + +export function renderBucketKeyDelete( + context: CommandContext, + descriptor: CommandDescriptor, + result: BucketKeyDeleteResult, +): string[] { + return renderMutate( + { + title: "Deleting bucket access key.", + descriptor, + context: [{ key: "key", value: result.key.id, tone: "dim" }], + operationDescription: "Deleting bucket key", + operationCount: 1, + details: ["The access key was revoked and removed."], + }, + context.ui, + ); +} + +export function serializeBucketKeyDelete(result: BucketKeyDeleteResult) { + return { key: result.key }; +} + +function formatBucketTarget( + projectName: string, + branchId: string | null, +): string { + return branchId ? `${projectName} / ${branchId}` : projectName; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 4a11edd6..441cdbb3 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -225,6 +225,78 @@ const DESCRIPTORS: CommandDescriptor[] = [ "prisma-cli database connection create db_123", ], }, + { + id: "bucket", + path: ["prisma", "bucket"], + description: "Manage object-store buckets for a project", + examples: [ + "prisma-cli bucket list", + "prisma-cli bucket create", + "prisma-cli bucket key create bkt_123", + ], + }, + { + id: "bucket.list", + path: ["prisma", "bucket", "list"], + description: "List object-store buckets for the resolved project", + examples: [ + "prisma-cli bucket list", + "prisma-cli bucket list --branch preview", + "prisma-cli bucket list --json", + ], + }, + { + id: "bucket.create", + path: ["prisma", "bucket", "create"], + description: "Create an object-store bucket", + examples: [ + "prisma-cli bucket create", + "prisma-cli bucket create --name my-store", + "prisma-cli bucket create --branch preview --json", + ], + }, + { + id: "bucket.delete", + path: ["prisma", "bucket", "delete"], + description: "Delete a bucket and all its access keys", + examples: ["prisma-cli bucket delete bkt_123"], + }, + { + id: "bucket.key", + path: ["prisma", "bucket", "key"], + description: "Manage access keys for an object-store bucket", + examples: [ + "prisma-cli bucket key list bkt_123", + "prisma-cli bucket key create bkt_123", + "prisma-cli bucket key delete bkt_123 bkey_456", + ], + }, + { + id: "bucket.key.list", + path: ["prisma", "bucket", "key", "list"], + description: "List access keys for a bucket", + examples: [ + "prisma-cli bucket key list bkt_123", + "prisma-cli bucket key list bkt_123 --json", + ], + }, + { + id: "bucket.key.create", + path: ["prisma", "bucket", "key", "create"], + description: + "Create a bucket access key and print its one-time credentials", + examples: [ + "prisma-cli bucket key create bkt_123", + "prisma-cli bucket key create bkt_123 --role read", + "prisma-cli bucket key create bkt_123 --name ci-key --role read_write", + ], + }, + { + id: "bucket.key.delete", + path: ["prisma", "bucket", "key", "delete"], + description: "Revoke and delete a bucket access key", + examples: ["prisma-cli bucket key delete bkt_123 bkey_456"], + }, { id: "git", path: ["prisma", "git"], diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index 4a45beb4..87230725 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -6,7 +6,8 @@ export type ErrorDomain = | "project" | "branch" | "app" - | "database"; + | "database" + | "bucket"; export type ErrorSeverity = "error"; export interface CliErrorOptions { diff --git a/packages/cli/src/types/bucket.ts b/packages/cli/src/types/bucket.ts new file mode 100644 index 00000000..2a810e82 --- /dev/null +++ b/packages/cli/src/types/bucket.ts @@ -0,0 +1,65 @@ +import type { AuthWorkspace } from "./auth"; +import type { ProjectResolution, ProjectSummary } from "./project"; + +export interface BucketResolvedContext { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; +} + +export interface BucketSummary { + id: string; + name: string; + status: string; + branchId: string | null; + createdAt: string; +} + +export interface BucketKeySummary { + id: string; + name: string; + role: "read" | "read_write"; + valueHint: string; + createdAt: string; +} + +export interface BucketListResult { + projectId: string; + projectName: string; + branchName: string | null; + verboseContext?: BucketResolvedContext; + buckets: BucketSummary[]; +} + +export interface BucketCreateResult { + projectId: string; + projectName: string; + verboseContext?: BucketResolvedContext; + bucket: BucketSummary; +} + +export interface BucketDeleteResult { + bucket: { + id: string; + }; +} + +export interface BucketKeyListResult { + bucketId: string; + keys: BucketKeySummary[]; +} + +export interface BucketKeyCreateResult { + bucketId: string; + key: BucketKeySummary; + secretAccessKey: string; + accessKeyId: string; + endpoint: string; + bucketName: string; +} + +export interface BucketKeyDeleteResult { + key: { + id: string; + }; +} diff --git a/packages/cli/tests/bucket.test.ts b/packages/cli/tests/bucket.test.ts new file mode 100644 index 00000000..305c68dc --- /dev/null +++ b/packages/cli/tests/bucket.test.ts @@ -0,0 +1,430 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; + +import { createTempCwd, executeCli } from "./helpers"; + +const fixturePath = path.resolve("fixtures/mock-api.json"); + +async function login(cwd: string, stateDir: string) { + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); +} + +async function writeLocalPin(cwd: string, projectId = "proj_123") { + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile( + path.join(cwd, ".prisma/local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId }, null, 2)}\n`, + "utf8", + ); +} + +async function setupLinkedProject() { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + await writeLocalPin(cwd); + return { cwd, stateDir }; +} + +describe("bucket commands", () => { + it("renders bucket and key help", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const root = await executeCli({ + argv: ["--help"], + cwd, + stateDir, + fixturePath, + }); + const bucket = await executeCli({ + argv: ["bucket", "--help"], + cwd, + stateDir, + fixturePath, + }); + const key = await executeCli({ + argv: ["bucket", "key", "--help"], + cwd, + stateDir, + fixturePath, + }); + + expect(root.exitCode).toBe(0); + expect(root.stderr).toMatch( + /bucket\s+Manage object-store buckets for a project/, + ); + + expect(bucket.exitCode).toBe(0); + const bucketHelp = stripAnsi(bucket.stderr).replace(/[ \t]+\n/g, "\n"); + expect(bucketHelp).toContain( + "bucket → Manage object-store buckets for a project", + ); + expect(bucketHelp).toContain("list"); + expect(bucketHelp).toContain("create"); + expect(bucketHelp).toContain("delete"); + expect(bucketHelp).toContain("key"); + + expect(key.exitCode).toBe(0); + const keyHelp = stripAnsi(key.stderr).replace(/[ \t]+\n/g, "\n"); + expect(keyHelp).toContain( + "bucket key → Manage access keys for an object-store bucket", + ); + expect(keyHelp).toContain("list"); + expect(keyHelp).toContain("create"); + expect(keyHelp).toContain("delete"); + }); + + it("lists buckets with the standard JSON envelope", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "list", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "bucket.list", + result: { + context: { + project: "Acme Dashboard", + }, + items: [ + { name: "acme-preview-store", id: "bkt_123" }, + { name: "acme-production-store", id: "bkt_456" }, + ], + count: 2, + projectId: "proj_123", + }, + warnings: [], + nextSteps: [], + }); + }); + + it("lists buckets in human-readable format", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "list"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(stderr).toContain("acme-preview-store"); + expect(stderr).toContain("bkt_123"); + expect(stderr).toContain("Acme Dashboard"); + expect(stderr).toContain("Name"); + expect(stderr).toContain("Id"); + expect(stderr).toContain("Status"); + }); + + it("creates a bucket and returns JSON with bucket metadata", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "create", "--name", "my-new-store", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "bucket.create", + result: { + bucket: { + name: "my-new-store", + status: "ready", + }, + projectName: "Acme Dashboard", + }, + }); + expect(payload.result.bucket.id).toMatch(/^bkt_/); + }); + + it("creates a bucket without a name (auto-generated)", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "create", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.bucket.id).toMatch(/^bkt_/); + expect(payload.result.bucket.name).toBeTruthy(); + }); + + it("prints a human success message when creating a bucket", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "create", "--name", "my-store"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(stderr).toContain('Created bucket "my-store"'); + expect(stderr).toContain("Acme Dashboard"); + }); + + it("deletes a bucket by id", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "delete", "bkt_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "bucket.delete", + result: { + bucket: { id: "bkt_123" }, + }, + }); + }); + + it("fails with BUCKET_NOT_FOUND when deleting an unknown bucket", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "delete", "bkt_999", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload).toMatchObject({ + ok: false, + command: "bucket.delete", + error: { code: "BUCKET_NOT_FOUND", domain: "bucket" }, + }); + }); + + it("lists bucket keys with the standard JSON envelope", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "list", "bkt_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "bucket.key.list", + result: { + bucketId: "bkt_123", + keys: [ + { + id: "bkey_123", + name: "primary", + role: "read_write", + }, + ], + count: 1, + }, + }); + }); + + it("lists bucket keys in human-readable format", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "list", "bkt_123"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(stderr).toContain("bkt_123"); + expect(stderr).toContain("bkey_123"); + expect(stderr).toContain("primary"); + expect(stderr).toContain("read_write"); + }); + + it("creates a bucket key and prints env block to stdout", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "create", "bkt_123"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("S3_ENDPOINT="); + expect(result.stdout).toContain("S3_ACCESS_KEY_ID="); + expect(result.stdout).toContain("S3_SECRET_ACCESS_KEY="); + expect(result.stdout).toContain("S3_BUCKET="); + expect(stderr).toContain("Creating bucket key..."); + expect(stderr).toContain("credentials below are shown once"); + expect(result.stdout.split("S3_SECRET_ACCESS_KEY=")).toHaveLength(2); + }); + + it("prints only the env block to stdout when creating a key quietly", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "create", "bkt_123", "--quiet"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("S3_ENDPOINT="); + expect(result.stdout).toContain("S3_ACCESS_KEY_ID="); + expect(result.stdout).toContain("S3_SECRET_ACCESS_KEY="); + expect(result.stdout).toContain("S3_BUCKET="); + }); + + it("returns key credentials in JSON including one-time secrets", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "create", "bkt_123", "--role", "read", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "bucket.key.create", + result: { + bucketId: "bkt_123", + key: { + role: "read", + }, + }, + }); + expect(payload.result.secretAccessKey).toBeTruthy(); + expect(payload.result.accessKeyId).toBeTruthy(); + expect(payload.result.endpoint).toBeTruthy(); + expect(payload.result.bucketName).toBeTruthy(); + }); + + it("rejects an invalid key role at parse time", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "create", "bkt_123", "--role", "admin"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("admin"); + }); + + it("fails with BRANCH_NOT_FOUND when creating a bucket with a non-existent branch", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "create", "--branch", "nonexistent-branch", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload).toMatchObject({ + ok: false, + command: "bucket.create", + error: { code: "BRANCH_NOT_FOUND", domain: "bucket" }, + }); + }); + + it("deletes a bucket key by bucket id and key id", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "delete", "bkt_123", "bkey_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "bucket.key.delete", + result: { + key: { id: "bkey_123" }, + }, + }); + }); + + it("fails with BUCKET_KEY_NOT_FOUND when deleting an unknown key", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "delete", "bkt_123", "bkey_999", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload).toMatchObject({ + ok: false, + command: "bucket.key.delete", + error: { code: "BUCKET_KEY_NOT_FOUND", domain: "bucket" }, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e72aa52..e2dbc82d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,13 +25,13 @@ importers: version: 1.5.0 '@prisma/compute-sdk': specifier: 0.34.0 - version: 0.34.0(@prisma/management-api-sdk@1.46.0)(rollup@4.62.2) + version: 0.34.0(@prisma/management-api-sdk@1.50.0)(rollup@4.62.2) '@prisma/credentials-store': specifier: ^7.8.0 version: 7.8.0 '@prisma/management-api-sdk': - specifier: ^1.46.0 - version: 1.46.0 + specifier: ^1.50.0 + version: 1.50.0 better-result: specifier: ^2.9.2 version: 2.9.2 @@ -560,8 +560,8 @@ packages: '@prisma/credentials-store@7.8.0': resolution: {integrity: sha512-T9yp5uYSowV2ZRkBeCZrqWFP4REUlxd5WEYgOFJDjZBRRV+zx3VFCBf0zJI7Z3/PYFF9o3+ZzLwokQ9nY5EbqA==} - '@prisma/management-api-sdk@1.46.0': - resolution: {integrity: sha512-a7VrApSeCa6wM0TsY3aGUCHOMH0I7dT1VTvVDUAXohgytJir7a45SbArkhK6uaiyOg+nlzeeM+fyWMU4PTHm7Q==} + '@prisma/management-api-sdk@1.50.0': + resolution: {integrity: sha512-xTg2xCKCwH1h6ZXzT8ieRmypINvFB5RIMAuWEuk2LpfsHUFs0RFuyRNw1CeYGVXnxWckkA8dWFQVLyjolZlDeA==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1932,9 +1932,9 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@prisma/compute-sdk@0.34.0(@prisma/management-api-sdk@1.46.0)(rollup@4.62.2)': + '@prisma/compute-sdk@0.34.0(@prisma/management-api-sdk@1.50.0)(rollup@4.62.2)': dependencies: - '@prisma/management-api-sdk': 1.46.0 + '@prisma/management-api-sdk': 1.50.0 '@vercel/nft': 1.10.2(rollup@4.62.2) better-result: 2.9.2 jiti: 2.7.0 @@ -1956,7 +1956,7 @@ snapshots: dependencies: xdg-app-paths: 8.3.0 - '@prisma/management-api-sdk@1.46.0': + '@prisma/management-api-sdk@1.50.0': dependencies: openapi-fetch: 0.14.0