From a3fe762e2f500d5df14e4d9560d03c5ce6eaeec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Mon, 20 Jul 2026 16:31:33 +0700 Subject: [PATCH 1/5] chore(cli): bump @prisma/management-api-sdk to ^1.50.0 Co-Authored-By: Claude Fable 5 --- packages/cli/package.json | 2 +- pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 12bdc11..ec677a4 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/pnpm-lock.yaml b/pnpm-lock.yaml index 7e72aa5..e2dbc82 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 From 18e9856abff05a1633ba6a08bf737e9bb32e4000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Mon, 20 Jul 2026 16:31:43 +0700 Subject: [PATCH 2/5] feat(cli): add bucket command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `prisma-cli bucket` with six commands for managing Tigris-backed object-store buckets through the Management API: bucket list – lists buckets for the resolved project bucket create – creates a bucket, optional --name and --branch bucket delete – deletes a bucket and all its access keys bucket key list – lists access keys for a bucket bucket key create – creates an access key; prints an S3 env block to stdout (S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET) and a "shown once" warning to stderr; --json includes the one-time secret in the JSON envelope bucket key delete – revokes and removes an access key Implementation follows the four-layer pattern of the database command group: types → provider (Management API pagination + normalization) → controllers (project resolution + fixture/real mode dispatch) → presenters (human/JSON) → Commander wiring. Co-Authored-By: Claude Fable 5 --- packages/cli/fixtures/mock-api.json | 28 ++ packages/cli/src/adapters/mock-api.ts | 154 ++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/bucket/index.ts | 264 +++++++++++++ packages/cli/src/controllers/bucket.ts | 427 ++++++++++++++++++++++ packages/cli/src/lib/bucket/provider.ts | 309 ++++++++++++++++ packages/cli/src/presenters/bucket.ts | 256 +++++++++++++ packages/cli/src/shell/command-meta.ts | 72 ++++ packages/cli/src/shell/errors.ts | 3 +- packages/cli/src/types/bucket.ts | 65 ++++ packages/cli/tests/bucket.test.ts | 415 +++++++++++++++++++++ 11 files changed, 1994 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/bucket/index.ts create mode 100644 packages/cli/src/controllers/bucket.ts create mode 100644 packages/cli/src/lib/bucket/provider.ts create mode 100644 packages/cli/src/presenters/bucket.ts create mode 100644 packages/cli/src/types/bucket.ts create mode 100644 packages/cli/tests/bucket.test.ts diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index 6749e8b..4d56507 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/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 8b43bc7..7efe556 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,140 @@ 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 { + this.data.buckets ??= []; + + const branchId = input.branchGitName + ? (this.data.branches.find( + (branch) => + branch.projectId === input.projectId && + branch.name === input.branchGitName, + )?.id ?? null) + : null; + 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 a13937d..d1c1a6b 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 0000000..199bf39 --- /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: "read" or "read_write" (default: 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 0000000..edffefc --- /dev/null +++ b/packages/cli/src/controllers/bucket.ts @@ -0,0 +1,427 @@ +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 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); + + if (isRealMode(context)) { + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(); + } + return createManagementBucketProvider(client); + } + + return createFixtureBucketProvider(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, + }); + 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" { + if (!role || role === "read_write") { + return "read_write"; + } + if (role === "read") { + return "read"; + } + throw new CliError({ + code: "USAGE_ERROR", + domain: "bucket", + summary: "Invalid key role", + why: `"${role}" is not a valid role. Valid roles are "read" and "read_write".`, + fix: "Pass --role read or --role read_write.", + exitCode: 2, + nextSteps: [], + }); +} + +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 0000000..741996c --- /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 0000000..20056a4 --- /dev/null +++ b/packages/cli/src/presenters/bucket.ts @@ -0,0 +1,256 @@ +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 { + ...serializeList({ + context: { + project: result.projectName, + ...(result.branchName ? { branch: result.branchName } : {}), + }, + items: result.buckets.map((bucket) => ({ + noun: "bucket", + label: bucket.name, + id: bucket.id, + status: null, + })), + }), + 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 4a11edd..441cdbb 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 4a45beb..8723072 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 0000000..2a810e8 --- /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 0000000..dbb52e5 --- /dev/null +++ b/packages/cli/tests/bucket.test.ts @@ -0,0 +1,415 @@ +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", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["bucket", "key", "create", "bkt_123", "--role", "admin", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + command: "bucket.key.create", + error: { code: "USAGE_ERROR", 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" }, + }); + }); +}); From eb827e8edf219face85c0fa17ba751550fd497c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Mon, 20 Jul 2026 16:54:26 +0700 Subject: [PATCH 3/5] fix(cli): apply review feedback on bucket commands - MockApi.createBucket returns undefined when branchGitName is supplied but no matching branch exists; the fixture provider throws BRANCH_NOT_FOUND instead of silently creating an unscoped bucket - --role on bucket key create uses Option.choices(["read", "read_write"]) for parse-time validation and self-documenting --help output; resolveKeyRole simplified to a single ternary now that Commander handles the invalid-choice rejection - resolveBucketProvider helper extracted from requireBucketContext / requireBucketProviderOnly; requireBucketProviderOnly delegates to it - serializeBucketList passes bucket.status through in items instead of hardcoding null; builds the items mapping directly instead of going through serializeList whose ListItem.status is restricted to the AnnotationStatus union Co-Authored-By: Claude Fable 5 --- packages/cli/src/adapters/mock-api.ts | 21 +++++---- packages/cli/src/commands/bucket/index.ts | 8 ++-- packages/cli/src/controllers/bucket.ts | 57 ++++++++++++----------- packages/cli/src/presenters/bucket.ts | 22 ++++----- packages/cli/tests/bucket.test.ts | 25 ++++++++-- 5 files changed, 78 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 7efe556..0d0bd35 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -571,16 +571,21 @@ export class MockApi { projectId: string; name?: string; branchGitName?: string; - }): BucketRecord { + }): BucketRecord | undefined { this.data.buckets ??= []; - const branchId = input.branchGitName - ? (this.data.branches.find( - (branch) => - branch.projectId === input.projectId && - branch.name === input.branchGitName, - )?.id ?? null) - : null; + 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, diff --git a/packages/cli/src/commands/bucket/index.ts b/packages/cli/src/commands/bucket/index.ts index 199bf39..c22eac3 100644 --- a/packages/cli/src/commands/bucket/index.ts +++ b/packages/cli/src/commands/bucket/index.ts @@ -202,10 +202,10 @@ function createBucketKeyCreateCommand(runtime: CliRuntime): Command { command .argument("", "Bucket id") .addOption( - new Option( - "--role ", - 'Access role: "read" or "read_write" (default: read_write)', - ), + new Option("--role ", "Access role (default: read_write)").choices([ + "read", + "read_write", + ]), ) .addOption( new Option( diff --git a/packages/cli/src/controllers/bucket.ts b/packages/cli/src/controllers/bucket.ts index edffefc..743cd37 100644 --- a/packages/cli/src/controllers/bucket.ts +++ b/packages/cli/src/controllers/bucket.ts @@ -250,6 +250,22 @@ export async function runBucketKeyDelete( }; } +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, @@ -309,19 +325,7 @@ async function requireBucketProviderOnly( context: CommandContext, ): Promise { await requireAuthenticatedAuthState(context); - - if (isRealMode(context)) { - const client = await requireComputeAuth( - context.runtime.env, - context.runtime.signal, - ); - if (!client) { - throw authRequiredError(); - } - return createManagementBucketProvider(client); - } - - return createFixtureBucketProvider(context); + return resolveBucketProvider(context); } function createFixtureBucketProvider(context: CommandContext): BucketProvider { @@ -338,6 +342,9 @@ function createFixtureBucketProvider(context: CommandContext): BucketProvider { name: options.name, branchGitName: options.branchGitName, }); + if (!created) { + throw branchNotFoundError(options.branchGitName ?? ""); + } return normalizeBucket(created); }, @@ -385,20 +392,18 @@ function createFixtureBucketProvider(context: CommandContext): BucketProvider { } function resolveKeyRole(role: string | undefined): "read" | "read_write" { - if (!role || role === "read_write") { - return "read_write"; - } - if (role === "read") { - return "read"; - } - throw new CliError({ - code: "USAGE_ERROR", + return role === "read" ? "read" : "read_write"; +} + +function branchNotFoundError(branchGitName: string): CliError { + return new CliError({ + code: "BRANCH_NOT_FOUND", domain: "bucket", - summary: "Invalid key role", - why: `"${role}" is not a valid role. Valid roles are "read" and "read_write".`, - fix: "Pass --role read or --role read_write.", - exitCode: 2, - nextSteps: [], + 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"], }); } diff --git a/packages/cli/src/presenters/bucket.ts b/packages/cli/src/presenters/bucket.ts index 20056a4..17fc4ec 100644 --- a/packages/cli/src/presenters/bucket.ts +++ b/packages/cli/src/presenters/bucket.ts @@ -71,18 +71,16 @@ export function renderBucketList( export function serializeBucketList(result: BucketListResult) { return { - ...serializeList({ - context: { - project: result.projectName, - ...(result.branchName ? { branch: result.branchName } : {}), - }, - items: result.buckets.map((bucket) => ({ - noun: "bucket", - label: bucket.name, - id: bucket.id, - status: null, - })), - }), + 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, diff --git a/packages/cli/tests/bucket.test.ts b/packages/cli/tests/bucket.test.ts index dbb52e5..305c68d 100644 --- a/packages/cli/tests/bucket.test.ts +++ b/packages/cli/tests/bucket.test.ts @@ -353,22 +353,37 @@ describe("bucket commands", () => { expect(payload.result.bucketName).toBeTruthy(); }); - it("rejects an invalid key role", async () => { + 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", "--json"], + argv: ["bucket", "key", "create", "bkt_123", "--role", "admin"], cwd, stateDir, fixturePath, }); - const payload = JSON.parse(result.stdout); 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.key.create", - error: { code: "USAGE_ERROR", domain: "bucket" }, + command: "bucket.create", + error: { code: "BRANCH_NOT_FOUND", domain: "bucket" }, }); }); From b8549e920fd884b7e1e34524eee26e31418fba89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Mon, 20 Jul 2026 17:47:25 +0700 Subject: [PATCH 4/5] docs: document bucket commands Covers all six shipped commands (bucket list, bucket create, bucket delete, bucket key list, bucket key create, bucket key delete) across the docs that enumerate commands or resources. - command-spec.md: add bucket to scope, add per-command sections matching the database connection group format; includes flag tables, JSON envelope shapes (derived from the serialize* functions), error codes, and the reveal-once S3 env-block stdout behavior of bucket key create - resource-model.md: add Bucket / Object Store section; update the North Star Hierarchy and Relationships diagram to include bucket and bucket_key; update the long-term resource description - error-conventions.md: add BUCKET_NOT_FOUND, BUCKET_KEY_NOT_FOUND, BUCKET_KEY_SECRET_MISSING, BRANCH_NOT_FOUND to the MVP error code list and recommended meanings; add bucket to the error.domain enumeration - output-conventions.md: add bucket commands to the pattern mapping table - command-principles.md: add bucket to the preview implementation list and stable nouns - glossary.md: add Bucket, Bucket key entries; fix the Database entry (it was still marked out of scope for the beta) - README.md (root): add database and bucket to the command model list - packages/cli/README.md: add database and bucket rows to the commands table Co-Authored-By: Claude Fable 5 --- README.md | 2 + docs/product/command-principles.md | 3 +- docs/product/command-spec.md | 247 +++++++++++++++++++++++++++++ docs/product/error-conventions.md | 10 +- docs/product/output-conventions.md | 6 + docs/product/resource-model.md | 41 ++++- docs/reference/glossary.md | 4 +- packages/cli/README.md | 2 + 8 files changed, 310 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dd68e43..c9385e6 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 d06b5b8..c660901 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 82d1faf..a451194 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,252 @@ 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 + +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 438a8c9..c914079 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 09c56d3..721343d 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 c247429..496d2d9 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,41 @@ 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 +- bucket deletion also revokes all of the bucket's access keys + +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 +252,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 5098529..35bf522 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 15c2398..db069fb 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, inspect, and remove Tigris object-store buckets and their access keys. | | `app` | Build, run, deploy, inspect, open, stream logs, promote, roll back, and remove apps. | Common examples: From 95b8e937469b331442869623e7647a6bdfbb172a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Mon, 20 Jul 2026 17:51:47 +0700 Subject: [PATCH 5/5] docs: qualify non-empty bucket deletion and fix README bucket verbs Co-Authored-By: Claude Fable 5 --- docs/product/command-spec.md | 3 +++ docs/product/resource-model.md | 4 +++- packages/cli/README.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index a451194..94b375c 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -1356,6 +1356,9 @@ Behavior: - 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: diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 496d2d9..890f6cc 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -179,7 +179,9 @@ Rules: 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 -- bucket deletion also revokes all of the bucket's access keys +- 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: diff --git a/packages/cli/README.md b/packages/cli/README.md index db069fb..3a66740 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -77,7 +77,7 @@ The beta package exposes `prisma-cli` so it can coexist with the existing | `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, inspect, and remove Tigris object-store buckets and their access keys. | +| `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: