From bab33c93765da84310d6f8e2788002bec86ebd72 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:29:06 +0200 Subject: [PATCH 01/27] Remove the init command The compute config it wrote is unsupported, so the wizard that created it goes with it. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/e2e/init.e2e.ts | 53 - packages/cli/src/cli.ts | 9 +- packages/cli/src/commands/init/agent-setup.ts | 117 -- packages/cli/src/commands/init/config-file.ts | 301 ----- packages/cli/src/commands/init/errors.ts | 37 - packages/cli/src/commands/init/init.ts | 363 ----- packages/cli/src/commands/init/link.ts | 95 -- .../cli/src/commands/init/presentation.ts | 124 -- packages/cli/src/commands/init/settings.ts | 330 ----- .../cli/src/commands/init/types-install.ts | 162 --- packages/cli/src/commands/init/types.ts | 86 -- packages/cli/src/types/init.ts | 59 - packages/cli/tests/e2e-coverage.test.ts | 2 +- packages/cli/tests/init-agent-setup.test.ts | 171 --- packages/cli/tests/init.test.ts | 1189 ----------------- packages/cli/tests/mount-coverage.test.ts | 9 +- 16 files changed, 3 insertions(+), 3104 deletions(-) delete mode 100644 packages/cli/e2e/init.e2e.ts delete mode 100644 packages/cli/src/commands/init/agent-setup.ts delete mode 100644 packages/cli/src/commands/init/config-file.ts delete mode 100644 packages/cli/src/commands/init/errors.ts delete mode 100644 packages/cli/src/commands/init/init.ts delete mode 100644 packages/cli/src/commands/init/link.ts delete mode 100644 packages/cli/src/commands/init/presentation.ts delete mode 100644 packages/cli/src/commands/init/settings.ts delete mode 100644 packages/cli/src/commands/init/types-install.ts delete mode 100644 packages/cli/src/commands/init/types.ts delete mode 100644 packages/cli/src/types/init.ts delete mode 100644 packages/cli/tests/init-agent-setup.test.ts delete mode 100644 packages/cli/tests/init.test.ts diff --git a/packages/cli/e2e/init.e2e.ts b/packages/cli/e2e/init.e2e.ts deleted file mode 100644 index 41912a55..00000000 --- a/packages/cli/e2e/init.e2e.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * `init` writes the committed compute config the service group reads. - * It touches no management API on this path — `--no-link` skips the - * one step that would — but it ships in the binary, so it gets the same - * real happy path as everything else, in a throwaway working directory. - */ -import { readFileSync } from "node:fs"; -import path from "node:path"; - -import { expect, it } from "vitest"; - -import { describeCommand, session } from "./suite"; - -interface InitResult { - readonly configPath: string; - readonly format: string; - readonly app: { readonly name: string; readonly framework: string } | null; - readonly link: { readonly status: string }; -} - -describeCommand("init", () => { - it("writes the compute config it reports, byte-for-byte readable", async () => { - const cli = await session(); - const workdir = await cli.workdir(); - - const run = await cli.run( - [ - "init", - "--framework", - "hono", - "--name", - "e2e-init", - "--no-link", - "--no-install", - ], - { cwd: workdir }, - ); - - expect(run.exitCode).toBe(0); - const result = run.envelope.result as InitResult; - expect(result.app).toMatchObject({ name: "e2e-init", framework: "hono" }); - expect(result.format).toBe("typescript"); - expect(result.configPath.endsWith("prisma.compute.ts")).toBe(true); - expect(result.link.status).toBe("skipped"); - - const written = readFileSync( - path.join(workdir, "prisma.compute.ts"), - "utf8", - ); - expect(written).toContain('name: "e2e-init"'); - expect(written).toContain('framework: "hono"'); - }); -}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ece568a9..fe12f874 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -29,7 +29,6 @@ import { buildLogsCommand } from "./commands/build/logs"; import { feedbackCommand } from "./commands/feedback"; import { gitConnectCommand } from "./commands/git/connect"; import { gitDisconnectCommand } from "./commands/git/disconnect"; -import { initCommand } from "./commands/init/init"; import { postgresBackupListCommand } from "./commands/postgres/backup-list"; import { postgresConnectionCreateCommand } from "./commands/postgres/connection-create"; import { postgresConnectionListCommand } from "./commands/postgres/connection-list"; @@ -263,8 +262,6 @@ export const mountedCommands: Readonly> = { "db update": ormCommandFamily.commands["db update"], "db verify": ormCommandFamily.commands["db verify"], format: ormCommandFamily.commands.format, - // Ruled (operator, 2026-08-12): the ORM's project initializer lives at - // `orm init`; top-level `init` is the platform's compute-config wizard. "orm init": ormCommandFamily.commands.init, lsp: ormCommandFamily.commands.lsp, migrate: ormCommandFamily.commands.migrate, @@ -286,10 +283,6 @@ export const mountedCommands: Readonly> = { feedback: feedbackCommand, // The engine's consent surface, mounted whole (no command family). ...telemetry.commands, - // Top-level, and not the platform package's: init writes the local - // compute config the service group reads. It joins the compute family - // when one exists. - init: initCommand, }; export function buildCli(): Cli { @@ -307,7 +300,7 @@ export function buildCli(): Cli { tagline: "The Prisma Developer Platform, from your terminal", description: "Deploy your app with isolated infrastructure for every branch.", - examples: ["init", "auth login", "project list"], + examples: ["auth login", "project list"], docsUrl: CLI_DOCS_URL, }, telemetry: { docsUrl: CLI_DOCS_URL }, diff --git a/packages/cli/src/commands/init/agent-setup.ts b/packages/cli/src/commands/init/agent-setup.ts deleted file mode 100644 index 8b12dd3c..00000000 --- a/packages/cli/src/commands/init/agent-setup.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * The one-time offer to install the Prisma Compute skill for this - * project. A "no" is remembered in the local state store, so whichever - * command asks first is the only one that asks. An install that fails is - * a finding, never a failed init. - * - * The legacy `maybePromptForAgentSetup` calls `runAgentInstall`, which - * takes the commander `CommandContext`; the engine cannot build one. The status - * read, the dismissal record and the skills-CLI argv are all taken from - * the same `src/lib/agent` modules the legacy controller uses. - * - * CI is checked here rather than left to the prompt surface, following - * `commands/auth/agent-setup-tip.ts`: handlers cannot read TTY state, and an - * unattended run that answered the offer from its default would record a - * dismissal nobody gave. - */ -import { execa } from "execa"; -import { LocalStateStore } from "../../adapters/local-state"; -import { resolvePrismaCliPackageCommand } from "../../lib/agent/cli-command"; -import { - DEFAULT_PRISMA_AGENT_TARGETS, - PRISMA_AGENT_INSTALL_ARGS, - PRISMA_COMPUTE_AGENT_SKILL, - PRISMA_SKILLS_SOURCE, - SKILLS_CLI_PACKAGE, -} from "../../lib/agent/constants"; -import { resolveSkillsPackageRunner } from "../../lib/agent/package-manager"; -import { - readPrismaAgentSetupStatus, - shouldOfferPrismaAgentSetup, -} from "../../lib/agent/setup-status"; -import { resolveStateDir } from "../../state-dir"; -import type { InitStepContext } from "./types"; - -async function skillsInstallCommand( - cwd: string, - signal: AbortSignal, - platform: string, -): Promise { - return [ - ...(await resolveSkillsPackageRunner({ cwd, signal })), - SKILLS_CLI_PACKAGE, - "add", - PRISMA_SKILLS_SOURCE, - "--skill", - PRISMA_COMPUTE_AGENT_SKILL, - ...DEFAULT_PRISMA_AGENT_TARGETS.flatMap((agent) => ["--agent", agent]), - ...(platform === "win32" ? ["--copy"] : []), - "--yes", - ]; -} - -export async function maybeOfferAgentSetup( - step: InitStepContext, -): Promise { - const ctx = step.engine; - if (ctx.env.CI) { - return; - } - - const stateStore = new LocalStateStore( - await resolveStateDir({ env: ctx.env, cwd: ctx.cwd, signal: ctx.signal }), - ctx.signal, - ); - const status = await readPrismaAgentSetupStatus({ - cwd: ctx.cwd, - stateStore, - signal: ctx.signal, - requiredSkill: PRISMA_COMPUTE_AGENT_SKILL, - }); - if (!shouldOfferPrismaAgentSetup(status)) { - return; - } - - const shouldInstall = await ctx.prompt.confirm( - "Install the Prisma Compute skill for this project?", - { default: false }, - ); - if (!shouldInstall) { - await stateStore.setAgentSetupPromptDismissedAt(new Date().toISOString()); - return; - } - - const command = await skillsInstallCommand( - ctx.cwd, - ctx.signal, - ctx.host.platform, - ); - try { - const [executable, ...args] = command; - await execa(executable as string, args, { - cwd: ctx.cwd, - env: ctx.env, - cancelSignal: ctx.signal, - stdin: "ignore", - }); - } catch { - ctx.signal.throwIfAborted(); - const retryCommand = await resolvePrismaCliPackageCommand({ - cwd: ctx.cwd, - signal: ctx.signal, - args: [ - ...PRISMA_AGENT_INSTALL_ARGS, - "--skill", - PRISMA_COMPUTE_AGENT_SKILL, - ], - }); - step.record({ - code: "INIT.AGENT_SETUP_FAILED", - severity: "warn", - summary: `The Prisma Compute skill was not installed. Run ${retryCommand} to try again.`, - nextActions: [ - { kind: "run-command", label: retryCommand, command: retryCommand }, - ], - }); - } -} diff --git a/packages/cli/src/commands/init/config-file.ts b/packages/cli/src/commands/init/config-file.ts deleted file mode 100644 index 1386fc8c..00000000 --- a/packages/cli/src/commands/init/config-file.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Everything that touches the compute config on disk: finding an - * existing one, writing a new one, and the JSON-to-TypeScript - * conversion. The serialized bytes come from the compute SDK, so what a - * user ends up with is byte-identical to the legacy command's output. - */ -import { readFile, rm, writeFile } from "node:fs/promises"; -import path from "node:path"; -import type { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { - COMPUTE_CONFIG_FILENAME, - COMPUTE_CONFIG_JSON_FILENAME, - type ComputeConfig, - findComputeConfigCandidates, - findComputeConfigDir, - frameworkByKey, - type LoadedComputeConfig, - normalizeComputeConfig, - serializeComputeConfig, - serializeComputeConfigJson, -} from "@prisma/compute-sdk/config"; -import { initError } from "./errors"; -import type { - InitConfigFormat, - InitFlags, - InitResult, - InitSettingRow, - InitStepContext, -} from "./types"; - -const CUSTOM_BUILD_STUB = ` -// framework "custom" deploys a prebuilt artifact. Add its build settings: -// build: { -// command: "npm run build", -// outputDirectory: "dist", -// entrypoint: "server.js", -// }, -`; - -export interface ExistingConfig { - readonly directory: string; - readonly candidates: readonly string[]; -} - -/** Nearest existing compute config, searching from `cwd` up to the - * source root. Init routes on this: refuse, convert, or proceed fresh. */ -export async function findExistingConfig( - cwd: string, - signal: AbortSignal, -): Promise { - const directory = await findComputeConfigDir(cwd, signal); - if (!directory) { - return null; - } - return { - directory, - candidates: await findComputeConfigCandidates(directory, signal), - }; -} - -export function configExistsError(existingPath: string): CliStructuredError { - return initError({ - code: "INIT.CONFIG_EXISTS", - summary: "A compute config already exists", - why: `${existingPath} already defines this repository's compute config, and init never overwrites or merges.`, - fix: "Edit the existing config instead, or delete it first if you want init to regenerate it.", - where: existingPath, - meta: { existingConfigPath: existingPath }, - }); -} - -export function convertUnsupportedError( - existingPath: string, -): CliStructuredError { - return initError({ - code: "INIT.CONVERT_UNSUPPORTED", - summary: "TypeScript configs do not convert to JSON", - why: `${existingPath} may contain imports, expressions, or comments that the static ${COMPUTE_CONFIG_JSON_FILENAME} format cannot express, so an automatic conversion would be lossy.`, - fix: `If the config is fully static, rewrite it by hand as ${COMPUTE_CONFIG_JSON_FILENAME} and delete ${path.basename(existingPath)}.`, - where: existingPath, - meta: { existingConfigPath: existingPath }, - }); -} - -function convertIncompleteError( - jsonConfigPath: string, - tsConfigPath: string, -): CliStructuredError { - return initError({ - code: "INIT.CONVERT_INCOMPLETE", - summary: "Conversion left two config files behind", - why: `${path.basename(tsConfigPath)} was written but ${path.basename(jsonConfigPath)} could not be deleted, and rolling back the write also failed. Commands refuse to load a directory with two config files.`, - fix: `Delete one file by hand: keep ${path.basename(tsConfigPath)} to finish the conversion, or keep ${path.basename(jsonConfigPath)} to undo it.`, - meta: { jsonConfigPath, tsConfigPath }, - }); -} - -function convertInvalidError( - jsonConfigPath: string, - issues: readonly string[], -): CliStructuredError { - return initError({ - code: "INIT.COMPUTE_CONFIG_INVALID", - summary: `Invalid ${path.basename(jsonConfigPath)}`, - why: issues.join(" "), - fix: `Fix ${path.basename(jsonConfigPath)} and rerun the conversion.`, - where: jsonConfigPath, - meta: { configPath: jsonConfigPath, issues }, - }); -} - -/** - * Conversion transports the existing config's values; it never - * re-resolves settings. Refusing resolution flags beats silently - * ignoring them. - */ -export function rejectConversionResolutionFlags( - flags: InitFlags, - step: InitStepContext, -): void { - const passed = [ - flags.framework !== undefined ? "--framework" : null, - flags.entry !== undefined ? "--entry" : null, - flags.httpPort !== undefined ? "--http-port" : null, - flags.name !== undefined ? "--name" : null, - flags.region !== undefined ? "--region" : null, - ].filter((flag): flag is string => flag !== null); - if (passed.length === 0) { - return; - } - throw initError({ - code: "INIT.CONVERSION_FLAGS_NOT_APPLICABLE", - summary: `${passed.join(", ")} ${passed.length === 1 ? "does" : "do"} not apply when converting an existing config`, - why: `--config-format ts with an existing ${COMPUTE_CONFIG_JSON_FILENAME} converts it as-is; settings are transported, never re-resolved.`, - fix: `Convert first, then edit ${COMPUTE_CONFIG_FILENAME} directly.`, - commands: [step.formatCommand(["init", "--config-format", "ts"])], - meta: { flags: passed }, - }); -} - -async function writeNew(configPath: string, source: string): Promise { - try { - // wx: fail instead of clobbering a config that appeared since the check. - await writeFile(configPath, source, { encoding: "utf8", flag: "wx" }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - throw configExistsError(configPath); - } - throw error; - } -} - -export interface WrittenConfig { - readonly configPath: string; - readonly filename: string; -} - -export async function writeConfig(spec: { - readonly cwd: string; - readonly config: ComputeConfig; - readonly format: InitConfigFormat; - readonly custom: boolean; - readonly signal: AbortSignal; -}): Promise { - const filename = - spec.format === "json" - ? COMPUTE_CONFIG_JSON_FILENAME - : COMPUTE_CONFIG_FILENAME; - const configPath = path.join(spec.cwd, filename); - let source: string; - if (spec.format === "json") { - source = serializeComputeConfigJson(spec.config); - } else { - source = serializeComputeConfig(spec.config); - if (spec.custom) { - source += CUSTOM_BUILD_STUB; - } - } - - spec.signal.throwIfAborted(); - await writeNew(configPath, source); - return { configPath, filename }; -} - -function stripJsonSchemaKey(parsed: unknown): unknown { - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return parsed; - } - const { $schema: _schema, ...config } = parsed as Record; - return config; -} - -export interface Conversion { - readonly tsConfigPath: string; - readonly configDir: string; - readonly settings: readonly InitSettingRow[]; - readonly app: InitResult["app"]; -} - -/** - * The graduation path: an explicit `--config-format ts` over an existing - * `prisma.compute.json` rewrites the same config as `prisma.compute.ts` - * and deletes the JSON file, so a static config can grow into a - * programmatic one. The values are transported, never re-resolved. - */ -export async function convertJsonConfig( - jsonConfigPath: string, - signal: AbortSignal, -): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(jsonConfigPath, "utf8")); - } catch (error) { - if (signal.aborted) { - throw error; - } - throw convertInvalidError(jsonConfigPath, [ - error instanceof Error - ? (error.message.split("\n")[0] as string) - : String(error), - ]); - } - - // "$schema" is editor tooling metadata, not config; the TypeScript - // format carries types through its import instead. - const config = stripJsonSchemaKey(parsed); - const normalized = normalizeComputeConfig(config, jsonConfigPath); - if (normalized.isErr()) { - throw convertInvalidError(jsonConfigPath, normalized.error.issues); - } - const loaded = normalized.value; - const tsConfigPath = path.join(loaded.configDir, COMPUTE_CONFIG_FILENAME); - - signal.throwIfAborted(); - await writeNew(tsConfigPath, serializeComputeConfig(config as ComputeConfig)); - try { - await rm(jsonConfigPath); - } catch (error) { - // Two coexisting config files are a hard loader error, so a failed - // delete rolls the write back and leaves the JSON config untouched. - try { - await rm(tsConfigPath, { force: true }); - } catch { - throw convertIncompleteError(jsonConfigPath, tsConfigPath); - } - throw error; - } - - return { - tsConfigPath, - configDir: loaded.configDir, - settings: conversionSettings(loaded), - app: conversionApp(loaded), - }; -} - -/** Preview rows for a conversion; every value is sourced from the JSON file. */ -function conversionSettings(loaded: LoadedComputeConfig): InitSettingRow[] { - const target = loaded.kind === "single" ? loaded.targets[0] : undefined; - if (!target) { - return []; - } - const source = COMPUTE_CONFIG_JSON_FILENAME; - return [ - ...(target.name ? [{ key: "app", value: target.name, source }] : []), - ...(target.framework - ? [ - { - key: "framework", - value: frameworkByKey(target.framework).displayName, - source, - }, - ] - : []), - ...(target.entry ? [{ key: "entry", value: target.entry, source }] : []), - ...(target.httpPort !== null - ? [{ key: "http port", value: String(target.httpPort), source }] - : []), - ...(target.region ? [{ key: "region", value: target.region, source }] : []), - ]; -} - -/** - * App identity for the conversion result. Configs written by init pin - * all of name, framework and httpPort; hand-written configs that omit - * any of them (or define multiple apps) report null instead of a - * partial identity. - */ -function conversionApp(loaded: LoadedComputeConfig): InitResult["app"] { - const target = loaded.kind === "single" ? loaded.targets[0] : undefined; - if (!target?.name || !target.framework || target.httpPort === null) { - return null; - } - return { - name: target.name, - framework: target.framework, - httpPort: target.httpPort, - ...(target.entry ? { entry: target.entry } : {}), - ...(target.region ? { region: target.region } : {}), - }; -} diff --git a/packages/cli/src/commands/init/errors.ts b/packages/cli/src/commands/init/errors.ts deleted file mode 100644 index bae01b77..00000000 --- a/packages/cli/src/commands/init/errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * The `INIT.*` error vocabulary. Each constructor carries the legacy - * summary and why text unchanged (R-S2b-5); the legacy `fix` prose - * becomes a `user-choice` next action and the legacy `nextSteps` become - * `run-command` ones. - */ -import { - CliStructuredError, - type NextAction, -} from "@prisma/cli-engine/protocol"; - -export function initError(spec: { - readonly code: `INIT.${string}`; - readonly summary: string; - readonly why: string; - readonly fix: string; - readonly commands?: readonly string[]; - readonly where?: string; - readonly meta?: Record; -}): CliStructuredError { - const nextActions: NextAction[] = [ - { kind: "user-choice", label: spec.fix }, - ...(spec.commands ?? []).map( - (command): NextAction => ({ - kind: "run-command", - label: command, - command, - }), - ), - ]; - return new CliStructuredError(spec.code, spec.summary, { - why: spec.why, - nextActions, - ...(spec.where === undefined ? {} : { where: { path: spec.where } }), - ...(spec.meta === undefined ? {} : { meta: spec.meta }), - }); -} diff --git a/packages/cli/src/commands/init/init.ts b/packages/cli/src/commands/init/init.ts deleted file mode 100644 index 13c996e0..00000000 --- a/packages/cli/src/commands/init/init.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** `prisma init`: write a committed compute config for this app, then - * offer the steps that make it useful — editor types, a Project link, - * and the agent skill. */ -import path from "node:path"; -import { defineCommand, flag } from "@prisma/cli-engine"; -import { type Diagnostic, ok } from "@prisma/cli-engine/protocol"; -import { - COMPUTE_CONFIG_JSON_FILENAME, - COMPUTE_REGIONS, - type ComputeConfig, - defaultHttpPortForBuildType, - FRAMEWORK_KEYS, - frameworkByKey, -} from "@prisma/compute-sdk/config"; -import { resolvePrismaCliPackageCommandFormatterSync } from "../../lib/agent/cli-command"; -import { maybeOfferAgentSetup } from "./agent-setup"; -import { - configExistsError, - convertJsonConfig, - convertUnsupportedError, - findExistingConfig, - rejectConversionResolutionFlags, - writeConfig, -} from "./config-file"; -import { resolveLink } from "./link"; -import { initPresentations, settingsPreview } from "./presentation"; -import { - customFrameworkNeedsTypescriptError, - installNotApplicableError, - maybeAdjustSettings, - parseFormat, - parseHttpPort, - parseRegion, - resolveAppName, - resolveEntry, - resolveFramework, -} from "./settings"; -import type { - InitFlags, - InitLinkState, - InitResult, - InitSettingRow, - InitStepContext, - InitTypesState, -} from "./types"; -import { resolveTypes, skippedTypes } from "./types-install"; - -function initDirectory(cwd: string): string { - const basename = path.basename(cwd); - return basename ? `./${basename}` : "."; -} - -export const initCommand = defineCommand({ - help: { - summary: "Write a committed compute config for this app", - examples: [ - "init", - "init --framework hono --entry src/index.ts", - "init --name api --http-port 8080 --no-link", - "init --config-format json", - ], - }, - args: { - flags: { - framework: flag.string({ - brief: `Framework override; detected when omitted (${FRAMEWORK_KEYS.join(", ")})`, - placeholder: "framework", - }), - entry: flag.string({ - brief: "Source entrypoint for entrypoint frameworks", - placeholder: "path", - }), - httpPort: flag.string({ - brief: "HTTP port the app listens on", - placeholder: "port", - }), - region: flag.string({ - brief: `Region used when deploy creates the app (${COMPUTE_REGIONS.join(", ")})`, - placeholder: "region", - }), - name: flag.string({ brief: "App name", placeholder: "app-name" }), - link: flag.optionalBoolean({ - brief: "Link this directory to a Project, or skip the question", - }), - project: flag.string({ - brief: "Project to link to", - placeholder: "id-or-name", - }), - install: flag.optionalBoolean({ - brief: - "Install @prisma/compute-sdk as a dev dependency for config types", - }), - // NOT --format: the engine reserves that name for the output - // format, and `--format json` there means a json envelope. - configFormat: flag.enum({ - brief: "Config file format", - values: ["ts", "json"], - }), - }, - }, - handler: async (args, ctx) => { - const flags: InitFlags = args.flags; - const diagnostics: Diagnostic[] = []; - // User-facing command hints use the project's package runner (pnpm - // dlx, bunx, npx -y), matching the agent group's convention. - const formatCommand = resolvePrismaCliPackageCommandFormatterSync(ctx.cwd); - const step: InitStepContext = { - engine: ctx, - formatCommand, - record: (diagnostic) => diagnostics.push(diagnostic), - }; - - const format = parseFormat(flags.configFormat); - if (format.value === "json" && flags.install === true) { - throw installNotApplicableError(step); - } - - const existing = await findExistingConfig(ctx.cwd, ctx.signal); - const result = existing - ? await runOverExistingConfig(existing, flags, format, step) - : await runFresh(flags, format, step); - - return ok( - ctx.present( - { data: result, diagnostics }, - initPresentations(result, formatCommand), - ), - ); - }, -}); - -type Format = ReturnType; - -/** The same step, acting from the directory the config lives in. */ -function at(step: InitStepContext, cwd: string): InitStepContext { - if (path.resolve(step.engine.cwd) === path.resolve(cwd)) { - return step; - } - return { ...step, engine: { ...step.engine, cwd } }; -} - -/** - * Conversion must be explicit: only `--config-format ts` over a lone - * prisma.compute.json converts. Plain init refuses every existing - * config, and a TypeScript config never converts to JSON. - */ -async function runOverExistingConfig( - existing: { - readonly directory: string; - readonly candidates: readonly string[]; - }, - flags: InitFlags, - format: Format, - step: InitStepContext, -): Promise { - const solePath = - existing.candidates.length === 1 ? existing.candidates[0] : undefined; - const soleIsJson = - solePath !== undefined && path.extname(solePath) === ".json"; - - if (soleIsJson && format.value === "typescript" && format.explicit) { - rejectConversionResolutionFlags(flags, step); - return runConversion(solePath as string, flags, step); - } - if (solePath && !soleIsJson && format.value === "json") { - throw convertUnsupportedError(solePath); - } - throw configExistsError(existing.candidates[0] ?? existing.directory); -} - -async function runFresh( - flags: InitFlags, - format: Format, - step: InitStepContext, -): Promise { - const ctx = step.engine; - const region = parseRegion(flags.region, step); - const detected = await resolveFramework(flags, step); - const name = await resolveAppName(flags, step); - const resolvedPort = parseHttpPort(flags.httpPort, step) ?? { - value: defaultHttpPortForBuildType(frameworkByKey(detected.key).buildType), - source: "framework default", - }; - const { framework, httpPort } = await maybeAdjustSettings( - { - framework: detected, - httpPort: resolvedPort, - portExplicit: flags.httpPort !== undefined, - }, - step, - ); - - // The custom framework needs build.outputDirectory and - // build.entrypoint, which init does not collect. The TypeScript format - // carries a commented build stub to fill in; strict JSON cannot hold - // comments, so refuse here instead of writing a config deploy rejects. - if (format.value === "json" && framework.key === "custom") { - throw customFrameworkNeedsTypescriptError(step); - } - - const entry = await resolveEntry(framework, flags, step); - const app = { - name: name.value, - framework: framework.key, - httpPort: httpPort.value, - ...(entry ? { entry: entry.value } : {}), - ...(region ? { region } : {}), - }; - const settings: InitSettingRow[] = [ - { key: "app", value: name.value, source: name.source }, - { - key: "framework", - value: framework.displayName, - source: framework.source, - }, - ...(entry - ? [{ key: "entry", value: entry.value, source: entry.source }] - : []), - { - key: "http port", - value: String(httpPort.value), - source: httpPort.source, - }, - ...(region ? [{ key: "region", value: region, source: "flag" }] : []), - ]; - reportSettings(step, settings); - - ctx.report({ kind: "step-started", step: "write-config" }); - const written = await writeConfig({ - cwd: ctx.cwd, - config: { app } as ComputeConfig, - format: format.value, - custom: framework.key === "custom", - signal: ctx.signal, - }); - ctx.report({ - kind: "step-finished", - step: "write-config", - outcome: "ok", - data: { path: written.filename }, - }); - - // The JSON format exists to be dependency-free, so the types install - // step never runs for it; validation happens when commands load the - // config. - const types = - format.value === "json" ? skippedTypes() : await runTypes(step, flags); - const link = await runLink(step, flags); - await runAgentSetup(step); - - return { - configPath: written.filename, - format: format.value, - converted: false, - directory: initDirectory(ctx.cwd), - app, - settings, - types, - link, - }; -} - -/** - * Conversion transports the config's values but its side-effect steps - * behave exactly like fresh init, and they act on the config's home, not - * the invocation directory: discovery may have found the config in an - * ancestor, and the types dependency and project pin belong where the - * config lives. - */ -async function runConversion( - jsonConfigPath: string, - flags: InitFlags, - step: InitStepContext, -): Promise { - const ctx = step.engine; - ctx.report({ kind: "step-started", step: "convert-config" }); - const converted = await convertJsonConfig(jsonConfigPath, ctx.signal); - ctx.report({ - kind: "step-finished", - step: "convert-config", - outcome: "ok", - data: { from: COMPUTE_CONFIG_JSON_FILENAME, to: converted.tsConfigPath }, - }); - reportSettings(step, converted.settings); - - const atConfigDir = at(step, converted.configDir); - const types = await runTypes(atConfigDir, flags); - const link = await runLink(atConfigDir, flags); - await runAgentSetup(atConfigDir); - - return { - configPath: - path.relative(ctx.cwd, converted.tsConfigPath) || "prisma.compute.ts", - format: "typescript", - converted: true, - directory: initDirectory(converted.configDir), - app: converted.app, - settings: converted.settings, - types, - link, - }; -} - -/** The legacy stderr preview of what init is about to write. */ -function reportSettings( - step: InitStepContext, - settings: readonly InitSettingRow[], -): void { - const preview = settingsPreview(settings); - if (preview !== null) { - step.engine.report(preview); - } -} - -function outcomeOf( - status: InitTypesState["status"] | InitLinkState["status"], -): "ok" | "warning" | "skipped" { - if (status === "failed") { - return "warning"; - } - return status === "skipped" || status === "declined" ? "skipped" : "ok"; -} - -async function runTypes( - step: InitStepContext, - flags: InitFlags, -): Promise { - step.engine.report({ kind: "step-started", step: "install-types" }); - const types = await resolveTypes(flags, step); - step.engine.report({ - kind: "step-finished", - step: "install-types", - outcome: outcomeOf(types.status), - data: { status: types.status }, - }); - return types; -} - -async function runLink( - step: InitStepContext, - flags: InitFlags, -): Promise { - step.engine.report({ kind: "step-started", step: "link-project" }); - const link = await resolveLink(flags, step); - step.engine.report({ - kind: "step-finished", - step: "link-project", - outcome: - link.status === "unauthenticated" ? "warning" : outcomeOf(link.status), - data: { status: link.status }, - }); - return link; -} - -async function runAgentSetup(step: InitStepContext): Promise { - step.engine.report({ kind: "step-started", step: "agent-setup" }); - await maybeOfferAgentSetup(step); - step.engine.report({ - kind: "step-finished", - step: "agent-setup", - outcome: "ok", - }); -} diff --git a/packages/cli/src/commands/init/link.ts b/packages/cli/src/commands/init/link.ts deleted file mode 100644 index fa828501..00000000 --- a/packages/cli/src/commands/init/link.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * The linking step: bind this directory to a Prisma Project. It runs - * `project link`'s own flow (`linkDirectoryToProject`), so the picker, - * the create-a-Project choice and the pin write have one implementation. - * - * Two things differ from `project link`. The legacy step called - * `runProjectLink`, which called `requireAuthenticatedAuthState` and - * launched a browser login on a terminal; this reads the credential the - * way `auth whoami` does and, when there is none, reports the step as - * unauthenticated and offers `auth login` as a next action (R-S2d-1). - * And the config write has already succeeded by the time this runs, so a - * link that fails is a warning on a successful init, never a failure. - */ -import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { CLI_NAME } from "../../cli-name"; -import { readLocalResolutionPin } from "../../lib/project/local-pin"; -import { linkDirectoryToProject } from "../project/link"; -import type { InitFlags, InitLinkState, InitStepContext } from "./types"; - -const SKIPPED: InitLinkState = { status: "skipped", project: null }; - -function signInRequired(step: InitStepContext): InitLinkState { - const link = step.formatCommand(["project", "link"]); - step.record({ - code: "INIT.LINK_REQUIRES_SIGN_IN", - severity: "warn", - summary: `Not linked to a Project: you are not signed in. Sign in, then link with ${link}.`, - nextActions: [ - { - kind: "run-command", - label: "Sign in", - command: `${CLI_NAME} auth login`, - }, - ], - }); - return { status: "unauthenticated", project: null }; -} - -/** Both CliError and CliStructuredError carry their summary as the - * message, which is the sentence the legacy warning quoted. */ -function linkFailed(step: InitStepContext, error: unknown): InitLinkState { - const detail = error instanceof Error ? error.message : String(error); - const link = step.formatCommand(["project", "link"]); - step.record({ - code: "INIT.LINK_FAILED", - severity: "warn", - summary: `Project link failed: ${detail}. Link later with ${link}.`, - nextActions: [{ kind: "run-command", label: link, command: link }], - }); - return { status: "failed", project: null }; -} - -export async function resolveLink( - flags: InitFlags, - step: InitStepContext, -): Promise { - const ctx = step.engine; - const pin = await readLocalResolutionPin(ctx.cwd, ctx.signal); - if (pin.isOk() && pin.value.kind === "present") { - return { status: "already-linked", project: null }; - } - if (flags.link === false) { - return SKIPPED; - } - - const explicitProject = flags.project?.trim(); - const shouldLink = - Boolean(explicitProject) || - flags.link === true || - (await ctx.prompt.confirm("Link this directory to a Prisma Project now?", { - default: false, - })); - if (!shouldLink) { - return { status: "declined", project: null }; - } - - if ((await ctx.activeCredential())?.workspaceId === undefined) { - return signInRequired(step); - } - - try { - const linked = await linkDirectoryToProject(ctx, explicitProject); - return { status: "linked", project: linked.project }; - } catch (error) { - ctx.signal.throwIfAborted(); - // Ctrl-C is the user leaving, not a step that failed: it settles the - // whole command at exit 3. Everything else — including a picker with - // nobody to answer it — downgrades to a warning, because the config - // write already succeeded and a failed link must not undo it. - if (CliStructuredError.is(error) && error.code === "CLI.PROMPT_CANCELLED") { - throw error; - } - return linkFailed(step, error); - } -} diff --git a/packages/cli/src/commands/init/presentation.ts b/packages/cli/src/commands/init/presentation.ts deleted file mode 100644 index 1d4c3898..00000000 --- a/packages/cli/src/commands/init/presentation.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Block, EngineEvent, Presentations } from "@prisma/cli-engine"; -import type { NextAction } from "@prisma/cli-engine/protocol"; -import { COMPUTE_CONFIG_JSON_FILENAME } from "@prisma/compute-sdk/config"; -import type { PrismaCliPackageCommandFormatter } from "../../lib/agent/cli-command"; -import type { InitResult } from "./types"; - -function runCommand(command: string): NextAction { - return { kind: "run-command", label: command, command }; -} - -/** Both statuses mean the pin is on disk, so nothing more is offered. */ -function isLinked(result: InitResult): boolean { - return ( - result.link.status === "linked" || result.link.status === "already-linked" - ); -} - -function typesMissing(result: InitResult): boolean { - return ( - result.types.status !== "installed" && - result.types.status !== "already-installed" - ); -} - -function typesBlock(result: InitResult): Block | undefined { - if (result.types.status === "installed") { - return { - kind: "summary", - status: "ok", - text: `Installed ${result.types.package} (config types)`, - }; - } - if ( - (result.types.status === "skipped" || result.types.status === "declined") && - result.types.installCommand - ) { - return { - kind: "summary", - status: "info", - text: `For editor types: ${result.types.installCommand}`, - }; - } - return undefined; -} - -/** A failed link already spoke through its own finding, and an - * already-linked directory has nothing to say. */ -function linkBlock( - result: InitResult, - formatCommand: PrismaCliPackageCommandFormatter, -): Block | undefined { - switch (result.link.status) { - case "linked": - return { - kind: "summary", - status: "ok", - text: `Linked "${result.directory}" to Project "${result.link.project?.name ?? ""}"`, - }; - case "already-linked": - case "failed": - return undefined; - default: - return { - kind: "summary", - status: "info", - text: `Not linked to a Project yet; link with ${formatCommand(["project", "link"])}.`, - }; - } -} - -export function initPresentations( - result: InitResult, - formatCommand: PrismaCliPackageCommandFormatter, -): Presentations { - return { - json: () => result, - human: () => - [ - { - kind: "summary", - status: "ok", - text: result.converted - ? `Converted ${COMPUTE_CONFIG_JSON_FILENAME} to ${result.configPath}` - : `Wrote ${result.configPath}`, - } as Block, - typesBlock(result), - linkBlock(result, formatCommand), - ].filter((block): block is Block => block !== undefined), - stdout: () => [result.configPath], - next: () => [ - ...(typesMissing(result) && result.types.installCommand - ? [runCommand(result.types.installCommand)] - : []), - runCommand(formatCommand(["git", "connect"])), - ...(isLinked(result) - ? [] - : [runCommand(formatCommand(["project", "link"]))]), - ], - }; -} - -/** The settings the run will write, in the legacy preview's padded - * columns, as the commentary event that replaces the legacy stderr - * preview. Null when a conversion transported a config with no single - * app to describe. */ -export function settingsPreview( - settings: InitResult["settings"], -): EngineEvent | null { - if (settings.length === 0) { - return null; - } - const keyWidth = Math.max(...settings.map((row) => row.key.length)); - const valueWidth = Math.max(...settings.map((row) => row.value.length)); - return { - kind: "message", - severity: "info", - text: settings - .map( - (row) => - ` ${row.key.padEnd(keyWidth)} ${row.value.padEnd(valueWidth)} ${row.source}`, - ) - .join("\n"), - }; -} diff --git a/packages/cli/src/commands/init/settings.ts b/packages/cli/src/commands/init/settings.ts deleted file mode 100644 index 6b873cd3..00000000 --- a/packages/cli/src/commands/init/settings.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Resolving what init will write: format, framework, app name, port, - * region and entrypoint, from flags, detection and prompts. The rules - * are the legacy controller's (`src/controllers/init.ts`); what changed - * is that interactivity is decided by the engine's prompt surface - * instead of by the handler reading TTY state. - */ -import path from "node:path"; -import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { - COMPUTE_CONFIG_JSON_FILENAME, - COMPUTE_REGIONS, - type ComputeFramework, - type ComputeRegion, - defaultHttpPortForBuildType, - FRAMEWORKS, - frameworkByKey, - frameworkFromAlias, -} from "@prisma/compute-sdk/config"; -import { - readBunPackageEntrypoint, - readBunPackageJson, -} from "../../lib/app/bun-project"; -import { initError } from "./errors"; -import type { InitConfigFormat, InitFlags, InitStepContext } from "./types"; - -export const COMPUTE_SDK_PACKAGE = "@prisma/compute-sdk"; - -export interface ResolvedFramework { - readonly key: ComputeFramework; - readonly displayName: string; - readonly source: string; -} - -export interface ResolvedValue { - readonly value: T; - readonly source: string; -} - -/** `--config-format ts` is only a conversion request when the user said it; - * the same value arrived at by default just writes TypeScript. */ -export function parseFormat(value: "ts" | "json" | undefined): { - readonly value: InitConfigFormat; - readonly explicit: boolean; -} { - if (value === undefined) { - return { value: "typescript", explicit: false }; - } - return value === "json" - ? { value: "json", explicit: true } - : { value: "typescript", explicit: true }; -} - -export function parseHttpPort( - value: string | undefined, - step: InitStepContext, -): ResolvedValue | undefined { - if (value === undefined) { - return undefined; - } - return { value: requirePort(value, step), source: "flag" }; -} - -function requirePort(value: string, step: InitStepContext): number { - const port = Number(value.trim()); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw initError({ - code: "INIT.HTTP_PORT_INVALID", - summary: "Invalid HTTP port", - why: "--http-port must be an integer between 1 and 65535.", - fix: "Pass a valid port.", - commands: [step.formatCommand(["init", "--http-port", "3000"])], - }); - } - return port; -} - -export function parseRegion( - value: string | undefined, - step: InitStepContext, -): ComputeRegion | undefined { - if (value === undefined) { - return undefined; - } - const trimmed = value.trim(); - if ((COMPUTE_REGIONS as readonly string[]).includes(trimmed)) { - return trimmed as ComputeRegion; - } - throw initError({ - code: "INIT.REGION_UNKNOWN", - summary: "Unknown region", - why: `"${value}" is not a supported Compute region.`, - fix: `Pass one of: ${COMPUTE_REGIONS.join(", ")}.`, - commands: [step.formatCommand(["init", "--region", "us-east-1"])], - }); -} - -function detectionFailedError(step: InitStepContext): CliStructuredError { - return initError({ - code: "INIT.DETECTION_FAILED", - summary: "No supported framework detected", - why: "The directory has none of the framework signals init detects from, and no --framework was passed.", - fix: `Pass --framework with one of: ${FRAMEWORKS.map((framework) => framework.key).join(", ")}.`, - commands: FRAMEWORKS.slice(0, 3).map((framework) => - step.formatCommand(["init", "--framework", framework.key]), - ), - meta: { frameworks: FRAMEWORKS.map((framework) => framework.key) }, - }); -} - -/** - * The flag, then directory detection, then the user. The picker has no - * default because nothing here is a defensible guess, so a run that - * cannot prompt reaches the same dead end the legacy command did — the - * engine's structural prompt failure is translated back into - * INIT.DETECTION_FAILED, which names the frameworks to choose from. - */ -export async function resolveFramework( - flags: InitFlags, - step: InitStepContext, -): Promise { - if (flags.framework) { - const framework = frameworkFromAlias(flags.framework.trim()); - if (!framework) { - throw initError({ - code: "INIT.FRAMEWORK_UNKNOWN", - summary: "Unknown framework", - why: `"${flags.framework}" is not a supported framework.`, - fix: `Pass one of: ${FRAMEWORKS.map((candidate) => candidate.key).join(", ")}.`, - commands: [step.formatCommand(["init", "--framework", "hono"])], - }); - } - return { - key: framework.key, - displayName: framework.displayName, - source: "flag", - }; - } - - const { detectDeployFramework } = await import( - "../../lib/app/deploy-framework" - ); - const detected = await detectDeployFramework( - step.engine.cwd, - step.engine.signal, - ); - if (detected) { - return { - key: detected.key as ComputeFramework, - displayName: detected.displayName, - source: detected.annotation, - }; - } - - let key: ComputeFramework; - try { - key = await step.engine.prompt.select( - "Which framework does this app use?", - FRAMEWORKS.map((framework) => ({ - label: framework.displayName, - value: framework.key, - })), - ); - } catch (error) { - if (CliStructuredError.is(error) && error.code === "CLI.PROMPT_REQUIRED") { - throw detectionFailedError(step); - } - throw error; - } - return { - key, - displayName: frameworkByKey(key).displayName, - source: "selected", - }; -} - -export async function resolveAppName( - flags: InitFlags, - step: InitStepContext, -): Promise> { - const trimmed = flags.name?.trim(); - if (flags.name !== undefined && !trimmed) { - throw initError({ - code: "INIT.NAME_EMPTY", - summary: "App name required", - why: "--name needs a non-empty value.", - fix: "Pass a non-empty app name.", - commands: [step.formatCommand(["init", "--name", "api"])], - }); - } - if (trimmed) { - return { value: trimmed, source: "flag" }; - } - - const packageJson = await readBunPackageJson( - step.engine.cwd, - step.engine.signal, - ); - const packageName = - typeof packageJson?.name === "string" ? packageJson.name.trim() : ""; - if (packageName) { - return { value: packageName, source: "package.json" }; - } - return { value: path.basename(step.engine.cwd), source: "directory name" }; -} - -/** Entry resolves against the FINAL framework, so an interactive - * framework switch cannot leave a stale entry in the written config. */ -export async function resolveEntry( - framework: ResolvedFramework, - flags: InitFlags, - step: InitStepContext, -): Promise | undefined> { - const descriptor = frameworkByKey(framework.key); - const trimmed = flags.entry?.trim(); - if (!descriptor.usesEntrypoint) { - if (trimmed) { - throw initError({ - code: "INIT.ENTRY_UNSUPPORTED", - summary: "--entry is not supported for this framework", - why: `${framework.displayName} derives its entrypoint from build output; --entry applies only to frameworks that run a source entrypoint (Bun, Hono).`, - fix: "Drop --entry, or pass an entrypoint framework with --framework.", - }); - } - return undefined; - } - if (trimmed) { - return { value: trimmed, source: "flag" }; - } - - const packageEntrypoint = readBunPackageEntrypoint( - await readBunPackageJson(step.engine.cwd, step.engine.signal), - ); - return packageEntrypoint === undefined - ? undefined - : { value: packageEntrypoint, source: "package.json" }; -} - -/** - * The one prompt that can change what gets written. Its default is no, - * so `--yes` and non-interactive runs take the resolved settings - * untouched — the legacy behavior. - */ -export async function maybeAdjustSettings( - spec: { - readonly framework: ResolvedFramework; - readonly httpPort: ResolvedValue; - readonly portExplicit: boolean; - }, - step: InitStepContext, -): Promise<{ - readonly framework: ResolvedFramework; - readonly httpPort: ResolvedValue; -}> { - const prompt = step.engine.prompt; - const adjust = await prompt.confirm( - `Adjust these settings? (${spec.framework.displayName}, HTTP ${spec.httpPort.value})`, - { default: false }, - ); - if (!adjust) { - return { framework: spec.framework, httpPort: spec.httpPort }; - } - - const key = await prompt.select( - "Framework", - FRAMEWORKS.map((candidate) => ({ - label: - candidate.key === spec.framework.key - ? `${candidate.displayName} (current)` - : candidate.displayName, - value: candidate.key, - })), - { default: spec.framework.key }, - ); - const framework: ResolvedFramework = - key === spec.framework.key - ? spec.framework - : { - key, - displayName: frameworkByKey(key).displayName, - source: "selected", - }; - - const defaultPort = spec.portExplicit - ? spec.httpPort.value - : defaultHttpPortForBuildType(frameworkByKey(key).buildType); - const answer = ( - await prompt.text("HTTP port", { - placeholder: String(defaultPort), - default: String(defaultPort), - }) - ).trim(); - - return { - framework, - httpPort: - answer === "" || Number(answer) === defaultPort - ? { - value: defaultPort, - source: spec.portExplicit - ? spec.httpPort.source - : "framework default", - } - : { value: requirePort(answer, step), source: "selected" }, - }; -} - -export function installNotApplicableError( - step: InitStepContext, -): CliStructuredError { - return initError({ - code: "INIT.INSTALL_NOT_APPLICABLE", - summary: "--install does not apply to the JSON config format", - why: `${COMPUTE_CONFIG_JSON_FILENAME} is a dependency-free static config; the ${COMPUTE_SDK_PACKAGE} devDependency exists only for prisma.compute.ts editor types.`, - fix: "Drop --install, or use the TypeScript format.", - commands: [step.formatCommand(["init", "--config-format", "json"])], - }); -} - -export function customFrameworkNeedsTypescriptError( - step: InitStepContext, -): CliStructuredError { - return initError({ - code: "INIT.CUSTOM_FRAMEWORK_NEEDS_TYPESCRIPT", - summary: "Custom framework requires the TypeScript config format", - why: "The custom framework needs build.outputDirectory and build.entrypoint, which init does not collect; the TypeScript format includes a commented build stub to complete, and strict JSON cannot carry it.", - fix: `Rerun without --config-format json and fill in the build stub, or write ${COMPUTE_CONFIG_JSON_FILENAME} by hand with a build object.`, - commands: [step.formatCommand(["init", "--framework", "custom"])], - }); -} diff --git a/packages/cli/src/commands/init/types-install.ts b/packages/cli/src/commands/init/types-install.ts deleted file mode 100644 index 2fb7b31d..00000000 --- a/packages/cli/src/commands/init/types-install.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Offers to install `@prisma/compute-sdk` as a devDependency so the - * generated config's typed import resolves in the editor. Deploy - * resolves the import without a local install, so every outcome short - * of success is a hint, never a failure. - */ -import { execa } from "execa"; -import { - type AgentPackageManager, - detectPackageManagerSync, -} from "../../lib/agent/package-manager"; -import { readBunPackageJson } from "../../lib/app/bun-project"; -import { COMPUTE_SDK_PACKAGE } from "./settings"; -import type { InitFlags, InitStepContext, InitTypesState } from "./types"; - -function packageAddCommand(packageManager: AgentPackageManager): string[] { - switch (packageManager) { - case "pnpm": - return ["pnpm", "add", "-D", COMPUTE_SDK_PACKAGE]; - case "bun": - return ["bun", "add", "-d", COMPUTE_SDK_PACKAGE]; - case "yarn": - return ["yarn", "add", "-D", COMPUTE_SDK_PACKAGE]; - case "npm": - return ["npm", "install", "-D", COMPUTE_SDK_PACKAGE]; - } -} - -function hasComputeSdkDependency( - packageJson: Awaited>, -): boolean { - for (const group of [ - packageJson?.dependencies, - packageJson?.devDependencies, - ]) { - if ( - group && - typeof group === "object" && - COMPUTE_SDK_PACKAGE in (group as Record) - ) { - return true; - } - } - return false; -} - -/** Test hook: JSON array command that replaces the real package-manager install. */ -function installCommandOverride(step: InitStepContext): string[] | null { - const raw = step.engine.env.PRISMA_CLI_INIT_INSTALL_COMMAND; - if (!raw) { - return null; - } - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) && parsed.every((p) => typeof p === "string") - ? parsed - : null; - } catch { - return null; - } -} - -export async function resolveTypes( - flags: InitFlags, - step: InitStepContext, -): Promise { - const ctx = step.engine; - // This step runs after the config is written; an unreadable - // package.json (malformed JSON, permissions) must not turn the already - // successful write into a command failure, so it degrades to a skip. - let packageJson: Awaited>; - try { - packageJson = await readBunPackageJson(ctx.cwd, ctx.signal); - } catch (error) { - ctx.signal.throwIfAborted(); - step.record({ - code: "INIT.TYPES_PACKAGE_JSON_UNREADABLE", - severity: "warn", - summary: `Skipped the ${COMPUTE_SDK_PACKAGE} types install: package.json could not be read (${firstLine(error)}).`, - nextActions: [], - }); - return skippedTypes(); - } - - if (hasComputeSdkDependency(packageJson)) { - return { - status: "already-installed", - package: COMPUTE_SDK_PACKAGE, - installCommand: null, - }; - } - - const installCommand = packageAddCommand( - detectPackageManagerSync(ctx.cwd) ?? "npm", - ); - const installCommandText = installCommand.join(" "); - const state = (status: InitTypesState["status"]): InitTypesState => ({ - status, - package: COMPUTE_SDK_PACKAGE, - installCommand: installCommandText, - }); - - // A directory without a package.json has nowhere to record the dependency. - if (!packageJson || flags.install === false) { - return state("skipped"); - } - - const shouldInstall = - flags.install === true || - (await ctx.prompt.confirm( - `Install ${COMPUTE_SDK_PACKAGE} for config types? (${installCommandText})`, - { default: false }, - )); - if (!shouldInstall) { - return state("declined"); - } - - const command = installCommandOverride(step) ?? installCommand; - try { - const [executable, ...args] = command; - await execa(executable as string, args, { - cwd: ctx.cwd, - env: ctx.env, - cancelSignal: ctx.signal, - stdin: "ignore", - }); - return state("installed"); - } catch (error) { - ctx.signal.throwIfAborted(); - step.record({ - code: "INIT.TYPES_INSTALL_FAILED", - severity: "warn", - summary: `Installing ${COMPUTE_SDK_PACKAGE} failed: ${firstLine(error)}. Install it later with ${installCommandText}.`, - nextActions: [ - { - kind: "run-command", - label: installCommandText, - command: installCommandText, - }, - ], - }); - return state("failed"); - } -} - -/** execa's first message line is the short "Command failed" summary; the - * full package-manager output stays out of the warning. */ -function firstLine(error: unknown): string { - return error instanceof Error - ? (error.message.split("\n")[0] as string) - : String(error); -} - -/** The JSON format is dependency-free by design, so its types step - * never runs and offers no install hint. */ -export function skippedTypes(): InitTypesState { - return { - status: "skipped", - package: COMPUTE_SDK_PACKAGE, - installCommand: null, - }; -} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts deleted file mode 100644 index 1c630643..00000000 --- a/packages/cli/src/commands/init/types.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Diagnostic } from "@prisma/cli-engine/protocol"; -import type { PrismaCliPackageCommandFormatter } from "../../lib/agent/cli-command"; -import type { InitConfigFormat, InitSettingRow } from "../../types/init"; -import type { ProjectCommandContext } from "../project/context"; - -export type { InitConfigFormat, InitSettingRow }; - -/** - * `unauthenticated` is the one status the legacy command had no name - * for: it auto-launched a browser login instead. Init now reads the - * auth state and says so. - */ -export type InitLinkStatus = - | "linked" - | "already-linked" - | "skipped" - | "declined" - | "unauthenticated" - | "failed"; - -export interface InitLinkState { - readonly status: InitLinkStatus; - readonly project: { readonly id: string; readonly name: string } | null; -} - -export type InitTypesStatus = - | "installed" - | "already-installed" - | "skipped" - | "declined" - | "failed"; - -export interface InitTypesState { - readonly status: InitTypesStatus; - readonly package: string; - /** Human-runnable install command for hints when not installed. */ - readonly installCommand: string | null; -} - -export interface InitResult { - readonly configPath: string; - readonly format: InitConfigFormat; - /** True when init converted an existing prisma.compute.json to TypeScript. */ - readonly converted: boolean; - readonly directory: string; - /** - * App identity pinned by the written config. Null when a conversion - * transported a config that does not pin a single fully-resolved app. - */ - readonly app: { - readonly name: string; - readonly framework: string; - readonly httpPort: number; - readonly entry?: string; - readonly region?: string; - } | null; - readonly settings: readonly InitSettingRow[]; - readonly types: InitTypesState; - readonly link: InitLinkState; -} - -/** The parsed flag surface, one property per declared flag. */ -export interface InitFlags { - readonly framework: string | undefined; - readonly entry: string | undefined; - readonly httpPort: string | undefined; - readonly region: string | undefined; - readonly name: string | undefined; - readonly link: boolean | undefined; - readonly project: string | undefined; - readonly install: boolean | undefined; - readonly configFormat: "ts" | "json" | undefined; -} - -/** - * What each step of the wizard needs. `engine.cwd` is the directory the - * step acts on, which is not always the invocation directory: a - * conversion discovered in an ancestor installs types and writes the - * project pin where the config lives. - */ -export interface InitStepContext { - readonly engine: ProjectCommandContext; - readonly formatCommand: PrismaCliPackageCommandFormatter; - /** Records a finding on the outcome; the legacy `warnings` channel. */ - readonly record: (diagnostic: Diagnostic) => void; -} diff --git a/packages/cli/src/types/init.ts b/packages/cli/src/types/init.ts deleted file mode 100644 index dad5e804..00000000 --- a/packages/cli/src/types/init.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Serialization the compute config was written in. */ -export type InitConfigFormat = "typescript" | "json"; - -export interface InitSettingRow { - key: string; - value: string; - source: string; -} - -export type InitLinkStatus = - | "linked" - | "already-linked" - | "skipped" - | "declined" - | "failed"; - -export interface InitLinkState { - status: InitLinkStatus; - project: { - id: string; - name: string; - } | null; -} - -export type InitTypesStatus = - | "installed" - | "already-installed" - | "skipped" - | "declined" - | "failed"; - -export interface InitTypesState { - status: InitTypesStatus; - package: string; - /** Human-runnable install command for hints when not installed. */ - installCommand: string | null; -} - -export interface InitResult { - configPath: string; - format: InitConfigFormat; - /** True when init converted an existing prisma.compute.json to TypeScript. */ - converted: boolean; - directory: string; - /** - * App identity pinned by the written config. Null when a conversion - * transported a config that does not pin a single fully-resolved app. - */ - app: { - name: string; - framework: string; - httpPort: number; - entry?: string; - region?: string; - } | null; - settings: InitSettingRow[]; - types: InitTypesState; - link: InitLinkState; -} diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index 07d96611..12843b8f 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -162,7 +162,7 @@ async function mountedCommands(): Promise { } const block = source.slice(start + marker.length, end); // Quoted keys are multi-word paths; bare identifier keys are the - // single-word mounts (init, feedback), which the quoted-only scan + // single-word mounts (feedback), which the quoted-only scan // used to miss entirely. return [ ...block.matchAll(/^\s{2}(?:"([^"]+)"|([A-Za-z][A-Za-z0-9]*)):/gm), diff --git a/packages/cli/tests/init-agent-setup.test.ts b/packages/cli/tests/init-agent-setup.test.ts deleted file mode 100644 index 84627cc8..00000000 --- a/packages/cli/tests/init-agent-setup.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * The agent-skill offer init makes once per project. The skills CLI runs - * as a child process, so execa is faked and the argv is asserted instead. - */ -import path from "node:path"; -import { createTestCli } from "@prisma/cli-engine/testing"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { LocalStateStore } from "../src/adapters/local-state"; -import { initCommand } from "../src/commands/init/init"; -import { DEFAULT_PRISMA_AGENT_TARGETS } from "../src/lib/agent/constants"; -import { createTempCwd } from "./helpers"; -import { writeSkillsLockWithSkill } from "./helpers/skills-lock"; - -const execa = vi.hoisted(() => vi.fn(async () => ({ exitCode: 0 }))); -vi.mock("execa", () => ({ execa })); - -const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/; - -let cwd: string; -let stateDir: string; - -beforeEach(async () => { - execa.mockClear(); - execa.mockImplementation(async () => ({ exitCode: 0 })); - cwd = await createTempCwd(); - stateDir = path.join(cwd, ".state"); -}); - -function run( - argv: readonly string[], - opts?: { - readonly answers?: ReadonlyArray; - readonly isTty?: { stdin?: boolean }; - readonly env?: Readonly>; - }, -) { - return createTestCli({ - commands: { init: initCommand }, - now: () => new Date(0), - }).run(argv, { - cwd, - env: { PRISMA_CLI_STATE_DIR: stateDir, CI: undefined, ...opts?.env }, - ...(opts?.answers === undefined ? {} : { answers: opts.answers }), - ...(opts?.isTty === undefined ? {} : { isTty: opts.isTty }), - }); -} - -function dismissedAt(): Promise { - return new LocalStateStore( - stateDir, - new AbortController().signal, - ).readAgentSetupPromptDismissedAt(); -} - -const BASE_ARGV = [ - "init", - "--framework", - "hono", - "--name", - "api", - "--no-install", - "--no-link", -]; - -describe("init agent-skill offer", () => { - it("installs the skill when the offer is accepted", async () => { - // adjust settings: no, then the skill offer: yes. - const result = await run(BASE_ARGV, { - isTty: { stdin: true }, - answers: [false, true], - }); - - expect(result.exitCode).toBe(0); - expect(execa).toHaveBeenCalledTimes(1); - const [executable, args] = execa.mock.calls[0] as unknown as [ - string, - string[], - ]; - expect([executable, ...args]).toEqual([ - "npx", - "-y", - "skills@latest", - "add", - "prisma/skills", - "--skill", - "prisma-compute", - ...DEFAULT_PRISMA_AGENT_TARGETS.flatMap((agent) => ["--agent", agent]), - "--yes", - ]); - expect(await dismissedAt()).toBeNull(); - }); - - it("remembers a declined offer so nothing asks again", async () => { - const result = await run(BASE_ARGV, { - isTty: { stdin: true }, - answers: [false, false], - }); - - expect(result.exitCode).toBe(0); - expect(execa).not.toHaveBeenCalled(); - expect(await dismissedAt()).toMatch(ISO_TIMESTAMP); - }); - - it("does not offer when the skill is already installed", async () => { - await writeSkillsLockWithSkill(cwd); - - const result = await run(BASE_ARGV, { - isTty: { stdin: true }, - answers: [false], - }); - - expect(result.exitCode).toBe(0); - expect(execa).not.toHaveBeenCalled(); - expect(await dismissedAt()).toBeNull(); - }); - - it("downgrades a failed skill install to a warn diagnostic", async () => { - execa.mockImplementation(async () => { - throw new Error("Command failed with exit code 1"); - }); - - const result = await run([...BASE_ARGV, "--json"], { - isTty: { stdin: true }, - answers: [false, true], - }); - - expect(result.exitCode).toBe(0); - const frame = result.json.at(-1) as unknown as { - envelope: { diagnostics: ReadonlyArray> }; - }; - expect(frame.envelope.diagnostics).toContainEqual( - expect.objectContaining({ - code: "INIT.AGENT_SETUP_FAILED", - severity: "warn", - }), - ); - }); - - it("takes the offer's default of no under --yes, and remembers it", async () => { - const result = await run([...BASE_ARGV, "--yes"]); - - expect(result.exitCode).toBe(0); - expect(execa).not.toHaveBeenCalled(); - expect(await dismissedAt()).toMatch(ISO_TIMESTAMP); - }); - - it("never offers or records anything in CI", async () => { - const result = await run(BASE_ARGV, { env: { CI: "true" } }); - - expect(result.exitCode).toBe(0); - expect(execa).not.toHaveBeenCalled(); - expect(await dismissedAt()).toBeNull(); - }); - - it("asks nothing once a previous run recorded a dismissal", async () => { - await new LocalStateStore( - stateDir, - new AbortController().signal, - ).setAgentSetupPromptDismissedAt("2026-01-01T00:00:00.000Z"); - - const result = await run(BASE_ARGV, { - isTty: { stdin: true }, - answers: [false], - }); - - expect(result.exitCode).toBe(0); - expect(execa).not.toHaveBeenCalled(); - expect(await dismissedAt()).toBe("2026-01-01T00:00:00.000Z"); - }); -}); diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts deleted file mode 100644 index 8d88a547..00000000 --- a/packages/cli/tests/init.test.ts +++ /dev/null @@ -1,1189 +0,0 @@ -/** - * The `init` wizard on the engine. Assertions are semantic — the - * envelope, the presented data, the events, the exit code — with one - * deliberate exception: the config files init writes are data, not - * rendering, so they are asserted byte for byte (R-S2d-1). - * - * Every test writes a skills-lock.json into its working directory so the - * agent-setup offer finds the skill already installed and stays out of - * the way; the offer itself is covered in init-agent-setup.test.ts. - */ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import type { ManagementApiClient } from "@prisma/cli-engine"; -import { createTestCli, mintTestJwt } from "@prisma/cli-engine/testing"; -import { COMPUTE_CONFIG_JSON_SCHEMA_URL } from "@prisma/compute-sdk/config"; -import { beforeEach, describe, expect, it } from "vitest"; - -import { initCommand } from "../src/commands/init/init"; -import { createTempCwd } from "./helpers"; -import { writeSkillsLockWithSkill } from "./helpers/skills-lock"; - -const WORKSPACE_ID = "ws_123"; - -/** A node that exits 0 without touching the network — what the types - * install step runs instead of a real package manager. */ -const FAKE_INSTALL = JSON.stringify(["node", "-e", "process.exit(0)"]); -const FAILING_INSTALL = JSON.stringify(["node", "-e", "process.exit(1)"]); - -let cwd: string; - -beforeEach(async () => { - cwd = await createTempCwd(); - await writeSkillsLockWithSkill(cwd); -}); - -function sessionRecord(workspaceId: string) { - return { - workspaceId, - workspaceName: "Acme", - credential: { - token: mintTestJwt({ workspace_id: workspaceId, sub: "usr_1" }), - refreshToken: "r", - expiresAt: undefined, - }, - }; -} - -function apiWithProjects( - projects: ReadonlyArray<{ id: string; name: string }>, -): ManagementApiClient { - return { - GET: async () => ({ - data: { - data: projects.map((project) => ({ - ...project, - slug: project.name, - workspace: { id: WORKSPACE_ID, name: "Acme" }, - })), - }, - response: { status: 200 }, - }), - } as unknown as ManagementApiClient; -} - -const OFFLINE_API = { - GET: async () => { - throw new Error("offline"); - }, -} as unknown as ManagementApiClient; - -function makeCli(spec?: { - readonly signedIn?: boolean; - readonly client?: ManagementApiClient; -}) { - return createTestCli({ - commands: { init: initCommand }, - sessions: spec?.signedIn === true ? [sessionRecord(WORKSPACE_ID)] : [], - selectedWorkspaceId: spec?.signedIn === true ? WORKSPACE_ID : undefined, - managementApi: { client: spec?.client ?? OFFLINE_API }, - now: () => new Date(0), - }); -} - -type RunOptions = Parameters["run"]>[1]; - -function run( - argv: readonly string[], - opts?: RunOptions & { - signedIn?: boolean; - client?: ManagementApiClient; - }, -) { - const { signedIn, client, ...runOpts } = opts ?? {}; - return makeCli({ signedIn, client }).run(argv, { - cwd, - ...runOpts, - env: { PRISMA_CLI_INIT_INSTALL_COMMAND: FAKE_INSTALL, ...runOpts?.env }, - }); -} - -type ResultFrame = { - readonly kind: string; - readonly envelope: { - readonly ok: boolean; - readonly error?: Record; - readonly result?: unknown; - readonly diagnostics?: ReadonlyArray>; - readonly nextActions?: ReadonlyArray>; - }; -}; - -function envelopeOf(result: { readonly json: readonly unknown[] }) { - const frame = (result.json as readonly ResultFrame[]).find( - (candidate) => candidate.kind === "result", - ); - if (frame === undefined) { - throw new Error("expected a terminal result frame"); - } - return frame.envelope; -} - -function errorOf(result: { readonly json: readonly unknown[] }) { - const envelope = envelopeOf(result); - if (envelope.ok) { - throw new Error("expected an errored result frame"); - } - return envelope.error as Record; -} - -function resultOf(result: { readonly json: readonly unknown[] }) { - const envelope = envelopeOf(result); - if (!envelope.ok) { - throw new Error( - `expected an ok result frame, got ${JSON.stringify(envelope.error)}`, - ); - } - return envelope.result as Record; -} - -async function readConfig(directory = cwd): Promise { - return readFile(path.join(directory, "prisma.compute.ts"), "utf8"); -} - -async function readJsonConfig(directory = cwd): Promise { - return readFile(path.join(directory, "prisma.compute.json"), "utf8"); -} - -async function writePackageJson( - directory: string, - contents: Record, -): Promise { - await writeFile( - path.join(directory, "package.json"), - `${JSON.stringify(contents, null, 2)}\n`, - "utf8", - ); -} - -/** The compute SDK's canonical key order, which is what "byte-identical - * to the legacy command" means: both call the same serializer. */ -const BILLING_API_TS = - `import { defineComputeConfig } from "@prisma/compute-sdk/config";\n` + - `\n` + - `export default defineComputeConfig({\n` + - ` app: {\n` + - ` name: "billing-api",\n` + - ` region: "us-east-1",\n` + - ` framework: "hono",\n` + - ` entry: "src/index.ts",\n` + - ` httpPort: 8080,\n` + - ` },\n` + - `});\n`; - -describe("init writes the config", () => { - it("writes a config for an explicit framework without auth or prompts", async () => { - await writePackageJson(cwd, { name: "billing-api" }); - - const result = await run([ - "init", - "--framework", - "hono", - "--entry", - "src/index.ts", - "--no-install", - "--no-link", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - configPath: "prisma.compute.ts", - format: "typescript", - converted: false, - app: { - name: "billing-api", - framework: "hono", - entry: "src/index.ts", - httpPort: 3000, - }, - types: { - status: "skipped", - package: "@prisma/compute-sdk", - installCommand: "npm install -D @prisma/compute-sdk", - }, - link: { status: "skipped", project: null }, - }); - }); - - it("writes the exact bytes the compute SDK serializes", async () => { - await writePackageJson(cwd, { name: "billing-api" }); - - await run([ - "init", - "--framework", - "hono", - "--entry", - "src/index.ts", - "--http-port", - "8080", - "--region", - "us-east-1", - "--no-install", - "--no-link", - "--json", - ]); - - expect(await readConfig()).toBe(BILLING_API_TS); - }); - - it("appends the commented build stub for the custom framework, byte for byte", async () => { - await run([ - "init", - "--framework", - "custom", - "--name", - "api", - "--no-install", - "--no-link", - "--json", - ]); - - expect(await readConfig()).toContain( - `\n` + - `// framework "custom" deploys a prebuilt artifact. Add its build settings:\n` + - `// build: {\n` + - `// command: "npm run build",\n` + - `// outputDirectory: "dist",\n` + - `// entrypoint: "server.js",\n` + - `// },\n`, - ); - }); - - it("writes prisma.compute.json byte for byte under --config-format json", async () => { - await writePackageJson(cwd, { name: "billing-api" }); - - const result = await run([ - "init", - "--framework", - "hono", - "--entry", - "src/index.ts", - "--no-link", - "--config-format", - "json", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - configPath: "prisma.compute.json", - format: "json", - // The JSON format is dependency-free by design, so the types step - // never runs and offers no install hint. - types: { - status: "skipped", - package: "@prisma/compute-sdk", - installCommand: null, - }, - }); - expect(await readJsonConfig()).toBe( - `{\n` + - ` "app": {\n` + - ` "name": "billing-api",\n` + - ` "framework": "hono",\n` + - ` "entry": "src/index.ts",\n` + - ` "httpPort": 3000\n` + - ` }\n` + - `}\n`, - ); - await expect(readConfig()).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("detects the framework from the directory and uses its default port", async () => { - await writePackageJson(cwd, { name: "web" }); - await writeFile(path.join(cwd, "next.config.ts"), "export default {};\n"); - - const result = await run(["init", "--no-install", "--no-link", "--json"]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - app: { name: "web", framework: "nextjs", httpPort: 3000 }, - }); - expect(resultOf(result).settings).toContainEqual( - expect.objectContaining({ - key: "framework", - source: expect.stringContaining("next.config.ts"), - }), - ); - }); - - it("falls back to the directory name when package.json has none", async () => { - const result = await run([ - "init", - "--framework", - "hono", - "--no-install", - "--no-link", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - app: { name: path.basename(cwd) }, - directory: `./${path.basename(cwd)}`, - }); - }); - - it("previews the settings, reports the write as a step, and puts the path on stdout", async () => { - const result = await run( - [ - "init", - "--framework", - "hono", - "--name", - "api", - "--http-port", - "8080", - "--no-install", - "--no-link", - ], - { isTty: { stdout: true, stderr: true } }, - ); - - expect(result.exitCode).toBe(0); - expect(result.events).toContainEqual({ - kind: "message", - severity: "info", - text: - " app api flag\n" + - " framework Hono flag\n" + - " http port 8080 flag", - }); - expect(result.events).toContainEqual({ - kind: "step-finished", - step: "write-config", - outcome: "ok", - data: { path: "prisma.compute.ts" }, - }); - // Both streams are the same terminal here, so the machine mirror is - // suppressed; the path still travels in the presented stdout lines. - expect(result.stdout).toBe(""); - expect(result.presented?.presentation.stdout).toEqual([ - "prisma.compute.ts", - ]); - expect(result.presented?.presentation.human).toContainEqual({ - kind: "summary", - status: "ok", - text: "Wrote prisma.compute.ts", - }); - }); - - it("offers deploy and link as next actions when nothing is linked", async () => { - const result = await run([ - "init", - "--framework", - "hono", - "--name", - "api", - "--no-install", - "--no-link", - "--json", - ]); - - expect(envelopeOf(result).nextActions).toEqual([ - expect.objectContaining({ - command: "npm install -D @prisma/compute-sdk", - }), - expect.objectContaining({ - command: "npx -y @prisma/cli@next git connect", - }), - expect.objectContaining({ - command: "npx -y @prisma/cli@next project link", - }), - ]); - }); -}); - -describe("init refuses to clobber", () => { - it("fails with INIT.CONFIG_EXISTS here and in an ancestor", async () => { - await writeFile( - path.join(cwd, "prisma.compute.ts"), - 'export default { app: { framework: "hono" } };\n', - ); - - const direct = await run(["init", "--framework", "hono", "--json"]); - expect(direct.exitCode).toBe(2); - expect(errorOf(direct).code).toBe("INIT.CONFIG_EXISTS"); - expect(errorOf(direct).meta).toMatchObject({ - existingConfigPath: expect.stringContaining("prisma.compute.ts"), - }); - - await mkdir(path.join(cwd, ".git"), { recursive: true }); - const nested = path.join(cwd, "apps", "api"); - await mkdir(nested, { recursive: true }); - const fromNested = await makeCli().run( - ["init", "--framework", "hono", "--json"], - { cwd: nested }, - ); - expect(fromNested.exitCode).toBe(2); - expect(errorOf(fromNested).code).toBe("INIT.CONFIG_EXISTS"); - }); - - it("refuses plain init and a repeated --config-format json over prisma.compute.json", async () => { - await writeFile( - path.join(cwd, "prisma.compute.json"), - `${JSON.stringify({ app: { framework: "hono" } })}\n`, - ); - - for (const argv of [ - ["init", "--framework", "hono", "--json"], - ["init", "--framework", "hono", "--config-format", "json", "--json"], - ]) { - // biome-ignore lint/performance/noAwaitInLoops: both spellings run in the same sandbox directory, so the first must settle before the second checks the same file. - const result = await run(argv); - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("INIT.CONFIG_EXISTS"); - expect(errorOf(result).meta).toMatchObject({ - existingConfigPath: expect.stringContaining("prisma.compute.json"), - }); - } - }); - - it("fails with INIT.CONVERT_UNSUPPORTED for a TypeScript config and --config-format json", async () => { - await writeFile( - path.join(cwd, "prisma.compute.ts"), - 'export default { app: { framework: "hono" } };\n', - ); - - const result = await run([ - "init", - "--framework", - "hono", - "--config-format", - "json", - "--json", - ]); - - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("INIT.CONVERT_UNSUPPORTED"); - await expect(readJsonConfig()).rejects.toMatchObject({ code: "ENOENT" }); - }); -}); - -describe("init argument validation", () => { - it("rejects a bad port, region and framework before writing anything", async () => { - const cases: ReadonlyArray = [ - [ - ["init", "--framework", "hono", "--http-port", "70000", "--json"], - "INIT.HTTP_PORT_INVALID", - ], - [ - ["init", "--framework", "hono", "--region", "mars-1", "--json"], - "INIT.REGION_UNKNOWN", - ], - [["init", "--framework", "rails", "--json"], "INIT.FRAMEWORK_UNKNOWN"], - [ - ["init", "--framework", "hono", "--name", " ", "--json"], - "INIT.NAME_EMPTY", - ], - [ - ["init", "--framework", "nextjs", "--entry", "src/index.ts", "--json"], - "INIT.ENTRY_UNSUPPORTED", - ], - [ - [ - "init", - "--framework", - "hono", - "--install", - "--config-format", - "json", - "--json", - ], - "INIT.INSTALL_NOT_APPLICABLE", - ], - [ - ["init", "--framework", "custom", "--config-format", "json", "--json"], - "INIT.CUSTOM_FRAMEWORK_NEEDS_TYPESCRIPT", - ], - ]; - - for (const [argv, code] of cases) { - // biome-ignore lint/performance/noAwaitInLoops: the cases share one sandbox, and the per-case failure message needs them one at a time. - const result = await run(argv); - expect(result.exitCode, code).toBe(2); - expect(errorOf(result).code).toBe(code); - } - - await expect(readConfig()).rejects.toMatchObject({ code: "ENOENT" }); - await expect(readJsonConfig()).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("rejects an unknown --config-format value through the engine's parser", async () => { - const result = await run([ - "init", - "--framework", - "hono", - "--config-format", - "yaml", - "--json", - ]); - - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("CLI.INVALID_ARGUMENTS"); - }); - - it("fails with INIT.DETECTION_FAILED when nothing is detectable and nobody can be asked", async () => { - const result = await run(["init", "--no-install", "--no-link", "--json"]); - - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("INIT.DETECTION_FAILED"); - expect(errorOf(result).meta).toMatchObject({ - frameworks: expect.arrayContaining(["hono"]), - }); - }); -}); - -describe("init prompt modes", () => { - it("interactive: asks for a framework when detection finds nothing", async () => { - const result = await run(["init", "--no-install", "--no-link", "--json"], { - isTty: { stdin: true }, - answers: ["hono", false], - }); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - app: { framework: "hono" }, - settings: expect.arrayContaining([ - { key: "framework", value: "Hono", source: "selected" }, - ]), - }); - }); - - it("interactive: adjusts the framework and port when the user says yes", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--no-link", "--json"], - { isTty: { stdin: true }, answers: [true, "nextjs", "4321"] }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - app: { framework: "nextjs", httpPort: 4321 }, - }); - }); - - it("interactive: keeps the resolved settings when the adjust prompt is declined", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--no-link", "--json"], - { isTty: { stdin: true }, answers: [false] }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - app: { framework: "hono", httpPort: 3000 }, - }); - }); - - it("interactive: an out-of-range port typed at the adjust prompt is rejected", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--no-link", "--json"], - { isTty: { stdin: true }, answers: [true, "hono", "70000"] }, - ); - - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("INIT.HTTP_PORT_INVALID"); - }); - - it("--yes takes every prompt default: settings as resolved, no install, no link", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run([ - "init", - "--framework", - "hono", - "--yes", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - app: { framework: "hono", httpPort: 3000 }, - types: { status: "declined" }, - link: { status: "declined", project: null }, - }); - expect(await readConfig()).toContain('framework: "hono"'); - }); - - it("non-interactive takes the same defaults as --yes", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run(["init", "--framework", "hono", "--json"], { - signedIn: true, - client: apiWithProjects([{ id: "proj_1", name: "One" }]), - }); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - types: { status: "declined" }, - link: { status: "declined", project: null }, - }); - await expect( - readFile(path.join(cwd, ".prisma/local.json"), "utf8"), - ).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("cancelled before the write settles at exit 3 and writes nothing", async () => { - const result = await run(["init", "--no-install", "--no-link", "--json"], { - isTty: { stdin: true }, - stdin: "", - }); - - expect(result.exitCode).toBe(3); - expect(errorOf(result).code).toBe("CLI.PROMPT_CANCELLED"); - await expect(readConfig()).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("cancelled at the link question settles at exit 3, leaving the written config", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--json"], - { isTty: { stdin: true }, stdin: "n\n" }, - ); - - expect(result.exitCode).toBe(3); - expect(errorOf(result).code).toBe("CLI.PROMPT_CANCELLED"); - expect(await readConfig()).toContain('framework: "hono"'); - }); -}); - -describe("init types install", () => { - it("reports already-installed when the sdk is a devDependency", async () => { - await writePackageJson(cwd, { - name: "api", - devDependencies: { "@prisma/compute-sdk": "^0.1.0" }, - }); - - const result = await run([ - "init", - "--framework", - "hono", - "--no-link", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).types).toEqual({ - status: "already-installed", - package: "@prisma/compute-sdk", - installCommand: null, - }); - expect(envelopeOf(result).nextActions).not.toContainEqual( - expect.objectContaining({ - command: "npm install -D @prisma/compute-sdk", - }), - ); - }); - - it("skips with --no-install and keeps the hint as a next action", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run([ - "init", - "--framework", - "hono", - "--no-install", - "--no-link", - "--json", - ]); - - expect(resultOf(result).types).toMatchObject({ status: "skipped" }); - expect(envelopeOf(result).nextActions).toContainEqual( - expect.objectContaining({ - command: "npm install -D @prisma/compute-sdk", - }), - ); - }); - - it("installs with --install and reports success without findings", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run([ - "init", - "--framework", - "hono", - "--install", - "--no-link", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).types).toMatchObject({ status: "installed" }); - expect(envelopeOf(result).diagnostics).toEqual([]); - expect(result.events).toContainEqual({ - kind: "step-finished", - step: "install-types", - outcome: "ok", - data: { status: "installed" }, - }); - }); - - it("installs when the prompt is answered yes", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-link", "--json"], - { isTty: { stdin: true }, answers: [false, true] }, - ); - - expect(resultOf(result).types).toMatchObject({ status: "installed" }); - }); - - it("declines the install when the prompt is answered no", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-link", "--json"], - { isTty: { stdin: true }, answers: [false, false] }, - ); - - expect(resultOf(result).types).toMatchObject({ status: "declined" }); - }); - - it("downgrades a failed install to a warn diagnostic and keeps the config", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--install", "--no-link", "--json"], - { env: { PRISMA_CLI_INIT_INSTALL_COMMAND: FAILING_INSTALL } }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).types).toMatchObject({ status: "failed" }); - expect(envelopeOf(result).diagnostics).toContainEqual( - expect.objectContaining({ - code: "INIT.TYPES_INSTALL_FAILED", - severity: "warn", - }), - ); - expect(await readConfig()).toContain('framework: "hono"'); - }); - - it("keeps the written config when package.json cannot be read", async () => { - await writeFile(path.join(cwd, "package.json"), "{ not json", "utf8"); - - const result = await run([ - "init", - "--framework", - "hono", - "--entry", - "src/index.ts", - "--name", - "api", - "--install", - "--no-link", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).types).toMatchObject({ status: "skipped" }); - expect(envelopeOf(result).diagnostics).toContainEqual( - expect.objectContaining({ - code: "INIT.TYPES_PACKAGE_JSON_UNREADABLE", - }), - ); - expect(await readConfig()).toContain('framework: "hono"'); - }); -}); - -describe("init link step", () => { - it("links to an explicit --project and writes the pin", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - [ - "init", - "--framework", - "hono", - "--no-install", - "--project", - "proj_123", - "--json", - ], - { - signedIn: true, - client: apiWithProjects([{ id: "proj_123", name: "Acme Dashboard" }]), - }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).link).toEqual({ - status: "linked", - project: { id: "proj_123", name: "Acme Dashboard" }, - }); - expect( - JSON.parse(await readFile(path.join(cwd, ".prisma/local.json"), "utf8")), - ).toEqual({ workspaceId: WORKSPACE_ID, projectId: "proj_123" }); - expect(envelopeOf(result).nextActions).not.toContainEqual( - expect.objectContaining({ - command: "npx -y @prisma/cli@next project link", - }), - ); - }); - - it("reads the auth state instead of forcing a login, and offers sign-in", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run([ - "init", - "--framework", - "hono", - "--no-install", - "--project", - "proj_123", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).link).toEqual({ - status: "unauthenticated", - project: null, - }); - expect(envelopeOf(result).diagnostics).toContainEqual( - expect.objectContaining({ - code: "INIT.LINK_REQUIRES_SIGN_IN", - severity: "warn", - nextActions: [ - expect.objectContaining({ command: "prisma-cli auth login" }), - ], - }), - ); - expect(await readConfig()).toContain('framework: "hono"'); - }); - - it("downgrades a project that does not exist to a warn diagnostic", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - [ - "init", - "--framework", - "hono", - "--no-install", - "--project", - "nope", - "--json", - ], - { signedIn: true, client: apiWithProjects([]) }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).link).toMatchObject({ status: "failed" }); - expect(envelopeOf(result).diagnostics).toContainEqual( - expect.objectContaining({ - code: "INIT.LINK_FAILED", - summary: expect.stringContaining("Project link failed"), - }), - ); - }); - - it("reports an already-linked directory without asking", async () => { - await writePackageJson(cwd, { name: "api" }); - await mkdir(path.join(cwd, ".prisma"), { recursive: true }); - await writeFile( - path.join(cwd, ".prisma/local.json"), - `${JSON.stringify({ workspaceId: WORKSPACE_ID, projectId: "proj_9" })}\n`, - ); - - const result = await run([ - "init", - "--framework", - "hono", - "--no-install", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).link).toEqual({ - status: "already-linked", - project: null, - }); - }); - - it("picks a project through the same picker project link uses", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--link", "--json"], - { - signedIn: true, - client: apiWithProjects([ - { id: "proj_1", name: "One" }, - { id: "proj_2", name: "Two" }, - ]), - isTty: { stdin: true }, - answers: [false, "proj_2"], - }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).link).toEqual({ - status: "linked", - project: { id: "proj_2", name: "Two" }, - }); - }); - - it("offers the picker's cancel choice, which downgrades to a warning", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--link", "--json"], - { - signedIn: true, - client: apiWithProjects([{ id: "proj_1", name: "One" }]), - isTty: { stdin: true }, - answers: [false, "__cancel__"], - }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).link).toMatchObject({ status: "failed" }); - expect(envelopeOf(result).diagnostics).toContainEqual( - expect.objectContaining({ - code: "INIT.LINK_FAILED", - summary: expect.stringContaining("Project setup canceled"), - }), - ); - }); - - it("declines the link when the question is answered no", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--json"], - { isTty: { stdin: true }, answers: [false, false] }, - ); - - expect(resultOf(result).link).toEqual({ - status: "declined", - project: null, - }); - }); - - it("has nobody to answer the picker under --link non-interactively, so the step warns", async () => { - await writePackageJson(cwd, { name: "api" }); - - const result = await run( - ["init", "--framework", "hono", "--no-install", "--link", "--json"], - { - signedIn: true, - client: apiWithProjects([ - { id: "proj_1", name: "One" }, - { id: "proj_2", name: "Two" }, - ]), - }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result).link).toMatchObject({ status: "failed" }); - expect(envelopeOf(result).diagnostics).toContainEqual( - expect.objectContaining({ code: "INIT.LINK_FAILED" }), - ); - }); -}); - -describe("init conversion", () => { - const jsonConfig = `${JSON.stringify( - { - $schema: COMPUTE_CONFIG_JSON_SCHEMA_URL, - app: { - name: "billing-api", - framework: "hono", - entry: "src/index.ts", - httpPort: 8080, - region: "us-east-1", - }, - }, - null, - 2, - )}\n`; - - it("converts prisma.compute.json to prisma.compute.ts byte for byte", async () => { - await writeFile(path.join(cwd, "prisma.compute.json"), jsonConfig); - - const result = await run([ - "init", - "--config-format", - "ts", - "--no-install", - "--no-link", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - configPath: "prisma.compute.ts", - format: "typescript", - converted: true, - app: { - name: "billing-api", - framework: "hono", - entry: "src/index.ts", - httpPort: 8080, - region: "us-east-1", - }, - }); - expect(resultOf(result).settings).toContainEqual({ - key: "framework", - value: "Hono", - source: "prisma.compute.json", - }); - expect(await readConfig()).toBe(BILLING_API_TS); - await expect(readJsonConfig()).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("reports no app identity for a multi-app config", async () => { - await writeFile( - path.join(cwd, "prisma.compute.json"), - `${JSON.stringify({ - apps: { - web: { - framework: "nextjs", - root: "apps/web", - build: { command: null }, - }, - api: { - framework: "hono", - root: "apps/api", - entry: "src/index.ts", - }, - }, - })}\n`, - ); - - const result = await run([ - "init", - "--config-format", - "ts", - "--no-install", - "--no-link", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ converted: true, app: null }); - // Nothing to preview when no single app was transported. - expect(result.events).not.toContainEqual( - expect.objectContaining({ kind: "message" }), - ); - expect(await readConfig()).toContain("command: null"); - }); - - it("rejects resolution flags during conversion and changes nothing on disk", async () => { - const source = `${JSON.stringify({ - app: { name: "api", framework: "hono", httpPort: 8080 }, - })}\n`; - await writeFile(path.join(cwd, "prisma.compute.json"), source); - - const result = await run([ - "init", - "--config-format", - "ts", - "--framework", - "nextjs", - "--http-port", - "3000", - "--json", - ]); - - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("INIT.CONVERSION_FLAGS_NOT_APPLICABLE"); - expect(errorOf(result).summary).toContain("--framework"); - expect(errorOf(result).summary).toContain("--http-port"); - await expect(readConfig()).rejects.toMatchObject({ code: "ENOENT" }); - expect(await readJsonConfig()).toBe(source); - }); - - it("fails with INIT.COMPUTE_CONFIG_INVALID for a malformed JSON config", async () => { - await writeFile(path.join(cwd, "prisma.compute.json"), "{ not json"); - - const result = await run(["init", "--config-format", "ts", "--json"]); - - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("INIT.COMPUTE_CONFIG_INVALID"); - await expect(readConfig()).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("runs the conversion's side effects where the config lives, not where init ran", async () => { - await mkdir(path.join(cwd, ".git"), { recursive: true }); - await writePackageJson(cwd, { name: "root-app" }); - await writeFile( - path.join(cwd, "prisma.compute.json"), - `${JSON.stringify({ app: { framework: "hono", httpPort: 8080 } })}\n`, - ); - const nested = path.join(cwd, "apps", "api"); - await mkdir(nested, { recursive: true }); - await writePackageJson(nested, { name: "api" }); - - const result = await makeCli({ - signedIn: true, - client: apiWithProjects([{ id: "proj_123", name: "Acme Dashboard" }]), - }).run( - [ - "init", - "--config-format", - "ts", - "--install", - "--project", - "proj_123", - "--json", - ], - { - cwd: nested, - env: { - // The fake installer records where it ran. - PRISMA_CLI_INIT_INSTALL_COMMAND: JSON.stringify([ - "node", - "-e", - "require('fs').writeFileSync('install-cwd.txt','ok')", - ]), - }, - }, - ); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - configPath: path.join("..", "..", "prisma.compute.ts"), - types: { status: "installed" }, - link: { status: "linked", project: { id: "proj_123" } }, - }); - await expect( - readFile(path.join(cwd, "install-cwd.txt"), "utf8"), - ).resolves.toBe("ok"); - await expect( - readFile(path.join(nested, "install-cwd.txt"), "utf8"), - ).rejects.toMatchObject({ code: "ENOENT" }); - await expect( - readFile(path.join(cwd, ".prisma/local.json"), "utf8"), - ).resolves.toContain("proj_123"); - await expect( - readFile(path.join(nested, ".prisma/local.json"), "utf8"), - ).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("presents the conversion summary in human mode", async () => { - await writeFile( - path.join(cwd, "prisma.compute.json"), - `${JSON.stringify({ app: { framework: "hono", httpPort: 8080 } })}\n`, - ); - - const result = await run( - ["init", "--config-format", "ts", "--no-install", "--no-link"], - { isTty: { stdout: true, stderr: true } }, - ); - - expect(result.exitCode).toBe(0); - expect(result.presented?.presentation.human).toContainEqual({ - kind: "summary", - status: "ok", - text: "Converted prisma.compute.json to prisma.compute.ts", - }); - }); -}); diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index 069738cf..40c75c26 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -8,11 +8,7 @@ * `agent install|update|status`, and `feedback`) was ratified by the * operator on 2026-08-12. Adding to it requires an operator ruling * recorded here; giving those commands a real owning family, so the - * exception set can shrink, is deferred work. `init` (the platform's - * compute-config wizard) joined with S2d: ruled top-level on - * 2026-08-12, when the ORM's initializer moved to `orm init`; it takes - * a family when a compute family exists. - + * exception set can shrink, is deferred work. */ import type { AnyCommand, CommandFamily } from "@prisma/cli-engine"; import { defineCommand, telemetryCommandGroup } from "@prisma/cli-engine"; @@ -30,7 +26,6 @@ import { agentInstallCommand } from "../src/commands/agent/install"; import { agentStatusCommand } from "../src/commands/agent/status"; import { agentUpdateCommand } from "../src/commands/agent/update"; import { feedbackCommand } from "../src/commands/feedback"; -import { initCommand } from "../src/commands/init/init"; /** * Commands that deliberately belong to no family: the engine's consent @@ -43,7 +38,6 @@ const FAMILYLESS: ReadonlySet = new Set([ agentUpdateCommand, agentStatusCommand, feedbackCommand, - initCommand, ]); /** The family commands the tree does not mount, by family key. */ @@ -111,7 +105,6 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "format", "git connect", "git disconnect", - "init", "lsp", "migrate", "migration check", From 6f1c3ae92d06b4ff11b8ad50d0771a2dba7df4b4 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:33:32 +0200 Subject: [PATCH 02/27] Stop reading the compute config Service commands drop the config-target positional and resolve the project from the invocation directory's link file alone. Agent setup status and the state directory no longer walk up to a config file. The local build-and-deploy path the config fed had no callers left, so app-provider loses deployApp with it. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/agent/install.ts | 11 +- packages/cli/src/commands/agent/status.ts | 11 +- .../cli/src/commands/auth/agent-setup-tip.ts | 14 +- packages/cli/src/commands/service/create.ts | 7 - .../src/commands/service/deployment-delete.ts | 6 - .../src/commands/service/deployment-list.ts | 8 - .../commands/service/deployment-promote.ts | 6 - .../commands/service/deployment-rollback.ts | 8 - .../commands/service/deployment-run-state.ts | 4 - .../src/commands/service/deployment-start.ts | 6 - .../src/commands/service/deployment-stop.ts | 6 - .../cli/src/commands/service/domain-add.ts | 1 - .../cli/src/commands/service/domain-remove.ts | 1 - .../cli/src/commands/service/domain-retry.ts | 1 - .../cli/src/commands/service/domain-shared.ts | 5 - .../cli/src/commands/service/domain-show.ts | 1 - .../cli/src/commands/service/domain-wait.ts | 1 - packages/cli/src/commands/service/errors.ts | 21 +- packages/cli/src/commands/service/list.ts | 9 - packages/cli/src/commands/service/logs.ts | 19 +- packages/cli/src/commands/service/open.ts | 8 - packages/cli/src/commands/service/release.ts | 4 - packages/cli/src/commands/service/remove.ts | 8 - packages/cli/src/commands/service/show.ts | 8 - packages/cli/src/commands/service/target.ts | 104 +- packages/cli/src/lib/agent/setup-status.ts | 16 +- packages/cli/src/lib/app/app-provider.ts | 133 +- packages/cli/src/lib/app/build-settings.ts | 209 --- packages/cli/src/lib/app/build.ts | 219 --- packages/cli/src/lib/app/bun-project.ts | 87 -- packages/cli/src/lib/app/compute-config.ts | 121 -- packages/cli/src/lib/app/deploy-framework.ts | 37 - packages/cli/src/state-dir.ts | 7 +- packages/cli/tests/agent.test.ts | 36 - packages/cli/tests/app-build.test.ts | 1266 ----------------- packages/cli/tests/app-bun-compat.test.ts | 114 -- packages/cli/tests/app-provider.test.ts | 543 ------- packages/cli/tests/compute-config.test.ts | 741 ---------- .../cli/tests/service-compute-config.test.ts | 222 --- 39 files changed, 21 insertions(+), 4008 deletions(-) delete mode 100644 packages/cli/src/lib/app/build-settings.ts delete mode 100644 packages/cli/src/lib/app/build.ts delete mode 100644 packages/cli/src/lib/app/bun-project.ts delete mode 100644 packages/cli/src/lib/app/compute-config.ts delete mode 100644 packages/cli/src/lib/app/deploy-framework.ts delete mode 100644 packages/cli/tests/app-build.test.ts delete mode 100644 packages/cli/tests/app-bun-compat.test.ts delete mode 100644 packages/cli/tests/compute-config.test.ts delete mode 100644 packages/cli/tests/service-compute-config.test.ts diff --git a/packages/cli/src/commands/agent/install.ts b/packages/cli/src/commands/agent/install.ts index 12b04b6f..678c8799 100644 --- a/packages/cli/src/commands/agent/install.ts +++ b/packages/cli/src/commands/agent/install.ts @@ -3,7 +3,6 @@ import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { resolvePrismaCliPackageCommand } from "../../lib/agent/cli-command"; import { PRISMA_AGENT_STATUS_ARGS } from "../../lib/agent/constants"; -import { resolvePrismaAgentSetupCwd } from "../../lib/agent/setup-status"; import { installPresentations } from "./presentation"; import type { AgentInstallResult } from "./results"; import { buildSkillsInstallCommand, runSkillsInstall } from "./skills-cli"; @@ -49,10 +48,6 @@ export async function runAgentSkillsInstall( ctx: CommandContext, operation: "install" | "update", ) { - const cwd = await resolvePrismaAgentSetupCwd({ - cwd: ctx.cwd, - signal: ctx.signal, - }); const command = await buildSkillsInstallCommand( ctx, { @@ -62,11 +57,11 @@ export async function runAgentSkillsInstall( copy: flags.copy, global: flags.global, }, - cwd, + ctx.cwd, ); if (!flags.dryRun) { - await runSkillsInstall(ctx, command, cwd); + await runSkillsInstall(ctx, command, ctx.cwd); } const result: AgentInstallResult = { @@ -79,7 +74,7 @@ export async function runAgentSkillsInstall( const statusCommand = flags.dryRun ? null : await resolvePrismaCliPackageCommand({ - cwd, + cwd: ctx.cwd, signal: ctx.signal, args: flags.global ? [...PRISMA_AGENT_STATUS_ARGS, "--global"] diff --git a/packages/cli/src/commands/agent/status.ts b/packages/cli/src/commands/agent/status.ts index 8b6ca7c6..97c68eca 100644 --- a/packages/cli/src/commands/agent/status.ts +++ b/packages/cli/src/commands/agent/status.ts @@ -6,7 +6,6 @@ import { resolvePrismaCliPackageCommand } from "../../lib/agent/cli-command"; import { PRISMA_AGENT_INSTALL_ARGS } from "../../lib/agent/constants"; import { readPrismaAgentSetupStatus, - resolvePrismaAgentSetupCwd, } from "../../lib/agent/setup-status"; import { formatShellCommand } from "../../shell-command"; import { resolveStateDir } from "../../state-dir"; @@ -70,16 +69,12 @@ export const agentStatusCommand = defineCommand({ }, handler: async (args, ctx) => { const statusScope = args.flags.global ? "global" : "project"; - const cwd = await resolvePrismaAgentSetupCwd({ - cwd: ctx.cwd, - signal: ctx.signal, - }); const setupStatus = await readPrismaAgentSetupStatus({ - cwd, + cwd: ctx.cwd, stateStore: await openStateStore(ctx), signal: ctx.signal, }); - const skillsList = await listInstalledPrismaSkills(ctx, cwd, statusScope); + const skillsList = await listInstalledPrismaSkills(ctx, ctx.cwd, statusScope); const skillsInstalled = skillsList.status === "ok" ? skillsList.skills.length > 0 @@ -98,7 +93,7 @@ export const agentStatusCommand = defineCommand({ const installCommand = skillsInstalled ? null : await resolvePrismaCliPackageCommand({ - cwd, + cwd: ctx.cwd, signal: ctx.signal, args: args.flags.global ? [...PRISMA_AGENT_INSTALL_ARGS, "--global"] diff --git a/packages/cli/src/commands/auth/agent-setup-tip.ts b/packages/cli/src/commands/auth/agent-setup-tip.ts index 04bce187..ca1bff69 100644 --- a/packages/cli/src/commands/auth/agent-setup-tip.ts +++ b/packages/cli/src/commands/auth/agent-setup-tip.ts @@ -13,7 +13,6 @@ import { PRISMA_AGENT_INSTALL_ARGS } from "../../lib/agent/constants"; import { isLikelyProjectDirectory, readPrismaAgentSetupStatus, - resolvePrismaAgentSetupCwd, shouldOfferPrismaAgentSetup, } from "../../lib/agent/setup-status"; import { resolveStateDir } from "../../state-dir"; @@ -31,14 +30,7 @@ export async function resolveAgentSetupTipCommand( return null; } - const setupCwd = await resolvePrismaAgentSetupCwd({ - cwd: ctx.cwd, - signal: ctx.signal, - }); - - if ( - !(await isLikelyProjectDirectory({ cwd: setupCwd, signal: ctx.signal })) - ) { + if (!(await isLikelyProjectDirectory({ cwd: ctx.cwd, signal: ctx.signal }))) { return null; } @@ -52,7 +44,7 @@ export async function resolveAgentSetupTipCommand( const shouldOffer = shouldOfferPrismaAgentSetup( await readPrismaAgentSetupStatus({ - cwd: setupCwd, + cwd: ctx.cwd, stateStore, signal: ctx.signal, }), @@ -62,7 +54,7 @@ export async function resolveAgentSetupTipCommand( } return await resolvePrismaCliPackageCommand({ - cwd: setupCwd, + cwd: ctx.cwd, signal: ctx.signal, args: PRISMA_AGENT_INSTALL_ARGS, }); diff --git a/packages/cli/src/commands/service/create.ts b/packages/cli/src/commands/service/create.ts index 71470139..ecc079af 100644 --- a/packages/cli/src/commands/service/create.ts +++ b/packages/cli/src/commands/service/create.ts @@ -10,7 +10,6 @@ import type { ServiceCreateResult } from "./results"; import { openServiceStateStore, rememberSelectedService, - resolveComputeManagementContext, resolveServiceProjectContext, serviceProvider, toServiceListEntry, @@ -53,14 +52,8 @@ export const serviceCreateCommand = defineCommand({ throw serviceNameRequiredError(); } - const compute = await resolveComputeManagementContext( - ctx, - undefined, - "create", - ); const target = await resolveServiceProjectContext(ctx, args.flags.project, { commandName: "service create", - projectDir: compute.projectDir, ...(args.flags.branch !== undefined ? { branchName: args.flags.branch } : {}), diff --git a/packages/cli/src/commands/service/deployment-delete.ts b/packages/cli/src/commands/service/deployment-delete.ts index a3ff6f5a..a6608198 100644 --- a/packages/cli/src/commands/service/deployment-delete.ts +++ b/packages/cli/src/commands/service/deployment-delete.ts @@ -34,11 +34,6 @@ export const serviceDeploymentDeleteCommand = defineCommand({ brief: "Deployment id to delete", placeholder: "deployment", }), - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), }, }, needs: { credentials: true }, @@ -46,7 +41,6 @@ export const serviceDeploymentDeleteCommand = defineCommand({ const state = await resolveServiceReleaseState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - configTarget: args.positionals.service, command: "delete", }); diff --git a/packages/cli/src/commands/service/deployment-list.ts b/packages/cli/src/commands/service/deployment-list.ts index 004b0f4b..9f8525c6 100644 --- a/packages/cli/src/commands/service/deployment-list.ts +++ b/packages/cli/src/commands/service/deployment-list.ts @@ -31,20 +31,12 @@ export const serviceDeploymentListCommand = defineCommand({ placeholder: "id-or-name", }), }, - positionals: { - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), - }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - configTarget: args.positionals.service, commandName: "service deployment list", }); diff --git a/packages/cli/src/commands/service/deployment-promote.ts b/packages/cli/src/commands/service/deployment-promote.ts index 36c73495..c38ab6d5 100644 --- a/packages/cli/src/commands/service/deployment-promote.ts +++ b/packages/cli/src/commands/service/deployment-promote.ts @@ -37,11 +37,6 @@ export const serviceDeploymentPromoteCommand = defineCommand({ brief: "Deployment id to promote", placeholder: "deployment", }), - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), }, }, needs: { credentials: true }, @@ -49,7 +44,6 @@ export const serviceDeploymentPromoteCommand = defineCommand({ const state = await resolveServiceReleaseState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - configTarget: args.positionals.service, command: "promote", }); diff --git a/packages/cli/src/commands/service/deployment-rollback.ts b/packages/cli/src/commands/service/deployment-rollback.ts index 0ee13aae..6eab958a 100644 --- a/packages/cli/src/commands/service/deployment-rollback.ts +++ b/packages/cli/src/commands/service/deployment-rollback.ts @@ -42,20 +42,12 @@ export const serviceDeploymentRollbackCommand = defineCommand({ placeholder: "deployment", }), }, - positionals: { - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), - }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReleaseState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - configTarget: args.positionals.service, command: "rollback", }); diff --git a/packages/cli/src/commands/service/deployment-run-state.ts b/packages/cli/src/commands/service/deployment-run-state.ts index 2ad4d28e..9c9ac406 100644 --- a/packages/cli/src/commands/service/deployment-run-state.ts +++ b/packages/cli/src/commands/service/deployment-run-state.ts @@ -42,7 +42,6 @@ export interface RunStateArgs { deployment: string; service?: string | undefined; project?: string | undefined; - configTarget?: string | undefined; } export interface RunStateOutcome { @@ -60,9 +59,6 @@ export async function changeDeploymentRunState( const state = await resolveServiceReleaseState(ctx, { ...(args.service !== undefined ? { serviceName: args.service } : {}), ...(args.project !== undefined ? { projectRef: args.project } : {}), - ...(args.configTarget !== undefined - ? { configTarget: args.configTarget } - : {}), command: verb, }); diff --git a/packages/cli/src/commands/service/deployment-start.ts b/packages/cli/src/commands/service/deployment-start.ts index bd693473..218da0b0 100644 --- a/packages/cli/src/commands/service/deployment-start.ts +++ b/packages/cli/src/commands/service/deployment-start.ts @@ -23,11 +23,6 @@ export const serviceDeploymentStartCommand = defineCommand({ brief: "Deployment id to start", placeholder: "deployment", }), - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), }, }, needs: { credentials: true }, @@ -39,7 +34,6 @@ export const serviceDeploymentStartCommand = defineCommand({ deployment: args.positionals.deployment, service: args.flags.service, project: args.flags.project, - configTarget: args.positionals.service, }, "start", ); diff --git a/packages/cli/src/commands/service/deployment-stop.ts b/packages/cli/src/commands/service/deployment-stop.ts index 975099eb..e979119a 100644 --- a/packages/cli/src/commands/service/deployment-stop.ts +++ b/packages/cli/src/commands/service/deployment-stop.ts @@ -23,11 +23,6 @@ export const serviceDeploymentStopCommand = defineCommand({ brief: "Deployment id to stop", placeholder: "deployment", }), - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), }, }, needs: { credentials: true }, @@ -39,7 +34,6 @@ export const serviceDeploymentStopCommand = defineCommand({ deployment: args.positionals.deployment, service: args.flags.service, project: args.flags.project, - configTarget: args.positionals.service, }, "stop", ); diff --git a/packages/cli/src/commands/service/domain-add.ts b/packages/cli/src/commands/service/domain-add.ts index 3d632c21..ca955e3b 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -23,7 +23,6 @@ export const serviceDomainAddCommand = defineCommand({ serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, - configTarget: args.positionals.service, commandName: `service domain add ${hostname}`, }); diff --git a/packages/cli/src/commands/service/domain-remove.ts b/packages/cli/src/commands/service/domain-remove.ts index ee3e88f2..318bf6f0 100644 --- a/packages/cli/src/commands/service/domain-remove.ts +++ b/packages/cli/src/commands/service/domain-remove.ts @@ -26,7 +26,6 @@ export const serviceDomainRemoveCommand = defineCommand({ serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, - configTarget: args.positionals.service, commandName: `service domain remove ${hostname}`, }); const domain = await resolveDomainByHostname( diff --git a/packages/cli/src/commands/service/domain-retry.ts b/packages/cli/src/commands/service/domain-retry.ts index 4979628a..9a2c41ad 100644 --- a/packages/cli/src/commands/service/domain-retry.ts +++ b/packages/cli/src/commands/service/domain-retry.ts @@ -24,7 +24,6 @@ export const serviceDomainRetryCommand = defineCommand({ serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, - configTarget: args.positionals.service, commandName: `service domain retry ${hostname}`, }); const domain = await resolveDomainByHostname( diff --git a/packages/cli/src/commands/service/domain-shared.ts b/packages/cli/src/commands/service/domain-shared.ts index c29dabdd..cde956c9 100644 --- a/packages/cli/src/commands/service/domain-shared.ts +++ b/packages/cli/src/commands/service/domain-shared.ts @@ -22,11 +22,6 @@ export function domainTargetArgs() { brief: "Custom domain hostname", placeholder: "hostname", }), - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), }, }; } diff --git a/packages/cli/src/commands/service/domain-show.ts b/packages/cli/src/commands/service/domain-show.ts index 3018a174..e7e85729 100644 --- a/packages/cli/src/commands/service/domain-show.ts +++ b/packages/cli/src/commands/service/domain-show.ts @@ -24,7 +24,6 @@ export const serviceDomainShowCommand = defineCommand({ serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, - configTarget: args.positionals.service, commandName: `service domain show ${hostname}`, }); const domain = await resolveDomainByHostname( diff --git a/packages/cli/src/commands/service/domain-wait.ts b/packages/cli/src/commands/service/domain-wait.ts index 7563398c..8ed8522b 100644 --- a/packages/cli/src/commands/service/domain-wait.ts +++ b/packages/cli/src/commands/service/domain-wait.ts @@ -101,7 +101,6 @@ export const serviceDomainWaitCommand = defineCommand({ serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, - configTarget: args.positionals.service, commandName: `service domain wait ${hostname}`, }); const domain = await resolveDomainByHostname( diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 3fa92799..ed98cd75 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -38,9 +38,7 @@ const PRISMA_BUILD_HOST = /\b((?:[a-z0-9-]+\.)+prisma\.build)\b/i; /** * The rename surface for copy that flows through legacy error builders: - * command lines and the "app target" noun in prose. Deliberately - * narrow — `prisma.compute.ts` keys stay `app` (SDK-owned), including - * prose that describes the config's `app`/`apps` entries. + * command lines and the "app target" noun in prose. */ export function renameAppCopy(text: string): string { return text @@ -764,20 +762,3 @@ function extractDomainDnsTarget(error: DomainApiError): string | null { return match?.[1]?.toLowerCase() ?? null; } -export function configTargetRequiresConfigError( - configTarget: string, - configFilename: string, -): CliStructuredError { - return new CliStructuredError( - "SERVICE.COMPUTE_CONFIG_TARGET_UNKNOWN", - `Service target "${configTarget}" requires a compute config file`, - { - why: `No ${configFilename} exists in the current directory, so there are no named service targets.`, - nextActions: [ - adviceAction( - `Create ${configFilename} with an apps entry named "${configTarget}", or rerun without the target argument.`, - ), - ], - }, - ); -} diff --git a/packages/cli/src/commands/service/list.ts b/packages/cli/src/commands/service/list.ts index c8dec0ff..fdb5b720 100644 --- a/packages/cli/src/commands/service/list.ts +++ b/packages/cli/src/commands/service/list.ts @@ -4,7 +4,6 @@ import { listPresentations } from "./presentation"; import type { ServiceListResult } from "./results"; import { listServices, - resolveComputeManagementContext, resolveServiceProjectContext, serviceProvider, toServiceListEntry, @@ -29,16 +28,8 @@ export const serviceListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - // Listing every service selects none, so no config target is passed: - // the only thing this call contributes is the project directory. - const compute = await resolveComputeManagementContext( - ctx, - undefined, - "list", - ); const target = await resolveServiceProjectContext(ctx, args.flags.project, { commandName: "service list", - projectDir: compute.projectDir, ...(args.flags.branch !== undefined ? { branchName: args.flags.branch } : {}), diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index e20c1c55..3a55a5fe 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -425,13 +425,6 @@ export const serviceLogsCommand = defineSessionCommand({ brief: "Keep polling for new lines until interrupted", }), }, - positionals: { - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), - }, }, needs: { credentials: true }, handler: async (args, ctx) => { @@ -441,11 +434,10 @@ export const serviceLogsCommand = defineSessionCommand({ throw logsRangeConflictError(); } - // "A service was named" — by --service or by the config target. It - // decides whether an explicit deployment id is looked up within that - // service or resolved globally, and a global lookup needs no service - // selection at all (so it never prompts for one). - const serviceNamed = args.flags.service ?? args.positionals.service; + // Naming a service decides whether an explicit deployment id is + // looked up within that service or resolved globally, and a global + // lookup needs no service selection at all (so it never prompts). + const serviceNamed = args.flags.service; const resolveGlobally = Boolean(args.flags.deployment) && !serviceNamed; const state = await resolveServiceReadState(ctx, { ...(args.flags.service !== undefined @@ -454,9 +446,6 @@ export const serviceLogsCommand = defineSessionCommand({ ...(args.flags.project !== undefined ? { projectRef: args.flags.project } : {}), - ...(args.positionals.service !== undefined - ? { configTarget: args.positionals.service } - : {}), commandName: "service logs", skipSelection: resolveGlobally, }); diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index d1bc1daf..8cce6c6a 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -33,20 +33,12 @@ export const serviceOpenCommand = defineCommand({ placeholder: "id-or-name", }), }, - positionals: { - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), - }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - configTarget: args.positionals.service, commandName: "service open", }); diff --git a/packages/cli/src/commands/service/release.ts b/packages/cli/src/commands/service/release.ts index 3795cec2..49f3c64f 100644 --- a/packages/cli/src/commands/service/release.ts +++ b/packages/cli/src/commands/service/release.ts @@ -29,7 +29,6 @@ export async function resolveServiceReleaseState( options: { serviceName?: string; projectRef?: string; - configTarget?: string; branchName?: string; command: "promote" | "rollback" | "remove" | "start" | "stop" | "delete"; }, @@ -45,9 +44,6 @@ export async function resolveServiceReleaseState( ...(options.projectRef !== undefined ? { projectRef: options.projectRef } : {}), - ...(options.configTarget !== undefined - ? { configTarget: options.configTarget } - : {}), ...(options.branchName !== undefined ? { branchName: options.branchName } : {}), diff --git a/packages/cli/src/commands/service/remove.ts b/packages/cli/src/commands/service/remove.ts index 10a76351..12d026e8 100644 --- a/packages/cli/src/commands/service/remove.ts +++ b/packages/cli/src/commands/service/remove.ts @@ -61,13 +61,6 @@ export const serviceRemoveCommand = defineCommand({ placeholder: "name", }), }, - positionals: { - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), - }, }, needs: { credentials: true }, handler: async (args, ctx) => { @@ -78,7 +71,6 @@ export const serviceRemoveCommand = defineCommand({ const state = await resolveServiceReleaseState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - configTarget: args.positionals.service, branchName: args.flags.branch, command: "remove", }); diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index 0229e6fc..ce96ddbe 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -28,20 +28,12 @@ export const serviceShowCommand = defineCommand({ placeholder: "id-or-name", }), }, - positionals: { - service: positional.optionalString({ - brief: - "Service target from prisma.compute.ts when the config defines multiple services", - placeholder: "service", - }), - }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - configTarget: args.positionals.service, commandName: "service show", }); diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 51a522aa..48a03061 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -1,10 +1,4 @@ import type { CommandContext } from "@prisma/cli-engine"; -import { - COMPUTE_CONFIG_FILENAME, - ComputeConfigTargetRequiredError, - inferComputeTargetFromCwd, - selectComputeDeployTarget, -} from "@prisma/compute-sdk/config"; import { LocalStateStore } from "../../adapters/local-state"; import { type AppProvider, @@ -12,12 +6,6 @@ import { createAppProvider, type DomainRecord, } from "../../lib/app/app-provider"; -import { - type ComputeDeployTarget, - computeConfigErrorToCliError, - type LoadedComputeConfig, - loadComputeConfig, -} from "../../lib/app/compute-config"; import { resolveReadBranch } from "../../lib/app/read-branch"; import { readLocalGitBranch } from "../../lib/git/local-branch"; import { projectApiError } from "../../lib/project/provider"; @@ -34,7 +22,6 @@ import type { BranchKind } from "../../types/branch"; import type { ProjectResolution, ProjectSummary } from "../../types/project"; import { branchNotDeployableError, - configTargetRequiresConfigError, deployFailedError, domainCommandError, domainHostnameInvalidError, @@ -60,8 +47,6 @@ const PRISMA_PROJECT_ID_ENV_VAR = "PRISMA_PROJECT_ID"; /** A hostname's optional root dot, and one DNS label. */ const TRAILING_DOT = /\.$/; const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; -/** The group prefix a compute-config error message drops. */ -const SERVICE_PREFIX = /^service /; const PRISMA_SERVICE_ID_ENV_VAR = "PRISMA_SERVICE_ID"; export type ServiceContext = Pick< @@ -132,72 +117,6 @@ function resolutionContext(ctx: ServiceContext): ProjectResolutionContext { return { runtime: { cwd: ctx.cwd, signal: ctx.signal } }; } -async function resolveComputeTarget( - ctx: ServiceContext, - configTarget: string | undefined, - commandName: string, - options?: { - targetOptional?: boolean; - }, -): Promise<{ - config: LoadedComputeConfig | null; - target: ComputeDeployTarget | null; -}> { - const loaded = await loadComputeConfig(ctx.cwd, ctx.signal); - if (loaded.isErr()) { - throw fromLegacyCliError( - computeConfigErrorToCliError(loaded.error, commandName), - ); - } - const config = loaded.value; - if (!config) { - if (configTarget) { - throw configTargetRequiresConfigError( - configTarget, - COMPUTE_CONFIG_FILENAME, - ); - } - return { config: null, target: null }; - } - - const requestedTarget = - configTarget ?? inferComputeTargetFromCwd(config, ctx.cwd); - const selected = selectComputeDeployTarget(config, requestedTarget); - if (selected.isErr()) { - if ( - options?.targetOptional && - selected.error instanceof ComputeConfigTargetRequiredError - ) { - return { config, target: null }; - } - throw fromLegacyCliError( - computeConfigErrorToCliError(selected.error, commandName), - ); - } - - return { config, target: selected.value }; -} - -/** - * Compute-config context for service management commands: the project - * directory (where `.prisma/local.json` lives) and the config-selected - * service name, which ranks below `--service` but above the remembered - * selection. - */ -export async function resolveComputeManagementContext( - ctx: ServiceContext, - configTarget: string | undefined, - commandName: string, -): Promise<{ projectDir: string; configServiceName: string | undefined }> { - const compute = await resolveComputeTarget(ctx, configTarget, commandName, { - targetOptional: true, - }); - return { - projectDir: compute.config?.configDir ?? ctx.cwd, - configServiceName: compute.target?.name ?? compute.target?.key ?? undefined, - }; -} - interface ResolvedReadBranchRequest { name: string; explicit: boolean; @@ -576,14 +495,13 @@ export interface ServiceReadState { selected: AppRecord | null; } -/** The shared read flow for show / deployment list / open: config context, - * project + branch resolution, service listing, and selection. */ +/** The shared read flow for show / deployment list / open: project + + * branch resolution, service listing, and selection. */ export async function resolveServiceReadState( ctx: ServiceContext, options: { serviceName?: string; projectRef?: string; - configTarget?: string; branchName?: string; commandName: string; /** Skip the service picker entirely. A caller resolving its target @@ -592,15 +510,9 @@ export async function resolveServiceReadState( skipSelection?: boolean; }, ): Promise { - const compute = await resolveComputeManagementContext( - ctx, - options.configTarget, - options.commandName.replace(SERVICE_PREFIX, ""), - ); const provider = serviceProvider(ctx); const target = await resolveServiceProjectContext(ctx, options.projectRef, { commandName: options.commandName, - projectDir: compute.projectDir, ...(options.branchName !== undefined ? { branchName: options.branchName } : {}), @@ -620,7 +532,7 @@ export async function resolveServiceReadState( stateStore, projectId, services, - options.serviceName ?? compute.configServiceName, + options.serviceName, ); return { provider, stateStore, target, projectId, selected }; } @@ -638,15 +550,9 @@ export async function resolveServiceDomainTarget( serviceName?: string; projectRef?: string; branchName?: string; - configTarget?: string; commandName: string; }, ): Promise { - const compute = await resolveComputeManagementContext( - ctx, - options.configTarget, - options.commandName.replace(SERVICE_PREFIX, ""), - ); const branchName = options.branchName?.trim() || "production"; if (toBranchKind(branchName) !== "production") { throw branchNotDeployableError(branchName); @@ -658,7 +564,6 @@ export async function resolveServiceDomainTarget( const provider = serviceProvider(ctx); const target = await resolveServiceProjectContext(ctx, options.projectRef, { commandName: options.commandName, - projectDir: compute.projectDir, branchName, ...(envProjectId !== undefined ? { envProjectId } : {}), }); @@ -671,7 +576,6 @@ export async function resolveServiceDomainTarget( target.branch.name, ); - const explicitServiceName = options.serviceName ?? compute.configServiceName; let selectedService: AppRecord | null; if (envServiceId) { selectedService = @@ -689,7 +593,7 @@ export async function resolveServiceDomainTarget( stateStore, projectId, services, - explicitServiceName, + options.serviceName, ); } if (!selectedService) { diff --git a/packages/cli/src/lib/agent/setup-status.ts b/packages/cli/src/lib/agent/setup-status.ts index 5a7ebb37..d0fabc15 100644 --- a/packages/cli/src/lib/agent/setup-status.ts +++ b/packages/cli/src/lib/agent/setup-status.ts @@ -1,7 +1,5 @@ import { readFile, stat } from "node:fs/promises"; import path from "node:path"; -import { findComputeConfigDir } from "@prisma/compute-sdk/config"; - import type { LocalStateStore } from "../../adapters/local-state"; import { PRISMA_SKILLS_LOCK_FILENAME, PRISMA_SKILLS_SOURCE } from "./constants"; @@ -17,8 +15,7 @@ export async function readPrismaAgentSetupStatus(options: { signal: AbortSignal; requiredSkill?: string; }): Promise { - const setupCwd = await resolvePrismaAgentSetupCwd(options); - const skillsLockPath = path.join(setupCwd, PRISMA_SKILLS_LOCK_FILENAME); + const skillsLockPath = path.join(options.cwd, PRISMA_SKILLS_LOCK_FILENAME); const [skillsInstalled, promptDismissedAt] = await Promise.all([ hasPrismaSkillsLock(skillsLockPath, options.signal, options.requiredSkill), options.stateStore?.readAgentSetupPromptDismissedAt() ?? null, @@ -31,16 +28,6 @@ export async function readPrismaAgentSetupStatus(options: { }; } -export async function resolvePrismaAgentSetupCwd(options: { - cwd: string; - signal: AbortSignal; -}): Promise { - options.signal.throwIfAborted(); - const configDir = await findComputeConfigDir(options.cwd, options.signal); - options.signal.throwIfAborted(); - return configDir ?? options.cwd; -} - export function isPrismaAgentSetupComplete( status: PrismaAgentSetupStatus, ): boolean { @@ -59,7 +46,6 @@ export async function isLikelyProjectDirectory(options: { }): Promise { const signals = [ "package.json", - "prisma.compute.ts", "prisma.config.ts", ".git", ]; diff --git a/packages/cli/src/lib/app/app-provider.ts b/packages/cli/src/lib/app/app-provider.ts index a6470ded..356985f2 100644 --- a/packages/cli/src/lib/app/app-provider.ts +++ b/packages/cli/src/lib/app/app-provider.ts @@ -1,8 +1,7 @@ // biome-ignore-all lint/performance/noAwaitInLoops: API pagination and deployment lookup scans are intentionally sequential. // biome-ignore-all lint/performance/useTopLevelRegex: Existing hostname normalization regexes are kept inline for readability. // biome-ignore-all lint/style/noNestedTernary: Existing app resolution expression is intentionally compact. -import path from "node:path"; -import type { PortMapping, StreamRecord } from "@prisma/compute-sdk"; +import type { StreamRecord } from "@prisma/compute-sdk"; import { ApiError, CancelledError, @@ -21,8 +20,6 @@ import { listEnvironmentVariables, updateEnvironmentVariable, } from "./branch-database-api"; -import type { AppBuildSettings, AppBuildType } from "./build"; -import { AppBuildStrategy } from "./build"; import { envVarNames } from "./env-vars"; export interface AppRecord { @@ -59,17 +56,6 @@ export interface DeploymentRecord { live: boolean | null; } -export interface DeployRecord { - projectId: string; - app: AppRecord; - deployment: { - id: string; - status: string; - url: string | null; - live: boolean; - }; - promoted: boolean; -} export interface EnvRecord { projectId: string; @@ -247,23 +233,6 @@ export interface AppProvider { deploymentId: string; signal?: AbortSignal; }): Promise; - deployApp(options: { - cwd: string; - projectId: string; - branchName?: string; - appId?: string; - appName?: string; - region?: string; - entrypoint?: string; - buildType?: AppBuildType; - buildSettings?: AppBuildSettings; - portMapping?: PortMapping; - envVars?: Record; - skipPromote?: boolean; - interaction?: unknown; - signal?: AbortSignal; - progress?: unknown; - }): Promise; updateAppEnv(options: { appId: string; envVars: Record; @@ -570,83 +539,6 @@ export function createAppProvider( }; }, - async deployApp(options) { - const resolvedApp = options.appId - ? { - appId: options.appId, - appName: options.appName, - region: options.region, - } - : options.branchName && options.appName - ? await createBranchApp(client, { - projectId: options.projectId, - branchName: options.branchName, - appName: options.appName, - region: options.region, - signal: options.signal, - }) - : { - appId: undefined, - appName: options.appName, - region: options.region, - }; - - const deployResult = await sdk.deploy({ - strategy: new AppBuildStrategy({ - appPath: path.resolve(options.cwd), - entrypoint: options.entrypoint, - buildType: options.buildType, - signal: options.signal, - buildSettings: options.buildSettings, - }), - projectId: options.projectId, - appId: resolvedApp.appId, - appName: resolvedApp.appName, - region: resolvedApp.region, - portMapping: options.portMapping, - envVars: options.envVars, - skipPromote: options.skipPromote, - timeoutSeconds: 120, - pollIntervalMs: 2000, - interaction: options.interaction as never, - signal: options.signal, - progress: options.progress as never, - }); - - if (deployResult.isErr()) { - throw new Error(deployResult.error.message); - } - - const deployed = deployResult.value; - - // On a promotionless deploy the SDK leaves appEndpointDomain null and the - // previous deployment serving live, so the live pointer stays on the old - // deployment and both URL expressions resolve to the candidate endpoint. - return { - projectId: deployed.projectId, - app: { - id: deployed.appId, - name: deployed.appName, - region: deployed.region ?? null, - liveDeploymentId: deployed.promoted - ? deployed.deploymentId - : deployed.previousDeploymentId, - liveUrl: toAbsoluteUrl(deployed.appEndpointDomain ?? null), - }, - deployment: { - id: deployed.deploymentId, - status: "running", - url: toAbsoluteUrl( - deployed.appEndpointDomain ?? - deployed.deploymentEndpointDomain ?? - null, - ), - live: deployed.promoted, - }, - promoted: deployed.promoted, - }; - }, - async updateAppEnv(options) { const updateResult = await sdk.updateEnv({ appId: options.appId, @@ -1178,29 +1070,6 @@ function normalizeHostnameForComparison(hostname: string): string { return hostname.trim().replace(/\.$/, "").toLowerCase(); } -async function createBranchApp( - client: ManagementApiClient, - options: { - projectId: string; - branchName: string; - appName: string; - region?: string; - signal?: AbortSignal; - }, -): Promise<{ appId: string; appName: string; region: string | undefined }> { - const created = await createComputeService(client, { - projectId: options.projectId, - branchName: options.branchName, - displayName: options.appName, - ...(options.region !== undefined ? { region: options.region } : {}), - ...(options.signal !== undefined ? { signal: options.signal } : {}), - }); - return { - appId: created.service.id, - appName: created.service.name, - region: created.service.region ?? options.region, - }; -} function apiCallError( summary: string, diff --git a/packages/cli/src/lib/app/build-settings.ts b/packages/cli/src/lib/app/build-settings.ts deleted file mode 100644 index 2474cabb..00000000 --- a/packages/cli/src/lib/app/build-settings.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { - type BuildSettings, - resolveBuildSettings, - resolveConfiguredBuildSettings, -} from "@prisma/compute-sdk"; -import type { ConfigBackedBuildType } from "@prisma/compute-sdk/config"; -import type { ResolvedAppBuildType } from "./build"; -import type { BunPackageJsonLike } from "./bun-project"; - -export type AppBuildSettingsBuildType = Extract< - ResolvedAppBuildType, - ConfigBackedBuildType ->; - -/** Legacy build-settings file: no longer read or written, only detected for migration. */ -export const PRISMA_APP_CONFIG_FILENAME = "prisma.app.json"; - -/** - * Build settings shape. Identical to the SDK's `BuildSettings`; kept as a - * CLI-facing alias so existing imports stay stable while the resolution logic - * lives in `@prisma/compute-sdk`. - */ -export type AppBuildSettings = BuildSettings; - -export interface AppBuildSettingsResolution { - /** "config" when the compute config owns the settings, "inferred" otherwise. */ - status: "config" | "inferred"; - /** The compute config path when status is "config". */ - configPath: string | null; - relativeConfigPath: string | null; - settings: AppBuildSettings; -} - -export type LegacyBuildSettingsDetection = - | { kind: "absent" } - | { kind: "matching"; configPath: string } - | { kind: "invalid"; configPath: string } - | { - kind: "custom"; - configPath: string; - buildCommand: string | null; - outputDirectory: string; - }; - -/** - * Detects a leftover `prisma.app.json`. The file is no longer used: one that - * matches the effective settings is reported for deletion, one with custom - * values must be migrated to the compute config so builds never silently - * change. - */ -export async function detectLegacyBuildSettings(options: { - appPath: string; - effective: AppBuildSettings; - signal?: AbortSignal; -}): Promise { - const configPath = path.join(options.appPath, PRISMA_APP_CONFIG_FILENAME); - let content: string; - try { - options.signal?.throwIfAborted(); - content = await readFile(configPath, { - encoding: "utf8", - signal: options.signal, - }); - } catch (error) { - if (options.signal?.aborted) throw error; - return { kind: "absent" }; - } - - let legacy: { buildCommand: string | null; outputDirectory: string }; - try { - const parsed = JSON.parse(content) as Record; - const buildCommand = normalizeLegacyBuildCommand(parsed.buildCommand); - const outputDirectory = - typeof parsed.outputDirectory === "string" - ? normalizeRelativePath(parsed.outputDirectory) - : undefined; - if (buildCommand === undefined || !outputDirectory) { - return { kind: "invalid", configPath }; - } - legacy = { buildCommand, outputDirectory }; - } catch { - return { kind: "invalid", configPath }; - } - - const matches = - legacy.buildCommand === options.effective.buildCommand && - legacy.outputDirectory === options.effective.outputDirectory; - return matches - ? { kind: "matching", configPath } - : { kind: "custom", configPath, ...legacy }; -} - -/** Resolves build settings purely from framework inference; nothing is read or written. */ -export async function resolveInferredAppBuildSettings(options: { - appPath: string; - buildType: ResolvedAppBuildType; - signal?: AbortSignal; -}): Promise { - return { - status: "inferred", - configPath: null, - relativeConfigPath: null, - settings: await resolveBuildSettings(options), - }; -} - -/** - * Resolves build settings when the compute config owns them: configured - * fields win, omitted fields fall back to framework defaults. - */ -export async function resolveConfiguredAppBuildSettings(options: { - appPath: string; - buildType: AppBuildSettingsBuildType; - configured: { - command: string | null | undefined; - outputDirectory: string | undefined; - entrypoint?: string | undefined; - }; - /** Absolute path of the compute config file owning these settings. */ - configPath: string; - signal?: AbortSignal; -}): Promise { - const configFilename = path.basename(options.configPath); - const settings = await resolveConfiguredBuildSettings({ - appPath: options.appPath, - buildType: options.buildType, - configured: options.configured, - source: `set by ${configFilename}`, - signal: options.signal, - }); - - return { - status: "config", - configPath: options.configPath, - relativeConfigPath: configFilename, - settings, - }; -} - -/** Inferred build settings for a resolved framework. Delegates to the SDK. */ -export const resolveAppBuildSettings = resolveBuildSettings; - -export function hasPackageDependency( - packageJson: BunPackageJsonLike | null, - dependencyName: string, -): boolean { - return hasAnyPackageDependency(packageJson, [dependencyName]); -} - -export function hasAnyPackageDependency( - packageJson: BunPackageJsonLike | null, - dependencyNames: readonly string[], -): boolean { - if (!packageJson) { - return false; - } - - const dependencyGroups = [ - packageJson.dependencies, - packageJson.devDependencies, - ]; - return dependencyGroups.some((group) => { - if (!group || typeof group !== "object") { - return false; - } - - return dependencyNames.some((dependencyName) => dependencyName in group); - }); -} - -const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/; - -function normalizeLegacyBuildCommand( - value: unknown, -): string | null | undefined { - if (typeof value === "string") { - return value.trim() || null; - } - if (value === null) { - return null; - } - return undefined; -} - -function normalizeRelativePath(value: string): string | undefined { - const raw = value.trim().replace(/\\/g, "/"); - if (raw.length === 0 || raw.split("/").includes("..")) { - return undefined; - } - // Windows drive-relative paths ("C:dir") escape the base directory but - // are not absolute under either path.win32 or path.posix. - if (WINDOWS_DRIVE_PREFIX.test(raw)) { - return undefined; - } - - const normalized = path.posix.normalize(raw); - const segments = normalized.split("/"); - if ( - path.win32.isAbsolute(value) || - path.posix.isAbsolute(normalized) || - segments.includes("..") - ) { - return undefined; - } - - return normalized === "." ? "." : normalized; -} diff --git a/packages/cli/src/lib/app/build.ts b/packages/cli/src/lib/app/build.ts deleted file mode 100644 index ec5e04df..00000000 --- a/packages/cli/src/lib/app/build.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { cp, rm, stat } from "node:fs/promises"; -import path from "node:path"; - -import { - type BuildArtifact, - type BuildStrategy, - type BuildType, - normalizeArtifactSymlinks, - resolveBuildStrategy, - stageStandaloneArtifact, -} from "@prisma/compute-sdk"; -import type { FrameworkBuildType } from "@prisma/compute-sdk/config"; - -import type { AppBuildSettings } from "./build-settings"; - -export type { - AppBuildSettings, - AppBuildSettingsBuildType, - AppBuildSettingsResolution, - LegacyBuildSettingsDetection, -} from "./build-settings"; - -export type AppBuildType = BuildType; -export type ResolvedAppBuildType = FrameworkBuildType; - -export class AppBuildStrategy implements BuildStrategy { - readonly #appPath: string; - readonly #entrypoint?: string; - readonly #buildType: AppBuildType; - readonly #signal?: AbortSignal; - readonly #buildSettings?: AppBuildSettings; - - constructor(options: { - appPath: string; - entrypoint?: string; - buildType?: AppBuildType; - signal?: AbortSignal; - buildSettings?: AppBuildSettings; - }) { - this.#appPath = options.appPath; - this.#entrypoint = options.entrypoint; - this.#buildType = options.buildType ?? "auto"; - this.#signal = options.signal; - this.#buildSettings = options.buildSettings; - } - - async canBuild(signal = this.#signal): Promise { - const { strategy } = await resolveAppBuildStrategy({ - appPath: this.#appPath, - entrypoint: this.#entrypoint, - buildType: this.#buildType, - signal, - buildSettings: this.#buildSettings, - }); - - return strategy.canBuild(signal); - } - - async execute(signal = this.#signal): Promise { - const { artifact } = await executeAppBuild({ - appPath: this.#appPath, - entrypoint: this.#entrypoint, - buildType: this.#buildType, - signal, - buildSettings: this.#buildSettings, - }); - - return artifact; - } -} - -export async function executeAppBuild(options: { - appPath: string; - entrypoint?: string; - buildType?: AppBuildType; - signal?: AbortSignal; - buildSettings?: AppBuildSettings; -}): Promise<{ - artifact: BuildArtifact; - buildType: ResolvedAppBuildType; -}> { - const { strategy, buildType } = await resolveAppBuildStrategy({ - appPath: options.appPath, - entrypoint: options.entrypoint, - buildType: options.buildType ?? "auto", - signal: options.signal, - buildSettings: options.buildSettings, - }); - const artifact = await strategy.execute(options.signal); - - try { - await normalizeArtifactSymlinks( - artifact.directory, - options.appPath, - options.signal, - ); - return { - artifact, - buildType, - }; - } catch (error) { - await artifact.cleanup?.().catch(() => undefined); - throw error; - } -} - -export async function resolveAppBuildStrategy(options: { - appPath: string; - entrypoint?: string; - buildType: AppBuildType; - signal?: AbortSignal; - buildSettings?: AppBuildSettings; -}): Promise<{ - strategy: BuildStrategy; - buildType: ResolvedAppBuildType; -}> { - // An explicit entrypoint targets a Bun build, so honor it over framework - // auto-detection instead of letting a detected framework (e.g. Next.js) - // silently ignore --entry. This mirrors how deploy resolves --entry and the - // "auto may fall back to Bun" contract in assertSupportedEntrypoint. - const buildType = - options.buildType === "auto" && options.entrypoint - ? "bun" - : options.buildType; - - // Detection, per-framework construction, and Bun entrypoint resolution - // (package.json `main`) all live in the SDK now; the CLI forwards. The CLI's - // build types map 1:1 to the SDK's, and `auto` is the SDK default. - return resolveBuildStrategy({ - appPath: options.appPath, - buildType, - entrypoint: options.entrypoint, - buildSettings: options.buildSettings, - signal: options.signal, - }); -} - -/** - * Re-stages a Next.js standalone artifact in place after a local rebuild, then - * refreshes the static assets next to the server entrypoint. Used by the local - * preview when files change on disk. - */ -export async function restageNextjsArtifact( - artifact: BuildArtifact, - appPath: string, - signal?: AbortSignal, -): Promise { - const artifactDir = artifact.directory; - const standaloneDir = path.join(appPath, ".next", "standalone"); - - await withSignal(signal, () => - rm(artifactDir, { recursive: true, force: true }), - ); - await stageStandaloneArtifact({ - standaloneDir, - artifactDir, - appPath, - signal, - }); - - // The SDK's Next.js strategy reports the entrypoint relative to the - // artifact root (e.g. "server.js" for single-app, "apps/web/server.js" - // for a monorepo). Next expects public/ and .next/static/ to live next - // to server.js, so re-stage them at the same subpath. - const serverSubpath = nextjsServerSubpath(artifact.entrypoint); - const serverDir = serverSubpath - ? path.join(artifactDir, serverSubpath) - : artifactDir; - - const publicDir = path.join(appPath, "public"); - if (await directoryExists(publicDir, signal)) { - await withSignal(signal, () => - cp(publicDir, path.join(serverDir, "public"), { - recursive: true, - verbatimSymlinks: true, - }), - ); - } - - const staticDir = path.join(appPath, ".next", "static"); - if (await directoryExists(staticDir, signal)) { - await withSignal(signal, () => - cp(staticDir, path.join(serverDir, ".next", "static"), { - recursive: true, - verbatimSymlinks: true, - }), - ); - } -} - -function nextjsServerSubpath(entrypoint: string): string { - // SDK emits posix-style entrypoints (path.posix.join). - const dir = path.posix.dirname(entrypoint); - return dir === "." ? "" : dir; -} - -async function directoryExists( - targetPath: string, - signal?: AbortSignal, -): Promise { - try { - const targetStat = await withSignal(signal, () => stat(targetPath)); - return targetStat.isDirectory(); - } catch (error) { - if (signal?.aborted) throw error; - return false; - } -} - -async function withSignal( - signal: AbortSignal | undefined, - operation: () => Promise, -): Promise { - // These Node fs promise APIs do not accept AbortSignal; check immediately before and after the boundary. - signal?.throwIfAborted(); - const result = await operation(); - signal?.throwIfAborted(); - return result; -} diff --git a/packages/cli/src/lib/app/bun-project.ts b/packages/cli/src/lib/app/bun-project.ts deleted file mode 100644 index c51a25ad..00000000 --- a/packages/cli/src/lib/app/bun-project.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { access, readFile } from "node:fs/promises"; -import path from "node:path"; - -export interface BunPackageJsonLike { - name?: unknown; - main?: unknown; - packageManager?: unknown; - scripts?: unknown; - dependencies?: unknown; - devDependencies?: unknown; -} - -export async function readBunPackageJson( - appPath: string, - signal?: AbortSignal, -): Promise { - const packageJsonPath = path.join(appPath, "package.json"); - - let content: string; - signal?.throwIfAborted(); - try { - content = await readFile(packageJsonPath, { encoding: "utf8", signal }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - - throw new Error( - `Failed to read ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - try { - return JSON.parse(content) as BunPackageJsonLike; - } catch (error) { - throw new Error( - `Failed to parse ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -export function readBunPackageEntrypoint( - packageJson: BunPackageJsonLike | null, -): string | undefined { - return typeof packageJson?.main === "string" ? packageJson.main : undefined; -} - -export async function resolveBunEntrypoint( - appPath: string, - explicitEntrypoint: string | undefined, - signal?: AbortSignal, -): Promise { - const packageJson = await readBunPackageJson(appPath, signal); - const candidate = explicitEntrypoint ?? readBunPackageEntrypoint(packageJson); - - if (!candidate) { - throw new Error( - "Entrypoint is required. Pass --entry or define package.json main.", - ); - } - - if (path.isAbsolute(candidate)) { - throw new Error("Entrypoint must be a relative path."); - } - - const normalized = path.normalize(candidate); - if ( - normalized.startsWith("..") || - path.isAbsolute(normalized) || - normalized.includes(`${path.sep}..${path.sep}`) - ) { - throw new Error("Entrypoint must not escape the app directory."); - } - - const entrypointPath = path.join(appPath, normalized); - signal?.throwIfAborted(); - try { - // access does not accept AbortSignal; check before and after the filesystem boundary. - await access(entrypointPath); - signal?.throwIfAborted(); - } catch (error) { - if (signal?.aborted) throw error; - throw new Error(`Entrypoint file does not exist: ${entrypointPath}`); - } - - return normalized.split(path.sep).join("/"); -} diff --git a/packages/cli/src/lib/app/compute-config.ts b/packages/cli/src/lib/app/compute-config.ts deleted file mode 100644 index 66d2b64e..00000000 --- a/packages/cli/src/lib/app/compute-config.ts +++ /dev/null @@ -1,121 +0,0 @@ -import path from "node:path"; - -import { - COMPUTE_CONFIG_FILENAME, - type ComputeConfigError, - type ComputeConfigTargetError, - type LoadedComputeConfig, - loadComputeConfig as loadComputeConfigFromSdk, -} from "@prisma/compute-sdk/config"; -import { matchError, type Result } from "better-result"; - -import { CliError } from "../../errors"; - -// The compute config contract (types, validation, discovery, loading) lives -// in @prisma/compute-sdk/config so the CLI, build-runner, and scaffolding -// share one implementation. Runtime values are imported straight from the -// SDK; this module re-exports the shared types and keeps the CLI-specific -// glue: flag/config precedence and CliError presentation. -export type { - ComputeConfigError, - ComputeConfigTargetError, - ComputeDeployTarget, - ComputeDeployTargetBuild, - LoadedComputeConfig, -} from "@prisma/compute-sdk/config"; - -/** - * Loads the nearest compute config, searching from `cwd` up to the source - * root (repository or workspace boundary). Thin adapter over the SDK loader - * keeping the CLI's positional-signal call shape. - */ -export async function loadComputeConfig( - cwd: string, - signal?: AbortSignal, -): Promise> { - return loadComputeConfigFromSdk(cwd, { signal }); -} - -/** The `service` subcommand used in error guidance text, e.g. "create" or "domain add". */ -export type ComputeConfigCommandName = string; - -export function computeConfigErrorToCliError( - error: ComputeConfigError | ComputeConfigTargetError, - commandName: ComputeConfigCommandName, -): CliError { - const command = `prisma-cli service ${commandName}`; - return matchError(error, { - ComputeConfigAmbiguousError: (ambiguous) => - new CliError({ - code: "COMPUTE_CONFIG_INVALID", - domain: "app", - summary: "Multiple compute config files found", - why: ambiguous.message, - fix: `Keep exactly one compute config file, preferably ${COMPUTE_CONFIG_FILENAME}.`, - meta: { configPaths: ambiguous.configPaths }, - exitCode: 2, - nextSteps: [command], - }), - ComputeConfigLoadError: (load) => - new CliError({ - code: "COMPUTE_CONFIG_INVALID", - domain: "app", - summary: `Could not load ${path.basename(load.configPath)}`, - why: load.message, - fix: `Fix the error in ${path.basename(load.configPath)} and rerun the command.`, - where: load.configPath, - meta: { configPath: load.configPath }, - exitCode: 2, - nextSteps: [command], - }), - ComputeConfigInvalidError: (invalid) => - new CliError({ - code: "COMPUTE_CONFIG_INVALID", - domain: "app", - summary: `Invalid ${path.basename(invalid.configPath)}`, - why: invalid.issues.join(" "), - fix: `Edit ${path.basename(invalid.configPath)} so it default-exports defineComputeConfig({ app }) or defineComputeConfig({ apps }).`, - where: invalid.configPath, - meta: { configPath: invalid.configPath, issues: invalid.issues }, - exitCode: 2, - nextSteps: [command], - }), - ComputeConfigTargetRequiredError: (required) => - new CliError({ - code: "COMPUTE_CONFIG_TARGET_REQUIRED", - domain: "app", - summary: "App target required", - why: required.message, - fix: `Pass the app target, for example ${command} .`, - meta: { - configPath: required.configPath, - availableTargets: required.availableTargets, - }, - exitCode: 2, - nextSteps: required.availableTargets.map( - (target) => `${command} ${target}`, - ), - }), - ComputeConfigTargetUnknownError: (unknown) => - new CliError({ - code: "COMPUTE_CONFIG_TARGET_UNKNOWN", - domain: "app", - summary: `Unknown app target "${unknown.requestedTarget}"`, - why: unknown.message, - fix: - unknown.availableTargets.length > 0 - ? `Pass one of the configured targets: ${unknown.availableTargets.join(", ")}.` - : "Remove the target argument; this config defines a single app.", - meta: { - configPath: unknown.configPath, - requestedTarget: unknown.requestedTarget, - availableTargets: unknown.availableTargets, - }, - exitCode: 2, - nextSteps: - unknown.availableTargets.length > 0 - ? unknown.availableTargets.map((target) => `${command} ${target}`) - : [command], - }), - }); -} diff --git a/packages/cli/src/lib/app/deploy-framework.ts b/packages/cli/src/lib/app/deploy-framework.ts deleted file mode 100644 index d48174d1..00000000 --- a/packages/cli/src/lib/app/deploy-framework.ts +++ /dev/null @@ -1,37 +0,0 @@ -import path from "node:path"; -import type { FrameworkBuildType } from "@prisma/compute-sdk/config"; -import { detectComputeAppFromDirectory } from "@prisma/compute-sdk/config/directory"; - -export interface ResolvedDeployFramework { - key: string; - buildType: FrameworkBuildType; - displayName: string; - annotation: string; -} - -/** Reads the directory's package.json and framework config to name the - * framework a deploy would use, or null when none is recognised. */ -export async function detectDeployFramework( - cwd: string, - signal: AbortSignal, -): Promise { - const detected = await detectComputeAppFromDirectory({ - appPath: cwd, - signal, - }); - if (!detected) return null; - - let annotation = "detected from package.json"; - if (detected.configFile?.standaloneOutput) { - annotation = "standalone output detected"; - } else if (detected.configFile) { - annotation = `detected from ${path.basename(detected.configFile.path)}`; - } - - return { - key: detected.framework, - buildType: detected.buildType, - displayName: detected.frameworkName, - annotation, - }; -} diff --git a/packages/cli/src/state-dir.ts b/packages/cli/src/state-dir.ts index e0563b16..6bb72e9e 100644 --- a/packages/cli/src/state-dir.ts +++ b/packages/cli/src/state-dir.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import { findComputeConfigDir } from "@prisma/compute-sdk/config"; export const DEFAULT_STATE_DIR_NAME = path.join(".prisma", "cli"); @@ -16,9 +15,5 @@ export async function resolveStateDir(inputs: StateDirInputs): Promise { return explicitStateDir; } - // The compute config marks the project root, so the local state cache lives - // next to it instead of fragmenting across invocation directories. This is - // location-only discovery; the config itself is not loaded here. - const projectDir = await findComputeConfigDir(inputs.cwd, inputs.signal); - return path.join(projectDir ?? inputs.cwd, DEFAULT_STATE_DIR_NAME); + return path.join(inputs.cwd, DEFAULT_STATE_DIR_NAME); } diff --git a/packages/cli/tests/agent.test.ts b/packages/cli/tests/agent.test.ts index 491c5879..f0fcbf21 100644 --- a/packages/cli/tests/agent.test.ts +++ b/packages/cli/tests/agent.test.ts @@ -518,40 +518,4 @@ describe("prisma-cli agent status", () => { }, ]); }); - - it("reads the skills lock from the compute config root when run in a subdirectory", async () => { - vi.mocked(execa).mockRejectedValue(new Error("skills exploded")); - const { cwd, env } = await makeCwd(); - const serviceDir = path.join(cwd, "services", "web"); - await mkdir(serviceDir, { recursive: true }); - // The compute-config search stops at the source root, so the - // temp directory needs one for the walk-up to reach it. - await mkdir(path.join(cwd, ".git"), { recursive: true }); - await writeFile( - path.join(cwd, "prisma.compute.json"), - JSON.stringify({ apps: { web: { root: "services/web" } } }), - "utf8", - ); - await writeFile( - path.join(cwd, "skills-lock.json"), - JSON.stringify({ sources: ["prisma/skills"] }), - "utf8", - ); - - const result = await makeCli().run(["agent", "status"], { - cwd: serviceDir, - env, - }); - - expect(result.exitCode).toBe(0); - expect(execa).toHaveBeenCalledWith( - "npx", - ["-y", "skills@latest", "list", "--json"], - expect.objectContaining({ cwd }), - ); - expect(result.presented?.data).toMatchObject({ - skillsLockInstalled: true, - skillsInstalled: true, - }); - }); }); diff --git a/packages/cli/tests/app-build.test.ts b/packages/cli/tests/app-build.test.ts deleted file mode 100644 index 74c2b1d1..00000000 --- a/packages/cli/tests/app-build.test.ts +++ /dev/null @@ -1,1266 +0,0 @@ -import { - access, - lstat, - mkdir, - readFile, - readlink, - symlink, - writeFile, -} from "node:fs/promises"; -import { createRequire } from "node:module"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTempCwd } from "./helpers"; - -afterEach(() => { - vi.doUnmock("node:child_process"); - vi.resetModules(); - vi.restoreAllMocks(); -}); - -describe("preview build strategy", () => { - it("resolves inferred Next.js settings without writing any file", async () => { - const { resolveInferredAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - packageManager: "bun@1.2.0", - scripts: { - build: "prisma generate && next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - - const resolution = await resolveInferredAppBuildSettings({ - appPath, - buildType: "nextjs", - }); - - expect(resolution.status).toBe("inferred"); - expect(resolution.configPath).toBeNull(); - expect(resolution.settings).toEqual({ - buildCommand: "bun run build", - buildCommandSource: "package.json scripts.build", - outputDirectory: ".next/standalone", - outputDirectorySource: "Next.js output", - }); - await expect( - readFile(path.join(appPath, "prisma.app.json"), "utf8"), - ).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("describes the strategy-owned builds for nuxt and astro", async () => { - const { resolveInferredAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - - const nuxt = await resolveInferredAppBuildSettings({ - appPath: cwd, - buildType: "nuxt", - }); - expect(nuxt.settings).toEqual({ - buildCommand: "nuxt build", - buildCommandSource: "Nuxt default", - outputDirectory: ".output", - outputDirectorySource: "Nuxt output", - }); - - const astro = await resolveInferredAppBuildSettings({ - appPath: cwd, - buildType: "astro", - }); - expect(astro.settings).toEqual({ - buildCommand: "astro build", - buildCommandSource: "Astro default", - outputDirectory: "dist", - outputDirectorySource: "Astro output", - }); - }); - - it("packages the full tree with a next start launcher when the build produces no standalone output", async () => { - const { AppBuildStrategy } = await import("../src/lib/app/build"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(path.join(appPath, ".next"), { recursive: true }); - await mkdir(path.join(appPath, "node_modules/next"), { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify({ dependencies: { next: "15.0.0" } }), - "utf8", - ); - await writeFile( - path.join(appPath, ".next/BUILD_ID"), - "fallback-test", - "utf8", - ); - await writeFile( - path.join(appPath, ".env"), - "SECRET=should-not-ship", - "utf8", - ); - await writeFile( - path.join(appPath, ".env.local"), - "SECRET=should-not-ship", - "utf8", - ); - await writeFile( - path.join(appPath, "node_modules/next/package.json"), - JSON.stringify({ name: "next", version: "15.0.0" }), - "utf8", - ); - await mkdir(path.join(appPath, "node_modules/.bin"), { recursive: true }); - await symlink( - "../next/package.json", - path.join(appPath, "node_modules/.bin/next-link"), - ); - - const strategy = new AppBuildStrategy({ - appPath, - buildType: "nextjs", - buildSettings: { - buildCommand: null, - buildCommandSource: null, - outputDirectory: ".next/standalone", - outputDirectorySource: null, - }, - }); - - const artifact = await strategy.execute(); - try { - expect(artifact.entrypoint).toBe("prisma-next-start.cjs"); - expect(artifact.defaultPortMapping).toEqual({ http: 3000 }); - - const launcher = await readFile( - path.join(artifact.directory, "prisma-next-start.cjs"), - "utf8", - ); - expect(launcher).toContain('require("next/dist/bin/next")'); - expect(launcher).toContain('process.argv.push("start"'); - expect(launcher).toContain("process.chdir(__dirname)"); - - await expect( - readFile(path.join(artifact.directory, ".next/BUILD_ID"), "utf8"), - ).resolves.toBe("fallback-test"); - await expect( - readFile( - path.join(artifact.directory, "node_modules/next/package.json"), - "utf8", - ), - ).resolves.toContain("15.0.0"); - - const linkPath = path.join( - artifact.directory, - "node_modules/.bin/next-link", - ); - expect((await lstat(linkPath)).isSymbolicLink()).toBe(true); - expect((await readlink(linkPath)).split(path.sep).join("/")).toBe( - "../next/package.json", - ); - - await expect( - access(path.join(artifact.directory, ".env")), - ).rejects.toThrow(); - await expect( - access(path.join(artifact.directory, ".env.local")), - ).rejects.toThrow(); - } finally { - const stagedDir = artifact.directory; - await artifact.cleanup?.(); - await expect(access(stagedDir)).rejects.toThrow(); - } - }); - - it("infers TanStack and Hono build defaults", async () => { - const { resolveInferredAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const tanstackPath = path.join(cwd, "tanstack"); - const honoPath = path.join(cwd, "hono"); - - await mkdir(tanstackPath, { recursive: true }); - await writeFile( - path.join(tanstackPath, "package.json"), - JSON.stringify( - { - dependencies: { - "@tanstack/react-start": "1.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - await mkdir(honoPath, { recursive: true }); - await writeFile( - path.join(honoPath, "package.json"), - JSON.stringify( - { - dependencies: { - hono: "4.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - - await expect( - resolveInferredAppBuildSettings({ - appPath: tanstackPath, - buildType: "tanstack-start", - }), - ).resolves.toMatchObject({ - status: "inferred", - settings: { - buildCommand: "vite build", - outputDirectory: ".output", - }, - }); - await expect( - resolveInferredAppBuildSettings({ - appPath: honoPath, - buildType: "bun", - }), - ).resolves.toMatchObject({ - status: "inferred", - settings: { - buildCommand: null, - outputDirectory: ".", - }, - }); - }); - - it("classifies leftover prisma.app.json files for migration", async () => { - const { detectLegacyBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const effective = { - buildCommand: "bun run build", - buildCommandSource: null, - outputDirectory: ".next/standalone", - outputDirectorySource: null, - }; - - await expect( - detectLegacyBuildSettings({ appPath: cwd, effective }), - ).resolves.toEqual({ kind: "absent" }); - - await writeFile( - path.join(cwd, "prisma.app.json"), - JSON.stringify({ - buildCommand: "bun run build", - outputDirectory: ".next/standalone", - }), - "utf8", - ); - await expect( - detectLegacyBuildSettings({ appPath: cwd, effective }), - ).resolves.toMatchObject({ kind: "matching" }); - - await writeFile( - path.join(cwd, "prisma.app.json"), - JSON.stringify({ - buildCommand: "custom-build", - outputDirectory: "dist", - }), - "utf8", - ); - await expect( - detectLegacyBuildSettings({ appPath: cwd, effective }), - ).resolves.toMatchObject({ - kind: "custom", - buildCommand: "custom-build", - outputDirectory: "dist", - }); - - await writeFile(path.join(cwd, "prisma.app.json"), "{ nope\n", "utf8"); - await expect( - detectLegacyBuildSettings({ appPath: cwd, effective }), - ).resolves.toMatchObject({ kind: "invalid" }); - }); - - it("resolves package.json build scripts and literal framework output directories", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - packageManager: "pnpm@10.0.0", - scripts: { - build: "prisma generate && next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile( - path.join(appPath, "next.config.js"), - "module.exports = { output: 'standalone', distDir: 'build' };\n", - "utf8", - ); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toEqual({ - buildCommand: "pnpm run build", - buildCommandSource: "package.json scripts.build", - outputDirectory: "build/standalone", - outputDirectorySource: "next.config distDir", - }); - }); - - it("only reads Next.js distDir from the exported config object", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - packageManager: "pnpm@10.0.0", - scripts: { - build: "next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile( - path.join(appPath, "next.config.ts"), - [ - "const unrelated = { distDir: 'wrong' };", - "const nextConfig = { output: 'standalone', distDir: 'build' } satisfies object;", - "export default defineConfig(nextConfig);", - ].join("\n"), - "utf8", - ); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - outputDirectory: "build/standalone", - outputDirectorySource: "next.config distDir", - }); - }); - - it("ignores commented or unrelated Next.js distDir values", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - packageManager: "pnpm@10.0.0", - scripts: { - build: "next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile( - path.join(appPath, "next.config.js"), - [ - "// distDir: 'commented'", - "const unrelated = { distDir: 'wrong' };", - "module.exports = { output: 'standalone' };", - ].join("\n"), - "utf8", - ); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - outputDirectory: ".next/standalone", - outputDirectorySource: "Next.js output", - }); - }); - - it("detects the package manager for package.json build scripts", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cases = [ - { lockfile: "bun.lock", command: "bun run build" }, - { lockfile: "pnpm-lock.yaml", command: "pnpm run build" }, - { lockfile: "yarn.lock", command: "yarn run build" }, - { lockfile: "package-lock.json", command: "npm run build" }, - ]; - - await Promise.all( - cases.map(async (testCase) => { - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - scripts: { - build: "next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile(path.join(appPath, testCase.lockfile), "", "utf8"); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - buildCommand: testCase.command, - buildCommandSource: "package.json scripts.build", - }); - }), - ); - }); - - it("detects the package manager from the workspace root for app build scripts", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cases = [ - { - rootFiles: ["pnpm-workspace.yaml", "pnpm-lock.yaml"], - command: "pnpm run build", - }, - { - rootFiles: ["package-lock.json"], - rootPackageJson: { workspaces: ["apps/*"] }, - command: "npm run build", - }, - { - rootFiles: ["yarn.lock"], - rootPackageJson: { workspaces: ["apps/*"] }, - command: "yarn run build", - }, - ]; - - await Promise.all( - cases.map(async (testCase) => { - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "apps", "web"); - - await mkdir(appPath, { recursive: true }); - if (testCase.rootPackageJson) { - await writeFile( - path.join(cwd, "package.json"), - JSON.stringify(testCase.rootPackageJson, null, 2), - "utf8", - ); - } - await Promise.all( - testCase.rootFiles.map((rootFile) => - writeFile(path.join(cwd, rootFile), "", "utf8"), - ), - ); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - scripts: { - build: "next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - buildCommand: testCase.command, - buildCommandSource: "package.json scripts.build", - }); - }), - ); - }); - - it("prefers the app-level lockfile over the workspace root lockfile", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "apps", "web"); - - await mkdir(appPath, { recursive: true }); - await writeFile(path.join(cwd, "pnpm-workspace.yaml"), "", "utf8"); - await writeFile(path.join(cwd, "pnpm-lock.yaml"), "", "utf8"); - await writeFile(path.join(appPath, "bun.lock"), "", "utf8"); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - scripts: { - build: "next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - buildCommand: "bun run build", - }); - }); - - it("does not use lockfiles above the repository root", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const repoPath = path.join(cwd, "repo"); - const appPath = path.join(repoPath, "app"); - - await mkdir(path.join(repoPath, ".git"), { recursive: true }); - await mkdir(appPath, { recursive: true }); - await writeFile(path.join(cwd, "pnpm-lock.yaml"), "", "utf8"); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - scripts: { - build: "custom-build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - buildCommand: "custom-build", - }); - }); - - it("uses the literal package.json build script when no package manager is detected", async () => { - const { resolveAppBuildSettings } = await import( - "../src/lib/app/build-settings" - ); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - scripts: { - build: "custom-build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - - await expect( - resolveAppBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - buildCommand: "custom-build", - buildCommandSource: "package.json scripts.build", - }); - }); - - it("does not detect unsupported next.config.cjs files as Next.js", async () => { - const { resolveAppBuildStrategy } = await import("../src/lib/app/build"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - `${JSON.stringify({}, null, 2)}\n`, - "utf8", - ); - await writeFile( - path.join(appPath, "server.ts"), - "export default { fetch: () => new Response('ok') };\n", - "utf8", - ); - await writeFile( - path.join(appPath, "next.config.cjs"), - "module.exports = { output: 'standalone' };\n", - "utf8", - ); - - await expect( - resolveAppBuildStrategy({ - appPath, - entrypoint: "server.ts", - buildType: "auto", - }), - ).resolves.toMatchObject({ - buildType: "bun", - }); - }); - - it("resolves an explicit entrypoint to Bun even when Next.js is detectable", async () => { - const { resolveAppBuildStrategy } = await import("../src/lib/app/build"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - `${JSON.stringify({ dependencies: { next: "15.0.0" } }, null, 2)}\n`, - "utf8", - ); - await writeFile( - path.join(appPath, "server.ts"), - "export default { fetch: () => new Response('ok') };\n", - "utf8", - ); - - await expect( - resolveAppBuildStrategy({ - appPath, - entrypoint: "server.ts", - buildType: "auto", - }), - ).resolves.toMatchObject({ - buildType: "bun", - }); - }); - - it("runs package.json build scripts before staging Next.js output", async () => { - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - packageManager: "npm@10.0.0", - scripts: { - build: "node build.mjs", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile( - path.join(appPath, "build.mjs"), - [ - "import { mkdir, writeFile } from 'node:fs/promises';", - "await mkdir('.next/standalone', { recursive: true });", - "await mkdir('.next/static', { recursive: true });", - "await mkdir('public', { recursive: true });", - "await writeFile('.next/standalone/server.js', \"console.log('next');\\n\");", - "await writeFile('.next/static/client.js', \"console.log('static');\\n\");", - "await writeFile('public/hello.txt', 'hello\\n');", - ].join("\n"), - "utf8", - ); - - const { executeAppBuild } = await import("../src/lib/app/build"); - const result = await executeAppBuild({ - appPath, - buildType: "nextjs", - }); - - expect(result.buildType).toBe("nextjs"); - expect(result.artifact.entrypoint).toBe("server.js"); - await expect( - readFile( - path.join(result.artifact.directory, ".next", "static", "client.js"), - "utf8", - ), - ).resolves.toContain("static"); - await expect( - readFile( - path.join(result.artifact.directory, "public", "hello.txt"), - "utf8", - ), - ).resolves.toContain("hello"); - await result.artifact.cleanup?.(); - }); - - it("skips the build command when prisma.app.json sets buildCommand to null", async () => { - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const outputDir = path.join(appPath, ".next", "standalone"); - - await mkdir(outputDir, { recursive: true }); - await writeFile( - path.join(outputDir, "server.js"), - "console.log('prebuilt');\n", - "utf8", - ); - - const { executeAppBuild } = await import("../src/lib/app/build"); - const result = await executeAppBuild({ - appPath, - buildType: "nextjs", - buildSettings: { - buildCommand: null, - buildCommandSource: null, - outputDirectory: ".next/standalone", - outputDirectorySource: null, - }, - }); - - expect(result.buildType).toBe("nextjs"); - expect(result.artifact.entrypoint).toBe("server.js"); - await expect( - readFile(path.join(result.artifact.directory, "server.js"), "utf8"), - ).resolves.toContain("prebuilt"); - await result.artifact.cleanup?.(); - }); - - it("returns the Next.js default HTTP port mapping in the built artifact", async () => { - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - - await mkdir(path.join(standaloneDir, ".next", "static"), { - recursive: true, - }); - await mkdir(path.join(appPath, ".next", "static"), { recursive: true }); - await writeFile( - path.join(appPath, ".next", "static", "client.js"), - "console.log('static');\n", - "utf8", - ); - await mkdir(path.join(appPath, "public"), { recursive: true }); - await writeFile( - path.join(appPath, "public", "hello.txt"), - "hello\n", - "utf8", - ); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify({ - scripts: { build: "node -e 0" }, - dependencies: { next: "15.0.0" }, - }), - "utf8", - ); - await writeFile( - path.join(appPath, "next.config.ts"), - "export default { output: 'standalone' };\n", - "utf8", - ); - await writeFile( - path.join(standaloneDir, "server.js"), - "console.log('next');\n", - "utf8", - ); - - const { executeAppBuild } = await import("../src/lib/app/build"); - const result = await executeAppBuild({ - appPath, - buildType: "nextjs", - }); - - expect(result.buildType).toBe("nextjs"); - expect(result.artifact.entrypoint).toBe("server.js"); - expect(result.artifact.defaultPortMapping).toEqual({ http: 3000 }); - await expect( - readFile( - path.join(result.artifact.directory, ".next", "static", "client.js"), - "utf8", - ), - ).resolves.toContain("static"); - await expect( - readFile( - path.join(result.artifact.directory, "public", "hello.txt"), - "utf8", - ), - ).resolves.toContain("hello"); - await result.artifact.cleanup?.(); - }); - - it("materializes symlinks that point back to the source app directory", async () => { - const { normalizeArtifactSymlinks } = await import("@prisma/compute-sdk"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const artifactDir = path.join(cwd, "artifact"); - const sourceTarget = path.join( - appPath, - ".next/standalone/node_modules/.pnpm/pkg-b/node_modules/pkg-b", - ); - const copiedLink = path.join( - artifactDir, - "node_modules/.pnpm/pkg-a/node_modules/pkg-b", - ); - - await mkdir(sourceTarget, { recursive: true }); - await writeFile( - path.join(sourceTarget, "index.js"), - "export const value = 1;\n", - "utf8", - ); - - await mkdir(path.dirname(copiedLink), { recursive: true }); - await symlink(sourceTarget, copiedLink, "dir"); - - await normalizeArtifactSymlinks(artifactDir, appPath); - - expect((await lstat(copiedLink)).isSymbolicLink()).toBe(false); - await expect( - readFile(path.join(copiedLink, "index.js"), "utf8"), - ).resolves.toContain("value = 1"); - }); - - it("stages Next.js standalone artifacts by preserving internal symlinks and materializing fallback targets", async () => { - const { stageStandaloneArtifact: stageNextjsStandaloneArtifact } = - await import("@prisma/compute-sdk"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - const artifactDir = path.join(cwd, "artifact"); - - const standaloneTarget = path.join( - standaloneDir, - "node_modules/.pnpm/sharp@0.34.5/node_modules/sharp", - ); - const standaloneLink = path.join( - standaloneDir, - "node_modules/.pnpm/node_modules/sharp", - ); - const appFallbackTarget = path.join( - appPath, - "node_modules/.pnpm/semver@6.3.1/node_modules/semver", - ); - const standaloneMissingLink = path.join( - standaloneDir, - "node_modules/.pnpm/node_modules/semver", - ); - - await mkdir(standaloneTarget, { recursive: true }); - await writeFile( - path.join(standaloneTarget, "index.js"), - "export const sharp = true;\n", - "utf8", - ); - await mkdir(path.dirname(standaloneLink), { recursive: true }); - await symlink("../sharp@0.34.5/node_modules/sharp", standaloneLink, "dir"); - - await mkdir(appFallbackTarget, { recursive: true }); - await writeFile( - path.join(appFallbackTarget, "index.js"), - "export const semver = true;\n", - "utf8", - ); - await mkdir(path.dirname(standaloneMissingLink), { recursive: true }); - await symlink( - "../semver@6.3.1/node_modules/semver", - standaloneMissingLink, - "dir", - ); - - await stageNextjsStandaloneArtifact({ - standaloneDir, - artifactDir, - appPath, - }); - - const copiedStandaloneTarget = path.join( - artifactDir, - "node_modules/.pnpm/node_modules/sharp", - ); - const copiedFallbackTarget = path.join( - artifactDir, - "node_modules/.pnpm/node_modules/semver", - ); - - expect((await lstat(copiedStandaloneTarget)).isSymbolicLink()).toBe(true); - expect((await lstat(copiedFallbackTarget)).isSymbolicLink()).toBe(false); - await expect( - readFile(path.join(copiedStandaloneTarget, "index.js"), "utf8"), - ).resolves.toContain("sharp = true"); - await expect( - readFile(path.join(copiedFallbackTarget, "index.js"), "utf8"), - ).resolves.toContain("semver = true"); - }); - - it("stages Next.js standalone symlinks that resolve through the monorepo root", async () => { - const { stageStandaloneArtifact: stageNextjsStandaloneArtifact } = - await import("@prisma/compute-sdk"); - const cwd = await createTempCwd(); - const repoRoot = path.join(cwd, "repo"); - const appPath = path.join(repoRoot, "apps", "web"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - const artifactDir = path.join(cwd, "artifact"); - const rootDependency = path.join(repoRoot, "node_modules", "pg"); - const standaloneLink = path.join(standaloneDir, "node_modules", "pg"); - - await mkdir(path.join(repoRoot, ".git"), { recursive: true }); - await mkdir(rootDependency, { recursive: true }); - await writeFile( - path.join(rootDependency, "index.js"), - "export const pg = true;\n", - "utf8", - ); - await mkdir(path.dirname(standaloneLink), { recursive: true }); - await symlink( - path.relative(path.dirname(standaloneLink), rootDependency), - standaloneLink, - "dir", - ); - - await stageNextjsStandaloneArtifact({ - standaloneDir, - artifactDir, - appPath, - }); - - const copiedDependency = path.join(artifactDir, "node_modules", "pg"); - - expect((await lstat(copiedDependency)).isSymbolicLink()).toBe(false); - await expect( - readFile(path.join(copiedDependency, "index.js"), "utf8"), - ).resolves.toContain("pg = true"); - }); - - it("keeps pnpm transitive dependencies resolvable after flattening Next.js standalone packages", async () => { - const { stageStandaloneArtifact: stageNextjsStandaloneArtifact } = - await import("@prisma/compute-sdk"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - const artifactDir = path.join(cwd, "artifact"); - const nextStorePackage = path.join( - standaloneDir, - "node_modules/.pnpm/next@16.2.3/node_modules/next", - ); - const nextLink = path.join(standaloneDir, "node_modules/next"); - const swcHelperPackage = path.join( - standaloneDir, - "node_modules/.pnpm/@swc+helpers@0.5.15/node_modules/@swc/helpers/_", - ); - const swcHoistedLink = path.join( - standaloneDir, - "node_modules/.pnpm/node_modules/@swc/helpers", - ); - - await mkdir(path.join(nextStorePackage, "dist/shared/lib"), { - recursive: true, - }); - await writeFile( - path.join(nextStorePackage, "dist/shared/lib/constants.js"), - "module.exports = require('@swc/helpers/_/_interop_require_default');\n", - "utf8", - ); - await mkdir(path.dirname(nextLink), { recursive: true }); - await symlink(".pnpm/next@16.2.3/node_modules/next", nextLink, "dir"); - - await mkdir(swcHelperPackage, { recursive: true }); - await writeFile( - path.join(swcHelperPackage, "_interop_require_default.js"), - "module.exports = { default: true };\n", - "utf8", - ); - await mkdir(path.dirname(swcHoistedLink), { recursive: true }); - await symlink( - "../../@swc+helpers@0.5.15/node_modules/@swc/helpers", - swcHoistedLink, - "dir", - ); - - await stageNextjsStandaloneArtifact({ - standaloneDir, - artifactDir, - appPath, - }); - - const constants = path.join( - artifactDir, - "node_modules/next/dist/shared/lib/constants.js", - ); - const requireFromNext = createRequire(constants); - - expect(() => - requireFromNext.resolve("@swc/helpers/_/_interop_require_default"), - ).not.toThrow(); - }); - - it("places public and .next/static next to server.js when the entrypoint is nested (monorepo)", async () => { - const { restageNextjsArtifact } = await import("../src/lib/app/build"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "repo", "apps", "web"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - const nestedServerDir = path.join(standaloneDir, "apps", "web"); - const artifactDir = path.join(cwd, "artifact"); - - await mkdir(path.join(cwd, "repo", ".git"), { recursive: true }); - await mkdir(nestedServerDir, { recursive: true }); - await writeFile( - path.join(nestedServerDir, "server.js"), - "// nested server\n", - "utf8", - ); - await mkdir(path.join(standaloneDir, "node_modules"), { recursive: true }); - - await mkdir(path.join(appPath, "public"), { recursive: true }); - await writeFile( - path.join(appPath, "public", "hello.txt"), - "hello\n", - "utf8", - ); - await mkdir(path.join(appPath, ".next", "static"), { recursive: true }); - await writeFile( - path.join(appPath, ".next", "static", "client.js"), - "// static\n", - "utf8", - ); - - // Seed an existing (incorrect) artifact directory to mirror what the SDK - // produces before the CLI re-stages it. - await mkdir(artifactDir, { recursive: true }); - - await restageNextjsArtifact( - { directory: artifactDir, entrypoint: "apps/web/server.js" }, - appPath, - ); - - await expect( - readFile( - path.join(artifactDir, "apps", "web", "public", "hello.txt"), - "utf8", - ), - ).resolves.toContain("hello"); - await expect( - readFile( - path.join(artifactDir, "apps", "web", ".next", "static", "client.js"), - "utf8", - ), - ).resolves.toContain("static"); - }); - - it("drops dangling pnpm hoist symlinks when staging Next.js standalone artifacts", async () => { - const { stageStandaloneArtifact: stageNextjsStandaloneArtifact } = - await import("@prisma/compute-sdk"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - const artifactDir = path.join(cwd, "artifact"); - - const realTarget = path.join( - standaloneDir, - "node_modules/.pnpm/real@1.0.0/node_modules/real", - ); - const realLink = path.join( - standaloneDir, - "node_modules/.pnpm/node_modules/real", - ); - await mkdir(realTarget, { recursive: true }); - await writeFile( - path.join(realTarget, "index.js"), - "export const real = true;\n", - "utf8", - ); - await mkdir(path.dirname(realLink), { recursive: true }); - await symlink("../real@1.0.0/node_modules/real", realLink, "dir"); - - const danglingLink = path.join( - standaloneDir, - "node_modules/.pnpm/node_modules/missing-pkg", - ); - await symlink( - "../missing-pkg@1.0.0/node_modules/missing-pkg", - danglingLink, - "dir", - ); - - const danglingScopedLink = path.join( - standaloneDir, - "node_modules/.pnpm/node_modules/@scope/missing-pkg", - ); - await mkdir(path.dirname(danglingScopedLink), { recursive: true }); - await symlink( - "../../@scope+missing-pkg@1.0.0/node_modules/@scope/missing-pkg", - danglingScopedLink, - "dir", - ); - - await stageNextjsStandaloneArtifact({ - standaloneDir, - artifactDir, - appPath, - }); - - await expect( - readFile(path.join(artifactDir, "node_modules/real/index.js"), "utf8"), - ).resolves.toContain("real = true"); - await expect( - lstat( - path.join(artifactDir, "node_modules/.pnpm/node_modules/missing-pkg"), - ), - ).rejects.toThrow(); - await expect( - lstat(path.join(artifactDir, "node_modules/missing-pkg")), - ).rejects.toThrow(); - await expect( - lstat(path.join(artifactDir, "node_modules/@scope/missing-pkg")), - ).rejects.toThrow(); - }); - - it("still rejects dangling Next.js standalone symlinks outside the pnpm hoist layer", async () => { - const { stageStandaloneArtifact: stageNextjsStandaloneArtifact } = - await import("@prisma/compute-sdk"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - const artifactDir = path.join(cwd, "artifact"); - - const brokenTopLevelLink = path.join( - standaloneDir, - "node_modules", - "missing-direct", - ); - await mkdir(path.dirname(brokenTopLevelLink), { recursive: true }); - await symlink( - ".pnpm/missing-direct@1.0.0/node_modules/missing-direct", - brokenTopLevelLink, - "dir", - ); - - await expect( - stageNextjsStandaloneArtifact({ - standaloneDir, - artifactDir, - appPath, - }), - ).rejects.toThrow("symlink target is missing"); - }); - - it("rejects Next.js standalone symlinks that escape the app directory", async () => { - const { stageStandaloneArtifact: stageNextjsStandaloneArtifact } = - await import("@prisma/compute-sdk"); - const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const standaloneDir = path.join(appPath, ".next", "standalone"); - const artifactDir = path.join(cwd, "artifact"); - const escapeTarget = path.join(cwd, "escape"); - const escapeLink = path.join(standaloneDir, "node_modules/escape"); - - await mkdir(path.join(appPath, ".git"), { recursive: true }); - await mkdir(escapeTarget, { recursive: true }); - await writeFile( - path.join(escapeTarget, "index.js"), - "export const escaped = true;\n", - "utf8", - ); - await mkdir(path.dirname(escapeLink), { recursive: true }); - await symlink(escapeTarget, escapeLink, "dir"); - - await expect( - stageNextjsStandaloneArtifact({ - standaloneDir, - artifactDir, - appPath, - }), - ).rejects.toThrow("escapes the app directory"); - }); -}); diff --git a/packages/cli/tests/app-bun-compat.test.ts b/packages/cli/tests/app-bun-compat.test.ts deleted file mode 100644 index 688a8628..00000000 --- a/packages/cli/tests/app-bun-compat.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { createTempCwd } from "./helpers"; - -afterEach(() => { - vi.doUnmock("@prisma/compute-sdk"); - vi.resetModules(); - vi.restoreAllMocks(); -}); - -describe("bun compatibility", () => { - it("does not fall back to package.json module for the Bun entrypoint", async () => { - const cwd = await createTempCwd(); - - await writeFile( - path.join(cwd, "package.json"), - JSON.stringify( - { - module: "index.ts", - devDependencies: { - "@types/bun": "latest", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile( - path.join(cwd, "index.ts"), - "console.log('hello');\n", - "utf8", - ); - - const { resolveBunEntrypoint } = await import("../src/lib/app/bun-project"); - - await expect(resolveBunEntrypoint(cwd, undefined)).rejects.toThrow( - "Entrypoint is required. Pass --entry or define package.json main.", - ); - }); - - it("rejects Bun package reads when the command signal is already aborted", async () => { - const cwd = await createTempCwd(); - const controller = new AbortController(); - const reason = new Error("cancelled"); - controller.abort(reason); - - const { readBunPackageJson } = await import("../src/lib/app/bun-project"); - - await expect(readBunPackageJson(cwd, controller.signal)).rejects.toBe( - reason, - ); - }); - - it("forwards explicit build types to the SDK strategy resolver", async () => { - const cwd = await createTempCwd(); - - const { resolveAppBuildStrategy } = await import("../src/lib/app/build"); - - await expect( - resolveAppBuildStrategy({ - appPath: cwd, - buildType: "astro", - entrypoint: undefined, - }), - ).resolves.toMatchObject({ buildType: "astro" }); - - await expect( - resolveAppBuildStrategy({ - appPath: cwd, - buildType: "tanstack-start", - entrypoint: undefined, - }), - ).resolves.toMatchObject({ buildType: "tanstack-start" }); - }); - - it("lets an explicit Bun entrypoint override package.json main", async () => { - const cwd = await createTempCwd(); - - await writeFile( - path.join(cwd, "package.json"), - JSON.stringify( - { - main: "index.ts", - devDependencies: { - "@types/bun": "latest", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile( - path.join(cwd, "index.ts"), - "console.log('hello');\n", - "utf8", - ); - await writeFile( - path.join(cwd, "server.ts"), - "console.log('server');\n", - "utf8", - ); - - const { resolveBunEntrypoint } = await import("../src/lib/app/bun-project"); - - await expect(resolveBunEntrypoint(cwd, "server.ts")).resolves.toBe( - "server.ts", - ); - }); -}); diff --git a/packages/cli/tests/app-provider.test.ts b/packages/cli/tests/app-provider.test.ts index dab2dffa..d8c6152a 100644 --- a/packages/cli/tests/app-provider.test.ts +++ b/packages/cli/tests/app-provider.test.ts @@ -1,22 +1,11 @@ -import path from "node:path"; - import { afterEach, describe, expect, it, vi } from "vitest"; afterEach(() => { vi.doUnmock("@prisma/compute-sdk"); - vi.doUnmock("../src/lib/app/build"); vi.resetModules(); vi.restoreAllMocks(); }); -function mockAppBuildStrategy() { - return vi.fn().mockImplementation(function AppBuildStrategyMock( - options: object, - ) { - return { options }; - }); -} - describe("preview app provider", () => { it("resolves branch role from the API without deriving it from name or isDefault", async () => { const client = { @@ -58,538 +47,6 @@ describe("preview app provider", () => { expect(client.POST).not.toHaveBeenCalled(); }); - it("forwards build strategy options and port mapping into compute deploy", async () => { - const deploy = vi.fn().mockResolvedValue({ - isErr: () => false, - isOk: () => true, - value: { - projectId: "proj_123", - appId: "app_1", - appName: "hello-world", - region: "eu-central-1", - deploymentId: "dep_123", - deploymentEndpointDomain: "cv-123.fra.prisma.build", - appEndpointDomain: "hello-world.fra.prisma.build", - }, - }); - const AppBuildStrategy = mockAppBuildStrategy(); - - vi.doMock("../src/lib/app/build", () => ({ - AppBuildStrategy, - })); - vi.doMock("@prisma/compute-sdk", () => ({ - ApiError: { is: () => false }, - ComputeClient: class { - deploy = deploy; - }, - })); - - const { createAppProvider } = await import("../src/lib/app/app-provider"); - - const provider = createAppProvider({} as never); - const cwd = path.resolve("/tmp/next-smoke"); - - await provider.deployApp({ - cwd, - projectId: "proj_123", - appName: "hello-world", - buildType: "nextjs", - entrypoint: undefined, - portMapping: { http: 3000 }, - }); - - expect(AppBuildStrategy).toHaveBeenCalledWith({ - appPath: cwd, - entrypoint: undefined, - buildType: "nextjs", - }); - expect(deploy).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "proj_123", - appName: "hello-world", - portMapping: { http: 3000 }, - }), - ); - }); - - it("creates a branch-scoped service before deploying a new branch app", async () => { - const deploy = vi.fn().mockResolvedValue({ - isErr: () => false, - isOk: () => true, - value: { - projectId: "proj_123", - appId: "svc_branch", - appName: "hello-world", - region: "eu-central-1", - deploymentId: "dep_123", - deploymentEndpointDomain: "cv-123.fra.prisma.build", - appEndpointDomain: "hello-world.fra.prisma.build", - }, - }); - const AppBuildStrategy = mockAppBuildStrategy(); - const client = { - GET: vi.fn().mockResolvedValue({ - data: { - data: [], - pagination: { hasMore: false, nextCursor: null }, - }, - response: { status: 200 }, - }), - POST: vi.fn().mockImplementation((pathName: string) => { - if (pathName === "/v1/projects/{projectId}/branches") { - return { - data: { - data: { - id: "br_billing", - gitName: "feat/billing", - isDefault: false, - role: "preview", - }, - }, - response: { status: 201 }, - }; - } - - if (pathName === "/v1/apps") { - return { - data: { - data: { - id: "svc_branch", - type: "app", - name: "hello-world", - region: { id: "eu-central-1", name: "Europe (Frankfurt)" }, - projectId: "proj_123", - branchId: "br_billing", - latestDeploymentId: null, - appEndpointDomain: "hello-world.fra.prisma.build", - }, - }, - response: { status: 201 }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }; - - vi.doMock("../src/lib/app/build", () => ({ - AppBuildStrategy, - })); - vi.doMock("@prisma/compute-sdk", () => ({ - ApiError: { is: () => false }, - ComputeClient: class { - deploy = deploy; - }, - })); - - const { createAppProvider } = await import("../src/lib/app/app-provider"); - - const provider = createAppProvider(client as never); - const cwd = path.resolve("/tmp/next-smoke"); - - await provider.deployApp({ - cwd, - projectId: "proj_123", - branchName: "feat/billing", - appName: "hello-world", - buildType: "nextjs", - portMapping: { http: 3000 }, - }); - - expect(client.GET).toHaveBeenCalledWith( - "/v1/projects/{projectId}/branches", - expect.objectContaining({ - params: { - path: { projectId: "proj_123" }, - query: { gitName: "feat/billing" }, - }, - }), - ); - expect(client.POST).toHaveBeenCalledWith( - "/v1/projects/{projectId}/branches", - expect.objectContaining({ - body: { - gitName: "feat/billing", - }, - }), - ); - expect(client.POST).toHaveBeenCalledWith( - "/v1/apps", - expect.objectContaining({ - body: { - projectId: "proj_123", - branchId: "br_billing", - displayName: "hello-world", - }, - }), - ); - expect(deploy).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "proj_123", - appId: "svc_branch", - appName: "hello-world", - portMapping: { http: 3000 }, - }), - ); - }); - - it("includes regionId in app POST body when region is specified", async () => { - const deploy = vi.fn().mockResolvedValue({ - isErr: () => false, - isOk: () => true, - value: { - projectId: "proj_123", - appId: "svc_branch", - appName: "hello-world", - region: "us-east-1", - deploymentId: "dep_123", - deploymentEndpointDomain: "cv-123.iad.prisma.build", - appEndpointDomain: "hello-world.iad.prisma.build", - }, - }); - const AppBuildStrategy = mockAppBuildStrategy(); - const client = { - GET: vi.fn().mockResolvedValue({ - data: { - data: [], - pagination: { hasMore: false, nextCursor: null }, - }, - response: { status: 200 }, - }), - POST: vi.fn().mockImplementation((pathName: string) => { - if (pathName === "/v1/projects/{projectId}/branches") { - return { - data: { - data: { - id: "br_billing", - gitName: "feat/billing", - isDefault: false, - role: "preview", - }, - }, - response: { status: 201 }, - }; - } - - if (pathName === "/v1/apps") { - return { - data: { - data: { - id: "svc_branch", - type: "app", - name: "hello-world", - region: { id: "us-east-1", name: "US East (N. Virginia)" }, - projectId: "proj_123", - branchId: "br_billing", - latestDeploymentId: null, - appEndpointDomain: "hello-world.iad.prisma.build", - }, - }, - response: { status: 201 }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }; - - vi.doMock("../src/lib/app/build", () => ({ - AppBuildStrategy, - })); - vi.doMock("@prisma/compute-sdk", () => ({ - ApiError: { is: () => false }, - ComputeClient: class { - deploy = deploy; - }, - })); - - const { createAppProvider } = await import("../src/lib/app/app-provider"); - - const provider = createAppProvider(client as never); - const cwd = path.resolve("/tmp/next-smoke"); - - await provider.deployApp({ - cwd, - projectId: "proj_123", - branchName: "feat/billing", - appName: "hello-world", - region: "us-east-1", - buildType: "nextjs", - portMapping: { http: 3000 }, - }); - - expect(client.POST).toHaveBeenCalledWith( - "/v1/apps", - expect.objectContaining({ - body: { - projectId: "proj_123", - branchId: "br_billing", - displayName: "hello-world", - regionId: "us-east-1", - }, - }), - ); - }); - - it("uses an existing branch-scoped service when app creation races", async () => { - const deploy = vi.fn().mockResolvedValue({ - isErr: () => false, - isOk: () => true, - value: { - projectId: "proj_123", - appId: "svc_branch", - appName: "hello-world", - region: "eu-central-1", - deploymentId: "dep_123", - deploymentEndpointDomain: "cv-123.fra.prisma.build", - appEndpointDomain: "hello-world.fra.prisma.build", - }, - }); - const AppBuildStrategy = mockAppBuildStrategy(); - const client = { - GET: vi.fn().mockImplementation((pathName: string) => { - if (pathName === "/v1/projects/{projectId}/branches") { - return { - data: { - data: [ - { - id: "br_billing", - gitName: "feat/billing", - isDefault: false, - role: "preview", - }, - ], - pagination: { hasMore: false, nextCursor: null }, - }, - response: { status: 200 }, - }; - } - - if (pathName === "/v1/apps") { - return { - data: { - data: [ - { - id: "svc_branch", - type: "app", - name: "hello-world", - region: { id: "eu-central-1", name: "Europe (Frankfurt)" }, - projectId: "proj_123", - branchId: "br_billing", - latestDeploymentId: null, - appEndpointDomain: "hello-world.fra.prisma.build", - }, - ], - pagination: { hasMore: false, nextCursor: null }, - }, - response: { status: 200 }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - POST: vi.fn().mockImplementation((pathName: string) => { - if (pathName === "/v1/apps") { - return { - error: { - error: { - code: "CONFLICT", - message: "Compute service already exists.", - }, - }, - response: { status: 409 }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }; - - vi.doMock("../src/lib/app/build", () => ({ - AppBuildStrategy, - })); - vi.doMock("@prisma/compute-sdk", () => ({ - ApiError: { is: () => false }, - ComputeClient: class { - deploy = deploy; - }, - })); - - const { createAppProvider } = await import("../src/lib/app/app-provider"); - - const provider = createAppProvider(client as never); - const cwd = path.resolve("/tmp/next-smoke"); - - await provider.deployApp({ - cwd, - projectId: "proj_123", - branchName: "feat/billing", - appName: "hello-world", - buildType: "nextjs", - portMapping: { http: 3000 }, - }); - - expect(client.POST).toHaveBeenCalledWith( - "/v1/apps", - expect.objectContaining({ - body: { - projectId: "proj_123", - branchId: "br_billing", - displayName: "hello-world", - }, - }), - ); - expect(client.GET).toHaveBeenCalledWith( - "/v1/apps", - expect.objectContaining({ - params: { - query: { - projectId: "proj_123", - branchGitName: "feat/billing", - cursor: undefined, - }, - }, - }), - ); - expect(deploy).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "proj_123", - appId: "svc_branch", - appName: "hello-world", - portMapping: { http: 3000 }, - }), - ); - }); - - it("forwards skipPromote and maps a promotionless deploy to the candidate", async () => { - const deploy = vi.fn().mockResolvedValue({ - isErr: () => false, - isOk: () => true, - value: { - projectId: "proj_123", - appId: "app_1", - appName: "hello-world", - region: "eu-central-1", - deploymentId: "dep_new", - deploymentEndpointDomain: "dep-new.fra.prisma.build", - appEndpointDomain: null, - promoted: false, - previousDeploymentId: "dep_live", - previousDeploymentAction: "still-active", - }, - }); - const AppBuildStrategy = mockAppBuildStrategy(); - - vi.doMock("../src/lib/app/build", () => ({ - AppBuildStrategy, - })); - vi.doMock("@prisma/compute-sdk", () => ({ - ApiError: { is: () => false }, - ComputeClient: class { - deploy = deploy; - }, - })); - - const { createAppProvider } = await import("../src/lib/app/app-provider"); - - const provider = createAppProvider({} as never); - const cwd = path.resolve("/tmp/next-smoke"); - - const record = await provider.deployApp({ - cwd, - projectId: "proj_123", - appId: "app_1", - appName: "hello-world", - buildType: "nextjs", - portMapping: { http: 3000 }, - skipPromote: true, - }); - - expect(deploy).toHaveBeenCalledWith( - expect.objectContaining({ skipPromote: true }), - ); - expect(record).toEqual({ - projectId: "proj_123", - app: { - id: "app_1", - name: "hello-world", - region: "eu-central-1", - liveDeploymentId: "dep_live", - liveUrl: null, - }, - deployment: { - id: "dep_new", - status: "running", - url: "https://dep-new.fra.prisma.build", - live: false, - }, - promoted: false, - }); - }); - - it("maps a promoted deploy to the live app URL", async () => { - const deploy = vi.fn().mockResolvedValue({ - isErr: () => false, - isOk: () => true, - value: { - projectId: "proj_123", - appId: "app_1", - appName: "hello-world", - region: "eu-central-1", - deploymentId: "dep_new", - deploymentEndpointDomain: "dep-new.fra.prisma.build", - appEndpointDomain: "hello-world.fra.prisma.build", - promoted: true, - previousDeploymentId: "dep_live", - previousDeploymentAction: "stopped", - }, - }); - const AppBuildStrategy = mockAppBuildStrategy(); - - vi.doMock("../src/lib/app/build", () => ({ - AppBuildStrategy, - })); - vi.doMock("@prisma/compute-sdk", () => ({ - ApiError: { is: () => false }, - ComputeClient: class { - deploy = deploy; - }, - })); - - const { createAppProvider } = await import("../src/lib/app/app-provider"); - - const provider = createAppProvider({} as never); - - const record = await provider.deployApp({ - cwd: path.resolve("/tmp/next-smoke"), - projectId: "proj_123", - appId: "app_1", - appName: "hello-world", - buildType: "nextjs", - portMapping: { http: 3000 }, - }); - - expect(deploy).toHaveBeenCalledWith( - expect.objectContaining({ skipPromote: undefined }), - ); - expect(record).toEqual({ - projectId: "proj_123", - app: { - id: "app_1", - name: "hello-world", - region: "eu-central-1", - liveDeploymentId: "dep_new", - liveUrl: "https://hello-world.fra.prisma.build", - }, - deployment: { - id: "dep_new", - status: "running", - url: "https://hello-world.fra.prisma.build", - live: true, - }, - promoted: true, - }); - }); it("treats re-adding an existing custom domain as idempotent", async () => { const client = { diff --git a/packages/cli/tests/compute-config.test.ts b/packages/cli/tests/compute-config.test.ts deleted file mode 100644 index e6d2bad1..00000000 --- a/packages/cli/tests/compute-config.test.ts +++ /dev/null @@ -1,741 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { - COMPUTE_CONFIG_FILENAME, - ComputeConfigAmbiguousError, - ComputeConfigInvalidError, - ComputeConfigLoadError, - ComputeConfigTargetRequiredError, - ComputeConfigTargetUnknownError, - defineComputeConfig, - inferComputeTargetFromCwd, - normalizeComputeConfig, - selectComputeDeployTarget, -} from "@prisma/compute-sdk/config"; -import { afterEach, describe, expect, it } from "vitest"; -import { CliError } from "../src/errors"; -import { - computeConfigErrorToCliError, - type LoadedComputeConfig, - loadComputeConfig, -} from "../src/lib/app/compute-config"; - -const CONFIG_PATH = "/repo/prisma.compute.ts"; - -function normalizeOrThrow(exported: unknown): LoadedComputeConfig { - const result = normalizeComputeConfig(exported, CONFIG_PATH); - if (result.isErr()) { - throw result.error; - } - return result.value; -} - -function normalizeIssues(exported: unknown): string[] { - const result = normalizeComputeConfig(exported, CONFIG_PATH); - expect(result.isErr()).toBe(true); - if (!result.isErr()) { - throw new Error("expected invalid config"); - } - expect(result.error).toBeInstanceOf(ComputeConfigInvalidError); - return result.error.issues; -} - -describe("normalizeComputeConfig", () => { - it("normalizes a single-app config", () => { - const config = normalizeOrThrow( - defineComputeConfig({ - app: { - name: "api", - framework: "hono", - httpPort: 8080, - env: ".env", - }, - }), - ); - - expect(config.kind).toBe("single"); - expect(config.relativeConfigPath).toBe(COMPUTE_CONFIG_FILENAME); - expect(config.targets).toHaveLength(1); - expect(config.targets[0]).toMatchObject({ - key: null, - name: "api", - root: null, - framework: "hono", - entry: null, - httpPort: 8080, - envInputs: [".env"], - build: null, - }); - }); - - it("normalizes a multi-app config with roots and env objects", () => { - const config = normalizeOrThrow( - defineComputeConfig({ - apps: { - web: { - root: "apps/web", - framework: "nextjs", - env: { file: "packages/db/.env" }, - }, - worker: { - root: "./apps/worker/", - framework: "bun", - entry: "src/index.ts", - env: { - file: [".env", ".env.production"], - vars: { LOG_LEVEL: "debug" }, - }, - }, - }, - }), - ); - - expect(config.kind).toBe("multi"); - expect(config.targets).toHaveLength(2); - expect(config.targets[0]).toMatchObject({ - key: "web", - root: "apps/web", - framework: "nextjs", - envInputs: ["packages/db/.env"], - }); - expect(config.targets[1]).toMatchObject({ - key: "worker", - root: "apps/worker", - entry: "src/index.ts", - envInputs: [".env", ".env.production", "LOG_LEVEL=debug"], - }); - }); - - it("rejects non-object default exports", () => { - expect(normalizeIssues(undefined).join(" ")).toContain( - "export default defineComputeConfig", - ); - expect(normalizeIssues([1]).join(" ")).toContain( - "export default defineComputeConfig", - ); - }); - - it("requires exactly one of app or apps", () => { - expect(normalizeIssues({})).toEqual([ - "Define `app` for a single-app repository or `apps` for a multi-app repository.", - ]); - expect(normalizeIssues({ app: {}, apps: {} })).toEqual([ - "Use either `app` (single app) or `apps` (multi-app), not both.", - ]); - expect(normalizeIssues({ apps: {} })).toEqual([ - "`apps` must define at least one app.", - ]); - }); - - it("rejects unknown keys to catch typos", () => { - expect(normalizeIssues({ app: {}, framework: "hono" }).join(" ")).toContain( - 'Unknown top-level key "framework"', - ); - expect(normalizeIssues({ app: { htpPort: 8080 } }).join(" ")).toContain( - 'Unknown key "htpPort"', - ); - expect( - normalizeIssues({ app: { env: { files: ".env" } } }).join(" "), - ).toContain('Unknown key "files"'); - }); - - it("validates field values", () => { - const issues = normalizeIssues({ - apps: { - web: { - name: "", - framework: "rails", - httpPort: 0, - root: "../outside", - env: { vars: { EMPTY: "" } }, - }, - }, - }); - - expect(issues.join(" ")).toContain( - "`apps.web.name` must be a non-empty string.", - ); - expect(issues.join(" ")).toContain( - "`apps.web.framework` must be one of: nextjs, nuxt, astro, hono, nestjs, tanstack-start, custom, bun.", - ); - expect(issues.join(" ")).toContain( - "`apps.web.httpPort` must be an integer between 1 and 65535.", - ); - expect(issues.join(" ")).toContain( - "`apps.web.root` must be a relative path inside the repository.", - ); - expect(issues.join(" ")).toContain( - "`apps.web.env.vars.EMPTY` must be a non-empty string.", - ); - }); - - it("rejects roots that escape the config directory, including Windows drive-relative paths", () => { - for (const root of ["C:apps", "C:/apps", "/apps", "..\\apps"]) { - expect(normalizeIssues({ app: { root } }).join(" ")).toContain( - "`app.root` must be a relative path inside the repository.", - ); - } - }); - - it("normalizes build blocks", () => { - const config = normalizeOrThrow( - defineComputeConfig({ - apps: { - web: { - root: "apps/web", - framework: "nextjs", - build: { - command: "pnpm --filter web build", - outputDirectory: ".next/standalone/", - }, - }, - api: { - root: "apps/api", - framework: "hono", - build: { command: null }, - }, - docs: { - root: "apps/docs", - framework: "astro", - build: { - command: "npm run build", - outputDirectory: "dist/server/", - }, - }, - frontend: { - root: "apps/frontend", - framework: "custom", - build: { - command: "npm run build", - outputDirectory: "build", - entrypoint: "handler.js", - }, - }, - }, - }), - ); - - expect(config.targets[0]).toMatchObject({ - build: { - command: "pnpm --filter web build", - outputDirectory: ".next/standalone", - }, - }); - expect(config.targets[1]).toMatchObject({ - build: { - command: null, - outputDirectory: undefined, - entrypoint: undefined, - }, - }); - expect(config.targets[2]).toMatchObject({ - framework: "astro", - build: { - command: "npm run build", - outputDirectory: "dist/server", - entrypoint: undefined, - }, - }); - expect(config.targets[3]).toMatchObject({ - framework: "custom", - build: { - command: "npm run build", - outputDirectory: "build", - entrypoint: "handler.js", - }, - }); - }); - - it("validates build blocks", () => { - const issues = normalizeIssues({ - apps: { - web: { - build: { command: "", outputDir: ".next" }, - db: true, - }, - api: { - build: {}, - }, - }, - }); - - expect(issues.join(" ")).toContain( - "`apps.web.build.command` must be a non-empty string, or null to skip the build step.", - ); - expect(issues.join(" ")).toContain( - 'Unknown key "outputDir" in `apps.web.build`.', - ); - expect(issues.join(" ")).toContain('Unknown key "db" in `apps.web`.'); - expect(issues.join(" ")).toContain( - "`apps.api.build` must set `command`, `outputDirectory`, and/or `entrypoint`.", - ); - }); - - it("rejects entry with frameworks that derive entrypoints from build output", () => { - const issues = normalizeIssues({ - app: { framework: "nextjs", entry: "src/index.ts" }, - }); - expect(issues.join(" ")).toContain( - "`app.entry` is not supported with the nextjs framework", - ); - - expect( - normalizeOrThrow({ app: { framework: "hono", entry: "src/index.ts" } }) - .targets[0]?.entry, - ).toBe("src/index.ts"); - }); -}); - -describe("selectComputeDeployTarget", () => { - const single = normalizeOrThrow({ app: { name: "api" } }); - const multi = normalizeOrThrow({ - apps: { - web: { root: "apps/web" }, - worker: { root: "apps/worker" }, - }, - }); - - it("returns the single app without a target argument", () => { - expect(selectComputeDeployTarget(single, undefined).unwrap().name).toBe( - "api", - ); - }); - - it("accepts a target argument matching the single app name", () => { - expect(selectComputeDeployTarget(single, "api").unwrap().name).toBe("api"); - }); - - it("rejects a target argument that does not match the single app", () => { - const result = selectComputeDeployTarget(single, "web"); - expect(result.isErr() && result.error).toBeInstanceOf( - ComputeConfigTargetUnknownError, - ); - }); - - it("requires a target when multiple apps are configured", () => { - const result = selectComputeDeployTarget(multi, undefined); - const error = result.isErr() ? result.error : undefined; - expect(error).toBeInstanceOf(ComputeConfigTargetRequiredError); - expect(error).toMatchObject({ availableTargets: ["web", "worker"] }); - }); - - it("selects a multi-app target by key", () => { - expect(selectComputeDeployTarget(multi, "worker").unwrap().root).toBe( - "apps/worker", - ); - }); - - it("defaults to the only app of a single-entry apps map", () => { - const oneEntry = normalizeOrThrow({ apps: { web: { root: "apps/web" } } }); - expect(selectComputeDeployTarget(oneEntry, undefined).unwrap().key).toBe( - "web", - ); - }); - - it("rejects unknown multi-app targets", () => { - const result = selectComputeDeployTarget(multi, "docs"); - expect(result.isErr() && result.error).toBeInstanceOf( - ComputeConfigTargetUnknownError, - ); - }); -}); - -describe("loadComputeConfig", () => { - const tempDirs: string[] = []; - - async function createTempDir(): Promise { - const dir = await mkdtemp(path.join(os.tmpdir(), "prisma-compute-config-")); - tempDirs.push(dir); - return dir; - } - - afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => rm(dir, { recursive: true, force: true })), - ); - }); - - it("returns null when no config file exists", async () => { - const dir = await createTempDir(); - expect((await loadComputeConfig(dir)).unwrap()).toBeNull(); - }); - - it("loads a TypeScript config that imports @prisma/compute-sdk/config", async () => { - const dir = await createTempDir(); - await writeFile( - path.join(dir, "prisma.compute.ts"), - [ - 'import { defineComputeConfig } from "@prisma/compute-sdk/config";', - "", - "export default defineComputeConfig({", - ' app: { name: "api", framework: "hono", httpPort: 8080 satisfies number },', - "});", - "", - ].join("\n"), - "utf8", - ); - - const config = (await loadComputeConfig(dir)).unwrap(); - expect(config?.kind).toBe("single"); - expect(config?.targets[0]).toMatchObject({ - name: "api", - framework: "hono", - httpPort: 8080, - }); - }); - - it("loads a plain JavaScript config", async () => { - const dir = await createTempDir(); - await mkdir(path.join(dir, "apps/web"), { recursive: true }); - await writeFile( - path.join(dir, "prisma.compute.mjs"), - [ - "export default {", - ' apps: { web: { root: "apps/web", framework: "nextjs" } },', - "};", - "", - ].join("\n"), - "utf8", - ); - - const config = (await loadComputeConfig(dir)).unwrap(); - expect(config?.kind).toBe("multi"); - expect(config?.targets[0]).toMatchObject({ - key: "web", - root: "apps/web", - framework: "nextjs", - }); - }); - - it("returns a load error for configs that fail to evaluate", async () => { - const dir = await createTempDir(); - await writeFile( - path.join(dir, "prisma.compute.ts"), - "export default {", - "utf8", - ); - - const result = await loadComputeConfig(dir); - expect(result.isErr() && result.error).toBeInstanceOf( - ComputeConfigLoadError, - ); - }); - - it("returns an invalid error for configs without a default export", async () => { - const dir = await createTempDir(); - await writeFile( - path.join(dir, "prisma.compute.ts"), - 'export const app = { name: "api" };\n', - "utf8", - ); - - const result = await loadComputeConfig(dir); - expect(result.isErr() && result.error).toBeInstanceOf( - ComputeConfigInvalidError, - ); - }); - - it("rejects multiple coexisting config files", async () => { - const dir = await createTempDir(); - await writeFile( - path.join(dir, "prisma.compute.ts"), - "export default { app: {} };\n", - "utf8", - ); - await writeFile( - path.join(dir, "prisma.compute.js"), - "export default { app: {} };\n", - "utf8", - ); - - const result = await loadComputeConfig(dir); - expect(result.isErr() && result.error).toBeInstanceOf( - ComputeConfigAmbiguousError, - ); - }); - - it("discovers the config upward from a nested directory inside a repository", async () => { - const dir = await createTempDir(); - await mkdir(path.join(dir, ".git"), { recursive: true }); - await mkdir(path.join(dir, "apps", "api", "src"), { recursive: true }); - await writeFile( - path.join(dir, "prisma.compute.ts"), - 'export default { apps: { api: { root: "apps/api" } } };\n', - "utf8", - ); - - const config = ( - await loadComputeConfig(path.join(dir, "apps", "api", "src")) - ).unwrap(); - expect(config?.configDir).toBe(dir); - expect(config?.targets[0]?.key).toBe("api"); - }); - - it("prefers the nearest config over an ancestor config", async () => { - const dir = await createTempDir(); - await mkdir(path.join(dir, ".git"), { recursive: true }); - await mkdir(path.join(dir, "apps", "api"), { recursive: true }); - await writeFile( - path.join(dir, "prisma.compute.ts"), - 'export default { app: { name: "root" } };\n', - "utf8", - ); - await writeFile( - path.join(dir, "apps", "api", "prisma.compute.ts"), - 'export default { app: { name: "nested" } };\n', - "utf8", - ); - - const config = ( - await loadComputeConfig(path.join(dir, "apps", "api")) - ).unwrap(); - expect(config?.targets[0]?.name).toBe("nested"); - expect(config?.configDir).toBe(path.join(dir, "apps", "api")); - }); - - it("does not search above the repository root", async () => { - const dir = await createTempDir(); - const repo = path.join(dir, "repo"); - await mkdir(path.join(repo, ".git"), { recursive: true }); - await mkdir(path.join(repo, "app"), { recursive: true }); - // A config above the repository boundary must never be picked up. - await writeFile( - path.join(dir, "prisma.compute.ts"), - 'export default { app: { name: "outside" } };\n', - "utf8", - ); - - expect( - (await loadComputeConfig(path.join(repo, "app"))).unwrap(), - ).toBeNull(); - }); - - it("does not walk upward without a repository boundary", async () => { - const dir = await createTempDir(); - await mkdir(path.join(dir, "nested"), { recursive: true }); - await writeFile( - path.join(dir, "prisma.compute.ts"), - 'export default { app: { name: "parent" } };\n', - "utf8", - ); - - // No .git or workspace marker anywhere: only the invocation directory is checked. - expect( - (await loadComputeConfig(path.join(dir, "nested"))).unwrap(), - ).toBeNull(); - }); - - it("observes config file edits across repeated loads", async () => { - const dir = await createTempDir(); - const configPath = path.join(dir, "prisma.compute.ts"); - await writeFile( - configPath, - 'export default { app: { name: "one" } };\n', - "utf8", - ); - expect((await loadComputeConfig(dir)).unwrap()?.targets[0]?.name).toBe( - "one", - ); - - await writeFile( - configPath, - 'export default { app: { name: "two" } };\n', - "utf8", - ); - expect((await loadComputeConfig(dir)).unwrap()?.targets[0]?.name).toBe( - "two", - ); - }); -}); - -describe("compute config discovery and state location", () => { - const tempDirs: string[] = []; - - async function createTempDir(): Promise { - const dir = await mkdtemp( - path.join(os.tmpdir(), "prisma-compute-discovery-"), - ); - tempDirs.push(dir); - return dir; - } - - afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => rm(dir, { recursive: true, force: true })), - ); - }); - - it("locates the nearest config directory without loading the config", async () => { - const { findComputeConfigDir } = await import("@prisma/compute-sdk/config"); - const dir = await createTempDir(); - await mkdir(path.join(dir, ".git"), { recursive: true }); - await mkdir(path.join(dir, "apps", "api"), { recursive: true }); - // Deliberately broken config: location discovery must not evaluate it. - await writeFile( - path.join(dir, "prisma.compute.ts"), - "export default {", - "utf8", - ); - - expect(await findComputeConfigDir(path.join(dir, "apps", "api"))).toBe(dir); - expect(await findComputeConfigDir(dir)).toBe(dir); - }); - - it("returns null without a config or outside the repository boundary", async () => { - const { findComputeConfigDir } = await import("@prisma/compute-sdk/config"); - const dir = await createTempDir(); - const repo = path.join(dir, "repo"); - await mkdir(path.join(repo, ".git"), { recursive: true }); - await mkdir(path.join(repo, "app"), { recursive: true }); - await writeFile( - path.join(dir, "prisma.compute.ts"), - "export default { app: {} };\n", - "utf8", - ); - - expect(await findComputeConfigDir(path.join(repo, "app"))).toBeNull(); - }); - - it("anchors the default state directory at the config directory", async () => { - const { resolveStateDir } = await import("../src/state-dir"); - const dir = await createTempDir(); - const appCwd = path.join(dir, "apps", "api"); - await mkdir(path.join(dir, ".git"), { recursive: true }); - await mkdir(appCwd, { recursive: true }); - await writeFile( - path.join(dir, "prisma.compute.ts"), - "export default { app: {} };\n", - "utf8", - ); - - const runtime = { cwd: appCwd, env: {} } as Parameters< - typeof resolveStateDir - >[0]; - expect(await resolveStateDir(runtime)).toBe( - path.join(dir, ".prisma", "cli"), - ); - - const standaloneRuntime = { - cwd: dir, - env: {}, - stateDir: "/explicit/state", - } as Parameters[0]; - expect(await resolveStateDir(standaloneRuntime)).toBe("/explicit/state"); - }); - - it("keeps the default state directory at the invocation directory without a config", async () => { - const { resolveStateDir } = await import("../src/state-dir"); - const dir = await createTempDir(); - const appCwd = path.join(dir, "apps", "api"); - await mkdir(path.join(dir, ".git"), { recursive: true }); - await mkdir(appCwd, { recursive: true }); - - const runtime = { cwd: appCwd, env: {} } as Parameters< - typeof resolveStateDir - >[0]; - expect(await resolveStateDir(runtime)).toBe( - path.join(appCwd, ".prisma", "cli"), - ); - }); -}); - -describe("inferComputeTargetFromCwd", () => { - function multiConfig( - configDir: string, - apps: Record, - ): LoadedComputeConfig { - const result = normalizeComputeConfig( - { apps }, - path.join(configDir, COMPUTE_CONFIG_FILENAME), - ); - if (result.isErr()) { - throw result.error; - } - return result.value; - } - - const config = multiConfig("/repo", { - api: { root: "apps/api" }, - web: { root: "apps/web" }, - }); - - it("infers the target whose root contains the invocation directory", () => { - expect(inferComputeTargetFromCwd(config, "/repo/apps/api")).toBe("api"); - expect(inferComputeTargetFromCwd(config, "/repo/apps/api/src/routes")).toBe( - "api", - ); - expect(inferComputeTargetFromCwd(config, "/repo/apps/web")).toBe("web"); - }); - - it("infers nothing from the config directory or outside any root", () => { - expect(inferComputeTargetFromCwd(config, "/repo")).toBeUndefined(); - expect( - inferComputeTargetFromCwd(config, "/repo/packages/db"), - ).toBeUndefined(); - }); - - it("picks the deepest root when targets nest", () => { - const nested = multiConfig("/repo", { - all: { root: "apps" }, - api: { root: "apps/api" }, - }); - - expect(inferComputeTargetFromCwd(nested, "/repo/apps/api")).toBe("api"); - expect(inferComputeTargetFromCwd(nested, "/repo/apps/web")).toBe("all"); - }); - - it("infers nothing on an ambiguous tie", () => { - const tied = multiConfig("/repo", { - one: { root: "apps/shared" }, - two: { root: "apps/shared" }, - }); - - expect( - inferComputeTargetFromCwd(tied, "/repo/apps/shared"), - ).toBeUndefined(); - }); - - it("never infers for single-app configs", () => { - const single = normalizeComputeConfig( - { app: { name: "api" } }, - "/repo/prisma.compute.ts", - ); - expect( - inferComputeTargetFromCwd(single.unwrap(), "/repo/anywhere"), - ).toBeUndefined(); - }); -}); - -describe("computeConfigErrorToCliError", () => { - it("maps config errors to structured CliErrors", () => { - const invalid = computeConfigErrorToCliError( - new ComputeConfigInvalidError(CONFIG_PATH, ["bad"]), - "create", - ); - expect(invalid).toBeInstanceOf(CliError); - expect(invalid.code).toBe("COMPUTE_CONFIG_INVALID"); - expect(invalid.exitCode).toBe(2); - - const required = computeConfigErrorToCliError( - new ComputeConfigTargetRequiredError(CONFIG_PATH, ["web", "worker"]), - "create", - ); - expect(required.code).toBe("COMPUTE_CONFIG_TARGET_REQUIRED"); - expect(required.nextSteps).toEqual([ - "prisma-cli service create web", - "prisma-cli service create worker", - ]); - - const unknown = computeConfigErrorToCliError( - new ComputeConfigTargetUnknownError(CONFIG_PATH, "docs", ["web"]), - "create", - ); - expect(unknown.code).toBe("COMPUTE_CONFIG_TARGET_UNKNOWN"); - expect(unknown.summary).toContain('"docs"'); - }); -}); diff --git a/packages/cli/tests/service-compute-config.test.ts b/packages/cli/tests/service-compute-config.test.ts deleted file mode 100644 index 797e30cd..00000000 --- a/packages/cli/tests/service-compute-config.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * The compute-config path that every service command shares. `service - * show` drives it here on behalf of all of them: the config load, the - * `[service]` positional and the config-named service are decided by - * `resolveComputeManagementContext`, which `resolveServiceReadState` and - * `resolveServiceDomainTarget` call with the same arguments, so one - * command proves the code the others run. - * - * The same runs pin the two pieces of error plumbing this path reaches: - * `fromLegacyCliError`, which maps a legacy CliError onto the engine's - * structured shape, and `renameAppCopy`, which keeps the `app` noun out - * of ported copy. The rename's other surface, the domain failure - * guidance, is pinned in service-domain-wait.test.ts. - */ -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import type { StreamEvent } from "@prisma/cli-engine"; -import { describe, expect, it } from "vitest"; - -import { - DEPLOYMENTS, - makeServiceCli, - page, - type RawService, - type Routes, - readFlowRoutes, - SERVICE, - SERVICE_DETAIL, -} from "./service-testkit"; - -const OTHER_SERVICE: RawService = { - ...SERVICE, - id: "svc_2", - name: "api", - latestDeploymentId: null, - appEndpointDomain: null, -}; - -/** Two services, so a run that reaches the picker cannot settle without - * a scripted answer. */ -function twoServiceRoutes(): Routes { - return readFlowRoutes({ - "GET /v1/apps": () => ({ data: page([SERVICE, OTHER_SERVICE]) }), - "GET /v1/apps/{appId}": (init) => - init.params?.path?.appId === OTHER_SERVICE.id - ? { - data: { - data: { - id: OTHER_SERVICE.id, - name: OTHER_SERVICE.name, - projectId: "proj_1", - region: { id: null }, - latestDeploymentId: null, - appEndpointDomain: null, - }, - }, - } - : { data: { data: SERVICE_DETAIL } }, - "GET /v1/apps/{appId}/deployments": (init) => - init.params?.path?.appId === OTHER_SERVICE.id - ? { data: page([]) } - : { data: page(DEPLOYMENTS) }, - }); -} - -async function writeComputeConfig( - cwd: string, - apps: Record>, -): Promise { - await writeFile( - path.join(cwd, "prisma.compute.json"), - JSON.stringify({ apps }), - ); -} - -function settledError(result: { readonly json: readonly StreamEvent[] }) { - const frame = result.json[result.json.length - 1]; - if (frame?.kind !== "result" || frame.envelope.ok) { - throw new Error("expected an errored envelope"); - } - return frame.envelope.error; -} - -describe("prisma-cli service — the compute config", () => { - it("maps an unknown target through the legacy error mapper, in service prose", async () => { - const harness = await makeServiceCli(); - await writeComputeConfig(harness.cwd, { - web: { framework: "nextjs" }, - api: { framework: "hono" }, - }); - - const result = await harness.cli.run( - ["service", "show", "nope", "--project", "acme-app", "--json"], - { cwd: harness.cwd, env: harness.env }, - ); - - expect(result.exitCode).toBe(2); - const error = settledError(result); - expect(error.code).toBe("SERVICE.COMPUTE_CONFIG_TARGET_UNKNOWN"); - expect(error.summary).toBe('Unknown service target "nope"'); - // The legacy `fix` survives as advice, ahead of a run-command per - // configured target built from the legacy nextSteps. - expect(error.nextActions).toEqual([ - { - kind: "user-choice", - label: "Pass one of the configured targets: web, api.", - }, - { - kind: "run-command", - label: "Run", - command: "prisma-cli service show web", - }, - { - kind: "run-command", - label: "Run", - command: "prisma-cli service show api", - }, - ]); - expect(error.meta).toMatchObject({ - requestedTarget: "nope", - availableTargets: ["web", "api"], - }); - const serialized = JSON.stringify(error); - expect(serialized).not.toContain("app target"); - expect(serialized).not.toContain("prisma-cli app "); - }); - - it("rejects a named target when the directory has no compute config", async () => { - const harness = await makeServiceCli(); - - const result = await harness.cli.run( - ["service", "show", "web", "--project", "acme-app", "--json"], - { cwd: harness.cwd, env: harness.env }, - ); - - expect(result.exitCode).toBe(2); - const error = settledError(result); - expect(error.code).toBe("SERVICE.COMPUTE_CONFIG_TARGET_UNKNOWN"); - expect(error.summary).toBe( - 'Service target "web" requires a compute config file', - ); - }); - - it("selects the service the config target names, without a picker", async () => { - const harness = await makeServiceCli({ routes: twoServiceRoutes() }); - await writeComputeConfig(harness.cwd, { - web: { framework: "nextjs", name: SERVICE.name }, - api: { framework: "hono" }, - }); - - // No scripted answers: reaching the picker would fail this run. - const result = await harness.cli.run( - ["service", "show", "web", "--project", "acme-app"], - { cwd: harness.cwd, env: harness.env }, - ); - - expect(result.exitCode).toBe(0); - expect(result.presented?.data).toMatchObject({ - service: { id: SERVICE.id, name: SERVICE.name }, - }); - }); - - it("prefers the service the config names over the remembered selection", async () => { - const harness = await makeServiceCli({ routes: twoServiceRoutes() }); - await writeComputeConfig(harness.cwd, { - web: { framework: "nextjs", name: SERVICE.name }, - api: { framework: "hono" }, - }); - - const remembering = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "api"], - { cwd: harness.cwd, env: harness.env }, - ); - expect(remembering.exitCode).toBe(0); - expect(remembering.presented?.data).toMatchObject({ - service: { id: OTHER_SERVICE.id }, - }); - - const result = await harness.cli.run( - ["service", "show", "web", "--project", "acme-app"], - { cwd: harness.cwd, env: harness.env }, - ); - - expect(result.exitCode).toBe(0); - expect(result.presented?.data).toMatchObject({ - service: { id: SERVICE.id, name: SERVICE.name }, - }); - }); - - it("settles an unusable config file through the mapper, naming the file", async () => { - const harness = await makeServiceCli(); - await writeComputeConfig(harness.cwd, { web: { framework: "cobol" } }); - - const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--json"], - { cwd: harness.cwd, env: harness.env }, - ); - - expect(result.exitCode).toBe(2); - const error = settledError(result); - expect(error.code).toBe("SERVICE.COMPUTE_CONFIG_INVALID"); - expect(error.summary).toBe("Invalid prisma.compute.json"); - expect(error.where?.path).toBe( - path.join(harness.cwd, "prisma.compute.json"), - ); - // The config's own keys are the SDK's, so the rename leaves the - // `app` in `defineComputeConfig({ app })` alone. - expect(error.nextActions).toEqual([ - { - kind: "user-choice", - label: - "Edit prisma.compute.json so it default-exports defineComputeConfig({ app }) or defineComputeConfig({ apps }).", - }, - { - kind: "run-command", - label: "Run", - command: "prisma-cli service show", - }, - ]); - expect(JSON.stringify(error)).not.toContain("prisma-cli app "); - }); -}); From 54fcf21e420525e04b30515226caa7be863e2506 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:35:02 +0200 Subject: [PATCH 03/27] Drop imports and formatting left by the compute-config removal Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/agent/status.ts | 10 ++++++---- packages/cli/src/commands/service/deployment-list.ts | 2 +- .../cli/src/commands/service/deployment-rollback.ts | 2 +- packages/cli/src/commands/service/errors.ts | 1 - packages/cli/src/commands/service/logs.ts | 2 +- packages/cli/src/commands/service/open.ts | 2 +- packages/cli/src/commands/service/remove.ts | 2 +- packages/cli/src/commands/service/show.ts | 2 +- packages/cli/src/lib/agent/setup-status.ts | 6 +----- packages/cli/src/lib/app/app-provider.ts | 2 -- packages/cli/tests/agent.test.ts | 2 +- packages/cli/tests/app-provider.test.ts | 1 - 12 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/agent/status.ts b/packages/cli/src/commands/agent/status.ts index 97c68eca..83d9379d 100644 --- a/packages/cli/src/commands/agent/status.ts +++ b/packages/cli/src/commands/agent/status.ts @@ -4,9 +4,7 @@ import { ok } from "@prisma/cli-engine/protocol"; import { LocalStateStore } from "../../adapters/local-state"; import { resolvePrismaCliPackageCommand } from "../../lib/agent/cli-command"; import { PRISMA_AGENT_INSTALL_ARGS } from "../../lib/agent/constants"; -import { - readPrismaAgentSetupStatus, -} from "../../lib/agent/setup-status"; +import { readPrismaAgentSetupStatus } from "../../lib/agent/setup-status"; import { formatShellCommand } from "../../shell-command"; import { resolveStateDir } from "../../state-dir"; import { statusPresentations } from "./presentation"; @@ -74,7 +72,11 @@ export const agentStatusCommand = defineCommand({ stateStore: await openStateStore(ctx), signal: ctx.signal, }); - const skillsList = await listInstalledPrismaSkills(ctx, ctx.cwd, statusScope); + const skillsList = await listInstalledPrismaSkills( + ctx, + ctx.cwd, + statusScope, + ); const skillsInstalled = skillsList.status === "ok" ? skillsList.skills.length > 0 diff --git a/packages/cli/src/commands/service/deployment-list.ts b/packages/cli/src/commands/service/deployment-list.ts index 9f8525c6..7dd4e1b9 100644 --- a/packages/cli/src/commands/service/deployment-list.ts +++ b/packages/cli/src/commands/service/deployment-list.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError } from "./errors"; import { deploymentListPresentations } from "./presentation"; diff --git a/packages/cli/src/commands/service/deployment-rollback.ts b/packages/cli/src/commands/service/deployment-rollback.ts index 6eab958a..f7ce8276 100644 --- a/packages/cli/src/commands/service/deployment-rollback.ts +++ b/packages/cli/src/commands/service/deployment-rollback.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; import { diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index ed98cd75..fe95ed3e 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -761,4 +761,3 @@ function extractDomainDnsTarget(error: DomainApiError): string | null { const match = PRISMA_BUILD_HOST.exec(text); return match?.[1]?.toLowerCase() ?? null; } - diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index 3a55a5fe..2dab92ac 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -1,5 +1,5 @@ import type { CommandContext } from "@prisma/cli-engine"; -import { defineSessionCommand, flag, positional } from "@prisma/cli-engine"; +import { defineSessionCommand, flag } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import type { AppProvider, AppRecord } from "../../lib/app/app-provider"; import { forEachNdjsonRecord } from "../../lib/ndjson"; diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index 8cce6c6a..dab3a877 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, diff --git a/packages/cli/src/commands/service/remove.ts b/packages/cli/src/commands/service/remove.ts index 12d026e8..5b10888e 100644 --- a/packages/cli/src/commands/service/remove.ts +++ b/packages/cli/src/commands/service/remove.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; import type { LocalStateStore } from "../../adapters/local-state"; diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index ce96ddbe..a5d2e066 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, flag } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, runCommandAction } from "./errors"; import { showPresentations } from "./presentation"; diff --git a/packages/cli/src/lib/agent/setup-status.ts b/packages/cli/src/lib/agent/setup-status.ts index d0fabc15..ad964155 100644 --- a/packages/cli/src/lib/agent/setup-status.ts +++ b/packages/cli/src/lib/agent/setup-status.ts @@ -44,11 +44,7 @@ export async function isLikelyProjectDirectory(options: { cwd: string; signal: AbortSignal; }): Promise { - const signals = [ - "package.json", - "prisma.config.ts", - ".git", - ]; + const signals = ["package.json", "prisma.config.ts", ".git"]; return ( await Promise.all( diff --git a/packages/cli/src/lib/app/app-provider.ts b/packages/cli/src/lib/app/app-provider.ts index 356985f2..f23e2b0f 100644 --- a/packages/cli/src/lib/app/app-provider.ts +++ b/packages/cli/src/lib/app/app-provider.ts @@ -56,7 +56,6 @@ export interface DeploymentRecord { live: boolean | null; } - export interface EnvRecord { projectId: string; app: AppRecord; @@ -1070,7 +1069,6 @@ function normalizeHostnameForComparison(hostname: string): string { return hostname.trim().replace(/\.$/, "").toLowerCase(); } - function apiCallError( summary: string, response: Response, diff --git a/packages/cli/tests/agent.test.ts b/packages/cli/tests/agent.test.ts index f0fcbf21..cd39ff93 100644 --- a/packages/cli/tests/agent.test.ts +++ b/packages/cli/tests/agent.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { createTestCli } from "@prisma/cli-engine/testing"; import { execa } from "execa"; diff --git a/packages/cli/tests/app-provider.test.ts b/packages/cli/tests/app-provider.test.ts index d8c6152a..52e8c969 100644 --- a/packages/cli/tests/app-provider.test.ts +++ b/packages/cli/tests/app-provider.test.ts @@ -47,7 +47,6 @@ describe("preview app provider", () => { expect(client.POST).not.toHaveBeenCalled(); }); - it("treats re-adding an existing custom domain as idempotent", async () => { const client = { GET: vi.fn().mockImplementation((pathName: string) => { From 5000aa63ec40dc711eb3e3a3b65f6e5b928d4c31 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:40:02 +0200 Subject: [PATCH 04/27] Point pre-commit verification at pnpm typecheck pnpm --recursive exec tsc --noEmit fails on packages/tsconfig, which has no typescript dependency. The root typecheck script (turbo run typecheck, per-package tsc --noEmit) already covers every TypeScript package. Signed-off-by: willbot Signed-off-by: Will Madden --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0641d56f..6002d150 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Why this rule exists: `prisma project list` reported "No projects found." and ex ## Pre-Commit Verification -- `pnpm --recursive exec tsc --noEmit` +- `pnpm typecheck` - `pnpm lint` - Package-specific tests for changed packages - `pnpm --filter @prisma/cli test` From cb3e0f8abaa32fee7e9f0c34399ec8e650bee661 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:50:57 +0200 Subject: [PATCH 05/27] Service commands take parameters only Every service command that acts on an existing service now resolves it from --service (by name) or PRISMA_SERVICE_ID (by id, the domain-flow mechanics generalized), with the flag winning. Neither present settles SERVICE.TARGET_REQUIRED at exit 2, interactive terminals included: the interactive picker, the saved selection (rememberSelectedService, LocalStateStore.readSelectedApp/setSelectedApp/clearSelectedApp, the selectedByProject state shape), and service remove's selection cleanup are gone. Branch targeting is --branch only: the git-branch inference is deleted, read flows keep their "main" default and the domain flow keeps "production" with its production-only check. lib/git/local-branch.ts stays: controllers/app-env.ts (project env scope resolution, outside this change's scope) still imports it. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/adapters/local-state.ts | 39 ---- packages/cli/src/commands/service/create.ts | 10 - .../src/commands/service/deployment-delete.ts | 8 +- .../src/commands/service/deployment-list.ts | 20 +- .../commands/service/deployment-promote.ts | 12 +- .../commands/service/deployment-rollback.ts | 12 +- .../commands/service/deployment-run-state.ts | 8 +- packages/cli/src/commands/service/errors.ts | 41 ++-- packages/cli/src/commands/service/logs.ts | 129 ++++++----- packages/cli/src/commands/service/open.ts | 16 +- packages/cli/src/commands/service/release.ts | 9 +- packages/cli/src/commands/service/remove.ts | 9 +- packages/cli/src/commands/service/show.ts | 20 +- packages/cli/src/commands/service/target.ts | 209 +++++++----------- packages/cli/tests/app-state.test.ts | 73 ------ packages/cli/tests/service-create.test.ts | 12 +- .../cli/tests/service-deployment-list.test.ts | 20 +- .../tests/service-deployment-promote.test.ts | 22 +- .../tests/service-deployment-rollback.test.ts | 4 +- packages/cli/tests/service-domain.test.ts | 25 ++- packages/cli/tests/service-open.test.ts | 23 +- packages/cli/tests/service-remove.test.ts | 28 ++- packages/cli/tests/service-session.test.ts | 10 +- packages/cli/tests/service-show.test.ts | 76 ++++--- 24 files changed, 290 insertions(+), 545 deletions(-) diff --git a/packages/cli/src/adapters/local-state.ts b/packages/cli/src/adapters/local-state.ts index 5d7f2bdf..38cd8869 100644 --- a/packages/cli/src/adapters/local-state.ts +++ b/packages/cli/src/adapters/local-state.ts @@ -19,7 +19,6 @@ export interface LocalState { active: string; }; app: { - selectedByProject: Record; knownLiveDeploymentByProject: Record>; }; agent: { @@ -27,11 +26,6 @@ export interface LocalState { }; } -export interface SelectedAppState { - id: string; - name: string; -} - export interface RememberedProjectState { id: string; name: string; @@ -49,7 +43,6 @@ const DEFAULT_STATE: LocalState = { active: "preview", }, app: { - selectedByProject: {}, knownLiveDeploymentByProject: {}, }, agent: { @@ -93,7 +86,6 @@ export class LocalStateStore { active: parsed.branch?.active ?? DEFAULT_STATE.branch.active, }, app: { - selectedByProject: parsed.app?.selectedByProject ?? {}, knownLiveDeploymentByProject: parsed.app?.knownLiveDeploymentByProject ?? {}, }, @@ -190,37 +182,6 @@ export class LocalStateStore { return state; } - async readSelectedApp(projectId: string): Promise { - const state = await this.read(); - return state.app.selectedByProject[projectId] ?? null; - } - - async setSelectedApp( - projectId: string, - app: SelectedAppState, - ): Promise { - const state = await this.read(); - state.app.selectedByProject[projectId] = app; - await this.write(state); - return state; - } - - async clearSelectedApp( - projectId: string, - appId: string, - ): Promise { - const state = await this.read(); - const selectedApp = state.app.selectedByProject[projectId]; - - if (!selectedApp || selectedApp.id !== appId) { - return state; - } - - delete state.app.selectedByProject[projectId]; - await this.write(state); - return state; - } - async readKnownLiveDeployment( projectId: string, appId: string, diff --git a/packages/cli/src/commands/service/create.ts b/packages/cli/src/commands/service/create.ts index ecc079af..81a0a118 100644 --- a/packages/cli/src/commands/service/create.ts +++ b/packages/cli/src/commands/service/create.ts @@ -8,8 +8,6 @@ import { import { createPresentations } from "./presentation"; import type { ServiceCreateResult } from "./results"; import { - openServiceStateStore, - rememberSelectedService, resolveServiceProjectContext, serviceProvider, toServiceListEntry, @@ -75,14 +73,6 @@ export const serviceCreateCommand = defineCommand({ ]); }); - // A just-created service is the one later commands should act on. - const stateStore = await openServiceStateStore(ctx); - await rememberSelectedService( - stateStore, - target.project.id, - created.service, - ); - const result: ServiceCreateResult = { projectId: target.project.id, branch: target.branch.name, diff --git a/packages/cli/src/commands/service/deployment-delete.ts b/packages/cli/src/commands/service/deployment-delete.ts index a6608198..8e22ef9a 100644 --- a/packages/cli/src/commands/service/deployment-delete.ts +++ b/packages/cli/src/commands/service/deployment-delete.ts @@ -11,7 +11,7 @@ import { resolveServiceReleaseState, } from "./release"; import type { ServiceDeploymentDeleteResult } from "./results"; -import { rememberSelectedService, toServiceSummary } from "./target"; +import { toServiceSummary } from "./target"; export const serviceDeploymentDeleteCommand = defineCommand({ help: { @@ -57,12 +57,6 @@ export const serviceDeploymentDeleteCommand = defineCommand({ state.service.name, ); - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - const granted = await ctx.prompt.consent( `Delete deployment "${targetDeployment.id}" from Service "${state.service.name}"?`, { token: targetDeployment.id }, diff --git a/packages/cli/src/commands/service/deployment-list.ts b/packages/cli/src/commands/service/deployment-list.ts index 7dd4e1b9..0a33e0ca 100644 --- a/packages/cli/src/commands/service/deployment-list.ts +++ b/packages/cli/src/commands/service/deployment-list.ts @@ -5,7 +5,6 @@ import { deploymentListPresentations } from "./presentation"; import type { ServiceDeploymentListResult } from "./results"; import { applyLiveDeploymentHint, - rememberSelectedService, resolveCurrentLiveDeploymentId, resolveServiceReadState, sortDeploymentsNewestFirst, @@ -40,19 +39,8 @@ export const serviceDeploymentListCommand = defineCommand({ commandName: "service deployment list", }); - if (!state.selected) { - const result: ServiceDeploymentListResult = { - projectId: state.projectId, - service: null, - deployments: [], - }; - return ok( - ctx.present({ data: result }, deploymentListPresentations(result)), - ); - } - const deploymentsResult = await state.provider - .listDeployments(state.selected.id, { signal: ctx.signal }) + .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError( "Failed to list service deployments", @@ -71,12 +59,6 @@ export const serviceDeploymentListCommand = defineCommand({ ), ); - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - const result: ServiceDeploymentListResult = { projectId: state.projectId, service: toServiceSummary(deploymentsResult.app), diff --git a/packages/cli/src/commands/service/deployment-promote.ts b/packages/cli/src/commands/service/deployment-promote.ts index c38ab6d5..2744d6fa 100644 --- a/packages/cli/src/commands/service/deployment-promote.ts +++ b/packages/cli/src/commands/service/deployment-promote.ts @@ -9,11 +9,7 @@ import { resolveServiceReleaseState, } from "./release"; import type { ServicePromoteResult } from "./results"; -import { - rememberSelectedService, - resolveCurrentLiveDeploymentId, - toServiceSummary, -} from "./target"; +import { resolveCurrentLiveDeploymentId, toServiceSummary } from "./target"; export const serviceDeploymentPromoteCommand = defineCommand({ help: { @@ -65,12 +61,6 @@ export const serviceDeploymentPromoteCommand = defineCommand({ ); const alreadyLive = currentLiveDeploymentId === targetDeployment.id; - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - if (!alreadyLive) { ctx.report({ kind: "step-started", step: "promote" }); try { diff --git a/packages/cli/src/commands/service/deployment-rollback.ts b/packages/cli/src/commands/service/deployment-rollback.ts index f7ce8276..46bedde3 100644 --- a/packages/cli/src/commands/service/deployment-rollback.ts +++ b/packages/cli/src/commands/service/deployment-rollback.ts @@ -14,11 +14,7 @@ import { resolveServiceReleaseState, } from "./release"; import type { ServiceRollbackResult } from "./results"; -import { - rememberSelectedService, - resolveCurrentLiveDeploymentId, - toServiceSummary, -} from "./target"; +import { resolveCurrentLiveDeploymentId, toServiceSummary } from "./target"; export const serviceDeploymentRollbackCommand = defineCommand({ help: { @@ -87,12 +83,6 @@ export const serviceDeploymentRollbackCommand = defineCommand({ const alreadyLive = currentLiveDeploymentId === targetDeployment.id; - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - if (!alreadyLive) { ctx.report({ kind: "step-started", step: "rollback" }); try { diff --git a/packages/cli/src/commands/service/deployment-run-state.ts b/packages/cli/src/commands/service/deployment-run-state.ts index 9c9ac406..2ae3bcd6 100644 --- a/packages/cli/src/commands/service/deployment-run-state.ts +++ b/packages/cli/src/commands/service/deployment-run-state.ts @@ -10,7 +10,7 @@ import { } from "./release"; import type { ServiceDeploymentRunStateResult } from "./results"; import type { ServiceContext } from "./target"; -import { rememberSelectedService, toServiceSummary } from "./target"; +import { toServiceSummary } from "./target"; /** * `start` and `stop` are the same command with the direction reversed, @@ -76,12 +76,6 @@ export async function changeDeploymentRunState( ); const alreadyInState = targetDeployment.status === spec.settledStatus; - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - let observed = targetDeployment; if (!alreadyInState) { ctx.report({ kind: "step-started", step: verb }); diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index fe95ed3e..1ca032aa 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -119,10 +119,8 @@ export function serviceSelectionInvalidError( { why: `The service "${serviceName}" could not be found in resolved project "${projectId}".`, nextActions: [ - adviceAction( - "Pass the name of an existing service, or rerun the command in a TTY to choose one.", - ), - // Not `service deployment list`: that command has to select a + adviceAction("Pass --service with the name of an existing service."), + // Not `service deployment list`: that command has to resolve a // service before it can list anything, so it fails the same way. runCommandAction("List services", "service list"), ], @@ -272,21 +270,20 @@ export function deploymentNotFoundForServiceError( ); } -/** promote / rollback / remove need a service that already exists. */ -export function releaseTargetRequiredError( +/** Every command that acts on an existing service needs its target + * named explicitly; nothing is inferred, remembered, or prompted for. */ +export function serviceTargetRequiredError( commandName: string, ): CliStructuredError { return new CliStructuredError( "SERVICE.TARGET_REQUIRED", - `Command "${commandName}" requires an existing service`, + `Command "${commandName}" requires --service`, { - why: "The resolved project does not have a service that can be selected for this command.", + why: "Service commands act only on an explicitly named service: pass --service , or set PRISMA_SERVICE_ID to a service id.", nextActions: [ - adviceAction( - `Deploy a service first, or rerun "${commandName}" with --service once a service exists.`, - ), - // Not `service deployment list`: it selects a service first, so - // it cannot help a run that could not select one. + adviceAction("Pass --service ."), + // Not `service deployment list`: it resolves a service first, so + // it cannot help a run that could not resolve one. runCommandAction("List services", "service list"), ], }, @@ -432,17 +429,6 @@ export function domainNotFoundError(hostname: string): CliStructuredError { ); } -export function domainTargetRequiredError(): CliStructuredError { - return new CliStructuredError( - "SERVICE.DOMAIN_TARGET_REQUIRED", - "Custom domain requires an existing service on the production branch", - { - why: "The resolved production branch does not have a service that can receive a custom domain.", - nextActions: [runCommandAction("Inspect the service", "service show")], - }, - ); -} - export function selectedServiceMissingError( envVarName: string, serviceId: string, @@ -450,13 +436,12 @@ export function selectedServiceMissingError( ): CliStructuredError { return new CliStructuredError( "SERVICE.SELECTION_INVALID", - "Selected service does not exist in the resolved production branch", + "Selected service does not exist in the resolved project", { why: `The service "${serviceId}" from ${envVarName} could not be found in resolved project "${projectId}".`, nextActions: [ - adviceAction( - `Unset ${envVarName}, pass --service , or deploy the service on the production branch.`, - ), + adviceAction(`Unset ${envVarName}, or pass --service .`), + runCommandAction("List services", "service list"), ], }, ); diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index 2dab92ac..6d928aad 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -15,12 +15,16 @@ import { } from "./errors"; import { requireDeploymentForService } from "./release"; import type { ServiceDeploymentSummary } from "./results"; -import type { ServiceContext, ServiceReadState } from "./target"; +import type { + ServiceContext, + ServiceProjectState, + ServiceReadState, +} from "./target"; import { applyLiveDeploymentHint, listServices, - rememberSelectedService, resolveCurrentLiveDeploymentId, + resolveServiceProjectState, resolveServiceReadState, } from "./target"; @@ -167,40 +171,35 @@ function listDeployments( }); } -/** `--deployment `: the id is global, so it is resolved directly and - * then checked against the resolved project — a deployment that exists - * but belongs elsewhere is reported as its own failure. */ -async function resolveExplicitDeployment( +/** `--deployment ` with a service target: the id must belong to + * the resolved service. */ +async function resolveDeploymentInService( ctx: ServiceContext, state: ServiceReadState, - serviceName: string | undefined, deploymentId: string, ): Promise { - if (serviceName) { - if (!state.selected) { - throw noDeploymentsError( - "No deployments available to read logs from", - "The resolved project does not have any deployed service yet.", - ); - } - const deploymentsResult = await listDeployments( - ctx, - state.provider, - state.selected.id, - ); - const deployment = requireDeploymentForService( - deploymentsResult.deployments, - deploymentId, - state.selected.name, - ); - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - return { service: deploymentsResult.app, deployment }; - } + const deploymentsResult = await listDeployments( + ctx, + state.provider, + state.service.id, + ); + const deployment = requireDeploymentForService( + deploymentsResult.deployments, + deploymentId, + state.service.name, + ); + return { service: deploymentsResult.app, deployment }; +} +/** `--deployment ` without a service target: the id is global, so + * it is resolved directly and then checked against the resolved + * project — a deployment that exists but belongs elsewhere is reported + * as its own failure. */ +async function resolveGlobalDeployment( + ctx: ServiceContext, + state: ServiceProjectState, + deploymentId: string, +): Promise { const shown = await state.provider .showDeployment(deploymentId, { signal: ctx.signal }) .catch((error) => { @@ -226,26 +225,18 @@ async function resolveExplicitDeployment( throw deploymentOutsideProjectError(deploymentId); } - await rememberSelectedService(state.stateStore, state.projectId, owning); return { service: owning, deployment: shown.deployment }; } -/** No `--deployment`: read whatever is live for the selected service. */ +/** No `--deployment`: read whatever is live for the resolved service. */ async function resolveLiveDeployment( ctx: ServiceContext, state: ServiceReadState, ): Promise { - if (!state.selected) { - throw noDeploymentsError( - "No deployments available to read logs from", - "The resolved project does not have any deployed service yet.", - ); - } - const deploymentsResult = await listDeployments( ctx, state.provider, - state.selected.id, + state.service.id, ); const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( deploymentsResult.app, @@ -261,12 +252,6 @@ async function resolveLiveDeployment( ) ?? null) : null; - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - if (!deployment) { throw noDeploymentsError( "No deployments available to read logs from", @@ -434,30 +419,42 @@ export const serviceLogsCommand = defineSessionCommand({ throw logsRangeConflictError(); } - // Naming a service decides whether an explicit deployment id is - // looked up within that service or resolved globally, and a global - // lookup needs no service selection at all (so it never prompts). - const serviceNamed = args.flags.service; - const resolveGlobally = Boolean(args.flags.deployment) && !serviceNamed; - const state = await resolveServiceReadState(ctx, { - ...(args.flags.service !== undefined - ? { serviceName: args.flags.service } - : {}), + // A globally-unique deployment id is a complete target on its own, + // so `--deployment` without `--service` skips service resolution + // and checks the deployment against the resolved project instead. + const explicitDeploymentId = args.flags.deployment; + const projectOptions = { ...(args.flags.project !== undefined ? { projectRef: args.flags.project } : {}), commandName: "service logs", - skipSelection: resolveGlobally, - }); + }; - const target = args.flags.deployment - ? await resolveExplicitDeployment( - ctx, - state, - serviceNamed, - args.flags.deployment, - ) - : await resolveLiveDeployment(ctx, state); + let state: ServiceProjectState; + let target: LogTarget; + if ( + explicitDeploymentId !== undefined && + args.flags.service === undefined + ) { + state = await resolveServiceProjectState(ctx, projectOptions); + target = await resolveGlobalDeployment(ctx, state, explicitDeploymentId); + } else { + const readState = await resolveServiceReadState(ctx, { + ...(args.flags.service !== undefined + ? { serviceName: args.flags.service } + : {}), + ...projectOptions, + }); + state = readState; + target = + explicitDeploymentId !== undefined + ? await resolveDeploymentInService( + ctx, + readState, + explicitDeploymentId, + ) + : await resolveLiveDeployment(ctx, readState); + } const deploymentId = target.deployment.id; for (const line of [ diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index dab3a877..07918219 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -10,7 +10,6 @@ import { openPresentations } from "./presentation"; import type { ServiceOpenResult } from "./results"; import { applyLiveDeploymentHint, - rememberSelectedService, resolveCurrentLiveDeploymentId, resolveServiceReadState, sortDeploymentsNewestFirst, @@ -42,15 +41,8 @@ export const serviceOpenCommand = defineCommand({ commandName: "service open", }); - if (!state.selected) { - throw noDeploymentsError( - "No deployments available to open", - "The resolved project does not have any deployed service yet.", - ); - } - const deploymentsResult = await state.provider - .listDeployments(state.selected.id, { signal: ctx.signal }) + .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to resolve service URL", error, [ runCommandAction("Inspect the service", "service show"), @@ -72,12 +64,6 @@ export const serviceOpenCommand = defineCommand({ ) ?? null) : null; - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - if (!liveDeployment) { throw noDeploymentsError( "No deployments available to open", diff --git a/packages/cli/src/commands/service/release.ts b/packages/cli/src/commands/service/release.ts index 49f3c64f..bdded0a5 100644 --- a/packages/cli/src/commands/service/release.ts +++ b/packages/cli/src/commands/service/release.ts @@ -1,5 +1,4 @@ import type { DestroyAppProgress, PromoteProgress } from "@prisma/compute-sdk"; -import type { LocalStateStore } from "../../adapters/local-state"; import type { AppProvider, AppRecord, @@ -9,14 +8,12 @@ import { deploymentNotFoundForServiceError, liveDeploymentUnknownError, noPreviousDeploymentError, - releaseTargetRequiredError, } from "./errors"; import type { ServiceContext } from "./target"; import { resolveServiceReadState } from "./target"; export interface ServiceReleaseState { provider: AppProvider; - stateStore: LocalStateStore; projectId: string; service: AppRecord; } @@ -49,14 +46,10 @@ export async function resolveServiceReleaseState( : {}), commandName, }); - if (!state.selected) { - throw releaseTargetRequiredError(commandName); - } return { provider: state.provider, - stateStore: state.stateStore, projectId: state.projectId, - service: state.selected, + service: state.service, }; } diff --git a/packages/cli/src/commands/service/remove.ts b/packages/cli/src/commands/service/remove.ts index 5b10888e..f41a43e6 100644 --- a/packages/cli/src/commands/service/remove.ts +++ b/packages/cli/src/commands/service/remove.ts @@ -10,7 +10,7 @@ import { import { removePresentations } from "./presentation"; import { destroyProgressReporter, resolveServiceReleaseState } from "./release"; import type { ServiceRemoveResult } from "./results"; -import { toServiceSummary } from "./target"; +import { openServiceStateStore, toServiceSummary } from "./target"; function cleanupWarning(target: string, error: unknown): Diagnostic { const cause = error instanceof Error ? error.message : String(error); @@ -28,11 +28,6 @@ async function clearRemovedServiceState( serviceId: string, ): Promise { const warnings: Diagnostic[] = []; - try { - await stateStore.clearSelectedApp(projectId, serviceId); - } catch (error) { - warnings.push(cleanupWarning("selected service", error)); - } try { await stateStore.clearKnownLiveDeployment(projectId, serviceId); } catch (error) { @@ -106,7 +101,7 @@ export const serviceRemoveCommand = defineCommand({ ctx.report({ kind: "step-finished", step: "remove", outcome: "ok" }); const diagnostics = await clearRemovedServiceState( - state.stateStore, + await openServiceStateStore(ctx), state.projectId, removedService.id, ); diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index a5d2e066..fbd9e0e1 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -5,7 +5,6 @@ import { showPresentations } from "./presentation"; import type { ServiceShowResult } from "./results"; import { applyLiveDeploymentHint, - rememberSelectedService, resolveCurrentLiveDeploymentId, resolveServiceReadState, sortDeploymentsNewestFirst, @@ -37,19 +36,8 @@ export const serviceShowCommand = defineCommand({ commandName: "service show", }); - if (!state.selected) { - const result: ServiceShowResult = { - projectId: state.projectId, - service: null, - liveDeployment: null, - liveUrl: null, - recentDeployments: [], - }; - return ok(ctx.present({ data: result }, showPresentations(result))); - } - const deploymentsResult = await state.provider - .listDeployments(state.selected.id, { signal: ctx.signal }) + .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to inspect service", error, [ runCommandAction("List deployments", "service deployment list"), @@ -71,12 +59,6 @@ export const serviceShowCommand = defineCommand({ ) ?? null) : null; - await rememberSelectedService( - state.stateStore, - state.projectId, - deploymentsResult.app, - ); - const result: ServiceShowResult = { projectId: state.projectId, service: toServiceSummary(deploymentsResult.app), diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 48a03061..b8ea873c 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -7,7 +7,6 @@ import { type DomainRecord, } from "../../lib/app/app-provider"; import { resolveReadBranch } from "../../lib/app/read-branch"; -import { readLocalGitBranch } from "../../lib/git/local-branch"; import { projectApiError } from "../../lib/project/provider"; import { type ProjectCandidate, @@ -26,12 +25,12 @@ import { domainCommandError, domainHostnameInvalidError, domainNotFoundError, - domainTargetRequiredError, fromLegacyCliError, projectNotFoundError, runCommandAction, selectedServiceMissingError, serviceSelectionInvalidError, + serviceTargetRequiredError, workspaceRequiredError, } from "./errors"; import type { @@ -117,22 +116,6 @@ function resolutionContext(ctx: ServiceContext): ProjectResolutionContext { return { runtime: { cwd: ctx.cwd, signal: ctx.signal } }; } -interface ResolvedReadBranchRequest { - name: string; - explicit: boolean; -} - -async function resolveRequestedBranch( - ctx: ServiceContext, - explicitBranchName: string | undefined, -): Promise { - if (explicitBranchName) { - return { name: explicitBranchName, explicit: true }; - } - const gitBranch = await readLocalGitBranch(ctx.cwd, ctx.signal); - return { name: gitBranch ?? "main", explicit: false }; -} - export function toBranchKind(name: string): BranchKind { return name === "production" || name === "main" ? "production" : "preview"; } @@ -225,7 +208,9 @@ export async function resolveServiceProjectContext( ); } const resolved = resolvedResult.value; - const requested = await resolveRequestedBranch(ctx, options.branchName); + const requested = options.branchName + ? { name: options.branchName, explicit: true } + : { name: "main", explicit: false }; const remoteBranch = requested.explicit ? null @@ -286,62 +271,52 @@ export async function listServices( }); } -/** - * The service picker: an explicit name must exist; otherwise the saved - * selection is reused when still valid; otherwise the engine prompt - * selects interactively (non-interactive contexts settle with the - * engine's structural prompt failure). - */ -export async function resolveExistingServiceSelection( +export interface RequestedServiceTarget { + kind: "name" | "id"; + value: string; +} + +/** The service target the run was given: `--service ` wins, then + * PRISMA_SERVICE_ID (a service id). Neither present refuses — service + * commands never infer, remember, or prompt for a target. */ +export function requireRequestedServiceTarget( ctx: ServiceContext, - stateStore: LocalStateStore, - projectId: string, - services: AppRecord[], explicitServiceName: string | undefined, -): Promise { + commandName: string, +): RequestedServiceTarget { if (explicitServiceName) { + return { kind: "name", value: explicitServiceName }; + } + const envServiceId = readServiceEnvOverride(ctx, PRISMA_SERVICE_ID_ENV_VAR); + if (envServiceId) { + return { kind: "id", value: envServiceId }; + } + throw serviceTargetRequiredError(commandName); +} + +export function matchRequestedService( + requested: RequestedServiceTarget, + services: AppRecord[], + projectId: string, +): AppRecord { + if (requested.kind === "name") { const matched = services.find( - (service) => service.name === explicitServiceName, + (service) => service.name === requested.value, ); if (!matched) { - throw serviceSelectionInvalidError(explicitServiceName, projectId); + throw serviceSelectionInvalidError(requested.value, projectId); } return matched; } - - const savedSelection = await stateStore.readSelectedApp(projectId); - if (savedSelection) { - const matched = - services.find((service) => service.id === savedSelection.id) ?? - services.find((service) => service.name === savedSelection.name); - if (matched) { - return matched; - } - } - - if (services.length === 0) { - return null; + const matched = services.find((service) => service.id === requested.value); + if (!matched) { + throw selectedServiceMissingError( + PRISMA_SERVICE_ID_ENV_VAR, + requested.value, + projectId, + ); } - - const selectedId = await ctx.prompt.select( - "Select a service", - sortServices(services).map((service) => ({ - value: service.id, - label: service.name, - })), - ); - return services.find((service) => service.id === selectedId) ?? null; -} - -export async function rememberSelectedService( - stateStore: LocalStateStore, - projectId: string, - service: Pick, -): Promise { - await stateStore.setSelectedApp(projectId, { - id: service.id, - name: service.name, - }); + return matched; } /** The live deployment is the one the service record names as its latest @@ -487,29 +462,27 @@ export async function resolveDomainByHostname( throw domainNotFoundError(hostname); } -export interface ServiceReadState { +export interface ServiceProjectState { provider: AppProvider; - stateStore: LocalStateStore; target: ResolvedServiceProjectContext; projectId: string; - selected: AppRecord | null; } -/** The shared read flow for show / deployment list / open: project + - * branch resolution, service listing, and selection. */ -export async function resolveServiceReadState( +export interface ServiceReadState extends ServiceProjectState { + service: AppRecord; +} + +/** Project + branch resolution without a service target. For the + * callers that resolve their subject by a globally-unique deployment + * id and never need a service parameter. */ +export async function resolveServiceProjectState( ctx: ServiceContext, options: { - serviceName?: string; projectRef?: string; branchName?: string; commandName: string; - /** Skip the service picker entirely. A caller resolving its target - * by a globally-unique deployment id does not use the selection, - * and selecting first would prompt for something it ignores. */ - skipSelection?: boolean; }, -): Promise { +): Promise { const provider = serviceProvider(ctx); const target = await resolveServiceProjectContext(ctx, options.projectRef, { commandName: options.commandName, @@ -517,29 +490,39 @@ export async function resolveServiceReadState( ? { branchName: options.branchName } : {}), }); - const projectId = target.project.id; - const stateStore = await openServiceStateStore(ctx); + return { provider, target, projectId: target.project.id }; +} + +/** The shared read flow for every command that acts on an existing + * service: project + branch resolution, service listing, and the + * parameter-only service match. */ +export async function resolveServiceReadState( + ctx: ServiceContext, + options: { + serviceName?: string; + projectRef?: string; + branchName?: string; + commandName: string; + }, +): Promise { + const requested = requireRequestedServiceTarget( + ctx, + options.serviceName, + options.commandName, + ); + const state = await resolveServiceProjectState(ctx, options); const services = await listServices( ctx, - provider, - projectId, - target.branch.name, + state.provider, + state.projectId, + state.target.branch.name, ); - const selected = options.skipSelection - ? null - : await resolveExistingServiceSelection( - ctx, - stateStore, - projectId, - services, - options.serviceName, - ); - return { provider, stateStore, target, projectId, selected }; + const service = matchRequestedService(requested, services, state.projectId); + return { ...state, service }; } export interface ResolvedServiceDomainTarget { provider: AppProvider; - stateStore: LocalStateStore; service: AppRecord; resultTarget: ServiceDomainTarget; } @@ -558,8 +541,13 @@ export async function resolveServiceDomainTarget( throw branchNotDeployableError(branchName); } + const requested = requireRequestedServiceTarget( + ctx, + options.serviceName, + options.commandName, + ); + const envProjectId = readServiceEnvOverride(ctx, PRISMA_PROJECT_ID_ENV_VAR); - const envServiceId = readServiceEnvOverride(ctx, PRISMA_SERVICE_ID_ENV_VAR); const provider = serviceProvider(ctx); const target = await resolveServiceProjectContext(ctx, options.projectRef, { @@ -568,44 +556,17 @@ export async function resolveServiceDomainTarget( ...(envProjectId !== undefined ? { envProjectId } : {}), }); const projectId = target.project.id; - const stateStore = await openServiceStateStore(ctx); const services = await listServices( ctx, provider, projectId, target.branch.name, ); - - let selectedService: AppRecord | null; - if (envServiceId) { - selectedService = - services.find((service) => service.id === envServiceId) ?? null; - if (!selectedService) { - throw selectedServiceMissingError( - PRISMA_SERVICE_ID_ENV_VAR, - envServiceId, - projectId, - ); - } - } else { - selectedService = await resolveExistingServiceSelection( - ctx, - stateStore, - projectId, - services, - options.serviceName, - ); - } - if (!selectedService) { - throw domainTargetRequiredError(); - } - - await rememberSelectedService(stateStore, projectId, selectedService); + const service = matchRequestedService(requested, services, projectId); return { provider, - stateStore, - service: selectedService, + service, resultTarget: { workspace: target.workspace, project: target.project, @@ -613,7 +574,7 @@ export async function resolveServiceDomainTarget( name: target.branch.name, kind: target.branch.kind, }, - service: toServiceSummary(selectedService), + service: toServiceSummary(service), }, }; } diff --git a/packages/cli/tests/app-state.test.ts b/packages/cli/tests/app-state.test.ts index 13e1fe1a..2e11e54c 100644 --- a/packages/cli/tests/app-state.test.ts +++ b/packages/cli/tests/app-state.test.ts @@ -8,57 +8,6 @@ import { DEFAULT_STATE_DIR_NAME } from "../src/state-dir"; import { createTempCwd } from "./helpers"; describe("app local state", () => { - it("persists selected app state under .prisma/cli/state.json by default", async () => { - const cwd = await createTempCwd(); - const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME)); - - await store.setSelectedApp("proj_123", { - id: "app_123", - name: "hello-world", - }); - - expect( - JSON.parse( - await readFile( - path.join(cwd, DEFAULT_STATE_DIR_NAME, "state.json"), - "utf8", - ), - ), - ).toMatchObject({ - app: { - selectedByProject: { - proj_123: { - id: "app_123", - name: "hello-world", - }, - }, - }, - }); - }); - - it("keys selected apps by project id", async () => { - const cwd = await createTempCwd(); - const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME)); - - await store.setSelectedApp("proj_123", { - id: "app_123", - name: "hello-world", - }); - await store.setSelectedApp("proj_456", { - id: "app_456", - name: "billing", - }); - - await expect(store.readSelectedApp("proj_123")).resolves.toEqual({ - id: "app_123", - name: "hello-world", - }); - await expect(store.readSelectedApp("proj_456")).resolves.toEqual({ - id: "app_456", - name: "billing", - }); - }); - it("rejects local state reads when the command signal is already aborted", async () => { const cwd = await createTempCwd(); const controller = new AbortController(); @@ -104,28 +53,6 @@ describe("app local state", () => { ).resolves.toBe("dep_456"); }); - it("clears the selected app only when the deleted app matches", async () => { - const cwd = await createTempCwd(); - const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME)); - - await store.setSelectedApp("proj_123", { - id: "app_123", - name: "hello-world", - }); - await store.setSelectedApp("proj_456", { - id: "app_456", - name: "billing", - }); - - await store.clearSelectedApp("proj_123", "app_123"); - - await expect(store.readSelectedApp("proj_123")).resolves.toBeNull(); - await expect(store.readSelectedApp("proj_456")).resolves.toEqual({ - id: "app_456", - name: "billing", - }); - }); - it("clears known live deployment only for the deleted app", async () => { const cwd = await createTempCwd(); const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME)); diff --git a/packages/cli/tests/service-create.test.ts b/packages/cli/tests/service-create.test.ts index 8fd1a948..1191fc3c 100644 --- a/packages/cli/tests/service-create.test.ts +++ b/packages/cli/tests/service-create.test.ts @@ -154,7 +154,7 @@ describe("prisma-cli service create", () => { expect(result.presented?.data).toMatchObject({ branch: "main" }); }); - it("remembers the created service as the selection for later commands", async () => { + it("writes no service selection into the local state store", async () => { const created = createRoutes(); const harness = await makeServiceCli({ routes: created.routes }); @@ -163,13 +163,9 @@ describe("prisma-cli service create", () => { { cwd: harness.cwd, env: harness.env }, ); - const state = JSON.parse( - await readFile(path.join(harness.stateDir, "state.json"), "utf8"), - ); - expect(state.app.selectedByProject.proj_1).toEqual({ - id: "svc_new", - name: "worker", - }); + await expect( + readFile(path.join(harness.stateDir, "state.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); }); it("reports the existing service when the name is already taken", async () => { diff --git a/packages/cli/tests/service-deployment-list.test.ts b/packages/cli/tests/service-deployment-list.test.ts index 215aae28..f3b7f9af 100644 --- a/packages/cli/tests/service-deployment-list.test.ts +++ b/packages/cli/tests/service-deployment-list.test.ts @@ -91,25 +91,23 @@ describe("prisma-cli service deployment list", () => { }); }); - it("treats a project with no services as a success with an empty listing", async () => { + it("refuses without --service or PRISMA_SERVICE_ID", async () => { const harness = await makeServiceCli({ routes: readFlowRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); const result = await harness.cli.run( - ["service", "deployment", "list", "--project", "acme-app"], + ["service", "deployment", "list", "--project", "acme-app", "--json"], { cwd: harness.cwd, env: harness.env }, ); - expect(result.exitCode).toBe(0); - expect(result.presented?.data).toEqual({ - projectId: "proj_1", - service: null, - deployments: [], - }); - // An empty listing offers no action: `service deploy` is not a command - // this binary answers to. - expect(result.presented?.presentation.next).toEqual([]); + expect(result.exitCode).toBe(2); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); + expect(frame.envelope.error.summary).toContain("--service"); }); it("emits the completed json envelope with commandId service.deployment.list", async () => { diff --git a/packages/cli/tests/service-deployment-promote.test.ts b/packages/cli/tests/service-deployment-promote.test.ts index 6d6b9197..3ddc72e0 100644 --- a/packages/cli/tests/service-deployment-promote.test.ts +++ b/packages/cli/tests/service-deployment-promote.test.ts @@ -82,7 +82,7 @@ describe("prisma-cli service deployment promote", () => { }); }); - it("caches the selected service and writes no local live-deployment state", async () => { + it("writes no local selection or live-deployment state", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); await harness.cli.run( @@ -99,14 +99,9 @@ describe("prisma-cli service deployment promote", () => { { cwd: harness.cwd, env: harness.env }, ); - const state = JSON.parse( - await readFile(path.join(harness.stateDir, "state.json"), "utf8"), - ); - expect(state.app.selectedByProject.proj_1).toEqual({ - id: "svc_1", - name: "hello-world", - }); - expect(state.app.knownLiveDeploymentByProject).toEqual({}); + await expect( + readFile(path.join(harness.stateDir, "state.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); }); it("warns instead of promoting when the target is already live", async () => { @@ -237,7 +232,7 @@ describe("prisma-cli service deployment promote", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); }); - it("requires an existing service", async () => { + it("requires --service or PRISMA_SERVICE_ID", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -262,13 +257,8 @@ describe("prisma-cli service deployment promote", () => { } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); expect(frame.envelope.error.summary).toBe( - 'Command "service deployment promote" requires an existing service', + 'Command "service deployment promote" requires --service', ); - expect(frame.envelope.error.nextActions).toContainEqual({ - kind: "user-choice", - label: - 'Deploy a service first, or rerun "service deployment promote" with --service once a service exists.', - }); }); it("fails early with the engine sign-in error when unauthenticated", async () => { diff --git a/packages/cli/tests/service-deployment-rollback.test.ts b/packages/cli/tests/service-deployment-rollback.test.ts index 79f1c598..04621af4 100644 --- a/packages/cli/tests/service-deployment-rollback.test.ts +++ b/packages/cli/tests/service-deployment-rollback.test.ts @@ -554,7 +554,7 @@ describe("prisma-cli service deployment rollback", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); }); - it("requires an existing service", async () => { + it("requires --service or PRISMA_SERVICE_ID", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -571,7 +571,7 @@ describe("prisma-cli service deployment rollback", () => { } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); expect(frame.envelope.error.summary).toBe( - 'Command "service deployment rollback" requires an existing service', + 'Command "service deployment rollback" requires --service', ); }); diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index 4006a4e1..ac326f79 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -278,13 +278,17 @@ describe("prisma-cli service domain add", () => { expect(frame.envelope.nextActions).toEqual([ { kind: "user-choice", - label: - "Unset PRISMA_SERVICE_ID, pass --service , or deploy the service on the production branch.", + label: "Unset PRISMA_SERVICE_ID, or pass --service .", + }, + { + kind: "run-command", + label: "List services", + command: "prisma-cli service list", }, ]); }); - it("requires an existing service on the production branch", async () => { + it("requires --service or PRISMA_SERVICE_ID", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -307,14 +311,13 @@ describe("prisma-cli service domain add", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_TARGET_REQUIRED"); - expect(frame.envelope.nextActions).toEqual([ - { - kind: "run-command", - label: "Inspect the service", - command: "prisma-cli service show", - }, - ]); + expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); + expect(frame.envelope.error.summary).toContain("--service"); + expect(frame.envelope.nextActions).toContainEqual({ + kind: "run-command", + label: "List services", + command: "prisma-cli service list", + }); }); it("fails early with the engine sign-in error when unauthenticated", async () => { diff --git a/packages/cli/tests/service-open.test.ts b/packages/cli/tests/service-open.test.ts index 5d1399f1..b3a26d57 100644 --- a/packages/cli/tests/service-open.test.ts +++ b/packages/cli/tests/service-open.test.ts @@ -4,6 +4,7 @@ import { page, presentedSummary, readFlowRoutes, + SERVICE, SERVICE_DETAIL, } from "./service-testkit"; @@ -98,13 +99,29 @@ describe("prisma-cli service open", () => { expect(result.presented?.data).toMatchObject({ opened: false }); }); - it("settles a project with no services as SERVICE.NO_DEPLOYMENTS", async () => { + it("settles a service with no deployments as SERVICE.NO_DEPLOYMENTS", async () => { const harness = await makeServiceCli({ - routes: readFlowRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), + routes: readFlowRoutes({ + "GET /v1/apps": () => ({ + data: page([{ ...SERVICE, latestDeploymentId: null }]), + }), + "GET /v1/apps/{appId}": () => ({ + data: { data: { ...SERVICE_DETAIL, latestDeploymentId: null } }, + }), + "GET /v1/apps/{appId}/deployments": () => ({ data: page([]) }), + }), }); const result = await harness.cli.run( - ["service", "open", "--project", "acme-app", "--json"], + [ + "service", + "open", + "--project", + "acme-app", + "--service", + "hello-world", + "--json", + ], { cwd: harness.cwd, env: harness.env }, ); diff --git a/packages/cli/tests/service-remove.test.ts b/packages/cli/tests/service-remove.test.ts index 46b8071d..697ad94b 100644 --- a/packages/cli/tests/service-remove.test.ts +++ b/packages/cli/tests/service-remove.test.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -99,21 +99,21 @@ describe("prisma-cli service remove", () => { }); }); - it("clears the selected service and known live deployment from local state", async () => { + it("clears the known live deployment from local state", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); - await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "hello-world"], - { cwd: harness.cwd, env: harness.env }, - ); // Nothing in the service family writes this key any more, but the // legacy `app` family still does for the same project, so clearing // it on removal has real effect until that family retires. Seeded // here so the assertion below observes a key that was present. const statePath = path.join(harness.stateDir, "state.json"); - const seeded = JSON.parse(await readFile(statePath, "utf8")); - seeded.app.knownLiveDeploymentByProject = { proj_1: { svc_1: "dep_2" } }; - await writeFile(statePath, JSON.stringify(seeded)); + await mkdir(path.dirname(statePath), { recursive: true }); + await writeFile( + statePath, + JSON.stringify({ + app: { knownLiveDeploymentByProject: { proj_1: { svc_1: "dep_2" } } }, + }), + ); await harness.cli.run( [ @@ -135,7 +135,6 @@ describe("prisma-cli service remove", () => { const state = JSON.parse( await readFile(path.join(harness.stateDir, "state.json"), "utf8"), ); - expect(state.app?.selectedByProject?.proj_1).toBeUndefined(); expect( state.app?.knownLiveDeploymentByProject?.proj_1?.svc_1, ).toBeUndefined(); @@ -388,7 +387,7 @@ describe("prisma-cli service remove", () => { expect(frame.envelope.error.code).toBe("SERVICE.REMOVE_FAILED"); }); - it("requires an existing service", async () => { + it("requires --service or PRISMA_SERVICE_ID, interactive terminals included", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -405,15 +404,14 @@ describe("prisma-cli service remove", () => { } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); expect(frame.envelope.error.summary).toBe( - 'Command "service remove" requires an existing service', + 'Command "service remove" requires --service', ); expect(frame.envelope.nextActions).toEqual([ { kind: "user-choice", - label: - 'Deploy a service first, or rerun "service remove" with --service once a service exists.', + label: "Pass --service .", }, - // Not `service deployment list`: that command selects a service + // Not `service deployment list`: that command resolves a service // before it lists anything, so it fails the same way this did. { kind: "run-command", diff --git a/packages/cli/tests/service-session.test.ts b/packages/cli/tests/service-session.test.ts index 8083182a..f8ffdf8f 100644 --- a/packages/cli/tests/service-session.test.ts +++ b/packages/cli/tests/service-session.test.ts @@ -134,7 +134,15 @@ describe("prisma-cli service — the workspace comes from the engine session", ( }); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--json"], + [ + "service", + "show", + "--project", + "acme-app", + "--service", + "hello-world", + "--json", + ], { cwd: harness.cwd, env: harness.env }, ); diff --git a/packages/cli/tests/service-show.test.ts b/packages/cli/tests/service-show.test.ts index cca276d3..3438b00f 100644 --- a/packages/cli/tests/service-show.test.ts +++ b/packages/cli/tests/service-show.test.ts @@ -79,7 +79,7 @@ describe("prisma-cli service show", () => { }); }); - it("caches the selected service in the local state store", async () => { + it("writes no service selection into the local state store", async () => { const harness = await makeServiceCli(); await harness.cli.run( @@ -87,35 +87,26 @@ describe("prisma-cli service show", () => { { cwd: harness.cwd, env: harness.env }, ); - const state = JSON.parse( - await readFile(path.join(harness.stateDir, "state.json"), "utf8"), - ); - expect(state.app.selectedByProject.proj_1).toEqual({ - id: "svc_1", - name: "hello-world", - }); + await expect( + readFile(path.join(harness.stateDir, "state.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); }); - it("treats a project with no services as an undeployed success", async () => { - const harness = await makeServiceCli({ - routes: readFlowRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), - }); + it("resolves the service by id from PRISMA_SERVICE_ID", async () => { + const harness = await makeServiceCli(); const result = await harness.cli.run( ["service", "show", "--project", "acme-app"], - { cwd: harness.cwd, env: harness.env }, + { + cwd: harness.cwd, + env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, + }, ); expect(result.exitCode).toBe(0); - expect(result.presented?.data).toEqual({ - projectId: "proj_1", - service: null, - liveDeployment: null, - liveUrl: null, - recentDeployments: [], + expect(result.presented?.data).toMatchObject({ + service: { id: "svc_1", name: "hello-world" }, }); - // Nothing to inspect and no `service deploy` to offer, so no actions. - expect(result.presented?.presentation.next).toEqual([]); }); it("emits the completed json envelope with commandId service.show", async () => { @@ -218,7 +209,7 @@ describe("prisma-cli service show", () => { expect(frame.envelope.error.code).toBe("SERVICE.SELECTION_INVALID"); }); - it("prompts to pick between several services and honors the answer", async () => { + it("prefers --service over PRISMA_SERVICE_ID", async () => { const second = { ...SERVICE, id: "svc_2", @@ -249,12 +240,11 @@ describe("prisma-cli service show", () => { }); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app"], + ["service", "show", "--project", "acme-app", "--service", "api"], { cwd: harness.cwd, - env: harness.env, - isTty: { stdin: true, stdout: true, stderr: true }, - answers: ["svc_2"], + env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, + isTty: { stdout: true }, }, ); @@ -264,13 +254,25 @@ describe("prisma-cli service show", () => { }); }); - it("settles the picker as a structural prompt failure when non-interactive", async () => { - const second = { ...SERVICE, id: "svc_2", name: "api" }; - const harness = await makeServiceCli({ - routes: readFlowRoutes({ - "GET /v1/apps": () => ({ data: page([SERVICE, second]) }), - }), - }); + it("refuses without --service or PRISMA_SERVICE_ID, interactive terminals included", async () => { + const harness = await makeServiceCli(); + + const result = await harness.cli.run( + ["service", "show", "--project", "acme-app"], + { + cwd: harness.cwd, + env: harness.env, + isTty: { stdin: true, stdout: true, stderr: true }, + }, + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("SERVICE.TARGET_REQUIRED"); + expect(result.stderr).toContain("--service"); + }); + + it("names --service in the structured missing-target error", async () => { + const harness = await makeServiceCli(); const result = await harness.cli.run( ["service", "show", "--project", "acme-app", "--json"], @@ -282,6 +284,12 @@ describe("prisma-cli service show", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("CLI.PROMPT_REQUIRED"); + expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); + expect(frame.envelope.error.summary).toContain("--service"); + expect(frame.envelope.nextActions).toContainEqual({ + kind: "run-command", + label: "List services", + command: "prisma-cli service list", + }); }); }); From 5110ddedad3d614000e139b8a66d654c1fab2e25 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:54:24 +0200 Subject: [PATCH 06/27] Address D1 review findings - project/link.ts: linkDirectoryToProject's doc comment no longer claims init runs it; the export drops to in-file scope, its only use. - cli.ts and mount-coverage note that orm init keeps its path; only the top-level init (the compute config wizard) was removed (2026-08-21 PM review). - service/target.ts: resolveServiceProjectContext loses the projectDir option no caller passed. - state-dir.ts: resolveStateDir is synchronous and takes no signal; it only joins paths. Call sites adjusted. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/cli.ts | 2 ++ packages/cli/src/commands/agent/status.ts | 10 +++------- packages/cli/src/commands/auth/agent-setup-tip.ts | 7 +------ packages/cli/src/commands/project/link.ts | 5 ++--- packages/cli/src/commands/service/remove.ts | 2 +- packages/cli/src/commands/service/target.ts | 14 ++------------ packages/cli/src/state-dir.ts | 3 +-- packages/cli/tests/mount-coverage.test.ts | 3 +++ 8 files changed, 15 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index fe12f874..1c4c6b15 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -262,6 +262,8 @@ export const mountedCommands: Readonly> = { "db update": ormCommandFamily.commands["db update"], "db verify": ormCommandFamily.commands["db verify"], format: ormCommandFamily.commands.format, + // `orm init` keeps this path: only the top-level `init` (the compute + // config wizard) was removed, by the 2026-08-21 PM review. "orm init": ormCommandFamily.commands.init, lsp: ormCommandFamily.commands.lsp, migrate: ormCommandFamily.commands.migrate, diff --git a/packages/cli/src/commands/agent/status.ts b/packages/cli/src/commands/agent/status.ts index 83d9379d..9799e8dd 100644 --- a/packages/cli/src/commands/agent/status.ts +++ b/packages/cli/src/commands/agent/status.ts @@ -26,12 +26,8 @@ function resolveStatusSource( return statusScope === "project" ? "skills-lock" : "unavailable"; } -async function openStateStore(ctx: AgentContext): Promise { - const stateDir = await resolveStateDir({ - env: ctx.env, - cwd: ctx.cwd, - signal: ctx.signal, - }); +function openStateStore(ctx: AgentContext): LocalStateStore { + const stateDir = resolveStateDir({ env: ctx.env, cwd: ctx.cwd }); return new LocalStateStore(stateDir, ctx.signal); } @@ -69,7 +65,7 @@ export const agentStatusCommand = defineCommand({ const statusScope = args.flags.global ? "global" : "project"; const setupStatus = await readPrismaAgentSetupStatus({ cwd: ctx.cwd, - stateStore: await openStateStore(ctx), + stateStore: openStateStore(ctx), signal: ctx.signal, }); const skillsList = await listInstalledPrismaSkills( diff --git a/packages/cli/src/commands/auth/agent-setup-tip.ts b/packages/cli/src/commands/auth/agent-setup-tip.ts index ca1bff69..172c3d28 100644 --- a/packages/cli/src/commands/auth/agent-setup-tip.ts +++ b/packages/cli/src/commands/auth/agent-setup-tip.ts @@ -34,12 +34,7 @@ export async function resolveAgentSetupTipCommand( return null; } - const stateDir = await resolveStateDir({ - stateDir: undefined, - env: ctx.env, - cwd: ctx.cwd, - signal: ctx.signal, - }); + const stateDir = resolveStateDir({ env: ctx.env, cwd: ctx.cwd }); const stateStore = new LocalStateStore(stateDir, ctx.signal); const shouldOffer = shouldOfferPrismaAgentSetup( diff --git a/packages/cli/src/commands/project/link.ts b/packages/cli/src/commands/project/link.ts index a0b6991e..a46b2d3a 100644 --- a/packages/cli/src/commands/project/link.ts +++ b/packages/cli/src/commands/project/link.ts @@ -139,9 +139,8 @@ async function pickProject( } /** The link itself, without the command around it: resolve the named - * Project or pick one, then bind `ctx.cwd` to it. `init`'s link step - * runs this, so there is one picker and one pin writer. */ -export async function linkDirectoryToProject( + * Project or pick one, then bind `ctx.cwd` to it. */ +async function linkDirectoryToProject( ctx: ProjectCommandContext, projectRef: string | undefined, ): Promise { diff --git a/packages/cli/src/commands/service/remove.ts b/packages/cli/src/commands/service/remove.ts index f41a43e6..fdbd5b82 100644 --- a/packages/cli/src/commands/service/remove.ts +++ b/packages/cli/src/commands/service/remove.ts @@ -101,7 +101,7 @@ export const serviceRemoveCommand = defineCommand({ ctx.report({ kind: "step-finished", step: "remove", outcome: "ok" }); const diagnostics = await clearRemovedServiceState( - await openServiceStateStore(ctx), + openServiceStateStore(ctx), state.projectId, removedService.id, ); diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index b8ea873c..4e0ea0fc 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -71,14 +71,8 @@ export interface ResolvedServiceProjectContext { resolution: ProjectResolution; } -export async function openServiceStateStore( - ctx: ServiceContext, -): Promise { - const stateDir = await resolveStateDir({ - env: ctx.env, - cwd: ctx.cwd, - signal: ctx.signal, - }); +export function openServiceStateStore(ctx: ServiceContext): LocalStateStore { + const stateDir = resolveStateDir({ env: ctx.env, cwd: ctx.cwd }); return new LocalStateStore(stateDir, ctx.signal); } @@ -178,7 +172,6 @@ export async function resolveServiceProjectContext( explicitProject: string | undefined, options: { commandName: string; - projectDir?: string; branchName?: string; envProjectId?: string; }, @@ -196,9 +189,6 @@ export async function resolveServiceProjectContext( ...(options.envProjectId !== undefined ? { envProjectId: options.envProjectId } : {}), - ...(options.projectDir !== undefined - ? { projectDir: options.projectDir } - : {}), listProjects: () => Promise.resolve(projects), commandName: options.commandName, }); diff --git a/packages/cli/src/state-dir.ts b/packages/cli/src/state-dir.ts index 6bb72e9e..5e4faaa3 100644 --- a/packages/cli/src/state-dir.ts +++ b/packages/cli/src/state-dir.ts @@ -6,10 +6,9 @@ export interface StateDirInputs { readonly stateDir?: string; readonly env: NodeJS.ProcessEnv; readonly cwd: string; - readonly signal: AbortSignal; } -export async function resolveStateDir(inputs: StateDirInputs): Promise { +export function resolveStateDir(inputs: StateDirInputs): string { const explicitStateDir = inputs.stateDir ?? inputs.env.PRISMA_CLI_STATE_DIR; if (explicitStateDir) { return explicitStateDir; diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index 40c75c26..4cb138ce 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -9,6 +9,9 @@ * operator on 2026-08-12. Adding to it requires an operator ruling * recorded here; giving those commands a real owning family, so the * exception set can shrink, is deferred work. + * + * `orm init` keeps its path: only the top-level `init` (the compute + * config wizard) was removed, by the 2026-08-21 PM review. */ import type { AnyCommand, CommandFamily } from "@prisma/cli-engine"; import { defineCommand, telemetryCommandGroup } from "@prisma/cli-engine"; From ab7b1bfcf92222b79b2693cd71b8a8a5083d6a7f Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:05:22 +0200 Subject: [PATCH 07/27] Rename the six destroying remove commands to delete project delete, project env delete, postgres delete, postgres connection delete, service delete, and service domain delete replace their remove spellings everywhere: mount paths, source file names, exported symbols, result types (removed: true becomes deleted: true), help, examples, next actions, error copy, consent questions, progress step/status names, unit tests, and e2e describeCommand markers. No aliases or redirects for the old spellings. Error codes tied to these commands follow the verb: SERVICE.REMOVE_FAILED -> SERVICE.DELETE_FAILED, PROJECT_REMOVE_BLOCKED -> PROJECT_DELETE_BLOCKED (PROJECT.DELETE_BLOCKED). The unused legacy AppRemoveResult / AppDomainRemoveResult types are deleted. git disconnect, auth logout, bucket delete, and provider-internal removeApp/removeProject/removeDatabase/removeConnection/removeDomain helpers are untouched. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/e2e/deployed-service.ts | 6 +- packages/cli/e2e/postgres.e2e.ts | 22 ++--- packages/cli/e2e/project-lifecycle.e2e.ts | 30 +++---- packages/cli/e2e/scratch.ts | 2 +- packages/cli/e2e/service.e2e.ts | 20 ++--- packages/cli/src/cli.ts | 36 ++++---- ...nection-remove.ts => connection-delete.ts} | 24 +++--- packages/cli/src/commands/postgres/context.ts | 2 +- .../postgres/{remove.ts => delete.ts} | 24 +++--- .../commands/project/{remove.ts => delete.ts} | 22 ++--- .../project/{env-remove.ts => env-delete.ts} | 28 +++--- .../cli/src/commands/project/env-shared.ts | 2 +- packages/cli/src/commands/project/errors.ts | 2 +- .../cli/src/commands/project/presentation.ts | 2 +- .../commands/service/{remove.ts => delete.ts} | 52 +++++------ .../src/commands/service/deployment-delete.ts | 2 +- .../commands/service/deployment-promote.ts | 2 +- .../commands/service/deployment-rollback.ts | 2 +- .../commands/service/deployment-run-state.ts | 2 +- .../{domain-remove.ts => domain-delete.ts} | 28 +++--- packages/cli/src/commands/service/errors.ts | 18 ++-- .../cli/src/commands/service/presentation.ts | 20 ++--- packages/cli/src/commands/service/release.ts | 12 +-- packages/cli/src/commands/service/results.ts | 8 +- packages/cli/src/commands/service/target.ts | 2 +- packages/cli/src/controllers/database.ts | 2 +- packages/cli/src/controllers/project.ts | 2 +- packages/cli/src/lib/app/env-config.ts | 6 +- packages/cli/src/lib/project/provider.ts | 12 +-- packages/cli/src/types/app.ts | 12 --- packages/cli/src/types/database.ts | 4 +- packages/cli/src/types/project.ts | 2 +- packages/cli/tests/e2e-coverage.test.ts | 2 +- packages/cli/tests/mount-coverage.test.ts | 12 +-- packages/cli/tests/postgres.test.ts | 86 +++++++++---------- packages/cli/tests/project.test.ts | 86 +++++++++---------- ...-remove.test.ts => service-delete.test.ts} | 74 ++++++++-------- packages/cli/tests/service-domain.test.ts | 84 +++++++++--------- 38 files changed, 369 insertions(+), 385 deletions(-) rename packages/cli/src/commands/postgres/{connection-remove.ts => connection-delete.ts} (73%) rename packages/cli/src/commands/postgres/{remove.ts => delete.ts} (72%) rename packages/cli/src/commands/project/{remove.ts => delete.ts} (79%) rename packages/cli/src/commands/project/{env-remove.ts => env-delete.ts} (78%) rename packages/cli/src/commands/service/{remove.ts => delete.ts} (66%) rename packages/cli/src/commands/service/{domain-remove.ts => domain-delete.ts} (65%) rename packages/cli/tests/{service-remove.test.ts => service-delete.test.ts} (87%) diff --git a/packages/cli/e2e/deployed-service.ts b/packages/cli/e2e/deployed-service.ts index cd0fe5f8..8230c794 100644 --- a/packages/cli/e2e/deployed-service.ts +++ b/packages/cli/e2e/deployed-service.ts @@ -144,7 +144,7 @@ export async function deployService( // Once the deployment exists it has to be deleted by someone, and // until this function returns the caller has no id to delete. A throw // from `start` or `promote` would otherwise leave a deployment nobody - // knows about, and `project remove` refuses while one exists — so the + // knows about, and `project delete` refuses while one exists — so the // failure would strand the whole scratch project, not just this // service. try { @@ -175,7 +175,7 @@ export async function deployService( /** * Deletes a deployment, warning rather than throwing. * - * The scratch project's own teardown cannot do this: `project remove` + * The scratch project's own teardown cannot do this: `project delete` * refuses while a deployment exists — "Cannot delete project: active * deployments exist. Please stop and delete all deployments first." — so * a file that deploys has to clean up in this order or it strands the @@ -203,7 +203,7 @@ export async function deleteDeployment( console.warn( `e2e teardown could not delete deployment ${deployment.id}: ` + `${removal.envelope.error?.code ?? "(no code)"}. The scratch ` + - "project cannot be removed until it is gone.", + "project cannot be deleted until it is gone.", ); } } catch (failure) { diff --git a/packages/cli/e2e/postgres.e2e.ts b/packages/cli/e2e/postgres.e2e.ts index 5d51e116..82802f54 100644 --- a/packages/cli/e2e/postgres.e2e.ts +++ b/packages/cli/e2e/postgres.e2e.ts @@ -1,7 +1,7 @@ /** * The Postgres lifecycle against the real API: create a database in a * scratch project, read it back every way the CLI offers, manage its - * connection strings, then remove it. + * connection strings, then delete it. * * `connection create` and `connection rotate` answer with a live * connection string, so nothing here stringifies a whole envelope — a @@ -302,21 +302,21 @@ describeCommand("postgres connection rotate", () => { }); }); -describeCommand("postgres connection remove", () => { - it("removes the connection, and the list agrees", async () => { +describeCommand("postgres connection delete", () => { + it("deletes the connection, and the list agrees", async () => { const run = await scratch.run([ "postgres", "connection", - "remove", + "delete", requireConnection(), "--confirm", requireConnection(), ]); - const removed = run.envelope.result as { + const deleted = run.envelope.result as { readonly connection: { readonly id: string }; }; - expect(removed.connection.id).toBe(requireConnection()); + expect(deleted.connection.id).toBe(requireConnection()); const after = await scratch.run([ "postgres", @@ -333,20 +333,20 @@ describeCommand("postgres connection remove", () => { }); }); -describeCommand("postgres remove", () => { - it("removes the database, and the list agrees", async () => { +describeCommand("postgres delete", () => { + it("deletes the database, and the list agrees", async () => { const run = await scratch.run([ "postgres", - "remove", + "delete", requireDatabase(), "--confirm", requireDatabase(), ]); - const removed = run.envelope.result as { + const deleted = run.envelope.result as { readonly database: { readonly id: string }; }; - expect(removed.database.id).toBe(requireDatabase()); + expect(deleted.database.id).toBe(requireDatabase()); const after = await scratch.run(["postgres", "list"]); const listed = after.envelope.result as { diff --git a/packages/cli/e2e/project-lifecycle.e2e.ts b/packages/cli/e2e/project-lifecycle.e2e.ts index 39a6f642..49a6700b 100644 --- a/packages/cli/e2e/project-lifecycle.e2e.ts +++ b/packages/cli/e2e/project-lifecycle.e2e.ts @@ -1,6 +1,6 @@ /** * The project lifecycle against the real API: create, link, rename, - * environment variables, branches, then remove. + * environment variables, branches, then delete. * * These share one scratch project, so the `it` blocks run in order and * depend on each other. Vitest runs them sequentially within a file. @@ -183,24 +183,24 @@ describeCommand("project env update", () => { }); }); -describeCommand("project env remove", () => { - it("removes the variable, and the list agrees", async () => { +describeCommand("project env delete", () => { + it("deletes the variable, and the list agrees", async () => { const run = await scratch.run([ "project", "env", - "remove", + "delete", KEY, ...ROLE, "--confirm", KEY, ]); - const removed = run.envelope.result as { + const deleted = run.envelope.result as { readonly projectId: string; readonly key: string; }; - expect(removed.projectId).toBe(scratch.project().id); - expect(removed.key).toBe(KEY); + expect(deleted.projectId).toBe(scratch.project().id); + expect(deleted.key).toBe(KEY); const after = await scratch.run(["project", "env", "list", ...ROLE]); const listed = after.envelope.result as EnvListResult; @@ -208,8 +208,8 @@ describeCommand("project env remove", () => { }); }); -describeCommand("project remove", () => { - it("removes a project it created for the purpose", async () => { +describeCommand("project delete", () => { + it("deletes a project it created for the purpose", async () => { const cli = await session(); const cwd = await cli.workdir(); const name = scratchName("removable"); @@ -220,10 +220,10 @@ describeCommand("project remove", () => { // This project is created outside useScratchProject, so nothing else // will clean it up. Without the finally, an assertion failing between - // here and the removal leaves it in the real workspace for good. - let removed = false; + // here and the deletion leaves it in the real workspace for good. + let deleted = false; try { - const run = await cli.run(["project", "remove", id, "--confirm", id], { + const run = await cli.run(["project", "delete", id, "--confirm", id], { cwd, }); const result = run.envelope.result as { @@ -231,10 +231,10 @@ describeCommand("project remove", () => { readonly localPin: { readonly cleared: boolean }; }; expect(result.project.id).toBe(id); - // Removing the project must also drop this directory's binding, + // Deleting the project must also drop this directory's binding, // or the next command here resolves a project that is gone. expect(result.localPin.cleared).toBe(true); - removed = true; + deleted = true; const remaining = (await cli.run(["project", "list"], { cwd })).envelope .result as { @@ -242,7 +242,7 @@ describeCommand("project remove", () => { }; expect(remaining.items.map((item) => item.id)).not.toContain(id); } finally { - if (!removed) { + if (!deleted) { await removeScratchProject(cli, { id, name, cwd }); } } diff --git a/packages/cli/e2e/scratch.ts b/packages/cli/e2e/scratch.ts index 81c0f34b..0bbf36cf 100644 --- a/packages/cli/e2e/scratch.ts +++ b/packages/cli/e2e/scratch.ts @@ -42,7 +42,7 @@ export async function removeScratchProject( ); try { const removal = await cli.run( - ["project", "remove", project.id, "--confirm", project.id], + ["project", "delete", project.id, "--confirm", project.id], { ...(project.cwd === undefined ? {} : { cwd: project.cwd }), expectOk: false, diff --git a/packages/cli/e2e/service.e2e.ts b/packages/cli/e2e/service.e2e.ts index 0e48faff..935d4d70 100644 --- a/packages/cli/e2e/service.e2e.ts +++ b/packages/cli/e2e/service.e2e.ts @@ -4,7 +4,7 @@ * exist on its own, and `service list` is what proves it did. * * The blocks run in file order, so the service one creates is the one - * the next two read and then remove. `service remove` asserts like any + * the next two read and then delete. `service delete` asserts like any * other case rather than swallowing failures: the scratch project's * teardown removes everything it contains regardless, so nothing here * has to double as cleanup. @@ -124,26 +124,26 @@ describeCommand("service show", () => { }); }); -describeCommand("service remove", () => { - it("removes the service, and the listing no longer reports it", async () => { +describeCommand("service delete", () => { + it("deletes the service, and the listing no longer reports it", async () => { const existing = requireService(); - const removal = await scratch.run([ + const deletion = await scratch.run([ "service", - "remove", + "delete", "--service", existing.name, "--confirm", existing.name, ]); - const removed = removal.envelope.result as { + const deleted = deletion.envelope.result as { readonly projectId: string; readonly service: { readonly id: string; readonly name: string }; - readonly removed: boolean; + readonly deleted: boolean; }; - expect(removed.projectId).toBe(scratch.project().id); - expect(removed.service.id).toBe(existing.id); - expect(removed.removed).toBe(true); + expect(deleted.projectId).toBe(scratch.project().id); + expect(deleted.service.id).toBe(existing.id); + expect(deleted.deleted).toBe(true); const after = await scratch.run(["service", "list"]); const remaining = after.envelope.result as { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1c4c6b15..7da12833 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -31,27 +31,28 @@ import { gitConnectCommand } from "./commands/git/connect"; import { gitDisconnectCommand } from "./commands/git/disconnect"; import { postgresBackupListCommand } from "./commands/postgres/backup-list"; import { postgresConnectionCreateCommand } from "./commands/postgres/connection-create"; +import { postgresConnectionDeleteCommand } from "./commands/postgres/connection-delete"; import { postgresConnectionListCommand } from "./commands/postgres/connection-list"; -import { postgresConnectionRemoveCommand } from "./commands/postgres/connection-remove"; import { postgresConnectionRotateCommand } from "./commands/postgres/connection-rotate"; import { postgresCreateCommand } from "./commands/postgres/create"; +import { postgresDeleteCommand } from "./commands/postgres/delete"; import { postgresListCommand } from "./commands/postgres/list"; -import { postgresRemoveCommand } from "./commands/postgres/remove"; import { postgresRestoreCommand } from "./commands/postgres/restore"; import { postgresShowCommand } from "./commands/postgres/show"; import { postgresUsageCommand } from "./commands/postgres/usage"; import { projectCreateCommand } from "./commands/project/create"; +import { projectDeleteCommand } from "./commands/project/delete"; import { projectEnvAddCommand } from "./commands/project/env-add"; +import { projectEnvDeleteCommand } from "./commands/project/env-delete"; import { projectEnvListCommand } from "./commands/project/env-list"; -import { projectEnvRemoveCommand } from "./commands/project/env-remove"; import { projectEnvUpdateCommand } from "./commands/project/env-update"; import { projectLinkCommand } from "./commands/project/link"; import { projectListCommand } from "./commands/project/list"; -import { projectRemoveCommand } from "./commands/project/remove"; import { projectRenameCommand } from "./commands/project/rename"; import { projectShowCommand } from "./commands/project/show"; import { projectTransferCommand } from "./commands/project/transfer"; import { serviceCreateCommand } from "./commands/service/create"; +import { serviceDeleteCommand } from "./commands/service/delete"; import { serviceDeploymentDeleteCommand } from "./commands/service/deployment-delete"; import { serviceDeploymentListCommand } from "./commands/service/deployment-list"; import { serviceDeploymentPromoteCommand } from "./commands/service/deployment-promote"; @@ -60,14 +61,13 @@ import { serviceDeploymentShowCommand } from "./commands/service/deployment-show import { serviceDeploymentStartCommand } from "./commands/service/deployment-start"; import { serviceDeploymentStopCommand } from "./commands/service/deployment-stop"; import { serviceDomainAddCommand } from "./commands/service/domain-add"; -import { serviceDomainRemoveCommand } from "./commands/service/domain-remove"; +import { serviceDomainDeleteCommand } from "./commands/service/domain-delete"; import { serviceDomainRetryCommand } from "./commands/service/domain-retry"; import { serviceDomainShowCommand } from "./commands/service/domain-show"; import { serviceDomainWaitCommand } from "./commands/service/domain-wait"; import { serviceListCommand } from "./commands/service/list"; import { serviceLogsCommand } from "./commands/service/logs"; import { serviceOpenCommand } from "./commands/service/open"; -import { serviceRemoveCommand } from "./commands/service/remove"; import { serviceShowCommand } from "./commands/service/show"; import { getCliVersion } from "./lib/version"; @@ -84,23 +84,23 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ projectCreate: projectCreateCommand, projectLink: projectLinkCommand, projectRename: projectRenameCommand, - projectRemove: projectRemoveCommand, + projectDelete: projectDeleteCommand, projectTransfer: projectTransferCommand, projectEnvAdd: projectEnvAddCommand, projectEnvUpdate: projectEnvUpdateCommand, projectEnvList: projectEnvListCommand, - projectEnvRemove: projectEnvRemoveCommand, + projectEnvDelete: projectEnvDeleteCommand, postgresList: postgresListCommand, postgresShow: postgresShowCommand, postgresCreate: postgresCreateCommand, postgresUsage: postgresUsageCommand, postgresRestore: postgresRestoreCommand, - postgresRemove: postgresRemoveCommand, + postgresDelete: postgresDeleteCommand, postgresBackupList: postgresBackupListCommand, postgresConnectionList: postgresConnectionListCommand, postgresConnectionCreate: postgresConnectionCreateCommand, postgresConnectionRotate: postgresConnectionRotateCommand, - postgresConnectionRemove: postgresConnectionRemoveCommand, + postgresConnectionDelete: postgresConnectionDeleteCommand, bucketList: bucketListCommand, bucketCreate: bucketCreateCommand, bucketDelete: bucketDeleteCommand, @@ -122,10 +122,10 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ serviceDeploymentStart: serviceDeploymentStartCommand, serviceDeploymentStop: serviceDeploymentStopCommand, serviceDeploymentDelete: serviceDeploymentDeleteCommand, - serviceRemove: serviceRemoveCommand, + serviceDelete: serviceDeleteCommand, serviceDomainAdd: serviceDomainAddCommand, serviceDomainShow: serviceDomainShowCommand, - serviceDomainRemove: serviceDomainRemoveCommand, + serviceDomainDelete: serviceDomainDeleteCommand, serviceDomainRetry: serviceDomainRetryCommand, serviceDomainWait: serviceDomainWaitCommand, buildLogs: buildLogsCommand, @@ -201,23 +201,23 @@ export const mountedCommands: Readonly> = { "project create": projectCreateCommand, "project link": projectLinkCommand, "project rename": projectRenameCommand, - "project remove": projectRemoveCommand, + "project delete": projectDeleteCommand, "project transfer": projectTransferCommand, "project env add": projectEnvAddCommand, "project env update": projectEnvUpdateCommand, "project env list": projectEnvListCommand, - "project env remove": projectEnvRemoveCommand, + "project env delete": projectEnvDeleteCommand, "postgres list": postgresListCommand, "postgres show": postgresShowCommand, "postgres create": postgresCreateCommand, "postgres usage": postgresUsageCommand, "postgres restore": postgresRestoreCommand, - "postgres remove": postgresRemoveCommand, + "postgres delete": postgresDeleteCommand, "postgres backup list": postgresBackupListCommand, "postgres connection list": postgresConnectionListCommand, "postgres connection create": postgresConnectionCreateCommand, "postgres connection rotate": postgresConnectionRotateCommand, - "postgres connection remove": postgresConnectionRemoveCommand, + "postgres connection delete": postgresConnectionDeleteCommand, "bucket list": bucketListCommand, "bucket create": bucketCreateCommand, "bucket delete": bucketDeleteCommand, @@ -239,10 +239,10 @@ export const mountedCommands: Readonly> = { "service deployment start": serviceDeploymentStartCommand, "service deployment stop": serviceDeploymentStopCommand, "service deployment delete": serviceDeploymentDeleteCommand, - "service remove": serviceRemoveCommand, + "service delete": serviceDeleteCommand, "service domain add": serviceDomainAddCommand, "service domain show": serviceDomainShowCommand, - "service domain remove": serviceDomainRemoveCommand, + "service domain delete": serviceDomainDeleteCommand, "service domain retry": serviceDomainRetryCommand, "service domain wait": serviceDomainWaitCommand, // Platform builds are their own group; there is no local build verb. diff --git a/packages/cli/src/commands/postgres/connection-remove.ts b/packages/cli/src/commands/postgres/connection-delete.ts similarity index 73% rename from packages/cli/src/commands/postgres/connection-remove.ts rename to packages/cli/src/commands/postgres/connection-delete.ts index 29cf76d5..22641fc9 100644 --- a/packages/cli/src/commands/postgres/connection-remove.ts +++ b/packages/cli/src/commands/postgres/connection-delete.ts @@ -1,16 +1,16 @@ -/** The `postgres connection remove` command. */ +/** The `postgres connection delete` command. */ import { type Block, defineCommand, positional } from "@prisma/cli-engine"; import { notOk, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { usageError } from "../../errors"; -import type { DatabaseConnectionRemoveResult } from "../../types/database"; +import type { DatabaseConnectionDeleteResult } from "../../types/database"; import { resolvePostgresProviderOnly } from "./context"; import { mapPostgresOperationError } from "./errors"; const CONSENT_QUESTION = - "Removing this database connection is destructive and requires the exact id."; + "Deleting this database connection is destructive and requires the exact id."; -export const postgresConnectionRemoveCommand = defineCommand({ +export const postgresConnectionDeleteCommand = defineCommand({ args: { positionals: { connection: positional.string({ @@ -20,8 +20,8 @@ export const postgresConnectionRemoveCommand = defineCommand({ }, }, help: { - summary: "Remove a database connection after exact id confirmation", - examples: ["postgres connection remove conn_123 --confirm conn_123"], + summary: "Delete a database connection after exact id confirmation", + examples: ["postgres connection delete conn_123 --confirm conn_123"], }, needs: { credentials: true }, handler: async (args, ctx) => { @@ -30,10 +30,10 @@ export const postgresConnectionRemoveCommand = defineCommand({ if (!connectionId) { throw usageError( "Connection id required", - "Database connection removal needs a connection id.", - "Pass the connection id to remove.", + "Database connection deletion needs a connection id.", + "Pass the connection id to delete.", [ - `${CLI_NAME} postgres connection remove --confirm `, + `${CLI_NAME} postgres connection delete --confirm `, ], "database", ); @@ -44,7 +44,7 @@ export const postgresConnectionRemoveCommand = defineCommand({ const provider = await resolvePostgresProviderOnly(ctx); await provider.removeConnection(connectionId, { signal: ctx.signal }); - const result: DatabaseConnectionRemoveResult = { + const result: DatabaseConnectionDeleteResult = { connection: { id: connectionId }, }; return ok( @@ -55,7 +55,7 @@ export const postgresConnectionRemoveCommand = defineCommand({ { kind: "summary", status: "ok", - text: "Removing database connection.", + text: "Deleting database connection.", }, { kind: "fields", @@ -64,7 +64,7 @@ export const postgresConnectionRemoveCommand = defineCommand({ { kind: "list", items: [ - "The connection metadata was removed. Existing one-time secrets were not shown.", + "The connection metadata was deleted. Existing one-time secrets were not shown.", ], }, ], diff --git a/packages/cli/src/commands/postgres/context.ts b/packages/cli/src/commands/postgres/context.ts index 6e8d03e3..5e73bc5d 100644 --- a/packages/cli/src/commands/postgres/context.ts +++ b/packages/cli/src/commands/postgres/context.ts @@ -64,7 +64,7 @@ export async function resolvePostgresContext( }; } -/** `connection rotate` and `connection remove` address a connection +/** `connection rotate` and `connection delete` address a connection * directly: no workspace requirement and no project resolution, so * the workspace is only a plan-limit lookup hint. */ export async function resolvePostgresProviderOnly( diff --git a/packages/cli/src/commands/postgres/remove.ts b/packages/cli/src/commands/postgres/delete.ts similarity index 72% rename from packages/cli/src/commands/postgres/remove.ts rename to packages/cli/src/commands/postgres/delete.ts index 3d79e15f..752dd2af 100644 --- a/packages/cli/src/commands/postgres/remove.ts +++ b/packages/cli/src/commands/postgres/delete.ts @@ -1,4 +1,4 @@ -/** The `postgres remove` command. */ +/** The `postgres delete` command. */ import { type Block, defineCommand, @@ -6,7 +6,7 @@ import { } from "@prisma/cli-engine"; import { notOk, ok } from "@prisma/cli-engine/protocol"; import { resolveDatabase } from "../../controllers/database"; -import type { DatabaseRemoveResult } from "../../types/database"; +import type { DatabaseDeleteResult } from "../../types/database"; import { branchFlag, databasePositional, @@ -16,15 +16,15 @@ import { import { mapPostgresOperationError } from "./errors"; const CONSENT_QUESTION = - "Removing this database is destructive and requires the exact id."; + "Deleting this database is destructive and requires the exact id."; -function removePresentations(result: DatabaseRemoveResult): Presentations { +function deletePresentations(result: DatabaseDeleteResult): Presentations { return { stdout: () => [], json: () => result, next: () => [], human: (): Block[] => [ - { kind: "summary", status: "ok", text: "Removing database." }, + { kind: "summary", status: "ok", text: "Deleting database." }, { kind: "fields", rows: [ @@ -35,26 +35,26 @@ function removePresentations(result: DatabaseRemoveResult): Presentations { }, { kind: "list", - items: ["Database and its connection metadata were removed."], + items: ["Database and its connection metadata were deleted."], }, ], }; } -export const postgresRemoveCommand = defineCommand({ +export const postgresDeleteCommand = defineCommand({ args: { positionals: { database: databasePositional }, flags: { project: projectFlag, branch: branchFlag }, }, help: { - summary: "Remove a database after exact id confirmation", - examples: ["postgres remove db_123 --confirm db_123"], + summary: "Delete a database after exact id confirmation", + examples: ["postgres delete db_123 --confirm db_123"], }, needs: { credentials: true }, handler: async (args, ctx) => { try { const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres remove"); + await resolvePostgresContext(ctx, args.flags, "postgres delete"); const database = await resolveDatabase( provider, target, @@ -67,12 +67,12 @@ export const postgresRemoveCommand = defineCommand({ await provider.removeDatabase(database.id, { signal: ctx.signal }); - const result: DatabaseRemoveResult = { + const result: DatabaseDeleteResult = { projectId, projectName, database, }; - return ok(ctx.present({ data: result }, removePresentations(result))); + return ok(ctx.present({ data: result }, deletePresentations(result))); } catch (error) { const mapped = mapPostgresOperationError(error); if (mapped) { diff --git a/packages/cli/src/commands/project/remove.ts b/packages/cli/src/commands/project/delete.ts similarity index 79% rename from packages/cli/src/commands/project/remove.ts rename to packages/cli/src/commands/project/delete.ts index 90c9ef57..c59167af 100644 --- a/packages/cli/src/commands/project/remove.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -1,4 +1,4 @@ -/** The `project remove` command. */ +/** The `project delete` command. */ import { type Block, defineCommand, @@ -13,22 +13,22 @@ import { resolveProjectForSetup, toProjectSummary, } from "../../lib/project/setup"; -import type { ProjectRemoveResult } from "../../types/project"; +import type { ProjectDeleteResult } from "../../types/project"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { legacyOperationContext, listWorkspaceProjects } from "./context"; import { mapProjectOperationError } from "./errors"; import { localPinDiagnostics } from "./presentation"; const CONSENT_QUESTION = - "Removing a project is permanent, deletes its databases, and stops its apps, so it requires the exact project id."; + "Deleting a project is permanent, destroys its databases, and stops its apps, so it requires the exact project id."; -function removePresentations(result: ProjectRemoveResult): Presentations { +function deletePresentations(result: ProjectDeleteResult): Presentations { return { stdout: () => [], json: () => result, next: () => [], human: (): Block[] => [ - { kind: "summary", status: "ok", text: "Removing project." }, + { kind: "summary", status: "ok", text: "Deleting project." }, { kind: "fields", rows: [ @@ -40,7 +40,7 @@ function removePresentations(result: ProjectRemoveResult): Presentations { { kind: "list", items: [ - "The project, its databases, and its apps were removed.", + "The project, its databases, and its apps were deleted.", ...(result.localPin.cleared ? ["This directory's local project binding was cleared."] : []), @@ -50,7 +50,7 @@ function removePresentations(result: ProjectRemoveResult): Presentations { }; } -export const projectRemoveCommand = defineCommand({ +export const projectDeleteCommand = defineCommand({ args: { positionals: { project: positional.string({ @@ -60,8 +60,8 @@ export const projectRemoveCommand = defineCommand({ }, }, help: { - summary: "Remove a Project permanently after exact id confirmation", - examples: ["project remove proj_123 --confirm proj_123"], + summary: "Delete a Project permanently after exact id confirmation", + examples: ["project delete proj_123 --confirm proj_123"], }, needs: { credentials: true }, handler: async (args, ctx) => { @@ -90,14 +90,14 @@ export const projectRemoveCommand = defineCommand({ { onError: (message) => warnings.push(message) }, ); - const result: ProjectRemoveResult = { + const result: ProjectDeleteResult = { workspace, project, localPin: { cleared }, }; const diagnostics: Diagnostic[] = localPinDiagnostics(warnings); return ok( - ctx.present({ data: result, diagnostics }, removePresentations(result)), + ctx.present({ data: result, diagnostics }, deletePresentations(result)), ); } catch (error) { const mapped = mapProjectOperationError(error); diff --git a/packages/cli/src/commands/project/env-remove.ts b/packages/cli/src/commands/project/env-delete.ts similarity index 78% rename from packages/cli/src/commands/project/env-remove.ts rename to packages/cli/src/commands/project/env-delete.ts index 138d2e46..79a9ea02 100644 --- a/packages/cli/src/commands/project/env-remove.ts +++ b/packages/cli/src/commands/project/env-delete.ts @@ -1,4 +1,4 @@ -/** The `project env remove` command. */ +/** The `project env delete` command. */ import { type Block, defineCommand, @@ -24,9 +24,9 @@ import { } from "./env-shared"; import { mapProjectOperationError } from "./errors"; -const TITLE = "Removing the environment variable from the scope."; +const TITLE = "Deleting the environment variable from the scope."; -function removePresentations(result: EnvRmResult): Presentations { +function deletePresentations(result: EnvRmResult): Presentations { return { stdout: () => [], json: () => result, @@ -45,11 +45,11 @@ function removePresentations(result: EnvRmResult): Presentations { }; } -export const projectEnvRemoveCommand = defineCommand({ +export const projectEnvDeleteCommand = defineCommand({ args: { positionals: { key: positional.string({ - brief: "Variable key to remove", + brief: "Variable key to delete", placeholder: "key", }), }, @@ -60,23 +60,23 @@ export const projectEnvRemoveCommand = defineCommand({ }, }, help: { - summary: "Remove an environment variable from a scope.", + summary: "Delete an environment variable from a scope.", examples: [ - "project env remove STRIPE_KEY --role production", - "project env remove STRIPE_KEY --role preview", - "project env remove DATABASE_URL --branch feature/foo", + "project env delete STRIPE_KEY --role production", + "project env delete STRIPE_KEY --role preview", + "project env delete DATABASE_URL --branch feature/foo", ], }, needs: { credentials: true }, handler: async (args, ctx) => { try { const key = args.positionals.key; - const scope = requireEnvScope(args.flags, "remove"); + const scope = requireEnvScope(args.flags, "delete"); const { projectId, resolved } = await resolveEnvTarget( ctx, args.flags, scope, - "project env remove", + "project env delete", false, ); @@ -92,7 +92,7 @@ export const projectEnvRemoveCommand = defineCommand({ code: "ENV_VARIABLE_NOT_FOUND", domain: "app", summary: `Variable "${key}" not found in ${formatScopeLabel(scope)}`, - why: "No variable with this key exists in the targeted scope, so there is nothing to remove.", + why: "No variable with this key exists in the targeted scope, so there is nothing to delete.", fix: "Run prisma-cli project env list with the same scope to see the available variables.", exitCode: 1, nextSteps: [`prisma-cli project env list ${formatScopeFlag(scope)}`], @@ -107,7 +107,7 @@ export const projectEnvRemoveCommand = defineCommand({ }, ); if (error) { - throw apiCallError(`Failed to remove ${key}`, response, error); + throw apiCallError(`Failed to delete ${key}`, response, error); } const result: EnvRmResult = { @@ -115,7 +115,7 @@ export const projectEnvRemoveCommand = defineCommand({ scope: resolved.descriptor, key, }; - return ok(ctx.present({ data: result }, removePresentations(result))); + return ok(ctx.present({ data: result }, deletePresentations(result))); } catch (error) { const mapped = mapProjectOperationError(error); if (mapped) { diff --git a/packages/cli/src/commands/project/env-shared.ts b/packages/cli/src/commands/project/env-shared.ts index bb74f8b3..e612be36 100644 --- a/packages/cli/src/commands/project/env-shared.ts +++ b/packages/cli/src/commands/project/env-shared.ts @@ -43,7 +43,7 @@ export interface EnvScopeFlags { export function requireEnvScope( flags: EnvScopeFlags, - command: "add" | "update" | "remove", + command: "add" | "update" | "delete", ): EnvScope { const scope = resolveEnvScope( { roleName: flags.role, branchName: flags.branch }, diff --git a/packages/cli/src/commands/project/errors.ts b/packages/cli/src/commands/project/errors.ts index a671918a..746ca268 100644 --- a/packages/cli/src/commands/project/errors.ts +++ b/packages/cli/src/commands/project/errors.ts @@ -23,7 +23,7 @@ const PROJECT_CODE_MAP: Readonly> = { LOCAL_STATE_WRITE_FAILED: "PROJECT.LOCAL_STATE_WRITE_FAILED", PROJECT_CREATE_FAILED: "PROJECT.CREATE_FAILED", PROJECT_RENAME_FAILED: "PROJECT.RENAME_FAILED", - PROJECT_REMOVE_BLOCKED: "PROJECT.REMOVE_BLOCKED", + PROJECT_DELETE_BLOCKED: "PROJECT.DELETE_BLOCKED", PROJECT_TRANSFER_REJECTED: "PROJECT.TRANSFER_REJECTED", TRANSFER_RECIPIENT_REQUIRED: "PROJECT.TRANSFER_RECIPIENT_REQUIRED", TRANSFER_RECIPIENT_UNAVAILABLE: "PROJECT.TRANSFER_RECIPIENT_UNAVAILABLE", diff --git a/packages/cli/src/commands/project/presentation.ts b/packages/cli/src/commands/project/presentation.ts index cb05620f..7cf07626 100644 --- a/packages/cli/src/commands/project/presentation.ts +++ b/packages/cli/src/commands/project/presentation.ts @@ -15,7 +15,7 @@ export const CONNECT_REPO_NEXT_ACTION: NextAction = { command: `${CLI_NAME} git connect`, }; -/** The legacy local-pin warnings of `project remove` / `project +/** The legacy local-pin warnings of `project delete` / `project * transfer`: the operation succeeded, so they are warn diagnostics * under the pinned local-state code, never errors. */ export function localPinDiagnostics(warnings: readonly string[]): Diagnostic[] { diff --git a/packages/cli/src/commands/service/remove.ts b/packages/cli/src/commands/service/delete.ts similarity index 66% rename from packages/cli/src/commands/service/remove.ts rename to packages/cli/src/commands/service/delete.ts index fdbd5b82..f50cfa16 100644 --- a/packages/cli/src/commands/service/remove.ts +++ b/packages/cli/src/commands/service/delete.ts @@ -4,12 +4,12 @@ import { ok } from "@prisma/cli-engine/protocol"; import type { LocalStateStore } from "../../adapters/local-state"; import { branchValueEmptyError, - removeFailedError, + deleteFailedError, userCancelledError, } from "./errors"; -import { removePresentations } from "./presentation"; +import { deletePresentations } from "./presentation"; import { destroyProgressReporter, resolveServiceReleaseState } from "./release"; -import type { ServiceRemoveResult } from "./results"; +import type { ServiceDeleteResult } from "./results"; import { openServiceStateStore, toServiceSummary } from "./target"; function cleanupWarning(target: string, error: unknown): Diagnostic { @@ -17,12 +17,12 @@ function cleanupWarning(target: string, error: unknown): Diagnostic { return { code: "SERVICE.LOCAL_STATE_CLEANUP_FAILED", severity: "warn", - summary: `The service was removed remotely, but the local ${target} state could not be cleared: ${cause}`, + summary: `The service was deleted remotely, but the local ${target} state could not be cleared: ${cause}`, nextActions: [], }; } -async function clearRemovedServiceState( +async function clearDeletedServiceState( stateStore: LocalStateStore, projectId: string, serviceId: string, @@ -36,12 +36,12 @@ async function clearRemovedServiceState( return warnings; } -export const serviceRemoveCommand = defineCommand({ +export const serviceDeleteCommand = defineCommand({ help: { - summary: "Remove the service from the resolved branch", + summary: "Delete the service from the resolved branch", examples: [ - "service remove", - "service remove --service my-service --confirm my-service", + "service delete --service my-service", + "service delete --service my-service --confirm my-service", ], }, args: { @@ -52,7 +52,7 @@ export const serviceRemoveCommand = defineCommand({ placeholder: "id-or-name", }), branch: flag.string({ - brief: "Branch the removal is scoped to", + brief: "Branch the deletion is scoped to", placeholder: "name", }), }, @@ -67,11 +67,11 @@ export const serviceRemoveCommand = defineCommand({ serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, - command: "remove", + commandName: "service delete", }); const granted = await ctx.prompt.consent( - `Remove Service "${state.service.name}" and every deployment it owns?`, + `Delete Service "${state.service.name}" and every deployment it owns?`, { token: state.service.name }, ); // A token consent resolves to true or throws (mismatch, or the @@ -79,40 +79,40 @@ export const serviceRemoveCommand = defineCommand({ // contract ever loosens — never proceed with a destructive call on a // falsy consent. if (!granted) { - throw userCancelledError("Service removal canceled"); + throw userCancelledError("Service deletion canceled"); } - ctx.report({ kind: "step-started", step: "remove" }); + ctx.report({ kind: "step-started", step: "delete" }); ctx.report({ kind: "status", subject: state.service.name, - status: "removing", + status: "deleting", }); - let removedService: { id: string; name: string }; + let deletedService: { id: string; name: string }; try { - removedService = await state.provider.removeApp(state.service.id, { + deletedService = await state.provider.removeApp(state.service.id, { signal: ctx.signal, progress: destroyProgressReporter(ctx, state.service.name), }); } catch (error) { - ctx.report({ kind: "step-finished", step: "remove", outcome: "failed" }); - throw removeFailedError("Failed to remove service", error); + ctx.report({ kind: "step-finished", step: "delete", outcome: "failed" }); + throw deleteFailedError("Failed to delete service", error); } - ctx.report({ kind: "step-finished", step: "remove", outcome: "ok" }); + ctx.report({ kind: "step-finished", step: "delete", outcome: "ok" }); - const diagnostics = await clearRemovedServiceState( + const diagnostics = await clearDeletedServiceState( openServiceStateStore(ctx), state.projectId, - removedService.id, + deletedService.id, ); - const result: ServiceRemoveResult = { + const result: ServiceDeleteResult = { projectId: state.projectId, - service: toServiceSummary(removedService), - removed: true, + service: toServiceSummary(deletedService), + deleted: true, }; return ok( - ctx.present({ data: result, diagnostics }, removePresentations(result)), + ctx.present({ data: result, diagnostics }, deletePresentations(result)), ); }, }); diff --git a/packages/cli/src/commands/service/deployment-delete.ts b/packages/cli/src/commands/service/deployment-delete.ts index 8e22ef9a..d77f1fb1 100644 --- a/packages/cli/src/commands/service/deployment-delete.ts +++ b/packages/cli/src/commands/service/deployment-delete.ts @@ -41,7 +41,7 @@ export const serviceDeploymentDeleteCommand = defineCommand({ const state = await resolveServiceReleaseState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - command: "delete", + commandName: "service deployment delete", }); const deploymentsResult = await state.provider diff --git a/packages/cli/src/commands/service/deployment-promote.ts b/packages/cli/src/commands/service/deployment-promote.ts index 2744d6fa..d0d47aab 100644 --- a/packages/cli/src/commands/service/deployment-promote.ts +++ b/packages/cli/src/commands/service/deployment-promote.ts @@ -40,7 +40,7 @@ export const serviceDeploymentPromoteCommand = defineCommand({ const state = await resolveServiceReleaseState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - command: "promote", + commandName: "service deployment promote", }); const deploymentsResult = await state.provider diff --git a/packages/cli/src/commands/service/deployment-rollback.ts b/packages/cli/src/commands/service/deployment-rollback.ts index 46bedde3..574a1f47 100644 --- a/packages/cli/src/commands/service/deployment-rollback.ts +++ b/packages/cli/src/commands/service/deployment-rollback.ts @@ -44,7 +44,7 @@ export const serviceDeploymentRollbackCommand = defineCommand({ const state = await resolveServiceReleaseState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, - command: "rollback", + commandName: "service deployment rollback", }); const deploymentsResult = await state.provider diff --git a/packages/cli/src/commands/service/deployment-run-state.ts b/packages/cli/src/commands/service/deployment-run-state.ts index 2ae3bcd6..43e3e406 100644 --- a/packages/cli/src/commands/service/deployment-run-state.ts +++ b/packages/cli/src/commands/service/deployment-run-state.ts @@ -59,7 +59,7 @@ export async function changeDeploymentRunState( const state = await resolveServiceReleaseState(ctx, { ...(args.service !== undefined ? { serviceName: args.service } : {}), ...(args.project !== undefined ? { projectRef: args.project } : {}), - command: verb, + commandName: `service deployment ${verb}`, }); const deploymentsResult = await state.provider diff --git a/packages/cli/src/commands/service/domain-remove.ts b/packages/cli/src/commands/service/domain-delete.ts similarity index 65% rename from packages/cli/src/commands/service/domain-remove.ts rename to packages/cli/src/commands/service/domain-delete.ts index 318bf6f0..fbbb7ead 100644 --- a/packages/cli/src/commands/service/domain-remove.ts +++ b/packages/cli/src/commands/service/domain-delete.ts @@ -2,20 +2,20 @@ import { defineCommand } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { domainTargetArgs } from "./domain-shared"; import { domainCommandError, userCancelledError } from "./errors"; -import { domainRemovePresentations } from "./presentation"; -import type { ServiceDomainRemoveResult } from "./results"; +import { domainDeletePresentations } from "./presentation"; +import type { ServiceDomainDeleteResult } from "./results"; import { normalizeDomainHostname, resolveDomainByHostname, resolveServiceDomainTarget, } from "./target"; -export const serviceDomainRemoveCommand = defineCommand({ +export const serviceDomainDeleteCommand = defineCommand({ help: { - summary: "Detach a custom domain from the service", + summary: "Delete a custom domain from the service", examples: [ - "service domain remove shop.acme.com", - "service domain remove shop.acme.com --confirm shop.acme.com", + "service domain delete shop.acme.com", + "service domain delete shop.acme.com --confirm shop.acme.com", ], }, args: domainTargetArgs(), @@ -26,18 +26,18 @@ export const serviceDomainRemoveCommand = defineCommand({ serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, - commandName: `service domain remove ${hostname}`, + commandName: `service domain delete ${hostname}`, }); const domain = await resolveDomainByHostname( target.provider, target.service.id, hostname, - "remove", + "delete", ctx.signal, ); const granted = await ctx.prompt.consent( - `Detach ${hostname} from Service "${target.resultTarget.service.name}"?`, + `Delete ${hostname} from Service "${target.resultTarget.service.name}"?`, { token: hostname }, ); // A token consent resolves to true or throws (mismatch, or the @@ -45,20 +45,20 @@ export const serviceDomainRemoveCommand = defineCommand({ // contract ever loosens — never proceed with a destructive call on a // falsy consent. if (!granted) { - throw userCancelledError("Custom domain removal canceled"); + throw userCancelledError("Custom domain deletion canceled"); } await target.provider .removeDomain(domain.id, { signal: ctx.signal }) .catch((error) => { - throw domainCommandError("remove", error, hostname); + throw domainCommandError("delete", error, hostname); }); - const result: ServiceDomainRemoveResult = { + const result: ServiceDomainDeleteResult = { ...target.resultTarget, hostname, - removed: true, + deleted: true, }; - return ok(ctx.present({ data: result }, domainRemovePresentations(result))); + return ok(ctx.present({ data: result }, domainDeletePresentations(result))); }, }); diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 1ca032aa..41f0efe5 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -6,7 +6,7 @@ import { DomainApiError, type DomainRecord } from "../../lib/app/app-provider"; import { formatDomainFailureFix } from "../../lib/app/domain-guidance"; import type { NextAction as LegacyNextAction } from "../../next-actions"; -type DomainCommand = "add" | "show" | "remove" | "retry" | "wait"; +type DomainCommand = "add" | "show" | "delete" | "retry" | "wait"; export function runCommandAction(label: string, command: string): NextAction { return { kind: "run-command", label, command: `${CLI_NAME} ${command}` }; @@ -325,11 +325,11 @@ export function liveDeploymentUnknownError(): CliStructuredError { ); } -export function removeFailedError( +export function deleteFailedError( summary: string, cause: unknown, ): CliStructuredError { - return new CliStructuredError("SERVICE.REMOVE_FAILED", summary, { + return new CliStructuredError("SERVICE.DELETE_FAILED", summary, { why: cause instanceof Error ? cause.message : String(cause), nextActions: [ runCommandAction("Inspect the service", "service show"), @@ -346,14 +346,14 @@ export function branchValueEmptyError(): CliStructuredError { "SERVICE.BRANCH_INVALID", "The --branch value cannot be empty", { - why: "service remove scopes the removal to the given branch; an empty --branch would silently fall back to the inferred (possibly production) branch.", + why: "service delete scopes the deletion to the given branch; an empty --branch would silently fall back to the inferred (possibly production) branch.", nextActions: [ adviceAction( "Pass a non-empty branch name, or omit --branch to use the inferred branch.", ), runCommandAction( - "Remove on a branch", - "service remove --service --branch ", + "Delete on a branch", + "service delete --service --branch ", ), ], }, @@ -617,9 +617,9 @@ function domainQuotaExceededError(error: DomainApiError): CliStructuredError { meta: debugMeta(error), nextActions: [ adviceAction( - "Remove an existing custom domain before adding another one.", + "Delete an existing custom domain before adding another one.", ), - runCommandAction("Remove a domain", "service domain remove "), + runCommandAction("Delete a domain", "service domain delete "), ], }, ); @@ -637,7 +637,7 @@ function domainAlreadyRegisteredError( meta: debugMeta(error), nextActions: [ adviceAction( - "Select the service that owns this hostname and remove it there, or contact Prisma support if you cannot access it.", + "Select the service that owns this hostname and delete it there, or contact Prisma support if you cannot access it.", ), ], }, diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index c08f0469..17e2f03c 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -3,13 +3,14 @@ import type { NextAction } from "@prisma/cli-engine/protocol"; import { adviceAction, runCommandAction } from "./errors"; import type { ServiceCreateResult, + ServiceDeleteResult, ServiceDeploymentDeleteResult, ServiceDeploymentListResult, ServiceDeploymentRunStateResult, ServiceDeploymentShowResult, ServiceDeploymentSummary, ServiceDomainAddResult, - ServiceDomainRemoveResult, + ServiceDomainDeleteResult, ServiceDomainRetryResult, ServiceDomainShowResult, ServiceDomainSummary, @@ -18,7 +19,6 @@ import type { ServiceListResult, ServiceOpenResult, ServicePromoteResult, - ServiceRemoveResult, ServiceRollbackResult, ServiceShowResult, } from "./results"; @@ -458,20 +458,20 @@ export function deploymentDeletePresentations( }; } -export function removePresentations( - result: ServiceRemoveResult, +export function deletePresentations( + result: ServiceDeleteResult, ): Presentations { return { stdout: () => [], json: () => result, human: () => [ completed( - `Removed ${result.service.name} and every deployment it owned.`, + `Deleted ${result.service.name} and every deployment it owned.`, ), fields([ { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, - { label: "removed", value: "yes" }, + { label: "deleted", value: "yes" }, ]), ], next: () => [ @@ -555,19 +555,19 @@ export function domainShowPresentations( }; } -export function domainRemovePresentations( - result: ServiceDomainRemoveResult, +export function domainDeletePresentations( + result: ServiceDomainDeleteResult, ): Presentations { return { stdout: () => [], json: () => result, next: () => [], human: () => [ - completed(`Removed ${result.hostname} from ${result.service.name}.`), + completed(`Deleted ${result.hostname} from ${result.service.name}.`), fields([ ...domainTargetRows(result), { label: "hostname", value: result.hostname }, - { label: "removed", value: "yes" }, + { label: "deleted", value: "yes" }, ]), ], }; diff --git a/packages/cli/src/commands/service/release.ts b/packages/cli/src/commands/service/release.ts index bdded0a5..7155e9a2 100644 --- a/packages/cli/src/commands/service/release.ts +++ b/packages/cli/src/commands/service/release.ts @@ -20,20 +20,16 @@ export interface ServiceReleaseState { /** The read flow for the commands that act on a service which must * already exist: every `service deployment` verb, plus `service - * remove`, which is the one that does not sit under that group. */ + * delete`, which is the one that does not sit under that group. */ export async function resolveServiceReleaseState( ctx: ServiceContext, options: { serviceName?: string; projectRef?: string; branchName?: string; - command: "promote" | "rollback" | "remove" | "start" | "stop" | "delete"; + commandName: string; }, ): Promise { - const commandName = - options.command === "remove" - ? "service remove" - : `service deployment ${options.command}`; const state = await resolveServiceReadState(ctx, { ...(options.serviceName !== undefined ? { serviceName: options.serviceName } @@ -44,7 +40,7 @@ export async function resolveServiceReleaseState( ...(options.branchName !== undefined ? { branchName: options.branchName } : {}), - commandName, + commandName: options.commandName, }); return { provider: state.provider, @@ -197,7 +193,7 @@ export function destroyProgressReporter( kind: "status", subject: serviceName, status: "deleted", - from: "removing", + from: "deleting", }); }, }; diff --git a/packages/cli/src/commands/service/results.ts b/packages/cli/src/commands/service/results.ts index 1fdf7bb1..d5ce88db 100644 --- a/packages/cli/src/commands/service/results.ts +++ b/packages/cli/src/commands/service/results.ts @@ -96,10 +96,10 @@ export interface ServiceDeploymentDeleteResult { deleted: true; } -export interface ServiceRemoveResult { +export interface ServiceDeleteResult { projectId: string; service: ServiceSummary; - removed: true; + deleted: true; } export interface ServiceDomainSummary { @@ -137,9 +137,9 @@ export interface ServiceDomainShowResult extends ServiceDomainTarget { domain: ServiceDomainSummary; } -export interface ServiceDomainRemoveResult extends ServiceDomainTarget { +export interface ServiceDomainDeleteResult extends ServiceDomainTarget { hostname: string; - removed: true; + deleted: true; } export interface ServiceDomainRetryResult extends ServiceDomainTarget { diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 4e0ea0fc..d3010f50 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -435,7 +435,7 @@ export async function resolveDomainByHostname( provider: AppProvider, serviceId: string, hostname: string, - command: "add" | "show" | "remove" | "retry" | "wait", + command: "add" | "show" | "delete" | "retry" | "wait", signal: AbortSignal, ): Promise { const domains = await provider diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts index 4c9a4fb1..fd02c0b9 100644 --- a/packages/cli/src/controllers/database.ts +++ b/packages/cli/src/controllers/database.ts @@ -140,7 +140,7 @@ export async function resolveDatabase( // `showDatabase` returns null for one condition only: a 404 that is not // a plan-limit error, which is the API saying the database is gone. // Falling back to the row from the list call taken moments earlier let - // `postgres remove` name a database in its confirmation prompt that no + // `postgres delete` name a database in its confirmation prompt that no // longer existed. A read the API refused is a failure, not a reason to // use an older copy. if (shown === null) { diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 78e8d81c..4d77a9be 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -138,7 +138,7 @@ export async function cleanupLocalPinForProject( return true; } catch { hooks.onError( - `The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the removed project but could not be deleted.`, + `The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the deleted project but could not be deleted.`, ); return false; } diff --git a/packages/cli/src/lib/app/env-config.ts b/packages/cli/src/lib/app/env-config.ts index 76af0293..7bb21a24 100644 --- a/packages/cli/src/lib/app/env-config.ts +++ b/packages/cli/src/lib/app/env-config.ts @@ -13,14 +13,14 @@ export interface ScopeFlagInput { export interface ScopeOptions { requireExplicit: boolean; - command: "add" | "update" | "remove" | "list"; + command: "add" | "update" | "delete" | "list"; } const VALID_ROLES: ReadonlySet = new Set(["production", "preview"]); function positionalHint(command: ScopeOptions["command"]): string { if (command === "add" || command === "update") return "KEY=value "; - if (command === "remove") return "KEY "; + if (command === "delete") return "KEY "; return ""; } @@ -138,7 +138,7 @@ export function parseKeyValuePositional( throw usageError( `KEY=VALUE argument has an empty value`, `"${raw}" has an empty value after the = separator.`, - `Pass a non-empty value, or use prisma-cli project env remove to remove a variable.`, + `Pass a non-empty value, or use prisma-cli project env delete to delete a variable.`, [`prisma-cli project env ${command} ${key}=value --role production`], "app", ); diff --git a/packages/cli/src/lib/project/provider.ts b/packages/cli/src/lib/project/provider.ts index 950d2968..164d9870 100644 --- a/packages/cli/src/lib/project/provider.ts +++ b/packages/cli/src/lib/project/provider.ts @@ -77,7 +77,7 @@ export function createManagementProjectProvider( signal: options.signal, }); if (result.response?.status === 400) { - throw projectRemoveBlockedError(options.projectId, result.error); + throw projectDeleteBlockedError(options.projectId, result.error); } if (result.error) { throw projectApiError( @@ -129,20 +129,20 @@ export function projectRenameFailedError( }); } -export function projectRemoveBlockedError( +export function projectDeleteBlockedError( projectId: string, error: RawApiErrorBody | undefined, ): CliError { return new CliError({ - code: "PROJECT_REMOVE_BLOCKED", + code: "PROJECT_DELETE_BLOCKED", domain: "project", - summary: "Project cannot be removed yet", + summary: "Project cannot be deleted yet", why: error?.error?.message ?? `Project "${projectId}" still has active deployments.`, - fix: "Remove the project's services first, then retry the removal.", + fix: "Delete the project's services first, then retry the deletion.", exitCode: 1, - nextSteps: [formatPrismaCliCommand(["service", "remove", ""])], + nextSteps: [formatPrismaCliCommand(["service", "delete", ""])], }); } diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts index ec17447c..6ebfc8f6 100644 --- a/packages/cli/src/types/app.ts +++ b/packages/cli/src/types/app.ts @@ -161,13 +161,6 @@ export interface AppRollbackResult { previousLiveDeploymentId: string | null; } -export interface AppRemoveResult { - projectId: string; - verboseContext?: AppResolvedContext; - app: AppSummary; - removed: true; -} - export type AppDomainStatus = | "pending_dns" | "verifying" @@ -226,11 +219,6 @@ export interface AppDomainShowResult extends AppDomainTarget { domain: AppDomainSummary; } -export interface AppDomainRemoveResult extends AppDomainTarget { - hostname: string; - removed: true; -} - export interface AppDomainRetryResult extends AppDomainTarget { domain: AppDomainSummary; } diff --git a/packages/cli/src/types/database.ts b/packages/cli/src/types/database.ts index 3eaef218..1e8970e9 100644 --- a/packages/cli/src/types/database.ts +++ b/packages/cli/src/types/database.ts @@ -51,7 +51,7 @@ export interface DatabaseCreateResult { connectionString: string; } -export interface DatabaseRemoveResult { +export interface DatabaseDeleteResult { projectId: string; projectName: string; verboseContext?: DatabaseResolvedContext; @@ -75,7 +75,7 @@ export interface DatabaseConnectionCreateResult { connectionString: string; } -export interface DatabaseConnectionRemoveResult { +export interface DatabaseConnectionDeleteResult { connection: { id: string; }; diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index e388df2d..25fc6d82 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -84,7 +84,7 @@ export interface ProjectRenameResult { previousName: string; } -export interface ProjectRemoveResult { +export interface ProjectDeleteResult { workspace: AuthWorkspace; project: ProjectSummary; localPin: { diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index 12843b8f..46efb920 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -139,7 +139,7 @@ const AWAITING_COVERAGE: readonly string[] = [ "service logs", "service domain add", "service domain show", - "service domain remove", + "service domain delete", "service domain retry", "service domain wait", "build logs", diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index 4cb138ce..32a73d4e 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -121,23 +121,23 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "orm init", "postgres backup list", "postgres connection create", + "postgres connection delete", "postgres connection list", - "postgres connection remove", "postgres connection rotate", "postgres create", + "postgres delete", "postgres list", - "postgres remove", "postgres restore", "postgres show", "postgres usage", "project create", + "project delete", "project env add", + "project env delete", "project env list", - "project env remove", "project env update", "project link", "project list", - "project remove", "project rename", "project show", "project transfer", @@ -145,6 +145,7 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "ref list", "ref set", "service create", + "service delete", "service deployment delete", "service deployment list", "service deployment promote", @@ -153,14 +154,13 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "service deployment start", "service deployment stop", "service domain add", - "service domain remove", + "service domain delete", "service domain retry", "service domain show", "service domain wait", "service list", "service logs", "service open", - "service remove", "service show", "telemetry disable", "telemetry enable", diff --git a/packages/cli/tests/postgres.test.ts b/packages/cli/tests/postgres.test.ts index 5b39840c..22dc2b30 100644 --- a/packages/cli/tests/postgres.test.ts +++ b/packages/cli/tests/postgres.test.ts @@ -7,12 +7,12 @@ import { describe, expect, it } from "vitest"; import { postgresBackupListCommand } from "../src/commands/postgres/backup-list"; import { postgresConnectionCreateCommand } from "../src/commands/postgres/connection-create"; +import { postgresConnectionDeleteCommand } from "../src/commands/postgres/connection-delete"; import { postgresConnectionListCommand } from "../src/commands/postgres/connection-list"; -import { postgresConnectionRemoveCommand } from "../src/commands/postgres/connection-remove"; import { postgresConnectionRotateCommand } from "../src/commands/postgres/connection-rotate"; import { postgresCreateCommand } from "../src/commands/postgres/create"; +import { postgresDeleteCommand } from "../src/commands/postgres/delete"; import { postgresListCommand } from "../src/commands/postgres/list"; -import { postgresRemoveCommand } from "../src/commands/postgres/remove"; import { postgresRestoreCommand } from "../src/commands/postgres/restore"; import { postgresShowCommand } from "../src/commands/postgres/show"; import { postgresUsageCommand } from "../src/commands/postgres/usage"; @@ -142,12 +142,12 @@ function makeCli(client: ManagementApiClient, signedIn = true) { "postgres create": postgresCreateCommand, "postgres usage": postgresUsageCommand, "postgres restore": postgresRestoreCommand, - "postgres remove": postgresRemoveCommand, + "postgres delete": postgresDeleteCommand, "postgres backup list": postgresBackupListCommand, "postgres connection list": postgresConnectionListCommand, "postgres connection create": postgresConnectionCreateCommand, "postgres connection rotate": postgresConnectionRotateCommand, - "postgres connection remove": postgresConnectionRemoveCommand, + "postgres connection delete": postgresConnectionDeleteCommand, }, groups: { postgres: { brief: "Manage Prisma Postgres databases for a project" }, @@ -530,7 +530,7 @@ describe("prisma-cli postgres show", () => { it("fails when the follow-up read says the database is gone", async () => { // The list call finds it and the read that follows returns 404, // which is the API saying it no longer exists. The command used to - // continue with the row from the list, so `postgres remove` could + // continue with the row from the list, so `postgres delete` could // name a database in its confirmation prompt that was already gone. const result = await makeCli( postgresClient({ @@ -1513,11 +1513,11 @@ describe("prisma-cli postgres restore", () => { }); }); -describe("prisma-cli postgres remove", () => { - it("removes the database", async () => { +describe("prisma-cli postgres delete", () => { + it("deletes the database", async () => { const calls: Call[] = []; const result = await makeCli(postgresClient({ calls })).run( - ["postgres", "remove", "db_1", "--confirm", "db_1"], + ["postgres", "delete", "db_1", "--confirm", "db_1"], { cwd: await pinnedCwd(), isTty: { stdout: true } }, ); @@ -1530,7 +1530,7 @@ describe("prisma-cli postgres remove", () => { ), ).toBe(true); expect(blocks(result.presented)).toEqual([ - { kind: "summary", status: "ok", text: "Removing database." }, + { kind: "summary", status: "ok", text: "Deleting database." }, { kind: "fields", rows: [ @@ -1541,14 +1541,14 @@ describe("prisma-cli postgres remove", () => { }, { kind: "list", - items: ["Database and its connection metadata were removed."], + items: ["Database and its connection metadata were deleted."], }, ]); }); - it("refuses to remove without consent in a non-interactive run", async () => { + it("refuses to delete without consent in a non-interactive run", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "remove", "db_1", "--json"], + ["postgres", "delete", "db_1", "--json"], { cwd: await pinnedCwd() }, ); @@ -1559,9 +1559,9 @@ describe("prisma-cli postgres remove", () => { }); }); - it("refuses to remove when --yes stands in for consent", async () => { + it("refuses to delete when --yes stands in for consent", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "remove", "db_1", "--yes", "--json"], + ["postgres", "delete", "db_1", "--yes", "--json"], { cwd: await pinnedCwd() }, ); @@ -1573,9 +1573,9 @@ describe("prisma-cli postgres remove", () => { }); }); - it("removes when the typed answer is the database id", async () => { + it("deletes when the typed answer is the database id", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "remove", "db_1"], + ["postgres", "delete", "db_1"], { cwd: await pinnedCwd(), answers: ["db_1"], @@ -1588,7 +1588,7 @@ describe("prisma-cli postgres remove", () => { it("fails when the typed answer is not the database id", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "remove", "db_1", "--json"], + ["postgres", "delete", "db_1", "--json"], { cwd: await pinnedCwd(), answers: ["nope"], @@ -1605,7 +1605,7 @@ describe("prisma-cli postgres remove", () => { it("maps an unknown database to POSTGRES.NOT_FOUND", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "remove", "nope", "--confirm", "nope", "--json"], + ["postgres", "delete", "nope", "--confirm", "nope", "--json"], { cwd: await pinnedCwd() }, ); @@ -1625,7 +1625,7 @@ describe("prisma-cli postgres remove", () => { ).run( [ "postgres", - "remove", + "delete", "acme-production", "--confirm", "acme-production", @@ -1642,9 +1642,9 @@ describe("prisma-cli postgres remove", () => { }); }); - it("returns the pre-removal summary in json mode", async () => { + it("returns the pre-deletion summary in json mode", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "remove", "db_1", "--confirm", "db_1", "--json"], + ["postgres", "delete", "db_1", "--confirm", "db_1", "--json"], { cwd: await pinnedCwd() }, ); @@ -1652,7 +1652,7 @@ describe("prisma-cli postgres remove", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: true, - commandId: "postgres.remove", + commandId: "postgres.delete", result: { projectId: "proj_1", projectName: "Billing", @@ -1665,7 +1665,7 @@ describe("prisma-cli postgres remove", () => { it("requires credentials", async () => { const result = await makeCli(postgresClient(), false).run([ "postgres", - "remove", + "delete", "db_1", "--confirm", "db_1", @@ -2421,11 +2421,11 @@ describe("prisma-cli postgres connection rotate", () => { }); }); -describe("prisma-cli postgres connection remove", () => { - it("removes the connection", async () => { +describe("prisma-cli postgres connection delete", () => { + it("deletes the connection", async () => { const calls: Call[] = []; const result = await makeCli(postgresClient({ calls })).run( - ["postgres", "connection", "remove", "conn_1", "--confirm", "conn_1"], + ["postgres", "connection", "delete", "conn_1", "--confirm", "conn_1"], { cwd: await pinnedCwd(), isTty: { stdout: true } }, ); @@ -2437,7 +2437,7 @@ describe("prisma-cli postgres connection remove", () => { ), ).toBe(true); expect(blocks(result.presented)).toEqual([ - { kind: "summary", status: "ok", text: "Removing database connection." }, + { kind: "summary", status: "ok", text: "Deleting database connection." }, { kind: "fields", rows: [{ label: "connection", value: "conn_1" }], @@ -2445,7 +2445,7 @@ describe("prisma-cli postgres connection remove", () => { { kind: "list", items: [ - "The connection metadata was removed. Existing one-time secrets were not shown.", + "The connection metadata was deleted. Existing one-time secrets were not shown.", ], }, ]); @@ -2453,7 +2453,7 @@ describe("prisma-cli postgres connection remove", () => { it("rejects a blank connection id", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "connection", "remove", " ", "--json"], + ["postgres", "connection", "delete", " ", "--json"], { cwd: await pinnedCwd() }, ); @@ -2463,22 +2463,22 @@ describe("prisma-cli postgres connection remove", () => { error: { code: "POSTGRES.USAGE_ERROR", summary: "Connection id required", - why: "Database connection removal needs a connection id.", + why: "Database connection deletion needs a connection id.", nextActions: [ - { kind: "user-choice", label: "Pass the connection id to remove." }, + { kind: "user-choice", label: "Pass the connection id to delete." }, { kind: "run-command", command: - "prisma-cli postgres connection remove --confirm ", + "prisma-cli postgres connection delete --confirm ", }, ], }, }); }); - it("refuses to remove without consent in a non-interactive run", async () => { + it("refuses to delete without consent in a non-interactive run", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "connection", "remove", "conn_1", "--json"], + ["postgres", "connection", "delete", "conn_1", "--json"], { cwd: await pinnedCwd() }, ); @@ -2489,9 +2489,9 @@ describe("prisma-cli postgres connection remove", () => { }); }); - it("refuses to remove when --yes stands in for consent", async () => { + it("refuses to delete when --yes stands in for consent", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "connection", "remove", "conn_1", "--yes", "--json"], + ["postgres", "connection", "delete", "conn_1", "--yes", "--json"], { cwd: await pinnedCwd() }, ); @@ -2503,9 +2503,9 @@ describe("prisma-cli postgres connection remove", () => { }); }); - it("removes when the typed answer is the connection id", async () => { + it("deletes when the typed answer is the connection id", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "connection", "remove", "conn_1"], + ["postgres", "connection", "delete", "conn_1"], { cwd: await pinnedCwd(), answers: ["conn_1"], @@ -2518,7 +2518,7 @@ describe("prisma-cli postgres connection remove", () => { it("fails when the typed answer is not the connection id", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "connection", "remove", "conn_1", "--json"], + ["postgres", "connection", "delete", "conn_1", "--json"], { cwd: await pinnedCwd(), answers: ["nope"], @@ -2533,12 +2533,12 @@ describe("prisma-cli postgres connection remove", () => { }); }); - it("returns the removed connection in json mode", async () => { + it("returns the deleted connection in json mode", async () => { const result = await makeCli(postgresClient()).run( [ "postgres", "connection", - "remove", + "delete", "conn_1", "--confirm", "conn_1", @@ -2551,7 +2551,7 @@ describe("prisma-cli postgres connection remove", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: true, - commandId: "postgres.connection.remove", + commandId: "postgres.connection.delete", result: { connection: { id: "conn_1" } }, nextActions: [], }); @@ -2561,7 +2561,7 @@ describe("prisma-cli postgres connection remove", () => { const result = await makeCli(postgresClient(), false).run([ "postgres", "connection", - "remove", + "delete", "conn_1", "--confirm", "conn_1", diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index c0680114..58ad1677 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -14,13 +14,13 @@ import { import { WorkspaceSelectionError } from "../src/auth/token-storage"; import { projectCreateCommand } from "../src/commands/project/create"; +import { projectDeleteCommand } from "../src/commands/project/delete"; import { projectEnvAddCommand } from "../src/commands/project/env-add"; +import { projectEnvDeleteCommand } from "../src/commands/project/env-delete"; import { projectEnvListCommand } from "../src/commands/project/env-list"; -import { projectEnvRemoveCommand } from "../src/commands/project/env-remove"; import { projectEnvUpdateCommand } from "../src/commands/project/env-update"; import { projectLinkCommand } from "../src/commands/project/link"; import { projectListCommand } from "../src/commands/project/list"; -import { projectRemoveCommand } from "../src/commands/project/remove"; import { projectRenameCommand } from "../src/commands/project/rename"; import { projectShowCommand } from "../src/commands/project/show"; import { projectTransferCommand } from "../src/commands/project/transfer"; @@ -112,12 +112,12 @@ function makeCli(client: ManagementApiClient, signedIn = true) { "project create": projectCreateCommand, "project link": projectLinkCommand, "project rename": projectRenameCommand, - "project remove": projectRemoveCommand, + "project delete": projectDeleteCommand, "project transfer": projectTransferCommand, "project env add": projectEnvAddCommand, "project env update": projectEnvUpdateCommand, "project env list": projectEnvListCommand, - "project env remove": projectEnvRemoveCommand, + "project env delete": projectEnvDeleteCommand, }, groups: { project: { brief: "Manage and inspect your Prisma projects" }, @@ -449,7 +449,7 @@ describe("prisma-cli project show", () => { }); }); - it("maps a pin pointing at a removed project to PROJECT.LOCAL_STATE_STALE", async () => { + it("maps a pin pointing at a deleted project to PROJECT.LOCAL_STATE_STALE", async () => { const cwd = await tempCwd({ workspaceId: "ws_1", projectId: "proj_gone" }); const result = await makeCli(fakeClient()).run( ["project", "show", "--json"], @@ -2313,12 +2313,12 @@ describe("prisma-cli project env list", () => { }); }); -describe("prisma-cli project env remove", () => { - it("removes the variable from the scope", async () => { +describe("prisma-cli project env delete", () => { + it("deletes the variable from the scope", async () => { const writes: unknown[] = []; const result = await makeCli( envClient({ writes, variables: [envRow()] }), - ).run(["project", "env", "remove", "STRIPE_KEY", "--role", "production"], { + ).run(["project", "env", "delete", "STRIPE_KEY", "--role", "production"], { cwd: await pinnedCwd(), isTty: { stdout: true }, }); @@ -2329,7 +2329,7 @@ describe("prisma-cli project env remove", () => { { kind: "summary", status: "info", - text: "Removing the environment variable from the scope.", + text: "Deleting the environment variable from the scope.", }, { kind: "fields", @@ -2347,7 +2347,7 @@ describe("prisma-cli project env remove", () => { [ "project", "env", - "remove", + "delete", "STRIPE_KEY", "--role", "production", @@ -2382,7 +2382,7 @@ describe("prisma-cli project env remove", () => { [ "project", "env", - "remove", + "delete", "STRIPE_KEY", "--role", "production", @@ -2395,7 +2395,7 @@ describe("prisma-cli project env remove", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: true, - commandId: "project.env.remove", + commandId: "project.env.delete", result: { projectId: "proj_1", scope: { kind: "role", role: "production" }, @@ -2408,7 +2408,7 @@ describe("prisma-cli project env remove", () => { const result = await makeCli(envClient(), false).run([ "project", "env", - "remove", + "delete", "STRIPE_KEY", "--role", "production", @@ -2422,7 +2422,7 @@ describe("prisma-cli project env remove", () => { }); }); - it("removes a variable from a branch scope", async () => { + it("deletes a variable from a branch scope", async () => { const writes: unknown[] = []; const result = await makeCli( envClient({ @@ -2440,7 +2440,7 @@ describe("prisma-cli project env remove", () => { ], }), ).run( - ["project", "env", "remove", "STRIPE_KEY", "--branch", "feature/foo"], + ["project", "env", "delete", "STRIPE_KEY", "--branch", "feature/foo"], { cwd: await pinnedCwd() }, ); @@ -2453,7 +2453,7 @@ describe("prisma-cli project env remove", () => { [ "project", "env", - "remove", + "delete", "STRIPE_KEY", "--role", "preview", @@ -2470,14 +2470,14 @@ describe("prisma-cli project env remove", () => { error: { code: "PROJECT.USAGE_ERROR", summary: - "prisma-cli project env remove accepts either --role or --branch", + "prisma-cli project env delete accepts either --role or --branch", }, }); }); it("requires an explicit scope", async () => { const result = await makeCli(envClient()).run( - ["project", "env", "remove", "STRIPE_KEY", "--json"], + ["project", "env", "delete", "STRIPE_KEY", "--json"], { cwd: await pinnedCwd() }, ); @@ -2487,17 +2487,17 @@ describe("prisma-cli project env remove", () => { ok: false, error: { code: "PROJECT.USAGE_ERROR", - summary: "prisma-cli project env remove requires --role or --branch", + summary: "prisma-cli project env delete requires --role or --branch", }, }); }); }); -describe("prisma-cli project remove", () => { - it("removes the project and clears a pin that points at it", async () => { +describe("prisma-cli project delete", () => { + it("deletes the project and clears a pin that points at it", async () => { const cwd = await tempCwd({ workspaceId: "ws_1", projectId: "proj_1" }); const result = await makeCli(fakeClient()).run( - ["project", "remove", "proj_1", "--confirm", "proj_1"], + ["project", "delete", "proj_1", "--confirm", "proj_1"], { cwd, isTty: { stdout: true } }, ); @@ -2507,7 +2507,7 @@ describe("prisma-cli project remove", () => { localPin: { cleared: true }, }); expect(blocks(result.presented)).toEqual([ - { kind: "summary", status: "ok", text: "Removing project." }, + { kind: "summary", status: "ok", text: "Deleting project." }, { kind: "fields", rows: [ @@ -2519,7 +2519,7 @@ describe("prisma-cli project remove", () => { { kind: "list", items: [ - "The project, its databases, and its apps were removed.", + "The project, its databases, and its apps were deleted.", "This directory's local project binding was cleared.", ], }, @@ -2538,7 +2538,7 @@ describe("prisma-cli project remove", () => { await chmod(path.join(cwd, ".prisma"), 0o555); try { const result = await makeCli(fakeClient()).run( - ["project", "remove", "proj_1", "--confirm", "proj_1"], + ["project", "delete", "proj_1", "--confirm", "proj_1"], { cwd }, ); @@ -2551,7 +2551,7 @@ describe("prisma-cli project remove", () => { code: "PROJECT.LOCAL_STATE_WRITE_FAILED", severity: "warn", summary: - "The local pin .prisma/local.json points at the removed project but could not be deleted.", + "The local pin .prisma/local.json points at the deleted project but could not be deleted.", nextActions: [], }, ]); @@ -2561,9 +2561,9 @@ describe("prisma-cli project remove", () => { }, ); - it("refuses to remove without consent in a non-interactive run", async () => { + it("refuses to delete without consent in a non-interactive run", async () => { const result = await makeCli(fakeClient()).run( - ["project", "remove", "proj_1", "--json"], + ["project", "delete", "proj_1", "--json"], { cwd: await tempCwd() }, ); @@ -2574,9 +2574,9 @@ describe("prisma-cli project remove", () => { }); }); - it("refuses to remove when --yes stands in for consent", async () => { + it("refuses to delete when --yes stands in for consent", async () => { const result = await makeCli(fakeClient()).run( - ["project", "remove", "proj_1", "--yes", "--json"], + ["project", "delete", "proj_1", "--yes", "--json"], { cwd: await tempCwd() }, ); @@ -2587,9 +2587,9 @@ describe("prisma-cli project remove", () => { }); }); - it("removes the project when the typed answer is the project id", async () => { + it("deletes the project when the typed answer is the project id", async () => { const result = await makeCli(fakeClient()).run( - ["project", "remove", "proj_1"], + ["project", "delete", "proj_1"], { cwd: await tempCwd(), answers: ["proj_1"], @@ -2603,7 +2603,7 @@ describe("prisma-cli project remove", () => { it("fails when the typed answer is not the project id", async () => { const result = await makeCli(fakeClient()).run( - ["project", "remove", "proj_1", "--json"], + ["project", "delete", "proj_1", "--json"], { cwd: await tempCwd(), answers: ["nope"], @@ -2618,7 +2618,7 @@ describe("prisma-cli project remove", () => { }); }); - it("maps a blocked removal to PROJECT.REMOVE_BLOCKED", async () => { + it("maps a blocked deletion to PROJECT.DELETE_BLOCKED", async () => { const result = await makeCli( fakeClient({ del: () => ({ @@ -2626,7 +2626,7 @@ describe("prisma-cli project remove", () => { response: new Response(null, { status: 400 }), }), }), - ).run(["project", "remove", "proj_1", "--confirm", "proj_1", "--json"], { + ).run(["project", "delete", "proj_1", "--confirm", "proj_1", "--json"], { cwd: await tempCwd(), }); @@ -2634,8 +2634,8 @@ describe("prisma-cli project remove", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "PROJECT.REMOVE_BLOCKED", - summary: "Project cannot be removed yet", + code: "PROJECT.DELETE_BLOCKED", + summary: "Project cannot be deleted yet", why: "Project still has deployments.", }, }); @@ -2643,7 +2643,7 @@ describe("prisma-cli project remove", () => { it("maps an unknown positional to PROJECT.NOT_FOUND", async () => { const result = await makeCli(fakeClient()).run( - ["project", "remove", "nope", "--confirm", "nope", "--json"], + ["project", "delete", "nope", "--confirm", "nope", "--json"], { cwd: await tempCwd() }, ); @@ -2660,7 +2660,7 @@ describe("prisma-cli project remove", () => { { ...API_PROJECTS[0], id: "proj_b", name: "Billing" }, ]; const result = await makeCli(fakeClient({ projects: duplicates })).run( - ["project", "remove", "Billing", "--confirm", "Billing", "--json"], + ["project", "delete", "Billing", "--confirm", "Billing", "--json"], { cwd: await tempCwd() }, ); @@ -2671,9 +2671,9 @@ describe("prisma-cli project remove", () => { }); }); - it("returns the remove result unchanged in json mode", async () => { + it("returns the delete result unchanged in json mode", async () => { const result = await makeCli(fakeClient()).run( - ["project", "remove", "proj_1", "--confirm", "proj_1", "--json"], + ["project", "delete", "proj_1", "--confirm", "proj_1", "--json"], { cwd: await tempCwd() }, ); @@ -2681,7 +2681,7 @@ describe("prisma-cli project remove", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: true, - commandId: "project.remove", + commandId: "project.delete", result: { workspace: { id: WORKSPACE_ID, name: "Acme Inc" }, project: { id: "proj_1", name: "Billing" }, @@ -2694,7 +2694,7 @@ describe("prisma-cli project remove", () => { it("requires credentials", async () => { const result = await makeCli(fakeClient(), false).run([ "project", - "remove", + "delete", "proj_1", "--confirm", "proj_1", diff --git a/packages/cli/tests/service-remove.test.ts b/packages/cli/tests/service-delete.test.ts similarity index 87% rename from packages/cli/tests/service-remove.test.ts rename to packages/cli/tests/service-delete.test.ts index 697ad94b..0c9faf38 100644 --- a/packages/cli/tests/service-remove.test.ts +++ b/packages/cli/tests/service-delete.test.ts @@ -11,14 +11,14 @@ import { const INTERACTIVE = { stdin: true, stdout: true, stderr: true }; -describe("prisma-cli service remove", () => { - it("removes the selected service once consent is granted", async () => { +describe("prisma-cli service delete", () => { + it("deletes the service once consent is granted", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -36,14 +36,14 @@ describe("prisma-cli service remove", () => { expect(result.presented?.data).toEqual({ projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, - removed: true, + deleted: true, }); expect(presentedSummary(result.presented)).toEqual({ kind: "summary", status: "ok", - text: "Removed hello-world and every deployment it owned.", + text: "Deleted hello-world and every deployment it owned.", }); - // A removal used to offer `service deploy`; the binary has no such + // A deletion used to offer `service deploy`; the binary has no such // command, so listing deployments is all that is left to suggest. expect(result.presented?.presentation.next).toEqual([ { @@ -54,13 +54,13 @@ describe("prisma-cli service remove", () => { ]); }); - it("emits the remove step around the teardown progress and the deleted status", async () => { + it("emits the delete step around the teardown progress and the deleted status", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -74,11 +74,11 @@ describe("prisma-cli service remove", () => { }, ); - expect(result.events[0]).toEqual({ kind: "step-started", step: "remove" }); + expect(result.events[0]).toEqual({ kind: "step-started", step: "delete" }); expect(result.events[1]).toEqual({ kind: "status", subject: "hello-world", - status: "removing", + status: "deleting", }); expect(result.events).toContainEqual({ kind: "progress", @@ -90,11 +90,11 @@ describe("prisma-cli service remove", () => { kind: "status", subject: "hello-world", status: "deleted", - from: "removing", + from: "deleting", }); expect(result.events.at(-1)).toEqual({ kind: "step-finished", - step: "remove", + step: "delete", outcome: "ok", }); }); @@ -104,7 +104,7 @@ describe("prisma-cli service remove", () => { // Nothing in the service family writes this key any more, but the // legacy `app` family still does for the same project, so clearing - // it on removal has real effect until that family retires. Seeded + // it on deletion has real effect until that family retires. Seeded // here so the assertion below observes a key that was present. const statePath = path.join(harness.stateDir, "state.json"); await mkdir(path.dirname(statePath), { recursive: true }); @@ -118,7 +118,7 @@ describe("prisma-cli service remove", () => { await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -140,13 +140,13 @@ describe("prisma-cli service remove", () => { ).toBeUndefined(); }); - it("emits the completed json envelope with commandId service.remove", async () => { + it("emits the completed json envelope with commandId service.delete", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -166,8 +166,8 @@ describe("prisma-cli service remove", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.remove"); - expect(frame.envelope.result).toMatchObject({ removed: true }); + expect(frame.envelope.commandId).toBe("service.delete"); + expect(frame.envelope.result).toMatchObject({ deleted: true }); }); it("settles a mistyped consent token as the engine mismatch error", async () => { @@ -176,7 +176,7 @@ describe("prisma-cli service remove", () => { const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -200,7 +200,7 @@ describe("prisma-cli service remove", () => { const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -224,7 +224,7 @@ describe("prisma-cli service remove", () => { const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -236,16 +236,16 @@ describe("prisma-cli service remove", () => { ); expect(result.exitCode).toBe(0); - expect(result.presented?.data).toMatchObject({ removed: true }); + expect(result.presented?.data).toMatchObject({ deleted: true }); }); - it("emits the completed json envelope for a --confirm removal", async () => { + it("emits the completed json envelope for a --confirm deletion", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -262,8 +262,8 @@ describe("prisma-cli service remove", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.remove"); - expect(frame.envelope.result).toMatchObject({ removed: true }); + expect(frame.envelope.commandId).toBe("service.delete"); + expect(frame.envelope.result).toMatchObject({ deleted: true }); }); it("refuses a --confirm value that is not the service name", async () => { @@ -272,7 +272,7 @@ describe("prisma-cli service remove", () => { const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -295,13 +295,13 @@ describe("prisma-cli service remove", () => { }); }); - it("never lets --yes alone grant the removal", async () => { + it("never lets --yes alone grant the deletion", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -326,7 +326,7 @@ describe("prisma-cli service remove", () => { const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -346,7 +346,7 @@ describe("prisma-cli service remove", () => { expect(frame.envelope.error.code).toBe("SERVICE.BRANCH_INVALID"); }); - it("settles a failing teardown as SERVICE.REMOVE_FAILED after a failed step", async () => { + it("settles a failing teardown as SERVICE.DELETE_FAILED after a failed step", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "DELETE /v1/apps/{appId}": () => ({ @@ -359,7 +359,7 @@ describe("prisma-cli service remove", () => { const result = await harness.cli.run( [ "service", - "remove", + "delete", "--project", "acme-app", "--service", @@ -377,14 +377,14 @@ describe("prisma-cli service remove", () => { expect(result.exitCode).toBe(2); expect(result.events.at(-1)).toEqual({ kind: "step-finished", - step: "remove", + step: "delete", outcome: "failed", }); const frame = result.json[result.json.length - 1]; if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.REMOVE_FAILED"); + expect(frame.envelope.error.code).toBe("SERVICE.DELETE_FAILED"); }); it("requires --service or PRISMA_SERVICE_ID, interactive terminals included", async () => { @@ -393,7 +393,7 @@ describe("prisma-cli service remove", () => { }); const result = await harness.cli.run( - ["service", "remove", "--project", "acme-app", "--json"], + ["service", "delete", "--project", "acme-app", "--json"], { cwd: harness.cwd, env: harness.env, isTty: INTERACTIVE }, ); @@ -404,7 +404,7 @@ describe("prisma-cli service remove", () => { } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); expect(frame.envelope.error.summary).toBe( - 'Command "service remove" requires --service', + 'Command "service delete" requires --service', ); expect(frame.envelope.nextActions).toEqual([ { @@ -428,7 +428,7 @@ describe("prisma-cli service remove", () => { }); const result = await harness.cli.run( - ["service", "remove", "--project", "acme-app"], + ["service", "delete", "--project", "acme-app"], { cwd: harness.cwd, env: harness.env, isTty: INTERACTIVE }, ); diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index ac326f79..fdbdb7d1 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -524,12 +524,12 @@ describe("prisma-cli service domain retry", () => { }); }); -describe("prisma-cli service domain remove", () => { - /** `removed` collects the id of every domain the run deleted. */ - function removeRoutes(removed: string[] = []): Routes { +describe("prisma-cli service domain delete", () => { + /** `deletedIds` collects the id of every domain the run deleted. */ + function deleteRoutes(deletedIds: string[] = []): Routes { return domainRoutes({ "DELETE /v1/domains/{domainId}": (init) => { - removed.push(String(init.params?.path?.domainId)); + deletedIds.push(String(init.params?.path?.domainId)); return { data: { data: null } }; }, }); @@ -539,15 +539,15 @@ describe("prisma-cli service domain remove", () => { isTty: { stdin: true, stdout: true, stderr: true }, }; - it("removes the domain when --confirm carries the hostname non-interactively", async () => { - const removed: string[] = []; - const harness = await makeServiceCli({ routes: removeRoutes(removed) }); + it("deletes the domain when --confirm carries the hostname non-interactively", async () => { + const deletedIds: string[] = []; + const harness = await makeServiceCli({ routes: deleteRoutes(deletedIds) }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--confirm", @@ -560,27 +560,27 @@ describe("prisma-cli service domain remove", () => { expect(result.presented?.data).toMatchObject({ ...EXPECTED_TARGET, hostname: "shop.acme.com", - removed: true, + deleted: true, }); expect(presentedSummary(result.presented)).toEqual({ kind: "summary", status: "ok", - text: "Removed shop.acme.com from hello-world.", + text: "Deleted shop.acme.com from hello-world.", }); - expect(removed).toEqual(["dom_1"]); + expect(deletedIds).toEqual(["dom_1"]); }); it("grants under --yes when --confirm carries the hostname", async () => { // --yes alone can never grant; --yes plus the matching token takes the // engine's non-interactive branch and grants without asking, so the run // completes with no scripted answer available. - const harness = await makeServiceCli({ routes: removeRoutes() }); + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--yes", @@ -591,17 +591,17 @@ describe("prisma-cli service domain remove", () => { ); expect(result.exitCode).toBe(0); - expect(result.presented?.data).toMatchObject({ removed: true }); + expect(result.presented?.data).toMatchObject({ deleted: true }); }); - it("emits the completed json envelope for a non-interactive --confirm removal", async () => { - const harness = await makeServiceCli({ routes: removeRoutes() }); + it("emits the completed json envelope for a non-interactive --confirm deletion", async () => { + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--confirm", @@ -616,23 +616,23 @@ describe("prisma-cli service domain remove", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.domain.remove"); + expect(frame.envelope.commandId).toBe("service.domain.delete"); expect(frame.envelope.result).toMatchObject({ hostname: "shop.acme.com", - removed: true, + deleted: true, }); }); it("still asks interactively when --confirm is present, and takes the typed token", async () => { // The grant is a non-interactive affordance: an interactive session // type-to-confirms regardless of --confirm. - const harness = await makeServiceCli({ routes: removeRoutes() }); + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--confirm", @@ -652,15 +652,15 @@ describe("prisma-cli service domain remove", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.domain.remove"); - expect(frame.envelope.result).toMatchObject({ removed: true }); + expect(frame.envelope.commandId).toBe("service.domain.delete"); + expect(frame.envelope.result).toMatchObject({ deleted: true }); }); - it("removes the domain after interactive consent", async () => { - const harness = await makeServiceCli({ routes: removeRoutes() }); + it("deletes the domain after interactive consent", async () => { + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( - ["service", "domain", "remove", "shop.acme.com", ...TARGET_ARGS], + ["service", "domain", "delete", "shop.acme.com", ...TARGET_ARGS], { cwd: harness.cwd, env: harness.env, @@ -673,20 +673,20 @@ describe("prisma-cli service domain remove", () => { expect(result.presented?.data).toMatchObject({ ...EXPECTED_TARGET, hostname: "shop.acme.com", - removed: true, + deleted: true, }); }); - it("emits the completed json envelope with commandId service.domain.remove", async () => { + it("emits the completed json envelope with commandId service.domain.delete", async () => { // An interactive json run still prompts (the prompt UI writes to // stderr); consent is granted through the scripted answer. - const harness = await makeServiceCli({ routes: removeRoutes() }); + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--json", @@ -704,22 +704,22 @@ describe("prisma-cli service domain remove", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.domain.remove"); + expect(frame.envelope.commandId).toBe("service.domain.delete"); expect(frame.envelope.result).toMatchObject({ ...EXPECTED_TARGET, hostname: "shop.acme.com", - removed: true, + deleted: true, }); }); it("fails early with the engine sign-in error when unauthenticated", async () => { const harness = await makeServiceCli({ - routes: removeRoutes(), + routes: deleteRoutes(), authenticated: false, }); const result = await harness.cli.run( - ["service", "domain", "remove", "shop.acme.com", ...TARGET_ARGS], + ["service", "domain", "delete", "shop.acme.com", ...TARGET_ARGS], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -728,10 +728,10 @@ describe("prisma-cli service domain remove", () => { }); it("settles a mistyped consent token as the engine mismatch error", async () => { - const harness = await makeServiceCli({ routes: removeRoutes() }); + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( - ["service", "domain", "remove", "shop.acme.com", ...TARGET_ARGS], + ["service", "domain", "delete", "shop.acme.com", ...TARGET_ARGS], { cwd: harness.cwd, env: harness.env, @@ -744,13 +744,13 @@ describe("prisma-cli service domain remove", () => { }); it("settles a non-interactive run as the engine consent-required error with exit 2", async () => { - const harness = await makeServiceCli({ routes: removeRoutes() }); + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--json", @@ -767,13 +767,13 @@ describe("prisma-cli service domain remove", () => { }); it("refuses a --confirm value that is not the hostname, naming the expected value", async () => { - const harness = await makeServiceCli({ routes: removeRoutes() }); + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--confirm", @@ -799,13 +799,13 @@ describe("prisma-cli service domain remove", () => { // Divergence from legacy: `--yes` used to skip the confirmation. The // engine rules consent structurally ungrantable by --yes; recorded in // parity-divergences-s2c.md and raised to the operator. - const harness = await makeServiceCli({ routes: removeRoutes() }); + const harness = await makeServiceCli({ routes: deleteRoutes() }); const result = await harness.cli.run( [ "service", "domain", - "remove", + "delete", "shop.acme.com", ...TARGET_ARGS, "--yes", From 10ff38a7d0a849c619b7ee60911e95f1ae1bee9d Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:10:07 +0200 Subject: [PATCH 08/27] Address D2 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service logs --deployment decides scoped-vs-global on whether any service target was requested: PRISMA_SERVICE_ID now scopes the lookup exactly like --service (D2-1), with tests for the missing-target refusal, the service-scoped path and its not-in-this-service refusal, and the env-var-scoped path (D2-2). - ServiceShowResult.service and ServiceDeploymentListResult.service are non-null; the "not selected" presenter fallbacks are gone (D2-3). - ResolveProjectOptions.projectDir deleted; the invocation directory is the only pin location (D2-4). - User-facing copy no longer says "selected service" — the service is named, not selected (D2-5). - serviceTargetRequiredError keeps why to the cause; the PRISMA_SERVICE_ID alternative is its own nextActions entry so --json consumers see it (D2-6). Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/commands/service/errors.ts | 15 +-- packages/cli/src/commands/service/logs.ts | 16 ++-- packages/cli/src/commands/service/open.ts | 2 +- .../cli/src/commands/service/presentation.ts | 18 ++-- packages/cli/src/commands/service/results.ts | 4 +- packages/cli/src/commands/service/target.ts | 26 ++++-- packages/cli/src/lib/project/resolution.ts | 4 +- packages/cli/tests/service-delete.test.ts | 4 + packages/cli/tests/service-domain.test.ts | 2 +- packages/cli/tests/service-logs.test.ts | 92 +++++++++++++++++++ packages/cli/tests/service-open.test.ts | 4 +- 11 files changed, 147 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 41f0efe5..e1b2f733 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -115,7 +115,7 @@ export function serviceSelectionInvalidError( ): CliStructuredError { return new CliStructuredError( "SERVICE.SELECTION_INVALID", - "Selected service does not exist in the resolved project", + "The requested service does not exist in the resolved project", { why: `The service "${serviceName}" could not be found in resolved project "${projectId}".`, nextActions: [ @@ -279,9 +279,10 @@ export function serviceTargetRequiredError( "SERVICE.TARGET_REQUIRED", `Command "${commandName}" requires --service`, { - why: "Service commands act only on an explicitly named service: pass --service , or set PRISMA_SERVICE_ID to a service id.", + why: "Service commands act only on an explicitly named service, and this run named none.", nextActions: [ adviceAction("Pass --service ."), + adviceAction("Or set PRISMA_SERVICE_ID to a service id."), // Not `service deployment list`: it resolves a service first, so // it cannot help a run that could not resolve one. runCommandAction("List services", "service list"), @@ -295,7 +296,7 @@ export function noPreviousDeploymentError(): CliStructuredError { "SERVICE.NO_PREVIOUS_DEPLOYMENT", "No previous deployment available for rollback", { - why: "The selected service does not have an earlier deployment to switch back to.", + why: "The service does not have an earlier deployment to switch back to.", nextActions: [ adviceAction( "Deploy a second version first, or pass --to for a specific earlier deployment.", @@ -363,7 +364,7 @@ export function branchValueEmptyError(): CliStructuredError { export function liveUrlUnavailableError(): CliStructuredError { return new CliStructuredError( "SERVICE.FEATURE_UNAVAILABLE", - "Live URL is not available for the selected service", + "Live URL is not available for this service", { why: "Deployments exist, but the provider does not expose a stable live service URL for this service yet.", nextActions: [ @@ -418,10 +419,10 @@ export function domainNotFoundError(hostname: string): CliStructuredError { "SERVICE.DOMAIN_NOT_FOUND", `Custom domain "${hostname}" not found`, { - why: "The hostname is not attached to the selected service.", + why: "The hostname is not attached to the service.", nextActions: [ adviceAction( - "Check the hostname and selected service, or add the domain first.", + "Check the hostname and the service, or add the domain first.", ), runCommandAction("Add the domain", `service domain add ${hostname}`), ], @@ -436,7 +437,7 @@ export function selectedServiceMissingError( ): CliStructuredError { return new CliStructuredError( "SERVICE.SELECTION_INVALID", - "Selected service does not exist in the resolved project", + "The requested service does not exist in the resolved project", { why: `The service "${serviceId}" from ${envVarName} could not be found in resolved project "${projectId}".`, nextActions: [ diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index 6d928aad..57e90b7c 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -23,6 +23,7 @@ import type { import { applyLiveDeploymentHint, listServices, + requestedServiceTarget, resolveCurrentLiveDeploymentId, resolveServiceProjectState, resolveServiceReadState, @@ -255,7 +256,7 @@ async function resolveLiveDeployment( if (!deployment) { throw noDeploymentsError( "No deployments available to read logs from", - `The selected service "${deploymentsResult.app.name}" does not have a live deployment.`, + `The service "${deploymentsResult.app.name}" does not have a live deployment.`, ); } return { service: deploymentsResult.app, deployment }; @@ -420,9 +421,13 @@ export const serviceLogsCommand = defineSessionCommand({ } // A globally-unique deployment id is a complete target on its own, - // so `--deployment` without `--service` skips service resolution - // and checks the deployment against the resolved project instead. + // so `--deployment` with no service target (neither --service nor + // PRISMA_SERVICE_ID) skips service resolution and checks the + // deployment against the resolved project instead. A named service + // scopes the lookup to that service. const explicitDeploymentId = args.flags.deployment; + const serviceRequested = + requestedServiceTarget(ctx, args.flags.service) !== null; const projectOptions = { ...(args.flags.project !== undefined ? { projectRef: args.flags.project } @@ -432,10 +437,7 @@ export const serviceLogsCommand = defineSessionCommand({ let state: ServiceProjectState; let target: LogTarget; - if ( - explicitDeploymentId !== undefined && - args.flags.service === undefined - ) { + if (explicitDeploymentId !== undefined && !serviceRequested) { state = await resolveServiceProjectState(ctx, projectOptions); target = await resolveGlobalDeployment(ctx, state, explicitDeploymentId); } else { diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index 07918219..1ec277a5 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -67,7 +67,7 @@ export const serviceOpenCommand = defineCommand({ if (!liveDeployment) { throw noDeploymentsError( "No deployments available to open", - `The selected service "${deploymentsResult.app.name}" does not have any deployments yet.`, + `The service "${deploymentsResult.app.name}" does not have any deployments yet.`, ); } if (!deploymentsResult.app.liveUrl) { diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index 17e2f03c..bcbc54a1 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -201,10 +201,10 @@ export function showPresentations(result: ServiceShowResult): Presentations { stdout: () => [], json: () => result, human: () => [ - title("Showing the selected service state."), + title(`Showing the state of service ${result.service.name}.`), fields([ { label: "project", value: result.projectId }, - { label: "service", value: result.service?.name ?? "not selected" }, + { label: "service", value: result.service.name }, { label: "live deployment", value: result.liveDeployment?.id ?? "", @@ -227,18 +227,16 @@ export function deploymentListPresentations( stdout: () => [], json: () => result, human: () => [ - title("Listing deployments for the selected service."), + title(`Listing deployments for service ${result.service.name}.`), fields([ { label: "project", value: result.projectId }, - { label: "service", value: result.service?.name ?? "not selected" }, + { label: "service", value: result.service.name }, ]), result.deployments.length === 0 ? { kind: "summary", status: "info", - text: result.service - ? "No deployments found." - : "No services found.", + text: "No deployments found.", } : { kind: "table", @@ -300,8 +298,8 @@ export function openPresentations( json: () => result, human: () => [ result.opened - ? completed("Opened the live URL for the selected service.") - : title("Resolved the live URL for the selected service."), + ? completed(`Opened the live URL for service ${result.service.name}.`) + : title(`Resolved the live URL for service ${result.service.name}.`), fields([ { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, @@ -488,7 +486,7 @@ export function domainAddPresentations( json: () => result, human: () => [ result.existing - ? title("Showing the existing custom domain for the selected service.") + ? title(`Showing the existing custom domain on ${result.service.name}.`) : completed( `Added ${result.domain.hostname} to ${result.service.name}.`, ), diff --git a/packages/cli/src/commands/service/results.ts b/packages/cli/src/commands/service/results.ts index d5ce88db..bb32dda3 100644 --- a/packages/cli/src/commands/service/results.ts +++ b/packages/cli/src/commands/service/results.ts @@ -45,7 +45,7 @@ export interface ServiceCreateResult { export interface ServiceShowResult { projectId: string; - service: ServiceSummary | null; + service: ServiceSummary; liveDeployment: ServiceDeploymentSummary | null; liveUrl: string | null; recentDeployments: ServiceDeploymentSummary[]; @@ -53,7 +53,7 @@ export interface ServiceShowResult { export interface ServiceDeploymentListResult { projectId: string; - service: ServiceSummary | null; + service: ServiceSummary; deployments: ServiceDeploymentSummary[]; } diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index d3010f50..ef3a9c40 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -266,14 +266,12 @@ export interface RequestedServiceTarget { value: string; } -/** The service target the run was given: `--service ` wins, then - * PRISMA_SERVICE_ID (a service id). Neither present refuses — service - * commands never infer, remember, or prompt for a target. */ -export function requireRequestedServiceTarget( +/** The service target the run was given, if any: `--service ` + * wins, then PRISMA_SERVICE_ID (a service id). */ +export function requestedServiceTarget( ctx: ServiceContext, explicitServiceName: string | undefined, - commandName: string, -): RequestedServiceTarget { +): RequestedServiceTarget | null { if (explicitServiceName) { return { kind: "name", value: explicitServiceName }; } @@ -281,7 +279,21 @@ export function requireRequestedServiceTarget( if (envServiceId) { return { kind: "id", value: envServiceId }; } - throw serviceTargetRequiredError(commandName); + return null; +} + +/** As `requestedServiceTarget`, but no target refuses — service + * commands never infer, remember, or prompt for one. */ +export function requireRequestedServiceTarget( + ctx: ServiceContext, + explicitServiceName: string | undefined, + commandName: string, +): RequestedServiceTarget { + const requested = requestedServiceTarget(ctx, explicitServiceName); + if (!requested) { + throw serviceTargetRequiredError(commandName); + } + return requested; } export function matchRequestedService( diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index b54916e3..4d316a8f 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -158,8 +158,6 @@ export interface ResolveProjectOptions { explicitProject?: string; envProjectId?: string; commandName?: string; - /** Directory holding `.prisma/local.json`. Defaults to the invocation directory. */ - projectDir?: string; listProjects(): Promise; } @@ -690,7 +688,7 @@ async function readImplicitLocalPin( } const localPinResult = await readLocalResolutionPin( - options.projectDir ?? options.context.runtime.cwd, + options.context.runtime.cwd, options.context.runtime.signal, ); if (localPinResult.isErr()) { diff --git a/packages/cli/tests/service-delete.test.ts b/packages/cli/tests/service-delete.test.ts index 0c9faf38..1c3c67b2 100644 --- a/packages/cli/tests/service-delete.test.ts +++ b/packages/cli/tests/service-delete.test.ts @@ -411,6 +411,10 @@ describe("prisma-cli service delete", () => { kind: "user-choice", label: "Pass --service .", }, + { + kind: "user-choice", + label: "Or set PRISMA_SERVICE_ID to a service id.", + }, // Not `service deployment list`: that command resolves a service // before it lists anything, so it fails the same way this did. { diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index fdbdb7d1..002e5f56 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -115,7 +115,7 @@ describe("prisma-cli service domain add", () => { expect(presentedSummary(result.presented)).toEqual({ kind: "summary", status: "info", - text: "Showing the existing custom domain for the selected service.", + text: "Showing the existing custom domain on hello-world.", }); }); diff --git a/packages/cli/tests/service-logs.test.ts b/packages/cli/tests/service-logs.test.ts index c5444da0..4c88b0bb 100644 --- a/packages/cli/tests/service-logs.test.ts +++ b/packages/cli/tests/service-logs.test.ts @@ -209,6 +209,98 @@ describe("prisma-cli service logs", () => { expect(dataLines(result.events)).toEqual(["from dep_1"]); }); + it("refuses without --service or PRISMA_SERVICE_ID as SERVICE.TARGET_REQUIRED", async () => { + const harness = await makeServiceCli({ + routes: logRoutes([[end(null)]], []), + }); + + const result = await harness.cli.run( + ["service", "logs", "--project", "acme-app", "--json"], + { cwd: harness.cwd, env: harness.env }, + ); + + expect(result.exitCode).toBe(2); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); + expect(frame.envelope.error.summary).toContain("--service"); + }); + + it("resolves --deployment within the named service", async () => { + const queries: Array | undefined> = []; + const harness = await makeServiceCli({ + routes: logRoutes([[log("from dep_1"), end("7")]], queries), + }); + + const result = await harness.cli.run( + ["service", "logs", "--deployment", "dep_1", ...TARGET], + { cwd: harness.cwd, env: harness.env }, + ); + + expect(result.exitCode).toBe(0); + expect(outputs(result.events)).toContainEqual({ + channel: "diagnostic", + line: "service: hello-world", + }); + expect(outputs(result.events)).toContainEqual({ + channel: "diagnostic", + line: "deployment: dep_1", + }); + expect(dataLines(result.events)).toEqual(["from dep_1"]); + }); + + it("refuses a --deployment the named service does not own", async () => { + const harness = await makeServiceCli({ + routes: logRoutes([[end(null)]], []), + }); + + const result = await harness.cli.run( + ["service", "logs", "--deployment", "dep_missing", ...TARGET, "--json"], + { cwd: harness.cwd, env: harness.env }, + ); + + expect(result.exitCode).toBe(2); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + // The service-scoped refusal, not the global lookup's 404. + expect(frame.envelope.error.summary).toContain('for service "hello-world"'); + }); + + it("scopes --deployment to the PRISMA_SERVICE_ID service like --service", async () => { + const harness = await makeServiceCli({ + routes: logRoutes([[end(null)]], []), + }); + + const result = await harness.cli.run( + [ + "service", + "logs", + "--deployment", + "dep_missing", + "--project", + "acme-app", + "--json", + ], + { + cwd: harness.cwd, + env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, + }, + ); + + expect(result.exitCode).toBe(2); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.summary).toContain('for service "hello-world"'); + }); + it("settles an unknown --deployment as SERVICE.DEPLOYMENT_NOT_FOUND", async () => { const harness = await makeServiceCli({ routes: logRoutes([[end(null)]], []), diff --git a/packages/cli/tests/service-open.test.ts b/packages/cli/tests/service-open.test.ts index b3a26d57..abf17edb 100644 --- a/packages/cli/tests/service-open.test.ts +++ b/packages/cli/tests/service-open.test.ts @@ -28,7 +28,7 @@ describe("prisma-cli service open", () => { expect(presentedSummary(result.presented)).toEqual({ kind: "summary", status: "info", - text: "Resolved the live URL for the selected service.", + text: "Resolved the live URL for service hello-world.", }); expect(result.events).toContainEqual({ kind: "endpoint", @@ -70,7 +70,7 @@ describe("prisma-cli service open", () => { expect(presentedSummary(result.presented)).toEqual({ kind: "summary", status: "ok", - text: "Opened the live URL for the selected service.", + text: "Opened the live URL for service hello-world.", }); expect(result.events).toContainEqual({ kind: "endpoint", From b7a6ebe53d041fd9262a03a71d5f1c217d2cfbb4 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:19:25 +0200 Subject: [PATCH 09/27] Move commands to their final mounts; wrap the external families The mounted tree now equals the slice spec's acceptance tree: postgres restore moves under postgres backup, ref list|set|delete move under migration ref, migrate becomes db migrate, format becomes contract format, and composer's dev and deploy mount at the root. The composer group, composer destroy, composer log, and the build group (build logs and its sources and tests) are gone. No aliases or redirects for the old spellings. Both external families are re-wrapped with defineCommandFamily, keeping their configSection and docsBaseUrl. Composer keeps only deploy and dev, so mount-coverage's family-completeness check stays honest about the dropped commands. The ORM family passes its commands through but rewrites its shipped redirects: the migration ref entry is dropped (that spelling is live again, and mounting it with the redirect in place fails buildCli's collision check) and migration apply's replacement is respelled to db migrate. Root help examples end as auth login, project list, deploy. Coverage tables, group briefs (postgres backup now covers restore; migration ref is new), and the bin/orm-mount/postgres tests follow. The cli-engine redirect fixtures are synthetic and stay as they are. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/cli.ts | 106 +++- packages/cli/src/commands/build/logs.ts | 223 --------- .../{restore.ts => backup-restore.ts} | 14 +- .../cli/src/commands/service/presentation.ts | 2 +- packages/cli/tests/bin.test.ts | 27 +- packages/cli/tests/build-logs.test.ts | 469 ------------------ packages/cli/tests/e2e-coverage.test.ts | 24 +- packages/cli/tests/mount-coverage.test.ts | 19 +- packages/cli/tests/orm-mount.test.ts | 6 +- packages/cli/tests/postgres.test.ts | 43 +- 10 files changed, 157 insertions(+), 776 deletions(-) delete mode 100644 packages/cli/src/commands/build/logs.ts rename packages/cli/src/commands/postgres/{restore.ts => backup-restore.ts} (93%) delete mode 100644 packages/cli/tests/build-logs.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7da12833..72c2e648 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,8 +2,10 @@ import { type AnyCommand, type Cli, type CommandFamily, + type CommandRedirect, createCli, defineCommandFamily, + type RedirectSpec, telemetryCommandGroup, } from "@prisma/cli-engine"; import { createComposerFamily } from "@prisma/composer-cli/family"; @@ -25,11 +27,11 @@ import { bucketKeyCreateCommand } from "./commands/bucket/key-create"; import { bucketKeyDeleteCommand } from "./commands/bucket/key-delete"; import { bucketKeyListCommand } from "./commands/bucket/key-list"; import { bucketListCommand } from "./commands/bucket/list"; -import { buildLogsCommand } from "./commands/build/logs"; import { feedbackCommand } from "./commands/feedback"; import { gitConnectCommand } from "./commands/git/connect"; import { gitDisconnectCommand } from "./commands/git/disconnect"; import { postgresBackupListCommand } from "./commands/postgres/backup-list"; +import { postgresBackupRestoreCommand } from "./commands/postgres/backup-restore"; import { postgresConnectionCreateCommand } from "./commands/postgres/connection-create"; import { postgresConnectionDeleteCommand } from "./commands/postgres/connection-delete"; import { postgresConnectionListCommand } from "./commands/postgres/connection-list"; @@ -37,7 +39,6 @@ import { postgresConnectionRotateCommand } from "./commands/postgres/connection- import { postgresCreateCommand } from "./commands/postgres/create"; import { postgresDeleteCommand } from "./commands/postgres/delete"; import { postgresListCommand } from "./commands/postgres/list"; -import { postgresRestoreCommand } from "./commands/postgres/restore"; import { postgresShowCommand } from "./commands/postgres/show"; import { postgresUsageCommand } from "./commands/postgres/usage"; import { projectCreateCommand } from "./commands/project/create"; @@ -94,7 +95,7 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ postgresShow: postgresShowCommand, postgresCreate: postgresCreateCommand, postgresUsage: postgresUsageCommand, - postgresRestore: postgresRestoreCommand, + postgresBackupRestore: postgresBackupRestoreCommand, postgresDelete: postgresDeleteCommand, postgresBackupList: postgresBackupListCommand, postgresConnectionList: postgresConnectionListCommand, @@ -128,18 +129,59 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ serviceDomainDelete: serviceDomainDeleteCommand, serviceDomainRetry: serviceDomainRetryCommand, serviceDomainWait: serviceDomainWaitCommand, - buildLogs: buildLogsCommand, }, }); +/** A normalized redirect, re-spelled as the input shape + * `defineCommandFamily` takes (optional fields instead of + * `| undefined`). */ +function toRedirectSpec(redirect: CommandRedirect): RedirectSpec { + return { + from: redirect.from, + ...(redirect.flag !== undefined ? { flag: redirect.flag } : {}), + replacement: redirect.replacement, + ...(redirect.reason !== undefined ? { reason: redirect.reason } : {}), + }; +} + +/** A family re-wrapped by the shell: the same configSection and docs + * base, but the shell's choice of commands and redirects. */ +function wrapCommandFamily( + family: CommandFamily, + commands: Readonly>, + redirects: readonly RedirectSpec[], +): CommandFamily { + return defineCommandFamily({ + ...(family.configSection !== undefined + ? { configSection: family.configSection } + : {}), + commands, + ...(family.docsBaseUrl !== undefined + ? { docsBaseUrl: family.docsBaseUrl } + : {}), + redirects, + }); +} + /** * Composer's commands, contributed by composer's own package and run by * this process. Only the command definitions and their handler entry * functions load here; the alchemy and effect constellation stays behind * composer's dynamic executor imports, so mounting costs an unrelated * command nothing. + * + * Re-wrapped to only `deploy` and `dev`, mounted at the root: `destroy` + * and `log` were dropped by the 2026-08-21 PM review. */ -export const composerCommandFamily: CommandFamily = createComposerFamily(); +const composerFamilySource = createComposerFamily(); +export const composerCommandFamily: CommandFamily = wrapCommandFamily( + composerFamilySource, + { + deploy: composerFamilySource.commands.deploy, + dev: composerFamilySource.commands.dev, + }, + composerFamilySource.redirects.map(toRedirectSpec), +); /** * The ORM commands, contributed by orm-toolchain's own package. The @@ -148,8 +190,27 @@ export const composerCommandFamily: CommandFamily = createComposerFamily(); * composer's, this family's entry module imports esbuild and arktype * statically, so every invocation of this bin pays that import; fixing * that is orm-toolchain's move. + * + * Re-wrapped to rewrite the shipped redirects for this shell's tree: + * the `migration ref` entry is dropped (that spelling is live again as + * `migration ref list|set|delete`, and mounting it with the redirect in + * place fails buildCli's collision check), and `migration apply`'s + * replacement is respelled to the `db migrate` mount. */ -export const ormCommandFamily: CommandFamily = ormToolchainFamily; +export const ormCommandFamily: CommandFamily = wrapCommandFamily( + ormToolchainFamily, + ormToolchainFamily.commands, + ormToolchainFamily.redirects + .filter((redirect) => redirect.from !== "migration ref") + .map((redirect) => + redirect.from === "migration apply" + ? toRedirectSpec({ + ...redirect, + replacement: "{bin} db migrate --to ", + }) + : toRedirectSpec(redirect), + ), +); /** The engine ships the three telemetry commands and the group help * text that belongs to them; both halves are spread in below. */ @@ -164,7 +225,9 @@ export const cliGroups: Readonly< brief: "Manage environment variables for the active project", }, postgres: { brief: "Manage Prisma Postgres databases for a project" }, - "postgres backup": { brief: "Inspect platform-created database backups" }, + "postgres backup": { + brief: "Inspect and restore platform-created database backups", + }, "postgres connection": { brief: "Manage one-time-view database connection strings", }, @@ -175,16 +238,12 @@ export const cliGroups: Readonly< service: { brief: "Manage services and deployments for a project" }, "service domain": { brief: "Manage custom domains for a service" }, "service deployment": { brief: "Manage deployments for a service" }, - build: { brief: "Inspect builds created by a git push or Console" }, - composer: { - brief: "Run and deploy applications composed from Prisma modules", - }, agent: { brief: "Manage Prisma skills for AI coding agents" }, "auth workspace": { brief: "Manage local workspace sessions" }, contract: { brief: "Define and emit your application data contract" }, db: { brief: "Verify, sign and update your database against the contract" }, migration: { brief: "Plan, inspect and scaffold on-disk migrations" }, - ref: { brief: "Manage named refs that point at contracts" }, + "migration ref": { brief: "Manage named refs that point at contracts" }, orm: { brief: "Initialize a Prisma ORM project" }, ...telemetry.groups, }; @@ -211,9 +270,9 @@ export const mountedCommands: Readonly> = { "postgres show": postgresShowCommand, "postgres create": postgresCreateCommand, "postgres usage": postgresUsageCommand, - "postgres restore": postgresRestoreCommand, "postgres delete": postgresDeleteCommand, "postgres backup list": postgresBackupListCommand, + "postgres backup restore": postgresBackupRestoreCommand, "postgres connection list": postgresConnectionListCommand, "postgres connection create": postgresConnectionCreateCommand, "postgres connection rotate": postgresConnectionRotateCommand, @@ -245,12 +304,9 @@ export const mountedCommands: Readonly> = { "service domain delete": serviceDomainDeleteCommand, "service domain retry": serviceDomainRetryCommand, "service domain wait": serviceDomainWaitCommand, - // Platform builds are their own group; there is no local build verb. - "build logs": buildLogsCommand, - "composer deploy": composerCommandFamily.commands.deploy, - "composer destroy": composerCommandFamily.commands.destroy, - "composer dev": composerCommandFamily.commands.dev, - "composer log": composerCommandFamily.commands.log, + // Composer's two product verbs, mounted at the root. + deploy: composerCommandFamily.commands.deploy, + dev: composerCommandFamily.commands.dev, // The ORM family. Written out per path: the shell owns the tree // (R12), so this map — not the family's own keying — is the source of // truth for where each command mounts. @@ -261,12 +317,12 @@ export const mountedCommands: Readonly> = { "db sign": ormCommandFamily.commands["db sign"], "db update": ormCommandFamily.commands["db update"], "db verify": ormCommandFamily.commands["db verify"], - format: ormCommandFamily.commands.format, + "db migrate": ormCommandFamily.commands.migrate, + "contract format": ormCommandFamily.commands.format, // `orm init` keeps this path: only the top-level `init` (the compute // config wizard) was removed, by the 2026-08-21 PM review. "orm init": ormCommandFamily.commands.init, lsp: ormCommandFamily.commands.lsp, - migrate: ormCommandFamily.commands.migrate, "migration check": ormCommandFamily.commands["migration check"], "migration graph": ormCommandFamily.commands["migration graph"], "migration list": ormCommandFamily.commands["migration list"], @@ -275,9 +331,9 @@ export const mountedCommands: Readonly> = { "migration plan": ormCommandFamily.commands["migration plan"], "migration show": ormCommandFamily.commands["migration show"], "migration status": ormCommandFamily.commands["migration status"], - "ref delete": ormCommandFamily.commands["ref delete"], - "ref list": ormCommandFamily.commands["ref list"], - "ref set": ormCommandFamily.commands["ref set"], + "migration ref delete": ormCommandFamily.commands["ref delete"], + "migration ref list": ormCommandFamily.commands["ref list"], + "migration ref set": ormCommandFamily.commands["ref set"], // Local utilities: no owning package, no config section, no API. "agent install": agentInstallCommand, "agent update": agentUpdateCommand, @@ -302,7 +358,7 @@ export function buildCli(): Cli { tagline: "The Prisma Developer Platform, from your terminal", description: "Deploy your app with isolated infrastructure for every branch.", - examples: ["auth login", "project list"], + examples: ["auth login", "project list", "deploy"], docsUrl: CLI_DOCS_URL, }, telemetry: { docsUrl: CLI_DOCS_URL }, diff --git a/packages/cli/src/commands/build/logs.ts b/packages/cli/src/commands/build/logs.ts deleted file mode 100644 index 99606e7f..00000000 --- a/packages/cli/src/commands/build/logs.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { CommandContext } from "@prisma/cli-engine"; -import { defineSessionCommand, flag, positional } from "@prisma/cli-engine"; -import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; -import { CLI_NAME } from "../../cli-name"; -import { forEachNdjsonRecord } from "../../lib/ndjson"; - -const TRAILING_NEWLINE = /\n$/; - -/** - * One line of `GET /v1/builds/{buildId}/logs` (the `BuildLogNdjsonLine` - * schema). Build logs are a separate system from deployment logs: this - * stream is keyed by Build.id (a git-push / Console build), not a - * deployment id. - */ -type BuildLogRecord = - | { - type: "log"; - text: string; - level: "info" | "error"; - source: "runner" | "stdout" | "stderr"; - step?: string; - cursor: string; - } - | { - type: "terminal"; - kind: "end" | "error"; - code: string; - message: string; - retryable: boolean; - cursor: string | null; - }; - -function buildNotFoundError(buildId: string): CliStructuredError { - return new CliStructuredError( - "BUILD.NOT_FOUND", - `Build ${buildId} was not found`, - { - why: "The build does not exist, or your workspace does not have access to it.", - nextActions: [ - { - kind: "user-choice", - label: "Check the build id, or switch to the workspace that owns it.", - }, - { - kind: "run-command", - label: "Switch workspace", - command: `${CLI_NAME} auth workspace use `, - }, - ], - }, - ); -} - -function buildLogsFailedError( - buildId: string, - status: number, -): CliStructuredError { - return new CliStructuredError( - "BUILD.LOGS_FAILED", - `Failed to read logs for build ${buildId}`, - { - why: `The Management API returned HTTP ${status}.`, - meta: { status }, - nextActions: [ - { - kind: "user-choice", - label: - "Retry the command, or rerun with --log-level verbose for more detail.", - }, - ], - }, - ); -} - -/** - * A terminal `error` record means the build itself failed. Legacy set - * `process.exitCode = 1` and still exited the stream normally; the - * engine has no way for a session command to settle a non-zero exit - * from a record (sessions carry no exit-code set, and documented exit - * codes are 4-99), so the failure is reported as an errored settlement. - * ESCALATED — see the divergence file; this is the one line to change - * when the engine gains a stream termination status. - */ -function buildFailedError( - buildId: string, - record: Extract, -): CliStructuredError { - return new CliStructuredError("BUILD.FAILED", `Build ${buildId} failed`, { - why: record.message, - meta: { - code: record.code, - retryable: record.retryable, - ...(record.cursor === null ? {} : { cursor: record.cursor }), - }, - nextActions: [ - ...(record.cursor - ? [ - { - kind: "run-command" as const, - label: "Resume the log stream", - command: `${CLI_NAME} build logs ${buildId} --cursor ${record.cursor}`, - }, - ] - : []), - ], - }); -} - -function reportRecord( - ctx: Pick, - record: BuildLogRecord, -): void { - if (record.type === "log") { - ctx.report({ - kind: "output", - source: "build", - channel: - record.source === "stderr" || record.level === "error" - ? "diagnostic" - : "data", - line: record.text.replace(TRAILING_NEWLINE, ""), - // The record's own fields, so a json consumer keeps everything - // legacy published per record — the cursor above all, because - // `--cursor` resumes from one. - data: { - cursor: record.cursor, - level: record.level, - source: record.source, - ...(record.step === undefined ? {} : { step: record.step }), - }, - }); - return; - } - - // A terminal `end` is the normal stream close — nothing to show. A - // `no_logs` end, or any error terminal, carries a message the user - // should see. - if (record.code !== "end") { - ctx.report({ - kind: "output", - source: "build", - channel: "diagnostic", - line: record.message, - data: { - kind: record.kind, - cursor: record.cursor, - code: record.code, - retryable: record.retryable, - }, - }); - } -} - -export const buildLogsCommand = defineSessionCommand({ - help: { - summary: "Stream logs for a build", - examples: [ - "build logs bld_123", - "build logs bld_123 --follow", - "build logs bld_123 --cursor 4096", - ], - }, - args: { - flags: { - follow: flag.boolean({ - brief: "Keep the connection open while the build is running", - }), - cursor: flag.string({ - brief: "Resume from a cursor a previous run reported", - placeholder: "cursor", - }), - }, - positionals: { - buildId: positional.string({ - brief: "Build id (from a git push or Console)", - placeholder: "buildId", - }), - }, - }, - needs: { credentials: true }, - handler: async (args, ctx) => { - const buildId = args.positionals.buildId; - ctx.report({ - kind: "output", - source: "build", - channel: "diagnostic", - line: `Streaming logs for build ${buildId}`, - }); - - const { data, response } = await ctx.api.GET("/v1/builds/{buildId}/logs", { - params: { - path: { buildId }, - query: { - ...(args.flags.follow ? { follow: "true" as const } : {}), - ...(args.flags.cursor ? { cursor: args.flags.cursor } : {}), - }, - }, - parseAs: "stream", - signal: ctx.signal, - }); - - const body = data as ReadableStream | null | undefined; - if (!response.ok || !body) { - await body?.cancel().catch(() => undefined); - throw response.status === 404 - ? buildNotFoundError(buildId) - : buildLogsFailedError(buildId, response.status); - } - - let failure: Extract | null = null; - await forEachNdjsonRecord(body, (record) => { - if (record.type === "terminal" && record.kind === "error") { - failure = record; - } - reportRecord(ctx, record); - }); - - if (failure !== null) { - throw buildFailedError(buildId, failure); - } - return ok(undefined); - }, -}); diff --git a/packages/cli/src/commands/postgres/restore.ts b/packages/cli/src/commands/postgres/backup-restore.ts similarity index 93% rename from packages/cli/src/commands/postgres/restore.ts rename to packages/cli/src/commands/postgres/backup-restore.ts index 960f9177..5153f62c 100644 --- a/packages/cli/src/commands/postgres/restore.ts +++ b/packages/cli/src/commands/postgres/backup-restore.ts @@ -1,4 +1,4 @@ -/** The `postgres restore` command. */ +/** The `postgres backup restore` command. */ import { type Block, defineCommand, @@ -64,7 +64,7 @@ function restorePresentations( }; } -export const postgresRestoreCommand = defineCommand({ +export const postgresBackupRestoreCommand = defineCommand({ args: { positionals: { database: positional.string({ @@ -87,7 +87,9 @@ export const postgresRestoreCommand = defineCommand({ }, help: { summary: "Restore a database from a backup after exact id confirmation", - examples: ["postgres restore db_123 --backup bkp_456 --confirm db_123"], + examples: [ + "postgres backup restore db_123 --backup bkp_456 --confirm db_123", + ], }, needs: { credentials: true }, handler: async (args, ctx) => { @@ -111,7 +113,11 @@ export const postgresRestoreCommand = defineCommand({ } const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres restore"); + await resolvePostgresContext( + ctx, + args.flags, + "postgres backup restore", + ); const database = await resolveDatabase( provider, target, diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index bcbc54a1..fcc65249 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -174,7 +174,7 @@ export function createPresentations( ]), ], next: () => [ - runCommandAction("Deploy to the service", "composer deploy"), + runCommandAction("Deploy to the service", "deploy"), runCommandAction( "Show the service", `service show --service ${result.service.name}`, diff --git a/packages/cli/tests/bin.test.ts b/packages/cli/tests/bin.test.ts index 69a40980..5d8cd547 100644 --- a/packages/cli/tests/bin.test.ts +++ b/packages/cli/tests/bin.test.ts @@ -445,9 +445,9 @@ describe("buildCli", () => { return last.envelope.error; } - /** `composer log` through the real bin, against the fixture whose + /** Composer's `dev` through the real bin, against the fixture whose * composer section names a config file that is not there. */ - async function runComposerLog(): Promise<{ + async function runComposerDev(): Promise<{ readonly exitCode: number; readonly error: Diagnostic; }> { @@ -455,8 +455,7 @@ describe("buildCli", () => { argv: [ "node", "bin.js", - "composer", - "log", + "dev", "--config", COMPOSER_SECTION_CONFIG_PATH, "src/service.ts", @@ -472,17 +471,17 @@ describe("buildCli", () => { * family declares, read by the bin's real disk loader, reaching * composer's own handler as the path it acts on. * - * Every platform but Windows. `dev` and `log` are the only composer - * commands that reach config discovery without credentials — deploy - * and destroy stop at the credential check — and both refuse Windows - * before they read the section they were handed, so no shipped - * command can show the section arriving there. The test after this - * one pins what Windows can still show. + * Every platform but Windows. `dev` is the only composer command + * that reaches config discovery without credentials — `deploy` stops + * at the credential check — and it refuses Windows before it reads + * the section it was handed, so no shipped command can show the + * section arriving there. The test after this one pins what Windows + * can still show. */ it.skipIf(process.platform === "win32")( "hands the composer section of prisma.config.ts to the composer family", async () => { - const { exitCode, error } = await runComposerLog(); + const { exitCode, error } = await runComposerDev(); expect(exitCode).toBe(2); expect(error.code).toBe("CONFIG.FILE_MISSING"); @@ -508,12 +507,12 @@ describe("buildCli", () => { * collapses back into the one above. */ it.runIf(process.platform === "win32")( - "on Windows, composer's log refuses the platform before it reads the section", + "on Windows, composer's dev refuses the platform before it reads the section", async () => { - const { exitCode, error } = await runComposerLog(); + const { exitCode, error } = await runComposerDev(); expect(exitCode).toBe(2); - expect(error.code).toBe("LOG.PLATFORM_UNSUPPORTED"); + expect(error.code).toBe("DEV.PLATFORM_UNSUPPORTED"); }, ); diff --git a/packages/cli/tests/build-logs.test.ts b/packages/cli/tests/build-logs.test.ts deleted file mode 100644 index 8cab54ca..00000000 --- a/packages/cli/tests/build-logs.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { makeServiceCli, type Routes } from "./service-testkit"; - -type Record_ = - | { - type: "log"; - text: string; - level: "info" | "error"; - source: "runner" | "stdout" | "stderr"; - step?: string; - cursor: string; - } - | { - type: "terminal"; - kind: "end" | "error"; - code: string; - message: string; - retryable: boolean; - cursor: string | null; - }; - -/** Chunks that ignore record boundaries: every record is cut in half - * and the last one carries no trailing newline, so each stream drives - * both the reader's partial-line buffer and its end-of-stream tail. */ -function ndjsonStream(records: Record_[]): ReadableStream { - return chunkedStream( - records.flatMap((record, index) => { - const line = JSON.stringify(record); - const split = Math.floor(line.length / 2); - return [ - line.slice(0, split), - line.slice(split) + (index === records.length - 1 ? "" : "\n"), - ]; - }), - ); -} - -function chunkedStream(chunks: string[]): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)); - } - controller.close(); - }, - }); -} - -function log( - text: string, - overrides: Partial> = {}, -): Record_ { - return { - type: "log", - text, - level: "info", - source: "stdout", - cursor: "1", - ...overrides, - }; -} - -const END: Record_ = { - type: "terminal", - kind: "end", - code: "end", - message: "stream complete", - retryable: false, - cursor: "9", -}; - -function logRoutes( - records: Record_[], - capture?: (query: Record | undefined) => void, -): Routes { - return { - "GET /v1/builds/{buildId}/logs": (init) => { - capture?.(init.params?.query); - return { data: ndjsonStream(records) }; - }, - }; -} - -/** A body that never closes and whose first line is not JSON, so the - * read loop leaves through the parse error instead of through `done`. */ -function malformedOpenBody(): { - body: ReadableStream; - cancelled: () => boolean; -} { - let cancelled = false; - let sent = false; - const encoder = new TextEncoder(); - const body = new ReadableStream({ - pull(controller) { - if (sent) { - return; - } - sent = true; - controller.enqueue(encoder.encode("{ not json }\n")); - }, - cancel() { - cancelled = true; - }, - }); - return { body, cancelled: () => cancelled }; -} - -function outputs(events: readonly { kind: string }[]) { - return events - .filter((event) => event.kind === "output") - .map((event) => { - const output = event as unknown as { channel: string; line: string }; - return { channel: output.channel, line: output.line }; - }); -} - -function outputData(events: readonly { kind: string }[]) { - return events - .filter((event) => event.kind === "output") - .map((event) => (event as unknown as { data?: unknown }).data); -} - -describe("prisma-cli build logs", () => { - it("streams every log record in order, routed by source and level", async () => { - const harness = await makeServiceCli({ - routes: logRoutes([ - log("installing dependencies"), - log("warning: peer dep", { source: "stderr" }), - log("build failed to lint", { level: "error" }), - log("runner note", { source: "runner", step: "prepare" }), - END, - ]), - }); - - const result = await harness.cli.run(["build", "logs", "bld_1"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(0); - expect(outputs(result.events)).toEqual([ - { channel: "diagnostic", line: "Streaming logs for build bld_1" }, - { channel: "data", line: "installing dependencies" }, - { channel: "diagnostic", line: "warning: peer dep" }, - { channel: "diagnostic", line: "build failed to lint" }, - { channel: "data", line: "runner note" }, - ]); - }); - - it("reassembles records split across chunks, with no trailing newline", async () => { - const body = [log("first"), log("second"), END] - .map((record) => JSON.stringify(record)) - .join("\n"); - const harness = await makeServiceCli({ - routes: { - // One chunk per character: every record spans many chunks and the - // stream ends mid-line, so the reader's buffer and its - // end-of-stream tail both have to work. - "GET /v1/builds/{buildId}/logs": () => ({ - data: chunkedStream([...body]), - }), - }, - }); - - const result = await harness.cli.run(["build", "logs", "bld_1"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(0); - expect(outputs(result.events)).toEqual([ - { channel: "diagnostic", line: "Streaming logs for build bld_1" }, - { channel: "data", line: "first" }, - { channel: "data", line: "second" }, - ]); - }); - - it("carries each record's cursor, level, source and step in the event data", async () => { - const harness = await makeServiceCli({ - routes: logRoutes([ - log("installing", { cursor: "12" }), - log("runner note", { source: "runner", step: "prepare", cursor: "13" }), - END, - ]), - }); - - const result = await harness.cli.run(["build", "logs", "bld_1"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(0); - expect(outputData(result.events)).toEqual([ - undefined, - { cursor: "12", level: "info", source: "stdout" }, - { cursor: "13", level: "info", source: "runner", step: "prepare" }, - ]); - }); - - it("carries a reported terminal record's kind, cursor, code and retryable", async () => { - const harness = await makeServiceCli({ - routes: logRoutes([ - { - type: "terminal", - kind: "end", - code: "no_logs", - message: "This build produced no logs.", - retryable: false, - cursor: "9", - }, - ]), - }); - - const result = await harness.cli.run(["build", "logs", "bld_1"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(0); - expect(outputData(result.events)).toEqual([ - undefined, - { kind: "end", cursor: "9", code: "no_logs", retryable: false }, - ]); - }); - - it("writes log text to stdout and everything else to stderr", async () => { - const harness = await makeServiceCli({ - routes: logRoutes([ - log("compiled ok"), - log("deprecation notice", { source: "stderr" }), - END, - ]), - }); - - // Sessions default to json when stdout is not a TTY; this case is - // about the human rendering, so it asks for it explicitly. - const result = await harness.cli.run( - ["build", "logs", "bld_1", "--format", "human"], - { cwd: harness.cwd, env: harness.env }, - ); - - expect(result.stdout).toBe("compiled ok\n"); - expect(result.stderr).toContain("deprecation notice"); - expect(result.stderr).toContain("Streaming logs for build bld_1"); - }); - - it("says nothing extra for a terminal end, but surfaces any other terminal message", async () => { - const harness = await makeServiceCli({ - routes: logRoutes([ - { - type: "terminal", - kind: "end", - code: "no_logs", - message: "This build produced no logs.", - retryable: false, - cursor: null, - }, - ]), - }); - - const result = await harness.cli.run(["build", "logs", "bld_1"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(0); - expect(outputs(result.events)).toContainEqual({ - channel: "diagnostic", - line: "This build produced no logs.", - }); - }); - - it("frames every record in json mode and terminates with one result frame", async () => { - const harness = await makeServiceCli({ - routes: logRoutes([log("first"), log("second"), END]), - }); - - const result = await harness.cli.run(["build", "logs", "bld_1", "--json"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(0); - const kinds = result.json.map((frame) => frame.kind); - expect(kinds).toEqual(["output", "output", "output", "result"]); - const last = result.json[result.json.length - 1]; - if (last?.kind !== "result" || !last.envelope.ok) { - throw new Error("expected a completed envelope"); - } - expect(last.envelope.commandId).toBe("build.logs"); - expect(result.stdout).not.toContain("first\n\n"); - }); - - it("passes --follow and --cursor through to the request", async () => { - let query: Record | undefined; - const harness = await makeServiceCli({ - routes: logRoutes([END], (captured) => { - query = captured; - }), - }); - - const result = await harness.cli.run( - ["build", "logs", "bld_1", "--follow", "--cursor", "4096"], - { cwd: harness.cwd, env: harness.env }, - ); - - expect(result.exitCode).toBe(0); - expect(query).toEqual({ follow: "true", cursor: "4096" }); - }); - - it("sends neither query parameter when the flags are absent", async () => { - let query: Record | undefined; - const harness = await makeServiceCli({ - routes: logRoutes([END], (captured) => { - query = captured; - }), - }); - - await harness.cli.run(["build", "logs", "bld_1"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(query).toEqual({}); - }); - - it("reports a terminal error record as a failed build, after streaming its logs", async () => { - const harness = await makeServiceCli({ - routes: logRoutes([ - log("step 1 ok"), - { - type: "terminal", - kind: "error", - code: "build_failed", - message: "The build step exited with status 1.", - retryable: false, - cursor: "77", - }, - ]), - }); - - const result = await harness.cli.run(["build", "logs", "bld_1", "--json"], { - cwd: harness.cwd, - env: harness.env, - }); - - // ESCALATED: legacy exited 1 here. A session command cannot settle a - // non-zero exit from a record, so the failure settles as an errored - // envelope (exit 2) instead. Recorded in the divergence file. - expect(result.exitCode).toBe(2); - expect(outputs(result.events)).toContainEqual({ - channel: "data", - line: "step 1 ok", - }); - const frame = result.json[result.json.length - 1]; - if (frame?.kind !== "result" || frame.envelope.ok) { - throw new Error("expected an errored envelope"); - } - expect(frame.envelope.error.code).toBe("BUILD.FAILED"); - expect(frame.envelope.error.why).toBe( - "The build step exited with status 1.", - ); - expect(frame.envelope.error.meta).toMatchObject({ - code: "build_failed", - cursor: "77", - }); - expect( - frame.envelope.error.nextActions.some((action) => - action.command?.includes("--cursor 77"), - ), - ).toBe(true); - }); - - it("settles an unknown build as BUILD.NOT_FOUND", async () => { - const harness = await makeServiceCli({ - routes: { - "GET /v1/builds/{buildId}/logs": () => ({ - error: { error: { message: "not found" } }, - status: 404, - }), - }, - }); - - const result = await harness.cli.run(["build", "logs", "bld_x", "--json"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(2); - const frame = result.json[result.json.length - 1]; - if (frame?.kind !== "result" || frame.envelope.ok) { - throw new Error("expected an errored envelope"); - } - expect(frame.envelope.error.code).toBe("BUILD.NOT_FOUND"); - }); - - it("settles any other request failure as BUILD.LOGS_FAILED with the status", async () => { - const harness = await makeServiceCli({ - routes: { - "GET /v1/builds/{buildId}/logs": () => ({ - error: { error: { message: "boom" } }, - status: 503, - }), - }, - }); - - const result = await harness.cli.run(["build", "logs", "bld_1", "--json"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).toBe(2); - const frame = result.json[result.json.length - 1]; - if (frame?.kind !== "result" || frame.envelope.ok) { - throw new Error("expected an errored envelope"); - } - expect(frame.envelope.error.code).toBe("BUILD.LOGS_FAILED"); - expect(frame.envelope.error.meta).toMatchObject({ status: 503 }); - }); - - it("closes the response body when the read loop leaves on a malformed line", async () => { - const { body, cancelled } = malformedOpenBody(); - const harness = await makeServiceCli({ - routes: { "GET /v1/builds/{buildId}/logs": () => ({ data: body }) }, - }); - - const result = await harness.cli.run(["build", "logs", "bld_1", "--json"], { - cwd: harness.cwd, - env: harness.env, - }); - - expect(result.exitCode).not.toBe(0); - // Without the cleanup the reader keeps its lock and the HTTP body - // stays open until garbage collection, which a --follow run holds for - // as long as it runs. - expect(cancelled()).toBe(true); - expect(body.locked).toBe(false); - }); - - it("fails early with the engine sign-in error when unauthenticated", async () => { - const harness = await makeServiceCli({ - authenticated: false, - routes: logRoutes([END]), - }); - - const result = await harness.cli.run(["build", "logs", "bld_1"], { - cwd: harness.cwd, - env: harness.env, - isTty: { stdout: true }, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("CLI.CREDENTIALS_REQUIRED"); - }); - - it("requires a build id", async () => { - const harness = await makeServiceCli({ routes: logRoutes([END]) }); - - const result = await harness.cli.run(["build", "logs"], { - cwd: harness.cwd, - env: harness.env, - isTty: { stdout: true }, - }); - - expect(result.exitCode).toBe(2); - }); -}); diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index 46efb920..e64e948b 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -58,9 +58,9 @@ const EXCLUSIONS: Readonly> = { "db sign": ORM_FAMILY_REASON, "db update": ORM_FAMILY_REASON, "db verify": ORM_FAMILY_REASON, - format: ORM_FAMILY_REASON, + "contract format": ORM_FAMILY_REASON, lsp: ORM_FAMILY_REASON, - migrate: ORM_FAMILY_REASON, + "db migrate": ORM_FAMILY_REASON, "migration check": ORM_FAMILY_REASON, "migration graph": ORM_FAMILY_REASON, "migration list": ORM_FAMILY_REASON, @@ -70,9 +70,9 @@ const EXCLUSIONS: Readonly> = { "migration show": ORM_FAMILY_REASON, "migration status": ORM_FAMILY_REASON, "orm init": ORM_FAMILY_REASON, - "ref delete": ORM_FAMILY_REASON, - "ref list": ORM_FAMILY_REASON, - "ref set": ORM_FAMILY_REASON, + "migration ref delete": ORM_FAMILY_REASON, + "migration ref list": ORM_FAMILY_REASON, + "migration ref set": ORM_FAMILY_REASON, feedback: "Posts a real message to the feedback service the CLI team reads. A per-CI-run post is spam, not a test.", "auth login": @@ -83,20 +83,15 @@ const EXCLUSIONS: Readonly> = { "Ends a stored OAuth session. Same reason as `auth workspace use`.", "project transfer": "Irreversibly moves a project to another workspace, and needs a second workspace plus a recipient who accepts. Not safe to run unattended.", - "postgres restore": + "postgres backup restore": "Needs an existing backup. Backups are created on the platform's own schedule, so a database made during the run never has one.", "git connect": "Needs a GitHub App installation on the account under test, which CI cannot provision.", "git disconnect": "Needs a connected repository, which `git connect` cannot create here.", - "composer deploy": + deploy: "Provisions real cloud infrastructure for an app entry point, through a child `alchemy deploy`. Standing that up per CI run is neither cheap nor unattended-safe.", - "composer destroy": - "Tears down what `composer deploy` provisioned, which this suite cannot create.", - "composer dev": - "A session command: it runs until SIGINT or SIGTERM, redeploying on file change, so it has no happy path that terminates on its own.", - "composer log": - "A session command that streams until interrupted, against an app only `composer deploy` could have deployed.", + dev: "A session command: it runs until SIGINT or SIGTERM, redeploying on file change, so it has no happy path that terminates on its own.", }; /** @@ -125,8 +120,6 @@ const EXCLUSIONS: Readonly> = { * switchboard.ewr.prisma.build." No fixture inside this repo can satisfy * that; it needs a domain the test account owns and a DNS record. * - * `build logs` needs a build, which comes from a git push or a Console - * action, not from anything the CLI can do. * * `service logs` arrived while this was being written, excluded because * "only `composer deploy` produces" a deployment to read logs from. That @@ -142,7 +135,6 @@ const AWAITING_COVERAGE: readonly string[] = [ "service domain delete", "service domain retry", "service domain wait", - "build logs", ]; async function mountedCommands(): Promise { diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index 32a73d4e..739cc927 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -92,34 +92,35 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "bucket key delete", "bucket key list", "bucket list", - "build logs", - "composer deploy", - "composer destroy", - "composer dev", - "composer log", "contract emit", + "contract format", "contract infer", "db init", + "db migrate", "db schema", "db sign", "db update", "db verify", + "deploy", + "dev", "feedback", - "format", "git connect", "git disconnect", "lsp", - "migrate", "migration check", "migration graph", "migration list", "migration log", "migration new", "migration plan", + "migration ref delete", + "migration ref list", + "migration ref set", "migration show", "migration status", "orm init", "postgres backup list", + "postgres backup restore", "postgres connection create", "postgres connection delete", "postgres connection list", @@ -127,7 +128,6 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "postgres create", "postgres delete", "postgres list", - "postgres restore", "postgres show", "postgres usage", "project create", @@ -141,9 +141,6 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "project rename", "project show", "project transfer", - "ref delete", - "ref list", - "ref set", "service create", "service delete", "service deployment delete", diff --git a/packages/cli/tests/orm-mount.test.ts b/packages/cli/tests/orm-mount.test.ts index cfa07f09..aaaa634c 100644 --- a/packages/cli/tests/orm-mount.test.ts +++ b/packages/cli/tests/orm-mount.test.ts @@ -138,11 +138,11 @@ describe("the ORM family answers from the assembled tree", () => { expect(frame.envelope.error.code).toBe("CLI.COMMAND_MOVED"); expect(frame.envelope.nextActions[0]).toMatchObject({ kind: "run-command", - command: "prisma-test migrate --to ", + command: "prisma-test db migrate --to ", }); }); - it("names the four new groups in the root help", async () => { + it("names the ORM groups in the root help", async () => { const result = await shell().run(["--help"], { isTty: { stdout: true }, }); @@ -152,7 +152,7 @@ describe("the ORM family answers from the assembled tree", () => { contract: cliGroups.contract, db: cliGroups.db, migration: cliGroups.migration, - ref: cliGroups.ref, + orm: cliGroups.orm, })) { expect(result.stdout).toContain(group); expect(result.stdout).toContain(brief); diff --git a/packages/cli/tests/postgres.test.ts b/packages/cli/tests/postgres.test.ts index 22dc2b30..6a4f6c08 100644 --- a/packages/cli/tests/postgres.test.ts +++ b/packages/cli/tests/postgres.test.ts @@ -6,6 +6,7 @@ import { createTestCli, mintTestJwt } from "@prisma/cli-engine/testing"; import { describe, expect, it } from "vitest"; import { postgresBackupListCommand } from "../src/commands/postgres/backup-list"; +import { postgresBackupRestoreCommand } from "../src/commands/postgres/backup-restore"; import { postgresConnectionCreateCommand } from "../src/commands/postgres/connection-create"; import { postgresConnectionDeleteCommand } from "../src/commands/postgres/connection-delete"; import { postgresConnectionListCommand } from "../src/commands/postgres/connection-list"; @@ -13,7 +14,6 @@ import { postgresConnectionRotateCommand } from "../src/commands/postgres/connec import { postgresCreateCommand } from "../src/commands/postgres/create"; import { postgresDeleteCommand } from "../src/commands/postgres/delete"; import { postgresListCommand } from "../src/commands/postgres/list"; -import { postgresRestoreCommand } from "../src/commands/postgres/restore"; import { postgresShowCommand } from "../src/commands/postgres/show"; import { postgresUsageCommand } from "../src/commands/postgres/usage"; @@ -141,7 +141,7 @@ function makeCli(client: ManagementApiClient, signedIn = true) { "postgres show": postgresShowCommand, "postgres create": postgresCreateCommand, "postgres usage": postgresUsageCommand, - "postgres restore": postgresRestoreCommand, + "postgres backup restore": postgresBackupRestoreCommand, "postgres delete": postgresDeleteCommand, "postgres backup list": postgresBackupListCommand, "postgres connection list": postgresConnectionListCommand, @@ -1179,7 +1179,7 @@ describe("prisma-cli postgres usage", () => { const RESTORED = { ...DB_ONE, status: "recovering" }; -describe("prisma-cli postgres restore", () => { +describe("prisma-cli postgres backup restore", () => { it("restores the database and points at the show command", async () => { const calls: Call[] = []; const result = await makeCli( @@ -1192,7 +1192,16 @@ describe("prisma-cli postgres restore", () => { }, }), ).run( - ["postgres", "restore", "db_1", "--backup", "bkp_1", "--confirm", "db_1"], + [ + "postgres", + "backup", + "restore", + "db_1", + "--backup", + "bkp_1", + "--confirm", + "db_1", + ], { cwd: await pinnedCwd(), isTty: { stdout: true } }, ); @@ -1248,6 +1257,7 @@ describe("prisma-cli postgres restore", () => { ).run( [ "postgres", + "backup", "restore", "db_1", "--backup", @@ -1279,7 +1289,7 @@ describe("prisma-cli postgres restore", () => { it("requires --backup", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "restore", "db_1", "--confirm", "db_1", "--json"], + ["postgres", "backup", "restore", "db_1", "--confirm", "db_1", "--json"], { cwd: await pinnedCwd() }, ); @@ -1307,7 +1317,7 @@ describe("prisma-cli postgres restore", () => { it("refuses to restore without consent in a non-interactive run", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "restore", "db_1", "--backup", "bkp_1", "--json"], + ["postgres", "backup", "restore", "db_1", "--backup", "bkp_1", "--json"], { cwd: await pinnedCwd() }, ); @@ -1320,7 +1330,16 @@ describe("prisma-cli postgres restore", () => { it("refuses to restore when --yes stands in for consent", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "restore", "db_1", "--backup", "bkp_1", "--yes", "--json"], + [ + "postgres", + "backup", + "restore", + "db_1", + "--backup", + "bkp_1", + "--yes", + "--json", + ], { cwd: await pinnedCwd() }, ); @@ -1341,7 +1360,7 @@ describe("prisma-cli postgres restore", () => { }), }, }), - ).run(["postgres", "restore", "db_1", "--backup", "bkp_1"], { + ).run(["postgres", "backup", "restore", "db_1", "--backup", "bkp_1"], { cwd: await pinnedCwd(), answers: ["db_1"], isTty: { stdin: true, stdout: true }, @@ -1352,7 +1371,7 @@ describe("prisma-cli postgres restore", () => { it("fails when the typed answer is not the target database id", async () => { const result = await makeCli(postgresClient()).run( - ["postgres", "restore", "db_1", "--backup", "bkp_1", "--json"], + ["postgres", "backup", "restore", "db_1", "--backup", "bkp_1", "--json"], { cwd: await pinnedCwd(), answers: ["db_2"], @@ -1378,6 +1397,7 @@ describe("prisma-cli postgres restore", () => { ).run( [ "postgres", + "backup", "restore", "db_1", "--backup", @@ -1418,6 +1438,7 @@ describe("prisma-cli postgres restore", () => { ).run( [ "postgres", + "backup", "restore", "db_1", "--backup", @@ -1463,6 +1484,7 @@ describe("prisma-cli postgres restore", () => { ).run( [ "postgres", + "backup", "restore", "db_1", "--backup", @@ -1478,7 +1500,7 @@ describe("prisma-cli postgres restore", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: true, - commandId: "postgres.restore", + commandId: "postgres.backup.restore", result: { projectId: "proj_1", database: { id: "db_1", status: "recovering" }, @@ -1496,6 +1518,7 @@ describe("prisma-cli postgres restore", () => { it("requires credentials", async () => { const result = await makeCli(postgresClient(), false).run([ "postgres", + "backup", "restore", "db_1", "--backup", From 8828bb40ce0335a687cbb8f5a9415b17f8dc9a06 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:21:39 +0200 Subject: [PATCH 10/27] Address carried D3 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider-layer failure summaries follow the verb rename: deleting a project, database, database connection, or custom domain now fails as "Failed to delete …", and the env branch-not-found why-text says delete. A postgres delete failure test pins the summary. - validateKey narrows to its reachable commands (add | update); the unreachable remove template branch goes with it. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/controllers/app-env.ts | 2 +- packages/cli/src/lib/app/app-provider.ts | 2 +- packages/cli/src/lib/app/env-config.ts | 6 +++--- packages/cli/src/lib/database/provider.ts | 4 ++-- packages/cli/src/lib/project/provider.ts | 2 +- packages/cli/tests/postgres.test.ts | 18 ++++++++++++++++++ 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 48f1e8d4..f7b31aa7 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -339,7 +339,7 @@ async function resolveExistingBranch( code: "ENV_BRANCH_NOT_FOUND", domain: "app", summary: `Branch "${branchName}" not found`, - why: "Branch update, list, and remove commands only target existing preview branches.", + why: "Branch update, list, and delete commands only target existing preview branches.", fix: "Create the branch by deploying it, or use `project env add --branch` to create its first override.", exitCode: 1, nextSteps: [ diff --git a/packages/cli/src/lib/app/app-provider.ts b/packages/cli/src/lib/app/app-provider.ts index f23e2b0f..b2d98224 100644 --- a/packages/cli/src/lib/app/app-provider.ts +++ b/packages/cli/src/lib/app/app-provider.ts @@ -447,7 +447,7 @@ export function createAppProvider( if (result.error) { throw domainApiCallError( - "Failed to remove custom domain", + "Failed to delete custom domain", result.response, result.error, ); diff --git a/packages/cli/src/lib/app/env-config.ts b/packages/cli/src/lib/app/env-config.ts index 7bb21a24..5434f11d 100644 --- a/packages/cli/src/lib/app/env-config.ts +++ b/packages/cli/src/lib/app/env-config.ts @@ -151,7 +151,7 @@ const KEY_SHAPE = /^[A-Z_][A-Z0-9_]*$/; export function validateKey( key: string, - command: "add" | "update" | "remove", + command: "add" | "update", ): void { if (key.length === 0) { throw usageError( @@ -159,7 +159,7 @@ export function validateKey( "An empty key was passed.", "Pass an env-var key, e.g. STRIPE_KEY.", [ - `prisma-cli project env ${command} STRIPE_KEY${command === "remove" ? "" : "=value"} --role production`, + `prisma-cli project env ${command} STRIPE_KEY=value --role production`, ], "app", ); @@ -181,7 +181,7 @@ export function validateKey( "Keys must start with an uppercase letter or underscore and contain only uppercase letters, digits, and underscores.", "Rename the key to match [A-Z_][A-Z0-9_]*.", [ - `prisma-cli project env ${command} STRIPE_KEY${command === "remove" ? "" : "=value"} --role production`, + `prisma-cli project env ${command} STRIPE_KEY=value --role production`, ], "app", ); diff --git a/packages/cli/src/lib/database/provider.ts b/packages/cli/src/lib/database/provider.ts index 814228f1..3a90bcad 100644 --- a/packages/cli/src/lib/database/provider.ts +++ b/packages/cli/src/lib/database/provider.ts @@ -338,7 +338,7 @@ export function createManagementDatabaseProvider( }); if (result.error) { throw await toDatabaseApiError( - "Failed to remove database", + "Failed to delete database", result.response, result.error, options?.signal, @@ -407,7 +407,7 @@ export function createManagementDatabaseProvider( }); if (result.error) { throw await toDatabaseApiError( - "Failed to remove database connection", + "Failed to delete database connection", result.response, result.error, options?.signal, diff --git a/packages/cli/src/lib/project/provider.ts b/packages/cli/src/lib/project/provider.ts index 164d9870..9008feb9 100644 --- a/packages/cli/src/lib/project/provider.ts +++ b/packages/cli/src/lib/project/provider.ts @@ -81,7 +81,7 @@ export function createManagementProjectProvider( } if (result.error) { throw projectApiError( - "Failed to remove project", + "Failed to delete project", result.response, result.error, ); diff --git a/packages/cli/tests/postgres.test.ts b/packages/cli/tests/postgres.test.ts index 6a4f6c08..2c1a06b4 100644 --- a/packages/cli/tests/postgres.test.ts +++ b/packages/cli/tests/postgres.test.ts @@ -1537,6 +1537,24 @@ describe("prisma-cli postgres backup restore", () => { }); describe("prisma-cli postgres delete", () => { + it("reports a refused deletion as a failure to delete", async () => { + const result = await makeCli( + postgresClient({ + routes: { + "DELETE /v1/databases/{databaseId}": () => apiFailure(500), + }, + }), + ).run(["postgres", "delete", "db_1", "--confirm", "db_1", "--json"], { + cwd: await pinnedCwd(), + }); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { summary: "Failed to delete database" }, + }); + }); + it("deletes the database", async () => { const calls: Call[] = []; const result = await makeCli(postgresClient({ calls })).run( From 7926c4e5ec41d1a53b42ee2f858a39b7dacfb902 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:24:22 +0200 Subject: [PATCH 11/27] Docs and READMEs follow the new command grammar Command tables and prose in both package READMEs, the root README, packages/cli/AGENTS.md, and the example READMEs now name the live tree: deploy and dev at the root, delete for the destroying commands, postgres backup restore, and the ORM groups without top-level migrate/format/init. command-principles drops init from the stable groups, respells migrate as db, and states the verb rule (delete destroys, remove detaches). The style/output guides use auth login as the first-run banner example; error-conventions drops the removed compute-config and init codes and follows the DELETE_BLOCKED / DELETE_FAILED renames; cli-engine-requirements respells db migrate and orm init; output-conventions loses the compute-config build settings bullet and respells the connection delete rows. Signed-off-by: willbot Signed-off-by: Will Madden --- README.md | 11 ++++---- docs/architecture/cli-engine-requirements.md | 7 ++--- docs/product/cli-style-guide.md | 2 +- docs/product/command-principles.md | 10 +++++-- docs/product/error-conventions.md | 28 +++----------------- docs/product/output-conventions.md | 7 +++-- examples/hello-world/README.md | 2 +- examples/next-smoke/README.md | 2 +- packages/cli/AGENTS.md | 4 +-- packages/cli/README.md | 12 ++++----- packages/prisma/README.md | 12 ++++----- 11 files changed, 40 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 5f0f25c3..94ad08c1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ pnpm prisma-cli project env list --role preview ``` Deployments start from pushing the connected repository, the Console, or -`prisma-cli composer deploy` — there is no standalone deploy command. +`prisma-cli deploy`. ## Local Development @@ -86,10 +86,11 @@ prisma ``` The package includes project, environment-variable, service and deployment -inspection, promotion, rollback, and removal commands, plus the Prisma ORM -(`contract`, `db`, `migrate`, `migration`, `orm init`) and Composer -workflows, and the `postgres` and `bucket` resource groups. The product -model intentionally avoids product-specific namespaces. +inspection, promotion, rollback, and deletion commands, plus the Prisma ORM +(`contract`, `db`, `migration`, `orm init`) and Composer +workflows (root `dev` and `deploy`), and the `postgres` and `bucket` +resource groups. The product model intentionally avoids product-specific +namespaces. ## Documentation diff --git a/docs/architecture/cli-engine-requirements.md b/docs/architecture/cli-engine-requirements.md index 3e9bfb3b..7cb4fa83 100644 --- a/docs/architecture/cli-engine-requirements.md +++ b/docs/architecture/cli-engine-requirements.md @@ -155,7 +155,7 @@ references, and nothing about the tree is discovered at run time. help without executing product code, and fails at build time when it is wrong. The expensive parts — driver stacks, Composer's dependency tree (which imports the user's own modules and has crashed at import in the past) — stay out of -the startup path, so `prisma migrate` can never be taken down by a product it +the startup path, so `prisma db migrate` can never be taken down by a product it isn't using. The split follows the existing design for isolating heavy dependency subtrees behind execution-time imports. @@ -214,8 +214,9 @@ and how to install it with the user's own package manager. Running the user's own package manager is a different act, and a command may do it: adding dependencies to the project the user is working in, or running a package's binary once, because the user asked for it. The case -this exists for is `prisma init`, which scaffolds a project and then -installs the dependencies it just wrote into `package.json`. A command +this exists for is a scaffolding command like `prisma orm init`, which +sets up a project and then installs the dependencies it just wrote into +`package.json`. A command declares that it installs packages, and the work goes through the engine's package operations, whose terms are what keep the two apart. The manager is the one the user's project already uses, detected rather than imposed. diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index 28422ec4..8cda22aa 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -58,7 +58,7 @@ Recommended symbols: - Human-facing paths should usually be shown relative to the current working directory. - Structured output should use the literal machine-meaningful value. -- Banners are reserved for `init` and similar first-run experiences. +- Banners are reserved for first-run experiences such as `auth login`. - Outside those flows, focus on status, context, result, and next steps. Human-oriented command output in TTY mode should usually start with a compact header. diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index c660901e..747c7199 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -28,14 +28,13 @@ Use the other convention docs for adjacent concerns: The long-term command surface grows through workflow groups such as: -- `init` - `agent` - `auth` - `project` - `branch` - `schema` - `database` -- `migrate` +- `db` - `app` - `git` @@ -98,6 +97,13 @@ Build and release an app into a target branch. Resolve a deployment and show or stream its logs. +### `delete` and `remove` + +`delete` destroys a resource; `remove` detaches one thing from another +without destroying it. A command that permanently destroys what it +targets is spelled `delete` (`project delete`, `service delete`, +`postgres delete`); `remove` is reserved for detachment. + ### `wait` Block until a remote resource reaches a terminal state. diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index ee1cef6b..957a754a 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -171,7 +171,7 @@ These codes are the minimum stable set for the MVP: - `PROJECT_LINK_TARGET_REQUIRED` - `PROJECT_CREATE_FAILED` - `PROJECT_RENAME_FAILED` -- `PROJECT_REMOVE_BLOCKED` +- `PROJECT_DELETE_BLOCKED` - `PROJECT_TRANSFER_REJECTED` - `PROJECT_API_ERROR` - `TRANSFER_RECIPIENT_REQUIRED` @@ -183,16 +183,6 @@ These codes are the minimum stable set for the MVP: - `LOCAL_STATE_WRITE_FAILED` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` -- `COMPUTE_CONFIG_INVALID` -- `COMPUTE_CONFIG_TARGET_REQUIRED` -- `COMPUTE_CONFIG_TARGET_UNKNOWN` -- `BUILD_SETTINGS_MIGRATION_REQUIRED` -- `BUILD_SETTINGS_UNSUPPORTED` -- `FRAMEWORK_NOT_DETECTED` -- `INIT_CONFIG_EXISTS` -- `INIT_CONVERT_UNSUPPORTED` -- `INIT_CONVERT_INCOMPLETE` -- `INIT_DETECTION_FAILED` - `DEPLOYMENT_NOT_FOUND` - `NO_DEPLOYMENTS` - `NO_PREVIOUS_DEPLOYMENT` @@ -208,7 +198,7 @@ These codes are the minimum stable set for the MVP: - `DOMAIN_RETRY_NOT_ELIGIBLE` - `DOMAIN_VERIFICATION_FAILED` - `DOMAIN_VERIFICATION_TIMEOUT` -- `REMOVE_FAILED` +- `DELETE_FAILED` - `FEATURE_UNAVAILABLE` - `REPO_PROVIDER_UNSUPPORTED` - `REPO_INSTALLATION_REQUIRED` @@ -253,7 +243,7 @@ Recommended meanings: - `PROJECT_LINK_TARGET_REQUIRED`: `project link` needs the user to choose an existing Project or create a new one - `PROJECT_CREATE_FAILED`: Project creation failed before deployment or linking could continue - `PROJECT_RENAME_FAILED`: the platform rejected the new project name -- `PROJECT_REMOVE_BLOCKED`: project removal is blocked while it still has active deployments +- `PROJECT_DELETE_BLOCKED`: project deletion is blocked while it still has active deployments - `PROJECT_TRANSFER_REJECTED`: the platform rejected the transfer, for example an invalid or expired recipient token - `PROJECT_API_ERROR`: project Management API request failed without a more specific CLI error code - `TRANSFER_RECIPIENT_REQUIRED`: project transfer needs --to-workspace or --recipient-token @@ -265,16 +255,6 @@ Recommended meanings: - `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context -- `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate -- `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred -- `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app -- `BUILD_SETTINGS_MIGRATION_REQUIRED`: a legacy `prisma.app.json` contains custom build settings that must move into the `build` block of `prisma.compute.ts` -- `BUILD_SETTINGS_UNSUPPORTED`: a compute config `build` block targets a framework whose SDK strategy does not consume committed build settings -- `FRAMEWORK_NOT_DETECTED`: app deploy could not detect a supported Beta framework and no explicit framework/build type was provided -- `INIT_CONFIG_EXISTS`: a compute config already exists in this directory or an ancestor; init never overwrites or merges -- `INIT_CONVERT_UNSUPPORTED`: `init --format json` found an existing TypeScript config; TypeScript configs may contain logic, so converting them to JSON automatically would be lossy and the rewrite is manual -- `INIT_CONVERT_INCOMPLETE`: a conversion wrote prisma.compute.ts but could not delete prisma.compute.json, and rolling back the write also failed; both files exist and one must be deleted by hand -- `INIT_DETECTION_FAILED`: no supported framework detected and no --framework passed; `meta.frameworks` lists the valid values - `DEPLOYMENT_NOT_FOUND`: requested deployment id does not exist - `NO_DEPLOYMENTS`: command resolved a branch or app but found no deployments - `NO_PREVIOUS_DEPLOYMENT`: rollback could not find an earlier deployment for the selected app @@ -290,7 +270,7 @@ Recommended meanings: - `DOMAIN_RETRY_NOT_ELIGIBLE`: requested custom domain is not in a state where verification can be retried - `DOMAIN_VERIFICATION_FAILED`: custom-domain verification reached a terminal failed state - `DOMAIN_VERIFICATION_TIMEOUT`: custom-domain verification did not reach a terminal state before the requested timeout -- `REMOVE_FAILED`: app removal could not complete remotely +- `DELETE_FAILED`: service deletion could not complete remotely - `FEATURE_UNAVAILABLE`: the command exists in the CLI model, but the current preview cannot support it yet - `REPO_PROVIDER_UNSUPPORTED`: repository connection received a non-GitHub repository URL - `REPO_INSTALLATION_REQUIRED`: repository connection needs a GitHub App installation before the project can be linked diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 0ca9d38d..a2707fcb 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -142,10 +142,10 @@ Current MVP commands map to patterns like this: | `database list` | `list` | | `database show` | `show` | | `database create` | compact mutate stderr + raw secret stdout + JSON envelope | -| `database remove` | `mutate` | +| `database delete` | `mutate` | | `database connection list` | `list` | | `database connection create` | compact mutate stderr + raw secret stdout + JSON envelope | -| `database connection remove` | `mutate` | +| `database connection delete` | `mutate` | | `bucket list` | `list` | | `bucket create` | `mutate` | | `bucket delete` | `mutate` | @@ -310,7 +310,7 @@ Human output should: - use symbols rather than emojis - prefer relative paths when a path is explanatory text - keep ceremony low -- reserve banners for `init` and similar first-run flows +- reserve banners for first-run flows such as `auth login` - keep header metadata compact and aligned - avoid placeholder rows for unknown values @@ -379,7 +379,6 @@ Examples: - `app deploy` should state the resolved target that matters in the current slice - first local `app deploy` binding should make the Project choice explicit before work begins - subsequent `app deploy` calls should use a compact target header such as `Deploying ./j1 to j1 / main / j1` -- config-backed `app deploy` builds should show the resolved build settings before build starts: `Build Command` and `Output Directory` with their sources (`prisma.compute.ts` or inference), `Output Directory` as a literal path such as `.next/standalone` rather than an opaque framework default label - `app logs` should state the deployment it resolved - `app list-deploys` should state which app or branch is being listed diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md index 31f0b730..f0b538bd 100644 --- a/examples/hello-world/README.md +++ b/examples/hello-world/README.md @@ -2,7 +2,7 @@ Manual Bun smoke app for exercising the local source Prisma CLI from inside this repo. -This example mirrors the recommended external Bun workflow: `bun init --yes`, replace `index.ts` with a small `Bun.serve(...)` server, then wire it to a Prisma project with the CLI. Deployments start from pushing a connected repository (`git connect`), the Console, or `composer deploy` — there is no standalone deploy command. +This example mirrors the recommended external Bun workflow: `bun init --yes`, replace `index.ts` with a small `Bun.serve(...)` server, then wire it to a Prisma project with the CLI. Deployments start from pushing a connected repository (`git connect`), the Console, or `deploy`. This example is intentionally not part of the root pnpm workspace. Install it only when you want to run manual end-to-end checks. diff --git a/examples/next-smoke/README.md b/examples/next-smoke/README.md index 43fcc925..8f981c31 100644 --- a/examples/next-smoke/README.md +++ b/examples/next-smoke/README.md @@ -19,7 +19,7 @@ pnpm prisma service deployment list pnpm prisma service deployment show DEPLOYMENT_ID ``` -Deployments start from pushing the connected repository, the Console, or `composer deploy` — there is no standalone deploy command. +Deployments start from pushing the connected repository, the Console, or `deploy`. What this validates: diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 0ef6d4e7..1e33db40 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -9,7 +9,7 @@ The docs in `docs/product` are the source of truth. Do not invent product behavi ## What This CLI Is - This is the future unified Prisma CLI. -- The command model must preserve the long-term CLI for ORM, Postgres, and service workflows; deployments start from a pushed connected repository, the Console, or `composer deploy`. +- The command model must preserve the long-term CLI for ORM, Postgres, and service workflows; deployments start from a pushed connected repository, the Console, or `deploy`. ## Read These First @@ -33,7 +33,7 @@ Architecture and contributor workflow references: - Group commands by developer workflow, not product ownership. - No `orm`, `postgres`, or `compute` namespaces in the command surface. - Canonical command shape is `prisma `. -- The shipped groups are `auth`, `project`, `git`, `branch`, `postgres`, `bucket`, `service`, `build`, `composer`, and the ORM family (`contract`, `db`, `migrate`, `migration`, `format`, `orm init`, `lsp`). +- The shipped groups are `auth`, `project`, `git`, `branch`, `postgres`, `bucket`, `service`, the root `dev` and `deploy` verbs, and the ORM family (`contract`, `db`, `migration`, `orm init`, `lsp`). - Preserve the long-term resource model: `workspace -> project -> branch -> { service, database, bucket }`. The mounted tree in `packages/cli/src/cli.ts` is the authoritative command surface. diff --git a/packages/cli/README.md b/packages/cli/README.md index f5abb967..375a55c1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -39,7 +39,7 @@ npx prisma-cli git connect git@github.com:owner/repo.git ``` Deployments start from pushing the connected repository, the Console, or -`prisma-cli composer deploy` — there is no standalone deploy command. +`prisma-cli deploy`. With `pnpm`: @@ -73,13 +73,11 @@ The beta package exposes `prisma-cli` so it can coexist with the existing | `project` | List, create, link, and manage projects and their environment variables. | | `git` | Connect or disconnect a project from a GitHub repository; pushes deploy. | | `branch` | List Prisma branches for the resolved project. | -| `postgres` | Create, inspect, restore, and remove Prisma Postgres databases and their connections. | +| `postgres` | Create, inspect, back up, restore, and delete Prisma Postgres databases and their connections. | | `bucket` | Create, list, and delete object-store buckets and their access keys. | -| `service` | Inspect services: deployments, logs, domains, promote, roll back, remove. | -| `build` | Stream platform build logs. | -| `composer` | Deploy, destroy, and develop Composer apps. | -| `contract`, `db`, `migrate`, `migration`, `format`, `orm init`, `lsp` | The Prisma ORM workflow. | -| `init` | Write a committed compute config for the project. | +| `service` | Inspect services: deployments, logs, domains, promote, roll back, delete. | +| `dev`, `deploy` | Run a Composer app locally; deploy it to the platform. | +| `contract`, `db`, `migration`, `orm init`, `lsp` | The Prisma ORM workflow. | Common examples: diff --git a/packages/prisma/README.md b/packages/prisma/README.md index d9355839..8d6310d1 100644 --- a/packages/prisma/README.md +++ b/packages/prisma/README.md @@ -39,7 +39,7 @@ npx prisma git connect git@github.com:owner/repo.git ``` Deployments start from pushing the connected repository, the Console, or -`prisma composer deploy` — there is no standalone deploy command. +`prisma deploy`. With `pnpm`: @@ -70,13 +70,11 @@ npx prisma project env list --role preview | `project` | List, create, link, and manage projects and their environment variables. | | `git` | Connect or disconnect a project from a GitHub repository; pushes deploy. | | `branch` | List Prisma branches for the resolved project. | -| `postgres` | Create, inspect, restore, and remove Prisma Postgres databases and their connections. | +| `postgres` | Create, inspect, back up, restore, and delete Prisma Postgres databases and their connections. | | `bucket` | Create, list, and delete object-store buckets and their access keys. | -| `service` | Inspect services: deployments, logs, domains, promote, roll back, remove. | -| `build` | Stream platform build logs. | -| `composer` | Deploy, destroy, and develop Composer apps. | -| `contract`, `db`, `migrate`, `migration`, `format`, `orm init`, `lsp` | The Prisma ORM workflow. | -| `init` | Write a committed compute config for the project. | +| `service` | Inspect services: deployments, logs, domains, promote, roll back, delete. | +| `dev`, `deploy` | Run a Composer app locally; deploy it to the platform. | +| `contract`, `db`, `migration`, `orm init`, `lsp` | The Prisma ORM workflow. | Common examples: From e137ae9febc6a84a89c51c14ee225799e9c4bdfd Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:24:29 +0200 Subject: [PATCH 12/27] Respell a historical comment quoting the old composer spelling Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/tests/e2e-coverage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index e64e948b..cbe30b5e 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -122,7 +122,7 @@ const EXCLUSIONS: Readonly> = { * * * `service logs` arrived while this was being written, excluded because - * "only `composer deploy` produces" a deployment to read logs from. That + * only Composer's deploy produced a deployment to read logs from. That * is no longer true, so it is owed rather than excused — it needs a * deployment that has actually served traffic, which is a little more * than the fixture does today. From dcb3a976cd5d2410fdea48f88f77d7ffa1004468 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:27:19 +0200 Subject: [PATCH 13/27] Record the grammar cleanup in the s2 divergence files; regenerate command-review Each s2 parity record that documents a renamed or moved command gets a short dated entry (2026-08-21 PM review); command-inventory.md gets a pointer note. command-review.md is recreated in the 76a2c8a format against the new 86-command tree. Signed-off-by: willbot Signed-off-by: Will Madden --- .../prisma-cli-v8/assets/command-review.md | 205 ++++++++++++++++++ .../assets/s2/command-inventory.md | 11 + .../assets/s2/parity-divergences-s3.md | 7 + .../assets/s2/parity-divergences-s7.md | 8 + .../assets/s2/parity-divergences-s8.md | 9 + .../s2/parity-divergences-service-logs.md | 7 + .../assets/s2/parity-divergences.md | 13 ++ 7 files changed, 260 insertions(+) create mode 100644 .drive/projects/prisma-cli-v8/assets/command-review.md diff --git a/.drive/projects/prisma-cli-v8/assets/command-review.md b/.drive/projects/prisma-cli-v8/assets/command-review.md new file mode 100644 index 00000000..f8c0c190 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/command-review.md @@ -0,0 +1,205 @@ +# Prisma CLI v8 — command review + +Every command the CLI mounts, for a semantic review of what each command means and how the tree is organized. Regenerated 2026-08-21 after the command grammar cleanup (PM review: compute config and `init` removed, destroying `remove` commands renamed `delete`, `postgres restore`/`ref`/`migrate`/`format` moved, composer's verbs mounted at the root). Generated from the mounted command tree in `packages/cli/src/cli.ts`. Flags and options are deliberately omitted. 86 commands in total. + +The tree has five sources: the platform family (this repo), the Composer family (`@prisma/composer-cli`, re-wrapped to `deploy` and `dev`), the ORM family (`@prisma/orm-toolchain`), the engine's telemetry group, and a few local utilities with no owning package. + +## Top-level commands + +| Command | Meaning | +| --- | --- | +| `dev` | Bring up the application whose root node is the entry's default export, entirely on this machine | +| `deploy` | Deploy the application whose root node is the entry's default export | +| `lsp` | Start the Prisma Next language server | +| `feedback` | Send feedback to the Prisma CLI team | + +Notes for the review: there is no top-level `init` (the compute-config wizard was removed; the ORM's project initializer stays at `orm init`) and no `version` command — the engine's `--version` answers. + +## `auth` — Manage local authentication for the CLI + +| Command | Meaning | +| --- | --- | +| `auth login` | Log in to your Prisma platform account | +| `auth logout` | Clear stored authentication credentials | +| `auth whoami` | Show the authenticated user and accessible workspace | + +### `auth workspace` — Manage local workspace sessions + +| Command | Meaning | +| --- | --- | +| `auth workspace list` | List your workspace sessions | +| `auth workspace use` | Make one of your workspace sessions current | +| `auth workspace logout` | End one workspace session | + +## `project` — Manage and inspect your Prisma projects + +| Command | Meaning | +| --- | --- | +| `project list` | List all projects in your workspace | +| `project show` | Show this directory's Project binding | +| `project create` | Create a Project and link this directory | +| `project link` | Link this directory to a Project | +| `project rename` | Rename the resolved Project | +| `project delete` | Delete a Project permanently after exact id confirmation | +| `project transfer` | Transfer a Project to another workspace after exact id confirmation | + +### `project env` — Manage environment variables for the active project + +| Command | Meaning | +| --- | --- | +| `project env add` | Create a new environment variable | +| `project env update` | Replace an existing environment variable's value | +| `project env list` | List environment variable metadata for a scope (no values) | +| `project env delete` | Delete an environment variable from a scope | + +## `postgres` — Manage Prisma Postgres databases for a project + +| Command | Meaning | +| --- | --- | +| `postgres list` | List Prisma Postgres databases for the resolved project | +| `postgres show` | Show database metadata without secret values | +| `postgres create` | Create a Prisma Postgres database and print its one-time connection URL | +| `postgres usage` | Show usage metrics for a database | +| `postgres delete` | Delete a database after exact id confirmation | + +### `postgres backup` — Inspect and restore platform-created database backups + +| Command | Meaning | +| --- | --- | +| `postgres backup list` | List backups for a database | +| `postgres backup restore` | Restore a database from a backup after exact id confirmation | + +### `postgres connection` — Manage one-time-view database connection strings + +| Command | Meaning | +| --- | --- | +| `postgres connection list` | List database connection metadata without secret values | +| `postgres connection create` | Create a database connection and print its one-time connection URL | +| `postgres connection rotate` | Rotate connection credentials and print the new one-time connection URL | +| `postgres connection delete` | Delete a database connection after exact id confirmation | + +## `bucket` — Manage object-store buckets for a project + +| Command | Meaning | +| --- | --- | +| `bucket list` | List object-store buckets for the resolved project | +| `bucket create` | Create an object-store bucket | +| `bucket delete` | Delete a bucket and all its access keys | + +### `bucket key` — Manage access keys for an object-store bucket + +| Command | Meaning | +| --- | --- | +| `bucket key list` | List access keys for a bucket | +| `bucket key create` | Create a bucket access key and print its one-time credentials | +| `bucket key delete` | Revoke and delete a bucket access key | + +## `branch` — View your Platform branches + +| Command | Meaning | +| --- | --- | +| `branch list` | List Platform branches for the resolved project | + +## `git` — Manage Git repository connections for a project + +| Command | Meaning | +| --- | --- | +| `git connect` | Connect the resolved project to a GitHub repository | +| `git disconnect` | Disconnect the GitHub repository from the resolved project | + +## `service` — Manage services and deployments for a project + +| Command | Meaning | +| --- | --- | +| `service list` | List the services in a project | +| `service create` | Create a service in a project | +| `service show` | Show the service and its current deployment | +| `service open` | Open the service's live URL | +| `service logs` | Read logs for a deployment of the service | +| `service delete` | Delete the service from the resolved branch | + +### `service deployment` — Manage deployments for a service + +| Command | Meaning | +| --- | --- | +| `service deployment list` | List deployments for the service | +| `service deployment show` | Show a deployment in detail | +| `service deployment promote` | Promote a deployment to production by rebuilding with production env vars | +| `service deployment rollback` | Roll back production to a previous deployment | +| `service deployment start` | Start a stopped deployment | +| `service deployment stop` | Stop a running deployment | +| `service deployment delete` | Delete a deployment and the artifact it holds | + +### `service domain` — Manage custom domains for a service + +| Command | Meaning | +| --- | --- | +| `service domain add` | Register a custom domain on the service's production branch | +| `service domain show` | Show custom domain status and certificate details | +| `service domain delete` | Delete a custom domain from the service | +| `service domain retry` | Retry custom domain DNS verification and TLS provisioning | +| `service domain wait` | Wait until a custom domain is active or failed | + +## `contract` — Define and emit your application data contract + +| Command | Meaning | +| --- | --- | +| `contract emit` | Emit your contract artifacts | +| `contract infer` | Infer a PSL contract from the live database schema | +| `contract format` | Format your PSL contract source | + +## `db` — Verify, sign and update your database against the contract + +| Command | Meaning | +| --- | --- | +| `db init` | Bootstrap a database to match the current contract and sign it | +| `db schema` | Inspect the live database schema | +| `db sign` | Sign the database with your contract so you can safely run queries | +| `db update` | Update your database schema to match your contract | +| `db verify` | Check whether the database marker and live schema match your contract | +| `db migrate` | Apply planned migrations to advance the database | + +## `migration` — Plan, inspect and scaffold on-disk migrations + +| Command | Meaning | +| --- | --- | +| `migration plan` | Plan a migration from contract changes | +| `migration new` | Scaffold a new migration for manual authoring | +| `migration list` | List on-disk migrations per contract space | +| `migration show` | Display migration package contents | +| `migration status` | Show migration path and pending status | +| `migration log` | Show executed migration history | +| `migration graph` | Show the migration graph topology | +| `migration check` | Verify artifact and graph integrity | + +Applying migrations is `db migrate`. + +### `migration ref` — Manage named refs that point at contracts + +| Command | Meaning | +| --- | --- | +| `migration ref list` | List every named ref | +| `migration ref set` | Point a ref at a contract | +| `migration ref delete` | Delete a ref | + +## `orm` — Initialize a Prisma ORM project + +| Command | Meaning | +| --- | --- | +| `orm init` | Initialize a new Prisma Next project | + +## `agent` — Manage Prisma skills for AI coding agents + +| Command | Meaning | +| --- | --- | +| `agent install` | Install Prisma skills for AI coding agents | +| `agent update` | Refresh Prisma skills for AI coding agents | +| `agent status` | Show installed Prisma skills | + +## `telemetry` — Inspect and change anonymous CLI telemetry + +| Command | Meaning | +| --- | --- | +| `telemetry status` | Show whether anonymous CLI telemetry is enabled and why | +| `telemetry enable` | Enable anonymous CLI telemetry | +| `telemetry disable` | Disable anonymous CLI telemetry | diff --git a/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md b/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md index cd37c02f..d7617467 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md @@ -699,3 +699,14 @@ Fixture-mode counts are references to `fixturePath` per file (see command sectio - `auth logout --workspace X` duplicating `auth workspace logout X` is a compat shim worth collapsing. - `project env` is the env surface (moved off `app`); the S2 brief's "app (incl. env…)" reflects the old layout — env controllers still live in files named `app-env*.ts` and types in `types/app-env.ts` even though the commands are `project env *`. - Group descriptor `branch` says "View your Platform branches" — read-only group with one verb; fine, but the deploy path creates branches implicitly (POST branches), which the grammar should own explicitly. + +--- + +**2026-08-21 note (PM review, command grammar cleanup):** this +inventory is a historical grounding document for the S2 ports. The +mounted tree has since changed: top-level `init` is removed, the six +destroying `remove` commands are `delete`, `postgres restore` is +`postgres backup restore`, `migrate`/`format`/`ref *` are `db +migrate`/`contract format`/`migration ref *`, and composer's `deploy` +and `dev` are root commands. The regenerated tree lives in +[`../command-review.md`](../command-review.md). diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md index a338d8d5..3646de76 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md @@ -272,3 +272,10 @@ added; the guard remains the required explicit `--stage` / `--production` target, exactly as legacy. If destroy deserves a confirmation it is a composer product decision, raised upstream rather than built at the mount. + +## Command grammar cleanup (2026-08-21 PM review) + +The `composer` group is dissolved: `composer deploy` and `composer +dev` mount at the root as `deploy` and `dev`, and `composer destroy` +and `composer log` are dropped from the mounted tree entirely. The +sections above describe the family as S3 mounted it. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md index 2905abe0..8027ae78 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md @@ -42,3 +42,11 @@ imports behind dynamic imports, which is prisma/prisma's change to make, not this repo's. Mirrored in [`../../deferred.md`](../../deferred.md) under "Upstream, not ours to land". + +## Command grammar cleanup (2026-08-21 PM review) + +The ORM mounts moved: `migrate` → `db migrate`, `format` → `contract +format`, `ref list|set|delete` → `migration ref list|set|delete`. The +shipped `migration ref` redirect is dropped at the mount (the spelling +is live again) and `migration apply`'s replacement is respelled to +`{bin} db migrate --to `. Command behaviour is unchanged. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md index cfd09285..3eaa2a71 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md @@ -213,3 +213,12 @@ of a check here is the requirement being met, not a gap in it. The same holds for `delete` against a running deployment: the API states the stop precondition, the CLI does not pre-empt it. + +## Command grammar cleanup (2026-08-21 PM review) + +`service remove` is renamed `service delete` (result field `removed` +becomes `deleted`; `SERVICE.REMOVE_FAILED` becomes +`SERVICE.DELETE_FAILED`), and every service command that targets an +existing service now requires `--service` or `PRISMA_SERVICE_ID` — the +interactive picker and the remembered selection are gone, and `--branch` +no longer falls back to the local git branch. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md index acad90fe..eb996461 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md @@ -107,3 +107,10 @@ signalled run at 128 + the signal "whatever the handler concluded" [`parity-divergences-s3.md`](parity-divergences-s3.md)). S2c would have settled 130 too. The exit code comes from the engine rule, not from anything this slice changed. + +## Command grammar cleanup (2026-08-21 PM review) + +`service logs` now requires a service target (`--service` or +`PRISMA_SERVICE_ID`) unless `--deployment` names a deployment id with +no service target, which resolves globally within the project. The +picker and remembered selection are gone. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index 3dbf489e..1eba5a21 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -1606,3 +1606,16 @@ Three ported commands reached directly into `process` for host facts, because no ## Ruled during the S7 merge (operator, 2026-08-12) **Top-level `init` is the platform's compute-config wizard; the ORM's project initializer mounts at `orm init`.** The unified grammar had both families claiming `init` — the S2d contract for the platform wizard, the ORM family's own command key for the initializer — and the collision only became mountable when S7 landed the ORM family. Users of the old ORM CLI who type `prisma init` expecting a schema scaffold now get the compute wizard and must type `orm init`. The `orm` group exists solely for this command until the ORM family grows more residents or the ruling is revisited (TML-3189 holds the final grammar). + +## Command grammar cleanup (2026-08-21 PM review) + +The grammar cleanup slice supersedes several spellings this record +documents. Top-level `init` and the compute config +(`prisma.compute.ts`/`.json`) are removed; service commands take +parameters only (`--service`/`PRISMA_SERVICE_ID`, `--branch`; the +picker, remembered selection, and git-branch inference are gone); +`project remove`, `project env remove`, `postgres remove`, `postgres +connection remove`, `service remove`, and `service domain remove` are +renamed to `delete`; `postgres restore` moves to `postgres backup +restore`. No aliases or redirects for the old spellings. This entry +records the change; the sections above stay as written for history. From c107c506b46d617f4af743d5dab76efaeb82b9ff Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:27:19 +0200 Subject: [PATCH 14/27] Collapse validateKey's signature onto one line per the formatter Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/lib/app/env-config.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/app/env-config.ts b/packages/cli/src/lib/app/env-config.ts index 5434f11d..dceb2e2d 100644 --- a/packages/cli/src/lib/app/env-config.ts +++ b/packages/cli/src/lib/app/env-config.ts @@ -149,18 +149,13 @@ export function parseKeyValuePositional( const KEY_SHAPE = /^[A-Z_][A-Z0-9_]*$/; -export function validateKey( - key: string, - command: "add" | "update", -): void { +export function validateKey(key: string, command: "add" | "update"): void { if (key.length === 0) { throw usageError( `Variable key cannot be empty`, "An empty key was passed.", "Pass an env-var key, e.g. STRIPE_KEY.", - [ - `prisma-cli project env ${command} STRIPE_KEY=value --role production`, - ], + [`prisma-cli project env ${command} STRIPE_KEY=value --role production`], "app", ); } @@ -180,9 +175,7 @@ export function validateKey( `Variable key "${key}" must match the POSIX env-var shape`, "Keys must start with an uppercase letter or underscore and contain only uppercase letters, digits, and underscores.", "Rename the key to match [A-Z_][A-Z0-9_]*.", - [ - `prisma-cli project env ${command} STRIPE_KEY=value --role production`, - ], + [`prisma-cli project env ${command} STRIPE_KEY=value --role production`], "app", ); } From 5ad2633d5a2b746bcbb0dfe602e07fc1e70df14f Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:30:47 +0200 Subject: [PATCH 15/27] Address D4 review findings - The wrapped ORM family respells the moved commands' shipped help examples to their mounted paths (contract format, db migrate, migration ref list|set|delete); a rendered-help test pins that no retired spelling survives in any of the five (D4-1). - The composer wrap subtracts destroy and log from the source family instead of selecting deploy and dev, so a command composer adds upstream reaches the wrapped family and mount-coverage flags it until the shell mounts it (D4-2). Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/cli.ts | 66 ++++++++++++++++++++++++---- packages/cli/tests/orm-mount.test.ts | 19 ++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 72c2e648..bd9a8d3a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -170,19 +170,66 @@ function wrapCommandFamily( * composer's dynamic executor imports, so mounting costs an unrelated * command nothing. * - * Re-wrapped to only `deploy` and `dev`, mounted at the root: `destroy` - * and `log` were dropped by the 2026-08-21 PM review. + * Re-wrapped without `destroy` and `log`, which were dropped by the + * 2026-08-21 PM review. Subtraction, not selection: a command composer + * adds upstream enters the wrapped family, so mount-coverage's + * family-completeness check flags it until the shell mounts it. */ +const COMPOSER_DROPPED_COMMANDS = new Set(["destroy", "log"]); const composerFamilySource = createComposerFamily(); export const composerCommandFamily: CommandFamily = wrapCommandFamily( composerFamilySource, - { - deploy: composerFamilySource.commands.deploy, - dev: composerFamilySource.commands.dev, - }, + Object.fromEntries( + Object.entries(composerFamilySource.commands).filter( + ([key]) => !COMPOSER_DROPPED_COMMANDS.has(key), + ), + ), composerFamilySource.redirects.map(toRedirectSpec), ); +/** The ORM commands whose mount path differs from the family's own + * key. Their shipped help examples spell the family key, so the wrap + * respells them to the mounted path. */ +const ORM_MOUNT_RESPELLINGS: Readonly> = { + format: "contract format", + migrate: "db migrate", + "ref list": "migration ref list", + "ref set": "migration ref set", + "ref delete": "migration ref delete", +}; + +function respellHelpExamples( + command: AnyCommand, + from: string, + to: string, +): AnyCommand { + return { + ...command, + help: { + ...command.help, + examples: command.help.examples.map((example) => + example === from || example.startsWith(`${from} `) + ? `${to}${example.slice(from.length)}` + : example, + ), + }, + } as AnyCommand; +} + +function respellMovedOrmCommands( + commands: Readonly>, +): Readonly> { + return Object.fromEntries( + Object.entries(commands).map(([key, command]) => { + const mountPath = ORM_MOUNT_RESPELLINGS[key]; + return [ + key, + mountPath ? respellHelpExamples(command, key, mountPath) : command, + ]; + }), + ); +} + /** * The ORM commands, contributed by orm-toolchain's own package. The * family object carries its `orm` config section, its docs base and its @@ -194,12 +241,13 @@ export const composerCommandFamily: CommandFamily = wrapCommandFamily( * Re-wrapped to rewrite the shipped redirects for this shell's tree: * the `migration ref` entry is dropped (that spelling is live again as * `migration ref list|set|delete`, and mounting it with the redirect in - * place fails buildCli's collision check), and `migration apply`'s - * replacement is respelled to the `db migrate` mount. + * place fails buildCli's collision check), `migration apply`'s + * replacement is respelled to the `db migrate` mount, and the moved + * commands' help examples are respelled to their mounted paths. */ export const ormCommandFamily: CommandFamily = wrapCommandFamily( ormToolchainFamily, - ormToolchainFamily.commands, + respellMovedOrmCommands(ormToolchainFamily.commands), ormToolchainFamily.redirects .filter((redirect) => redirect.from !== "migration ref") .map((redirect) => diff --git a/packages/cli/tests/orm-mount.test.ts b/packages/cli/tests/orm-mount.test.ts index aaaa634c..06b9a375 100644 --- a/packages/cli/tests/orm-mount.test.ts +++ b/packages/cli/tests/orm-mount.test.ts @@ -58,6 +58,9 @@ function descriptor(kind: string) { }; } +/** The retired spellings the ORM family's own examples carry. */ +const RETIRED_ORM_SPELLING = /prisma-test (format|migrate|ref)(\s|$)/; + function shell(config?: Readonly>) { return createTestCli({ commandFamilies: [ @@ -142,6 +145,22 @@ describe("the ORM family answers from the assembled tree", () => { }); }); + it.each([ + [["contract", "format"], "contract format"], + [["db", "migrate"], "db migrate"], + [["migration", "ref", "list"], "migration ref list"], + [["migration", "ref", "set"], "migration ref set"], + [["migration", "ref", "delete"], "migration ref delete"], + ])("renders %j help with the mounted spelling, not the family key", async (path, mounted) => { + const result = await shell().run([...path, "--help"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`prisma-test ${mounted}`); + expect(result.stdout).not.toMatch(RETIRED_ORM_SPELLING); + }); + it("names the ORM groups in the root help", async () => { const result = await shell().run(["--help"], { isTty: { stdout: true }, From c7e113a598c59e4e42159860bd7b8cd99cebe302 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:32:48 +0200 Subject: [PATCH 16/27] Record the grammar-cleanup slice contract, plan and deferred items Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 33 +++ .../plans/command-grammar-cleanup.md | 196 ++++++++++++++++ .../specs/command-grammar-cleanup.md | 218 ++++++++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 .drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md create mode 100644 .drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index a8032062..a51da4ed 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -449,3 +449,36 @@ Still open: - **Neither product repo installs its own tarball before publishing.** That is why an uninstallable `@prisma/composer-cli@0.6.0` sat on `latest` unnoticed. prisma-cli's check 3 does exactly this — pack, install into a clean sandbox with `npm --ignore-scripts`, start every declared bin — and is worth porting to both. - **The engine-pin check compares for equality, not peer satisfaction.** Both families now declare an exact peer equal to the shell's pin, so equality is correct and stricter today. Widening to range satisfaction belongs with the post-GA move to engine ranges (ADR 0004), not before. - **`credential-manager.ts` uses the banned word.** `packages/cli/src/auth/credential-manager.ts` has a private `#repin` method (about the active-workspace marker, a different concept from dependency versions). The operator banned the word outright; renaming it is a mechanical change to a private method, left out of the publish-channel work to keep that diff to one subject. + +## Left open by the command grammar cleanup (2026-08-21) + +The cleanup PR removed the compute config and `init`, made service +commands parameter-only, renamed the six destructive `remove` commands +to `delete`, moved `postgres restore`/`ref *`/`migrate`/`format`/ +`composer dev|deploy`, and dropped `composer destroy|log` and the +`build` group. Deliberately left behind: + +- **`project env` still infers scope from the current git branch.** + `controllers/app-env.ts` imports `readLocalGitBranch` — the same + ambient context the brief removed from the service commands, but the + brief enumerated service commands only. Extending "parameters only" + to `project env` is a product call. +- **`knownLiveDeploymentByProject` has no writer.** `service delete` + clears a local-state key nothing sets; pre-existing on `main` at + `87ffd44`, not slice fallout. Delete the shape or reinstate the + writer. +- **Upstream family cleanups.** The shell now wraps both external + families: composer still ships `destroy`/`log` commands (and their + help) that nothing mounts, and orm-toolchain still keys its family + `ref *` and ships the `migration ref` → `ref` redirect the wrapper + drops, plus a `migration apply` replacement that says `migrate`. + Each repo should retire those surfaces so the wrapper shrinks to a + pass-through. +- **`PRISMA_PROJECT_ID` is honoured only by the domain commands** + (pre-existing): the other service commands take `--project` and the + link file but not the env var. Unify or document. +- **orm-toolchain's shipped help examples name retired spellings.** + The bundle's `format` and `ref list|delete` commands carry examples + the moves invalidated; the shell wrapper respells them (D4-1 ruling) + until orm-toolchain updates its own examples to `contract format` + and `migration ref *`. diff --git a/.drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md b/.drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md new file mode 100644 index 00000000..0d3b15cc --- /dev/null +++ b/.drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md @@ -0,0 +1,196 @@ +# Command grammar cleanup — dispatch plan + +Slice contract: `specs/command-grammar-cleanup.md`. One PR into +`main`, this worktree's branch. Sequential dispatches; each hands the +next a state where `pnpm --recursive exec tsc --noEmit` and +`pnpm --filter @prisma/cli test` are green (mount-coverage may be +legitimately red mid-slice only where a dispatch's note says so). + +## D1 — The compute config and `init` are gone + +**Outcome:** no source, test, or mount references +`prisma.compute.ts`/`.json`, `@prisma/compute-sdk/config`, +`src/commands/init/`, `src/types/init.ts`, `src/lib/app/{compute-config, +build-settings,deploy-framework,build}.ts`, or the +`SERVICE.COMPUTE_CONFIG_*` error codes. Service commands lose the +config-target positional; `resolveComputeManagementContext` and +`resolveComputeTarget` are deleted; agent setup-status stops reading +the config. `init` leaves the mount table, `FAMILYLESS`, and +`EXPECTED_MOUNT_PATHS`; root help examples drop `init` (respelled +fully in D4). + +**Builds on:** clean main. **Hands to D2:** `service/target.ts` free +of compute-config imports, service command files free of the +positional, suites green with config tests deleted. + +**Focus:** follow the import graph outward from the deleted modules; +delete dependents that only served the config path (candidates: +`tests/compute-config.test.ts`, `tests/service-compute-config.test.ts`, +`tests/init*.test.ts`, `tests/app-build.test.ts`, e2e/`init` entries, +`lib/app/bun-project.ts`/`env-config.ts` if orphaned — verify, don't +assume). + +## D2 — Service commands take parameters only + +**Outcome:** every service command that targets an existing service +accepts `--service ` (match by name) or `PRISMA_SERVICE_ID` +(match by id, the domain-flow mechanics generalized); neither present +→ structured error naming `--service`, exit 2, interactive terminals +included. The interactive picker, `readSelectedApp`/`setSelectedApp`/ +`clearSelectedApp`/`selectedByProject`, `rememberSelectedService`, and +`service remove`'s selection cleanup are deleted. Branch targeting is +`--branch` only: `resolveRequestedBranch`'s git inference and +`lib/git/local-branch.ts` go (keep the existing non-git defaults: +"main" read flows, "production" domain flow). Project resolution +unchanged; `project link` keeps its picker. + +**Builds on:** D1's target.ts. **Hands to D3:** parameter-only +resolution with updated unit tests (picker/selection tests deleted or +rewritten as missing-flag error tests), suites green. + +**Focus:** `service create` doesn't resolve an existing target — keep +it working. Callers using `skipSelection` (deployment-id flows) keep +skipping. Check `git connect`/other importers before deleting +`local-branch.ts`. + +## D3 — `remove` renamed to `delete` (six commands) + +**Outcome:** `project delete`, `project env delete`, `postgres +delete`, `postgres connection delete`, `service delete`, `service +domain delete` exist; no `remove` spelling survives for them in paths, +file names, exported symbols, command ids, help, examples, next +actions, error copy, consent questions, unit tests, or e2e +`describeCommand` markers. `EXPECTED_MOUNT_PATHS` respelled. +`git disconnect`, `auth … logout`, bucket commands untouched. + +**Builds on:** D2 (service files settled). **Hands to D4:** renamed +tree, suites green. + +**Focus:** mechanical fan-out; grep each old spelling after the rename +to prove extinction (`.drive/` history and changelog-like records +exempt). + +## D4 — Moves, family wrapping, group removals + +**Outcome:** mount table matches the spec's acceptance tree. `postgres +backup restore`, `migration ref list|set|delete`, `db migrate`, +`contract format`, root `dev` and `deploy` mounted; `ref` group, +`composer` group (+ brief), `build` group (`build logs`, +`src/commands/build/`, its tests, e2e entries) gone; `composer +destroy`/`log` not mounted. In `cli.ts`, both external families are +re-wrapped with `defineCommandFamily` preserving `configSection` and +`docsBaseUrl`: composer keeps only `deploy`/`dev`; ORM passes commands +through but drops the `migration ref` redirect and respells the +`migration apply` replacement to `{bin} db migrate --to `. +No aliases or redirects for any old spelling. `EXPECTED_MOUNT_PATHS` +equals the acceptance tree; group briefs updated (`postgres backup` +brief now covers restore); root help examples live spellings (e.g. +`auth login`, `project list`, `deploy`). + +**Builds on:** D3's tree. **Hands to D5:** final grammar, mount- +coverage green against the acceptance tree, suites green. + +**Focus:** mount-coverage's family-completeness check runs against the +wrapped families — wire `MOUNTED_FAMILIES`/`createCli` to the wrapped +objects. Watch `exactOptionalPropertyTypes` when re-passing normalized +`CommandRedirect`s as `RedirectSpec`s. + +## D5 — String sweep, docs, process records, full verification + +**Outcome:** no old spelling survives as a command reference anywhere +in `packages/`, `README.md`, `docs/` (run-command next actions, help +examples, error copy, comments that instruct); `tests/e2e-coverage.test.ts` +exclusions/backlog respelled; README/docs command enumerations match +the acceptance tree; each s2 divergence record under +`.drive/projects/prisma-cli-v8/assets/s2/` gets a short entry for the +renames/moves that touch it; `assets/command-review.md` is restored +from commit 76a2c8a and regenerated against the new tree. Full +verification per AGENTS.md: `pnpm --recursive exec tsc --noEmit`, +`pnpm lint`, `pnpm --filter @prisma/cli test`, +`pnpm --filter @prisma/compute test`, +`pnpm --filter @prisma/cli test:e2e` (report a credential-less skip +plainly). + +**Builds on:** D4's final grammar; the sweep inventory appended below. +**Hands to:** slice-DoD; PR-open. + +## Sweep inventory (2026-08-21) + +Hazards every dispatch must respect: + +- `packages/cli-engine/src/execution/command-tree.ts:295` throws at + `buildCli()` when a redirect's `from` collides with a mounted path. + Mounting `migration ref *` while the ORM family still carries the + `migration ref` redirect fails construction — the D4 family wrap + (which drops that redirect) is mandatory, not cosmetic. +- `tests/e2e-coverage.test.ts` parses `src/cli.ts` as TEXT via the + marker `mountedCommands: Readonly> = {`. + Keep that literal's shape when editing the mount table. +- `EXPECTED_MOUNT_PATHS` is asserted sorted; keep alphabetical order. +- No help snapshot tests exist; help is asserted by `toContain` in + `tests/bin.test.ts:344-430` and `tests/orm-mount.test.ts:145-160`. + +Per-spelling checklist (line numbers pre-change): + +- **init:** cli.ts:32,289-292,310; commands/init/* (init.ts, settings.ts, + config-file.ts, agent-setup.ts, link.ts, types.ts), types/init.ts, + project/link.ts:142 (prose); mount-coverage:11,46,114; tests/init*.test.ts, + compute-config.test.ts; e2e/init.e2e.ts; packages/cli/README.md:82, + packages/prisma/README.md:79, docs/product/command-principles.md:31, + cli-style-guide.md:61, output-conventions.md:313, + error-conventions.md:274-275, docs/architecture/cli-engine-requirements.md:217. +- **project remove:** cli.ts:205; project/remove.ts; project/presentation.ts:18; + mount-coverage:144; project.test.ts:115,2496; e2e/project-lifecycle.e2e.ts:211; + e2e/deployed-service.ts:147,178 (comments). +- **project env remove:** cli.ts:210; project/env-remove.ts; + lib/app/env-config.ts:141 (error fix copy); mount-coverage:140; + project.test.ts:120,2316,2473,2490; e2e/project-lifecycle.e2e.ts:186. +- **postgres remove:** cli.ts:216; postgres/remove.ts; controllers/database.ts:143; + mount-coverage:133; postgres.test.ts:145,533,1516; e2e/postgres.e2e.ts:336. +- **postgres connection remove:** cli.ts:221; postgres/connection-remove.ts + (:36 usage string); mount-coverage:129; postgres.test.ts:150,2424,2472; + e2e/postgres.e2e.ts:305. +- **service remove:** cli.ts:243; service/remove.ts; service/errors.ts:354,361 + (why + next action); service/release.ts:39; mount-coverage:167; + service-remove.test.ts; e2e/service.e2e.ts:7,127. +- **service domain remove:** cli.ts:246; service/domain-remove.ts; + service/errors.ts:639 (runCommandAction); mount-coverage:160; + service-domain.test.ts:524; e2e-coverage.test.ts:142 (AWAITING_COVERAGE). +- **postgres restore:** cli.ts:215; postgres/restore.ts:90,114; + mount-coverage:134; postgres.test.ts:144,1182; e2e-coverage.test.ts:86 + (EXCLUSIONS key); README prose (cli:76, prisma:73). +- **ref …:** cli.ts:279-281,:188 (group brief); mount-coverage:148-150; + e2e-coverage:73-75; orm-mount.test.ts:145-160 (root-help group + assertions incl. `ref`); cli-engine tests/redirects.test.ts:431 + (synthetic fixture string — respell for coherence). +- **migrate:** cli.ts:270; mount-coverage:116; e2e-coverage:63; + orm-mount.test.ts:141 (pins redirect next-action + "prisma-test migrate --to " — respell to db migrate per the + wrap); cli-engine redirects.test.ts + telemetry-payload.test.ts:80 + (synthetic fixtures); README.md:90, cli README:81, prisma README:78, + packages/cli/AGENTS.md:36; command-principles.md:38, + cli-engine-requirements.md:158. +- **format:** cli.ts:265; mount-coverage:109; e2e-coverage:62; READMEs + + packages/cli/AGENTS.md:36. cli-engine-requirements.md:196 already + describes `contract format` (doc ahead of code — now true). +- **composer:** cli.ts:251-254,:180-182; service/presentation.ts:177 + (runCommandAction "composer deploy" → "deploy"); mount-coverage:99-102,176; + e2e-coverage:92-99,132; bin.test.ts:448-511; v8-conformance.test.ts:41-43,69; + composer-isolation.test.ts (prose); orm-mount.test.ts:15,65; + scripts/conformance.ts:34,62; README.md:31, cli README:42,80, + prisma README:42,77, packages/cli/AGENTS.md:12,36; examples/*/README.md + (next-smoke:22, hello-world:5 — update, they ship in-repo). The + composer package's own help already says `{bin} deploy` — correct at + root; nothing to change upstream. +- **build logs:** cli.ts:250,:179; commands/build/logs.ts (:101 next + action, :158-160 examples); mount-coverage:98; build-logs.test.ts; + e2e-coverage:128,145; cli README:79, prisma README:76, + packages/cli/AGENTS.md:36. +- **README command tables:** packages/cli/README.md:70-82 and + packages/prisma/README.md:67-79 are hand-duplicated — edit both. +- **Pre-existing doc bug in touched copy:** error-conventions.md:275 + says `init --format json`; flag was `--config-format`. Moot once init + is removed — delete the example with the command. +- **.drive/ hits:** historical records (s2 specs/design docs, parity + divergence bodies, command-inventory) stay as history; only the + short new divergence entries + regenerated command-review.md change. diff --git a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md new file mode 100644 index 00000000..aaacae63 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md @@ -0,0 +1,218 @@ +# Command grammar cleanup (slice contract) + +Status: rev 1 (2026-08-21). One PR into `main`. Repo: prisma-cli only. +Source: the PM command-review brief (2026-08-20/21), reproduced in the +operator's message; this spec adds the grounded design decisions, not +new scope. No new commands; this pass removes, renames and moves +existing ones. + +## At a glance + +The shell owns the mount table (`packages/cli/src/cli.ts`), so most of +the work is mount-table edits plus every string that names a command: +help summaries, examples, `run-command` next actions, error copy, +tests, docs. Four behavioural changes ride along: the compute config +(`prisma.compute.ts`/`.json`) and `init` are removed outright; service +commands stop using ambient context (remembered selection, interactive +picker, git-branch inference); six `remove` commands become `delete`; +and the composer/build groups dissolve. + +## Chosen design + +### 1. Compute config and `init` removal + +- Delete `src/commands/init/` (all files), `src/types/init.ts`, the + `init` mount, its help presence, `tests/init.test.ts`, + `tests/init-agent-setup.test.ts`, and the e2e coverage entry. +- Delete `src/lib/app/compute-config.ts`, `build-settings.ts`, + `deploy-framework.ts`, `build.ts`, and whatever only the config path + reached (follow the import graph; `tests/compute-config.test.ts`, + `tests/service-compute-config.test.ts`, `tests/app-build.test.ts` + and friends go with their subjects). Drop the + `@prisma/compute-sdk/config` import if nothing else needs it. +- `src/commands/service/target.ts`: delete `resolveComputeTarget`, + `resolveComputeManagementContext`, the config-target positional + (`[service]`) on every service command, and the + `SERVICE.COMPUTE_CONFIG_INVALID` / + `SERVICE.COMPUTE_CONFIG_TARGET_UNKNOWN` error codes plus their + helpers (`configTargetRequiresConfigError`, + `computeConfigErrorToCliError` call sites) in `service/errors.ts`. +- `src/lib/agent/setup-status.ts`: remove the compute-config read; + agent status no longer depends on a config file. + +### 2. Service commands take parameters only + +The only ambient context a platform command may use is the directory's +project link (`.prisma/local.json`). + +- **Service targeting:** `--service ` (matched by name) or + `PRISMA_SERVICE_ID` (matched by id — the existing domain-flow + mechanics generalized to every service command that targets a + service). Neither present → a structured error naming `--service`, + exit 2, in interactive terminals too. Delete + `resolveExistingServiceSelection`'s saved-selection branch and its + `ctx.prompt.select` picker; delete `rememberSelectedService`, + `LocalStateStore.readSelectedApp` / `setSelectedApp` / + `clearSelectedApp` and the `selectedByProject` state shape + (`service delete` loses its local-state cleanup with it). +- **Branch:** `--branch ` only; when absent, the existing + non-git fallback stands (`"main"` for read flows, `"production"` + for the domain flow). Delete `resolveRequestedBranch`'s git + inference and `src/lib/git/local-branch.ts` + + `tests/local-branch.test.ts` if nothing else imports it. +- **Project:** unchanged (`--project`, `PRISMA_PROJECT_ID`, link + file). `project link` keeps its interactive picker. + +### 3. `delete` destroys, `remove` detaches — renames, no aliases + +`project remove`→`project delete`, `project env remove`→`project env +delete`, `postgres remove`→`postgres delete`, `postgres connection +remove`→`postgres connection delete`, `service remove`→`service +delete`, `service domain remove`→`service domain delete`. Rename +files, exported symbols, command ids, help, examples, next actions, +error copy, consent questions, and tests (unit + e2e +`describeCommand` markers). `git disconnect`, `auth … logout`, +`bucket delete`, `bucket key delete` unchanged. + +### 4. Moves + +| was | is | +| --- | --- | +| `postgres restore` | `postgres backup restore` | +| `ref list` / `ref set` / `ref delete` | `migration ref list` / `set` / `delete` | +| `migrate` | `db migrate` | +| `format` | `contract format` | +| `composer dev` | `dev` | +| `composer deploy` | `deploy` | + +No aliases, no redirects for old spellings; an old spelling settles +the engine's unknown-command error. + +**Family wrapping (the shell-side mechanism).** Both external +families are re-wrapped in `cli.ts` with `defineCommandFamily`, +preserving `configSection` and `docsBaseUrl`: + +- **Composer:** keep only `deploy` and `dev`; `destroy` and `log` are + dropped commands, and dropping them from the wrapped family is what + keeps mount-coverage's "mounts every family command" check honest. +- **ORM:** commands pass through unchanged (the mount table respells + their paths). Redirects are rewritten: the `migration ref` → `ref` + entry is dropped (mounting it as-is would redirect a now-live + spelling), and the `migration apply` entry's replacement + `{bin} migrate --to ` is respelled `{bin} db migrate --to + `. The `migration status` flag redirects name live + commands and pass through. + +### 5. Removals + +`composer destroy`, `composer log`, the `composer` group and its +brief, and the `build` group (`build logs`, `src/commands/build/`, +`tests/build-logs.test.ts`, the `build` group brief). + +### 6. Housekeeping in the same PR + +- `tests/mount-coverage.test.ts`: `EXPECTED_MOUNT_PATHS` becomes the + acceptance tree below; `initCommand` leaves `FAMILYLESS`; the + header's `init` ruling note is updated (the 2026-08-12 ruling is + superseded by the 2026-08-21 PM review). +- `tests/e2e-coverage.test.ts` exclusions/backlog entries respelled; + e2e `describeCommand` markers respelled; no command loses its + coverage status silently. +- Root help examples (`cli.ts` `help.examples`): drop `init`; use + live spellings (e.g. `auth login`, `project list`, `deploy`). +- Every `run-command` next action, help example, and error string + naming an old spelling, across `packages/` (the sweep inventory in + the slice plan is the checklist). +- README / docs pages that enumerate commands. +- Divergence records under `.drive/projects/prisma-cli-v8/assets/s2/` + get a short entry each for the renames and moves; + `assets/command-review.md` (currently only on branch + `claude/command-review-divergences-17ee99`, commit 76a2c8a) is + brought into this branch and regenerated against the new tree. + +## Coherence rationale + +One reviewer can hold this in one sitting because the change is one +rule applied uniformly: the grammar moves, and every string follows. +The three genuinely behavioural pieces (compute-config removal, +parameter-only service targeting, family wrapping) are each small and +local; the rest is mechanical renaming verified by the grammar +completeness test and the full suite. It rolls back as one unit — a +partial landing would leave the help, the tree, and the docs +disagreeing, which is exactly what the single-PR rule prevents. + +## Scope + +**In:** everything above, in this repo. + +**Deliberately out:** new commands (`project unlink`, `service domain +list`, `bucket show`, `contract validate`, `auth token *`); the +`app` → `service` control-plane API rename (CLI keeps calling +`/v1/apps`); any ORM command behaviour change (only mount paths +move); changes to `@prisma/composer-cli` or `@prisma/orm-toolchain` +packages themselves (the shell wraps, upstream cleanups are +follow-ups for those repos). + +## Pre-investigated edge cases + +- The ORM family ships a live redirect table (`cli.mjs`: + `migration apply`, `migration ref`, three `migration status` flag + entries). Mounting it unwrapped would point users at spellings this + PR retires (`migrate`) or capture a spelling it revives + (`migration ref`). Hence the wrap in §4. +- mount-coverage's family-completeness check fails on any family + command the tree does not mount — dropping `composer destroy`/`log` + therefore requires the wrapped composer family, not just mount-table + deletion. +- `PRISMA_SERVICE_ID` matches by id, `--service` by name; the + existing domain-flow behaviour is the model. Do not conflate them. +- `service create` names its service with a flag/argument already and + does not resolve an existing target; the picker removal must not + break it. +- Composer's help examples read `{bin} deploy …`; the root move makes + them correct by itself. Do not "fix" them in the composer package. + +## Slice-specific done conditions + +- The mounted tree equals the acceptance tree below + (mount-coverage green asserts it). +- `grep -rn` across `packages/`, `README.md`, `docs/` finds no + remaining old spelling used as a command reference (excepting + historical records: `.drive/` process artifacts, changelogs). +- Full pre-commit verification per AGENTS.md, including + `pnpm --filter @prisma/cli test:e2e` (credentialed run if the env + provides `PRISMA_E2E_SERVICE_TOKEN`; otherwise the suite's skip is + reported, not hidden). + +## Acceptance tree + +``` +auth login | logout | whoami | workspace list|use|logout +project list | show | create | link | rename | delete | transfer | env add|update|list|delete +postgres list | show | create | usage | delete | backup list|restore | connection list|create|rotate|delete +bucket list | create | delete | key list|create|delete +branch list +git connect | disconnect +service list | create | show | open | logs | delete + deployment list|show|promote|rollback|start|stop|delete + domain add|show|delete|retry|wait +dev | deploy +contract emit | infer | format +db init | schema | sign | update | verify | migrate +migration plan | new | list | show | status | log | graph | check | ref list|set|delete +orm init +lsp +agent install | update | status +telemetry status | enable | disable +feedback +``` + +## References + +- Brief: operator message 2026-08-21 (this spec's source of scope). +- `packages/cli/src/cli.ts` (mount table), + `packages/cli/tests/mount-coverage.test.ts` (grammar check), + `packages/cli/src/commands/service/target.ts` (ambient context), + `packages/cli/node_modules/@prisma/orm-toolchain/dist/cli.mjs` + (shipped redirect table), `@prisma/composer-cli/dist/family.mjs` + (composer family shape). From e7c9092771493d4af4f20363d09b68ce189f5f5a Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:37:53 +0200 Subject: [PATCH 17/27] Reconcile the deferred ledger with the grammar cleanup's deletions Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 30 ++++++++++++----------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index a51da4ed..b5d542f6 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -87,12 +87,10 @@ composer can use it. and composer bump their engine pins and publish → the rc1 bump PR here pins those versions. - **The prisma bin's mount makes composer's help examples wrong.** - Composer writes them as `{bin} deploy src/service.ts`; mounted under - the `composer` root the invocation is `prisma composer deploy`, and - the engine's `resolveExample` substitutes only `{bin}`. Fix needs both - repos: a mount-aware placeholder in the engine (`{command}` → the - command's mounted path) and composer's eight example strings — two on - each of the four commands — rewritten to use it. Recorded in + **Closed by the command grammar cleanup (2026-08-21):** `dev` and + `deploy` moved to the root, so `{bin} deploy src/service.ts` renders + correctly; `destroy` and `log` were dropped entirely. No engine + placeholder needed. Recorded in `assets/s2/parity-divergences-s3.md`. ## The ORM family does not work through the assembled binary (found 2026-08-13, writing the e2e happy paths) @@ -126,8 +124,9 @@ CLI does not do, and each restarts as engine work if wanted: secret handling before it is built. - **A `github` group for workspace-level GitHub connections** (#113): the engine ships repo-level `git connect|disconnect` only. -- **Transient-read retry on `build logs` streaming** (#104): joins the - existing streaming follow-ups below. +- **Transient-read retry on `build logs` streaming** (#104): moot — + the `build` group was removed by the command grammar cleanup + (2026-08-21). ## Ratified-as-shipped at the S2 sign-off (2026-08-12) — the gaps stay real @@ -141,8 +140,10 @@ CLI does not do, and each restarts as engine work if wanted: `--follow` polls on a 2s interval rather than holding a socket open. The open remainder is the WebSocket live tail, in the closed `service logs` entry further down this file. -- **`build logs` cannot exit 1 on a failed build** until the engine - grows a way for a stream to settle with a documented non-zero code. +- **`build logs` cannot exit 1 on a failed build** — moot: the + command was removed with the `build` group by the command grammar + cleanup (2026-08-21). The engine gap (a stream settling with a + documented non-zero code) remains real for future stream commands. - **The crash-recovery feedback action does not port** (the legacy crash envelope pre-filled a `feedback` command; the engine's crash path has no hook for it). @@ -478,7 +479,8 @@ to `delete`, moved `postgres restore`/`ref *`/`migrate`/`format`/ (pre-existing): the other service commands take `--project` and the link file but not the env var. Unify or document. - **orm-toolchain's shipped help examples name retired spellings.** - The bundle's `format` and `ref list|delete` commands carry examples - the moves invalidated; the shell wrapper respells them (D4-1 ruling) - until orm-toolchain updates its own examples to `contract format` - and `migration ref *`. + Six commands' shipped examples start with the family's own key — + `format`, `migrate`, `ref list|set|delete`, and `init` — which the + mounts respell to `contract format`, `db migrate`, `migration ref *` + and `orm init`. The shell wrapper rewrites the examples (D4-1 + ruling) until orm-toolchain updates its own. From b5114176ccfb75207b38469e8017592fd396bbde Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:38:59 +0200 Subject: [PATCH 18/27] Address D5 review findings - orm init joins the ORM mount respellings: its six shipped help examples now render as orm init, pinned by a sixth row in the rendered-help test and init in the retired-spelling check (D5-1). - The output gallery drops its two init shots (init --help and the hono wizard run) and the wizard's caption panel; both would now capture an unknown-command error (D5-2). Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/cli.ts | 1 + packages/cli/tests/orm-mount.test.ts | 3 ++- scripts/output-gallery/build.mjs | 5 ----- scripts/output-gallery/capture.zsh | 2 -- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index bd9a8d3a..09324a9d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -192,6 +192,7 @@ export const composerCommandFamily: CommandFamily = wrapCommandFamily( * respells them to the mounted path. */ const ORM_MOUNT_RESPELLINGS: Readonly> = { format: "contract format", + init: "orm init", migrate: "db migrate", "ref list": "migration ref list", "ref set": "migration ref set", diff --git a/packages/cli/tests/orm-mount.test.ts b/packages/cli/tests/orm-mount.test.ts index 06b9a375..c48677c2 100644 --- a/packages/cli/tests/orm-mount.test.ts +++ b/packages/cli/tests/orm-mount.test.ts @@ -59,7 +59,7 @@ function descriptor(kind: string) { } /** The retired spellings the ORM family's own examples carry. */ -const RETIRED_ORM_SPELLING = /prisma-test (format|migrate|ref)(\s|$)/; +const RETIRED_ORM_SPELLING = /prisma-test (format|migrate|ref|init)(\s|$)/; function shell(config?: Readonly>) { return createTestCli({ @@ -151,6 +151,7 @@ describe("the ORM family answers from the assembled tree", () => { [["migration", "ref", "list"], "migration ref list"], [["migration", "ref", "set"], "migration ref set"], [["migration", "ref", "delete"], "migration ref delete"], + [["orm", "init"], "orm init"], ])("renders %j help with the mounted spelling, not the family key", async (path, mounted) => { const result = await shell().run([...path, "--help"], { isTty: { stdout: true }, diff --git a/scripts/output-gallery/build.mjs b/scripts/output-gallery/build.mjs index 7be0585d..e7dc2794 100644 --- a/scripts/output-gallery/build.mjs +++ b/scripts/output-gallery/build.mjs @@ -151,11 +151,6 @@ const SECTIONS = [ ["branch-list", "prisma-cli branch list --project prisma-next-dev", ""], ["agent-status", "prisma-cli agent status", ""], ["telemetry-status", "prisma-cli telemetry status", ""], - [ - "init", - "prisma-cli init --framework hono (fresh app)", - "Step runner + fields card.", - ], ], ], [ diff --git a/scripts/output-gallery/capture.zsh b/scripts/output-gallery/capture.zsh index 955c56df..3a42e0cf 100644 --- a/scripts/output-gallery/capture.zsh +++ b/scripts/output-gallery/capture.zsh @@ -20,7 +20,6 @@ shot auth-help "$ROOT" auth --help shot auth-whoami "$ROOT" auth whoami shot project-help "$ROOT" project --help shot project-link-help "$ROOT" project link --help -shot init-help "$ROOT" init --help shot migration-help "$ROOT" migration --help shot db-help "$ROOT" db --help shot feedback-help "$ROOT" feedback --help @@ -34,7 +33,6 @@ shot service-list "$ROOT/wip/gallery/linked-demo" service list shot branch-list "$ROOT" branch list --project prisma-next-dev shot agent-status "$ROOT/wip/gallery/linked-demo" agent status shot telemetry-status "$ROOT" telemetry status -shot init "$ROOT/wip/gallery/demo-app2" init --no-interactive --no-link --no-install --framework hono --entry server.ts shot contract-emit "$ROOT/wip/gallery/orm-demo" contract emit shot db-init "$ROOT/wip/gallery/orm-demo" db init --yes shot db-verify "$ROOT/wip/gallery/orm-demo" db verify From c6151d24c26621a14e10be247af0136b6619b5a3 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 12:41:27 +0200 Subject: [PATCH 19/27] Defer documenting the deployment-id targeting asymmetry Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index b5d542f6..7cd29d56 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -484,3 +484,9 @@ to `delete`, moved `postgres restore`/`ref *`/`migrate`/`format`/ mounts respell to `contract format`, `db migrate`, `migration ref *` and `orm init`. The shell wrapper rewrites the examples (D4-1 ruling) until orm-toolchain updates its own. +- **The deployment-id targeting asymmetry is undocumented.** + `service deployment delete|promote|rollback|start|stop` require + `--service` even when given a globally-unique deployment id, while + `service logs --deployment` without a service target resolves the id + globally within the project. Deliberate (per the cleanup plan), but + no artifact explains it to a user who meets it. Document or unify. From 353d1318247d02fbb52bde27d1feac53a17c2c55 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 13:19:06 +0200 Subject: [PATCH 20/27] Address review findings: reachable branches, runnable hints, dead state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the grammar-cleanup branch surfaced that removing the git-branch inference left most service commands with no way to reach a service on a non-default branch, and that many help examples and next-action hints named invocations the new parameter-only grammar refuses. - service show/open/logs and every service deployment verb take --branch, threaded into the existing branchName resolution, so preview-branch services and deployments are reachable again. - An empty --branch is refused centrally in the target resolvers instead of only in service delete, and the error's copy no longer describes the deleted git inference. - Every shipped help example and next-action hint that named a service command now carries --service — interpolated where the name is in scope, a placeholder where it is not — and projectDeleteBlockedError suggests the flags-only service delete shape. After service delete, the next action is service list, since nothing service-scoped can run. - The knownLiveDeployment local state had no writers left: the schema, its store methods, and service delete's cleanup pass are gone. - resolveServiceReleaseState had become a passthrough around resolveServiceReadState; the callers use the read state directly. - types/app.ts kept ~18 interfaces nothing produced; the two live domain types moved into service/results.ts as ServiceDomainStatus and ServiceDomainDnsRecord. - New/edited markdown prose is unwrapped to one line per paragraph. Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/s2/command-inventory.md | 9 +- .../assets/s2/parity-divergences-s3.md | 5 +- .../assets/s2/parity-divergences-s7.md | 6 +- .../assets/s2/parity-divergences-s8.md | 7 +- .../s2/parity-divergences-service-logs.md | 5 +- .../assets/s2/parity-divergences.md | 11 +- .drive/projects/prisma-cli-v8/deferred.md | 61 +---- .../plans/command-grammar-cleanup.md | 209 ++++------------ .../specs/command-grammar-cleanup.md | 173 +++----------- README.md | 10 +- docs/architecture/cli-engine-requirements.md | 32 +-- docs/product/command-principles.md | 5 +- packages/cli/README.md | 3 +- packages/cli/src/adapters/local-state.ts | 52 ---- packages/cli/src/commands/service/delete.ts | 58 +---- .../src/commands/service/deployment-delete.ts | 28 ++- .../src/commands/service/deployment-list.ts | 7 +- .../commands/service/deployment-promote.ts | 26 +- .../commands/service/deployment-rollback.ts | 31 ++- .../commands/service/deployment-run-state.ts | 16 +- .../src/commands/service/deployment-start.ts | 7 +- .../src/commands/service/deployment-stop.ts | 7 +- .../cli/src/commands/service/domain-add.ts | 2 +- .../cli/src/commands/service/domain-delete.ts | 4 +- .../cli/src/commands/service/domain-retry.ts | 2 +- .../cli/src/commands/service/domain-show.ts | 2 +- .../cli/src/commands/service/domain-wait.ts | 4 +- packages/cli/src/commands/service/errors.ts | 117 ++++++--- packages/cli/src/commands/service/logs.ts | 111 +++++---- packages/cli/src/commands/service/open.ts | 18 +- .../cli/src/commands/service/presentation.ts | 44 +++- packages/cli/src/commands/service/release.ts | 51 +--- packages/cli/src/commands/service/results.ts | 23 +- packages/cli/src/commands/service/show.ts | 15 +- packages/cli/src/commands/service/target.ts | 20 +- packages/cli/src/lib/project/provider.ts | 4 +- packages/cli/src/types/app.ts | 224 ------------------ packages/cli/tests/app-state.test.ts | 50 ---- packages/cli/tests/service-delete.test.ts | 51 +--- .../tests/service-deployment-rollback.test.ts | 7 +- packages/cli/tests/service-domain.test.ts | 2 +- packages/cli/tests/service-open.test.ts | 4 +- packages/cli/tests/service-show.test.ts | 29 +++ packages/prisma/README.md | 3 +- 44 files changed, 491 insertions(+), 1064 deletions(-) delete mode 100644 packages/cli/src/types/app.ts diff --git a/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md b/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md index d7617467..f2d50557 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md @@ -702,11 +702,4 @@ Fixture-mode counts are references to `fixturePath` per file (see command sectio --- -**2026-08-21 note (PM review, command grammar cleanup):** this -inventory is a historical grounding document for the S2 ports. The -mounted tree has since changed: top-level `init` is removed, the six -destroying `remove` commands are `delete`, `postgres restore` is -`postgres backup restore`, `migrate`/`format`/`ref *` are `db -migrate`/`contract format`/`migration ref *`, and composer's `deploy` -and `dev` are root commands. The regenerated tree lives in -[`../command-review.md`](../command-review.md). +**2026-08-21 note (PM review, command grammar cleanup):** this inventory is a historical grounding document for the S2 ports. The mounted tree has since changed: top-level `init` is removed, the six destroying `remove` commands are `delete`, `postgres restore` is `postgres backup restore`, `migrate`/`format`/`ref *` are `db migrate`/`contract format`/`migration ref *`, and composer's `deploy` and `dev` are root commands. The regenerated tree lives in [`../command-review.md`](../command-review.md). diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md index 3646de76..eef1e647 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md @@ -275,7 +275,4 @@ than built at the mount. ## Command grammar cleanup (2026-08-21 PM review) -The `composer` group is dissolved: `composer deploy` and `composer -dev` mount at the root as `deploy` and `dev`, and `composer destroy` -and `composer log` are dropped from the mounted tree entirely. The -sections above describe the family as S3 mounted it. +The `composer` group is dissolved: `composer deploy` and `composer dev` mount at the root as `deploy` and `dev`, and `composer destroy` and `composer log` are dropped from the mounted tree entirely. The sections above describe the family as S3 mounted it. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md index 8027ae78..ab55f876 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s7.md @@ -45,8 +45,4 @@ land". ## Command grammar cleanup (2026-08-21 PM review) -The ORM mounts moved: `migrate` → `db migrate`, `format` → `contract -format`, `ref list|set|delete` → `migration ref list|set|delete`. The -shipped `migration ref` redirect is dropped at the mount (the spelling -is live again) and `migration apply`'s replacement is respelled to -`{bin} db migrate --to `. Command behaviour is unchanged. +The ORM mounts moved: `migrate` → `db migrate`, `format` → `contract format`, `ref list|set|delete` → `migration ref list|set|delete`. The shipped `migration ref` redirect is dropped at the mount (the spelling is live again) and `migration apply`'s replacement is respelled to `{bin} db migrate --to `. Command behaviour is unchanged. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md index 3eaa2a71..afa70a40 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s8.md @@ -216,9 +216,4 @@ the stop precondition, the CLI does not pre-empt it. ## Command grammar cleanup (2026-08-21 PM review) -`service remove` is renamed `service delete` (result field `removed` -becomes `deleted`; `SERVICE.REMOVE_FAILED` becomes -`SERVICE.DELETE_FAILED`), and every service command that targets an -existing service now requires `--service` or `PRISMA_SERVICE_ID` — the -interactive picker and the remembered selection are gone, and `--branch` -no longer falls back to the local git branch. +`service remove` is renamed `service delete` (result field `removed` becomes `deleted`; `SERVICE.REMOVE_FAILED` becomes `SERVICE.DELETE_FAILED`), and every service command that targets an existing service now requires `--service` or `PRISMA_SERVICE_ID` — the interactive picker and the remembered selection are gone, and `--branch` no longer falls back to the local git branch. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md index eb996461..243bfad1 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-service-logs.md @@ -110,7 +110,4 @@ anything this slice changed. ## Command grammar cleanup (2026-08-21 PM review) -`service logs` now requires a service target (`--service` or -`PRISMA_SERVICE_ID`) unless `--deployment` names a deployment id with -no service target, which resolves globally within the project. The -picker and remembered selection are gone. +`service logs` now requires a service target (`--service` or `PRISMA_SERVICE_ID`) unless `--deployment` names a deployment id with no service target, which resolves globally within the project. The picker and remembered selection are gone. diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index 1eba5a21..ae15f6f4 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -1609,13 +1609,4 @@ Three ported commands reached directly into `process` for host facts, because no ## Command grammar cleanup (2026-08-21 PM review) -The grammar cleanup slice supersedes several spellings this record -documents. Top-level `init` and the compute config -(`prisma.compute.ts`/`.json`) are removed; service commands take -parameters only (`--service`/`PRISMA_SERVICE_ID`, `--branch`; the -picker, remembered selection, and git-branch inference are gone); -`project remove`, `project env remove`, `postgres remove`, `postgres -connection remove`, `service remove`, and `service domain remove` are -renamed to `delete`; `postgres restore` moves to `postgres backup -restore`. No aliases or redirects for the old spellings. This entry -records the change; the sections above stay as written for history. +The grammar cleanup slice supersedes several spellings this record documents. Top-level `init` and the compute config (`prisma.compute.ts`/`.json`) are removed; service commands take parameters only (`--service`/`PRISMA_SERVICE_ID`, `--branch`; the picker, remembered selection, and git-branch inference are gone); `project remove`, `project env remove`, `postgres remove`, `postgres connection remove`, `service remove`, and `service domain remove` are renamed to `delete`; `postgres restore` moves to `postgres backup restore`. No aliases or redirects for the old spellings. This entry records the change; the sections above stay as written for history. diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 7cd29d56..41f3afcf 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -86,12 +86,7 @@ composer can use it. it runs: engine `8.0.0-rc.N` publishes from this repo → orm-toolchain and composer bump their engine pins and publish → the rc1 bump PR here pins those versions. -- **The prisma bin's mount makes composer's help examples wrong.** - **Closed by the command grammar cleanup (2026-08-21):** `dev` and - `deploy` moved to the root, so `{bin} deploy src/service.ts` renders - correctly; `destroy` and `log` were dropped entirely. No engine - placeholder needed. Recorded in - `assets/s2/parity-divergences-s3.md`. +- **The prisma bin's mount makes composer's help examples wrong.** **Closed by the command grammar cleanup (2026-08-21):** `dev` and `deploy` moved to the root, so `{bin} deploy src/service.ts` renders correctly; `destroy` and `log` were dropped entirely. No engine placeholder needed. Recorded in `assets/s2/parity-divergences-s3.md`. ## The ORM family does not work through the assembled binary (found 2026-08-13, writing the e2e happy paths) @@ -124,9 +119,7 @@ CLI does not do, and each restarts as engine work if wanted: secret handling before it is built. - **A `github` group for workspace-level GitHub connections** (#113): the engine ships repo-level `git connect|disconnect` only. -- **Transient-read retry on `build logs` streaming** (#104): moot — - the `build` group was removed by the command grammar cleanup - (2026-08-21). +- **Transient-read retry on `build logs` streaming** (#104): moot — the `build` group was removed by the command grammar cleanup (2026-08-21). ## Ratified-as-shipped at the S2 sign-off (2026-08-12) — the gaps stay real @@ -140,10 +133,7 @@ CLI does not do, and each restarts as engine work if wanted: `--follow` polls on a 2s interval rather than holding a socket open. The open remainder is the WebSocket live tail, in the closed `service logs` entry further down this file. -- **`build logs` cannot exit 1 on a failed build** — moot: the - command was removed with the `build` group by the command grammar - cleanup (2026-08-21). The engine gap (a stream settling with a - documented non-zero code) remains real for future stream commands. +- **`build logs` cannot exit 1 on a failed build** — moot: the command was removed with the `build` group by the command grammar cleanup (2026-08-21). The engine gap (a stream settling with a documented non-zero code) remains real for future stream commands. - **The crash-recovery feedback action does not port** (the legacy crash envelope pre-filled a `feedback` command; the engine's crash path has no hook for it). @@ -453,40 +443,11 @@ Still open: ## Left open by the command grammar cleanup (2026-08-21) -The cleanup PR removed the compute config and `init`, made service -commands parameter-only, renamed the six destructive `remove` commands -to `delete`, moved `postgres restore`/`ref *`/`migrate`/`format`/ -`composer dev|deploy`, and dropped `composer destroy|log` and the -`build` group. Deliberately left behind: - -- **`project env` still infers scope from the current git branch.** - `controllers/app-env.ts` imports `readLocalGitBranch` — the same - ambient context the brief removed from the service commands, but the - brief enumerated service commands only. Extending "parameters only" - to `project env` is a product call. -- **`knownLiveDeploymentByProject` has no writer.** `service delete` - clears a local-state key nothing sets; pre-existing on `main` at - `87ffd44`, not slice fallout. Delete the shape or reinstate the - writer. -- **Upstream family cleanups.** The shell now wraps both external - families: composer still ships `destroy`/`log` commands (and their - help) that nothing mounts, and orm-toolchain still keys its family - `ref *` and ships the `migration ref` → `ref` redirect the wrapper - drops, plus a `migration apply` replacement that says `migrate`. - Each repo should retire those surfaces so the wrapper shrinks to a - pass-through. -- **`PRISMA_PROJECT_ID` is honoured only by the domain commands** - (pre-existing): the other service commands take `--project` and the - link file but not the env var. Unify or document. -- **orm-toolchain's shipped help examples name retired spellings.** - Six commands' shipped examples start with the family's own key — - `format`, `migrate`, `ref list|set|delete`, and `init` — which the - mounts respell to `contract format`, `db migrate`, `migration ref *` - and `orm init`. The shell wrapper rewrites the examples (D4-1 - ruling) until orm-toolchain updates its own. -- **The deployment-id targeting asymmetry is undocumented.** - `service deployment delete|promote|rollback|start|stop` require - `--service` even when given a globally-unique deployment id, while - `service logs --deployment` without a service target resolves the id - globally within the project. Deliberate (per the cleanup plan), but - no artifact explains it to a user who meets it. Document or unify. +The cleanup PR removed the compute config and `init`, made service commands parameter-only, renamed the six destructive `remove` commands to `delete`, moved `postgres restore`/`ref *`/`migrate`/`format`/`composer dev|deploy`, and dropped `composer destroy|log` and the `build` group. Deliberately left behind: + +- **`project env` still infers scope from the current git branch.** `controllers/app-env.ts` imports `readLocalGitBranch` — the same ambient context the brief removed from the service commands, but the brief enumerated service commands only. Extending "parameters only" to `project env` is a product call. +- ~~**`knownLiveDeploymentByProject` has no writer.**~~ Closed on the PR branch (2026-08-21): the local-state shape, its store methods, and `service delete`'s cleanup pass were deleted. +- **Upstream family cleanups.** The shell now wraps both external families: composer still ships `destroy`/`log` commands (and their help) that nothing mounts, and orm-toolchain still keys its family `ref *` and ships the `migration ref` → `ref` redirect the wrapper drops, plus a `migration apply` replacement that says `migrate`. Each repo should retire those surfaces so the wrapper shrinks to a pass-through. +- **`PRISMA_PROJECT_ID` is honoured only by the domain commands** (pre-existing): the other service commands take `--project` and the link file but not the env var. Unify or document. +- **orm-toolchain's shipped help examples name retired spellings.** Six commands' shipped examples start with the family's own key — `format`, `migrate`, `ref list|set|delete`, and `init` — which the mounts respell to `contract format`, `db migrate`, `migration ref *` and `orm init`. The shell wrapper rewrites the examples (D4-1 ruling) until orm-toolchain updates its own. +- **The deployment-id targeting asymmetry is undocumented.** `service deployment delete|promote|rollback|start|stop` require `--service` even when given a globally-unique deployment id, while `service logs --deployment` without a service target resolves the id globally within the project. Deliberate (per the cleanup plan), but no artifact explains it to a user who meets it. Document or unify. diff --git a/.drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md b/.drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md index 0d3b15cc..51e4cc94 100644 --- a/.drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md +++ b/.drive/projects/prisma-cli-v8/plans/command-grammar-cleanup.md @@ -1,196 +1,69 @@ # Command grammar cleanup — dispatch plan -Slice contract: `specs/command-grammar-cleanup.md`. One PR into -`main`, this worktree's branch. Sequential dispatches; each hands the -next a state where `pnpm --recursive exec tsc --noEmit` and -`pnpm --filter @prisma/cli test` are green (mount-coverage may be -legitimately red mid-slice only where a dispatch's note says so). +Slice contract: `specs/command-grammar-cleanup.md`. One PR into `main`, this worktree's branch. Sequential dispatches; each hands the next a state where `pnpm --recursive exec tsc --noEmit` and `pnpm --filter @prisma/cli test` are green (mount-coverage may be legitimately red mid-slice only where a dispatch's note says so). ## D1 — The compute config and `init` are gone -**Outcome:** no source, test, or mount references -`prisma.compute.ts`/`.json`, `@prisma/compute-sdk/config`, -`src/commands/init/`, `src/types/init.ts`, `src/lib/app/{compute-config, -build-settings,deploy-framework,build}.ts`, or the -`SERVICE.COMPUTE_CONFIG_*` error codes. Service commands lose the -config-target positional; `resolveComputeManagementContext` and -`resolveComputeTarget` are deleted; agent setup-status stops reading -the config. `init` leaves the mount table, `FAMILYLESS`, and -`EXPECTED_MOUNT_PATHS`; root help examples drop `init` (respelled -fully in D4). - -**Builds on:** clean main. **Hands to D2:** `service/target.ts` free -of compute-config imports, service command files free of the -positional, suites green with config tests deleted. - -**Focus:** follow the import graph outward from the deleted modules; -delete dependents that only served the config path (candidates: -`tests/compute-config.test.ts`, `tests/service-compute-config.test.ts`, -`tests/init*.test.ts`, `tests/app-build.test.ts`, e2e/`init` entries, -`lib/app/bun-project.ts`/`env-config.ts` if orphaned — verify, don't -assume). +**Outcome:** no source, test, or mount references `prisma.compute.ts`/`.json`, `@prisma/compute-sdk/config`, `src/commands/init/`, `src/types/init.ts`, `src/lib/app/{compute-config,build-settings,deploy-framework,build}.ts`, or the `SERVICE.COMPUTE_CONFIG_*` error codes. Service commands lose the config-target positional; `resolveComputeManagementContext` and `resolveComputeTarget` are deleted; agent setup-status stops reading the config. `init` leaves the mount table, `FAMILYLESS`, and `EXPECTED_MOUNT_PATHS`; root help examples drop `init` (respelled fully in D4). + +**Builds on:** clean main. **Hands to D2:** `service/target.ts` free of compute-config imports, service command files free of the positional, suites green with config tests deleted. + +**Focus:** follow the import graph outward from the deleted modules; delete dependents that only served the config path (candidates: `tests/compute-config.test.ts`, `tests/service-compute-config.test.ts`, `tests/init*.test.ts`, `tests/app-build.test.ts`, e2e/`init` entries, `lib/app/bun-project.ts`/`env-config.ts` if orphaned — verify, don't assume). ## D2 — Service commands take parameters only -**Outcome:** every service command that targets an existing service -accepts `--service ` (match by name) or `PRISMA_SERVICE_ID` -(match by id, the domain-flow mechanics generalized); neither present -→ structured error naming `--service`, exit 2, interactive terminals -included. The interactive picker, `readSelectedApp`/`setSelectedApp`/ -`clearSelectedApp`/`selectedByProject`, `rememberSelectedService`, and -`service remove`'s selection cleanup are deleted. Branch targeting is -`--branch` only: `resolveRequestedBranch`'s git inference and -`lib/git/local-branch.ts` go (keep the existing non-git defaults: -"main" read flows, "production" domain flow). Project resolution -unchanged; `project link` keeps its picker. - -**Builds on:** D1's target.ts. **Hands to D3:** parameter-only -resolution with updated unit tests (picker/selection tests deleted or -rewritten as missing-flag error tests), suites green. - -**Focus:** `service create` doesn't resolve an existing target — keep -it working. Callers using `skipSelection` (deployment-id flows) keep -skipping. Check `git connect`/other importers before deleting -`local-branch.ts`. +**Outcome:** every service command that targets an existing service accepts `--service ` (match by name) or `PRISMA_SERVICE_ID` (match by id, the domain-flow mechanics generalized); neither present → structured error naming `--service`, exit 2, interactive terminals included. The interactive picker, `readSelectedApp`/`setSelectedApp`/`clearSelectedApp`/`selectedByProject`, `rememberSelectedService`, and `service remove`'s selection cleanup are deleted. Branch targeting is `--branch` only: `resolveRequestedBranch`'s git inference and `lib/git/local-branch.ts` go (keep the existing non-git defaults: "main" read flows, "production" domain flow). Project resolution unchanged; `project link` keeps its picker. + +**Builds on:** D1's target.ts. **Hands to D3:** parameter-only resolution with updated unit tests (picker/selection tests deleted or rewritten as missing-flag error tests), suites green. + +**Focus:** `service create` doesn't resolve an existing target — keep it working. Callers using `skipSelection` (deployment-id flows) keep skipping. Check `git connect`/other importers before deleting `local-branch.ts`. ## D3 — `remove` renamed to `delete` (six commands) -**Outcome:** `project delete`, `project env delete`, `postgres -delete`, `postgres connection delete`, `service delete`, `service -domain delete` exist; no `remove` spelling survives for them in paths, -file names, exported symbols, command ids, help, examples, next -actions, error copy, consent questions, unit tests, or e2e -`describeCommand` markers. `EXPECTED_MOUNT_PATHS` respelled. -`git disconnect`, `auth … logout`, bucket commands untouched. +**Outcome:** `project delete`, `project env delete`, `postgres delete`, `postgres connection delete`, `service delete`, `service domain delete` exist; no `remove` spelling survives for them in paths, file names, exported symbols, command ids, help, examples, next actions, error copy, consent questions, unit tests, or e2e `describeCommand` markers. `EXPECTED_MOUNT_PATHS` respelled. `git disconnect`, `auth … logout`, bucket commands untouched. -**Builds on:** D2 (service files settled). **Hands to D4:** renamed -tree, suites green. +**Builds on:** D2 (service files settled). **Hands to D4:** renamed tree, suites green. -**Focus:** mechanical fan-out; grep each old spelling after the rename -to prove extinction (`.drive/` history and changelog-like records -exempt). +**Focus:** mechanical fan-out; grep each old spelling after the rename to prove extinction (`.drive/` history and changelog-like records exempt). ## D4 — Moves, family wrapping, group removals -**Outcome:** mount table matches the spec's acceptance tree. `postgres -backup restore`, `migration ref list|set|delete`, `db migrate`, -`contract format`, root `dev` and `deploy` mounted; `ref` group, -`composer` group (+ brief), `build` group (`build logs`, -`src/commands/build/`, its tests, e2e entries) gone; `composer -destroy`/`log` not mounted. In `cli.ts`, both external families are -re-wrapped with `defineCommandFamily` preserving `configSection` and -`docsBaseUrl`: composer keeps only `deploy`/`dev`; ORM passes commands -through but drops the `migration ref` redirect and respells the -`migration apply` replacement to `{bin} db migrate --to `. -No aliases or redirects for any old spelling. `EXPECTED_MOUNT_PATHS` -equals the acceptance tree; group briefs updated (`postgres backup` -brief now covers restore); root help examples live spellings (e.g. -`auth login`, `project list`, `deploy`). - -**Builds on:** D3's tree. **Hands to D5:** final grammar, mount- -coverage green against the acceptance tree, suites green. - -**Focus:** mount-coverage's family-completeness check runs against the -wrapped families — wire `MOUNTED_FAMILIES`/`createCli` to the wrapped -objects. Watch `exactOptionalPropertyTypes` when re-passing normalized -`CommandRedirect`s as `RedirectSpec`s. +**Outcome:** mount table matches the spec's acceptance tree. `postgres backup restore`, `migration ref list|set|delete`, `db migrate`, `contract format`, root `dev` and `deploy` mounted; `ref` group, `composer` group (+ brief), `build` group (`build logs`, `src/commands/build/`, its tests, e2e entries) gone; `composer destroy`/`log` not mounted. In `cli.ts`, both external families are re-wrapped with `defineCommandFamily` preserving `configSection` and `docsBaseUrl`: composer keeps only `deploy`/`dev`; ORM passes commands through but drops the `migration ref` redirect and respells the `migration apply` replacement to `{bin} db migrate --to `. No aliases or redirects for any old spelling. `EXPECTED_MOUNT_PATHS` equals the acceptance tree; group briefs updated (`postgres backup` brief now covers restore); root help examples live spellings (e.g. `auth login`, `project list`, `deploy`). + +**Builds on:** D3's tree. **Hands to D5:** final grammar, mount-coverage green against the acceptance tree, suites green. + +**Focus:** mount-coverage's family-completeness check runs against the wrapped families — wire `MOUNTED_FAMILIES`/`createCli` to the wrapped objects. Watch `exactOptionalPropertyTypes` when re-passing normalized `CommandRedirect`s as `RedirectSpec`s. ## D5 — String sweep, docs, process records, full verification -**Outcome:** no old spelling survives as a command reference anywhere -in `packages/`, `README.md`, `docs/` (run-command next actions, help -examples, error copy, comments that instruct); `tests/e2e-coverage.test.ts` -exclusions/backlog respelled; README/docs command enumerations match -the acceptance tree; each s2 divergence record under -`.drive/projects/prisma-cli-v8/assets/s2/` gets a short entry for the -renames/moves that touch it; `assets/command-review.md` is restored -from commit 76a2c8a and regenerated against the new tree. Full -verification per AGENTS.md: `pnpm --recursive exec tsc --noEmit`, -`pnpm lint`, `pnpm --filter @prisma/cli test`, -`pnpm --filter @prisma/compute test`, -`pnpm --filter @prisma/cli test:e2e` (report a credential-less skip -plainly). - -**Builds on:** D4's final grammar; the sweep inventory appended below. -**Hands to:** slice-DoD; PR-open. +**Outcome:** no old spelling survives as a command reference anywhere in `packages/`, `README.md`, `docs/` (run-command next actions, help examples, error copy, comments that instruct); `tests/e2e-coverage.test.ts` exclusions/backlog respelled; README/docs command enumerations match the acceptance tree; each s2 divergence record under `.drive/projects/prisma-cli-v8/assets/s2/` gets a short entry for the renames/moves that touch it; `assets/command-review.md` is restored from commit 76a2c8a and regenerated against the new tree. Full verification per AGENTS.md: `pnpm --recursive exec tsc --noEmit`, `pnpm lint`, `pnpm --filter @prisma/cli test`, `pnpm --filter @prisma/compute test`, `pnpm --filter @prisma/cli test:e2e` (report a credential-less skip plainly). + +**Builds on:** D4's final grammar; the sweep inventory appended below. **Hands to:** slice-DoD; PR-open. ## Sweep inventory (2026-08-21) Hazards every dispatch must respect: -- `packages/cli-engine/src/execution/command-tree.ts:295` throws at - `buildCli()` when a redirect's `from` collides with a mounted path. - Mounting `migration ref *` while the ORM family still carries the - `migration ref` redirect fails construction — the D4 family wrap - (which drops that redirect) is mandatory, not cosmetic. -- `tests/e2e-coverage.test.ts` parses `src/cli.ts` as TEXT via the - marker `mountedCommands: Readonly> = {`. - Keep that literal's shape when editing the mount table. +- `packages/cli-engine/src/execution/command-tree.ts:295` throws at `buildCli()` when a redirect's `from` collides with a mounted path. Mounting `migration ref *` while the ORM family still carries the `migration ref` redirect fails construction — the D4 family wrap (which drops that redirect) is mandatory, not cosmetic. +- `tests/e2e-coverage.test.ts` parses `src/cli.ts` as TEXT via the marker `mountedCommands: Readonly> = {`. Keep that literal's shape when editing the mount table. - `EXPECTED_MOUNT_PATHS` is asserted sorted; keep alphabetical order. -- No help snapshot tests exist; help is asserted by `toContain` in - `tests/bin.test.ts:344-430` and `tests/orm-mount.test.ts:145-160`. +- No help snapshot tests exist; help is asserted by `toContain` in `tests/bin.test.ts:344-430` and `tests/orm-mount.test.ts:145-160`. Per-spelling checklist (line numbers pre-change): -- **init:** cli.ts:32,289-292,310; commands/init/* (init.ts, settings.ts, - config-file.ts, agent-setup.ts, link.ts, types.ts), types/init.ts, - project/link.ts:142 (prose); mount-coverage:11,46,114; tests/init*.test.ts, - compute-config.test.ts; e2e/init.e2e.ts; packages/cli/README.md:82, - packages/prisma/README.md:79, docs/product/command-principles.md:31, - cli-style-guide.md:61, output-conventions.md:313, - error-conventions.md:274-275, docs/architecture/cli-engine-requirements.md:217. -- **project remove:** cli.ts:205; project/remove.ts; project/presentation.ts:18; - mount-coverage:144; project.test.ts:115,2496; e2e/project-lifecycle.e2e.ts:211; - e2e/deployed-service.ts:147,178 (comments). -- **project env remove:** cli.ts:210; project/env-remove.ts; - lib/app/env-config.ts:141 (error fix copy); mount-coverage:140; - project.test.ts:120,2316,2473,2490; e2e/project-lifecycle.e2e.ts:186. -- **postgres remove:** cli.ts:216; postgres/remove.ts; controllers/database.ts:143; - mount-coverage:133; postgres.test.ts:145,533,1516; e2e/postgres.e2e.ts:336. -- **postgres connection remove:** cli.ts:221; postgres/connection-remove.ts - (:36 usage string); mount-coverage:129; postgres.test.ts:150,2424,2472; - e2e/postgres.e2e.ts:305. -- **service remove:** cli.ts:243; service/remove.ts; service/errors.ts:354,361 - (why + next action); service/release.ts:39; mount-coverage:167; - service-remove.test.ts; e2e/service.e2e.ts:7,127. -- **service domain remove:** cli.ts:246; service/domain-remove.ts; - service/errors.ts:639 (runCommandAction); mount-coverage:160; - service-domain.test.ts:524; e2e-coverage.test.ts:142 (AWAITING_COVERAGE). -- **postgres restore:** cli.ts:215; postgres/restore.ts:90,114; - mount-coverage:134; postgres.test.ts:144,1182; e2e-coverage.test.ts:86 - (EXCLUSIONS key); README prose (cli:76, prisma:73). -- **ref …:** cli.ts:279-281,:188 (group brief); mount-coverage:148-150; - e2e-coverage:73-75; orm-mount.test.ts:145-160 (root-help group - assertions incl. `ref`); cli-engine tests/redirects.test.ts:431 - (synthetic fixture string — respell for coherence). -- **migrate:** cli.ts:270; mount-coverage:116; e2e-coverage:63; - orm-mount.test.ts:141 (pins redirect next-action - "prisma-test migrate --to " — respell to db migrate per the - wrap); cli-engine redirects.test.ts + telemetry-payload.test.ts:80 - (synthetic fixtures); README.md:90, cli README:81, prisma README:78, - packages/cli/AGENTS.md:36; command-principles.md:38, - cli-engine-requirements.md:158. -- **format:** cli.ts:265; mount-coverage:109; e2e-coverage:62; READMEs + - packages/cli/AGENTS.md:36. cli-engine-requirements.md:196 already - describes `contract format` (doc ahead of code — now true). -- **composer:** cli.ts:251-254,:180-182; service/presentation.ts:177 - (runCommandAction "composer deploy" → "deploy"); mount-coverage:99-102,176; - e2e-coverage:92-99,132; bin.test.ts:448-511; v8-conformance.test.ts:41-43,69; - composer-isolation.test.ts (prose); orm-mount.test.ts:15,65; - scripts/conformance.ts:34,62; README.md:31, cli README:42,80, - prisma README:42,77, packages/cli/AGENTS.md:12,36; examples/*/README.md - (next-smoke:22, hello-world:5 — update, they ship in-repo). The - composer package's own help already says `{bin} deploy` — correct at - root; nothing to change upstream. -- **build logs:** cli.ts:250,:179; commands/build/logs.ts (:101 next - action, :158-160 examples); mount-coverage:98; build-logs.test.ts; - e2e-coverage:128,145; cli README:79, prisma README:76, - packages/cli/AGENTS.md:36. -- **README command tables:** packages/cli/README.md:70-82 and - packages/prisma/README.md:67-79 are hand-duplicated — edit both. -- **Pre-existing doc bug in touched copy:** error-conventions.md:275 - says `init --format json`; flag was `--config-format`. Moot once init - is removed — delete the example with the command. -- **.drive/ hits:** historical records (s2 specs/design docs, parity - divergence bodies, command-inventory) stay as history; only the - short new divergence entries + regenerated command-review.md change. +- **init:** cli.ts:32,289-292,310; commands/init/* (init.ts, settings.ts, config-file.ts, agent-setup.ts, link.ts, types.ts), types/init.ts, project/link.ts:142 (prose); mount-coverage:11,46,114; tests/init*.test.ts, compute-config.test.ts; e2e/init.e2e.ts; packages/cli/README.md:82, packages/prisma/README.md:79, docs/product/command-principles.md:31, cli-style-guide.md:61, output-conventions.md:313, error-conventions.md:274-275, docs/architecture/cli-engine-requirements.md:217. +- **project remove:** cli.ts:205; project/remove.ts; project/presentation.ts:18; mount-coverage:144; project.test.ts:115,2496; e2e/project-lifecycle.e2e.ts:211; e2e/deployed-service.ts:147,178 (comments). +- **project env remove:** cli.ts:210; project/env-remove.ts; lib/app/env-config.ts:141 (error fix copy); mount-coverage:140; project.test.ts:120,2316,2473,2490; e2e/project-lifecycle.e2e.ts:186. +- **postgres remove:** cli.ts:216; postgres/remove.ts; controllers/database.ts:143; mount-coverage:133; postgres.test.ts:145,533,1516; e2e/postgres.e2e.ts:336. +- **postgres connection remove:** cli.ts:221; postgres/connection-remove.ts (:36 usage string); mount-coverage:129; postgres.test.ts:150,2424,2472; e2e/postgres.e2e.ts:305. +- **service remove:** cli.ts:243; service/remove.ts; service/errors.ts:354,361 (why + next action); service/release.ts:39; mount-coverage:167; service-remove.test.ts; e2e/service.e2e.ts:7,127. +- **service domain remove:** cli.ts:246; service/domain-remove.ts; service/errors.ts:639 (runCommandAction); mount-coverage:160; service-domain.test.ts:524; e2e-coverage.test.ts:142 (AWAITING_COVERAGE). +- **postgres restore:** cli.ts:215; postgres/restore.ts:90,114; mount-coverage:134; postgres.test.ts:144,1182; e2e-coverage.test.ts:86 (EXCLUSIONS key); README prose (cli:76, prisma:73). +- **ref …:** cli.ts:279-281,:188 (group brief); mount-coverage:148-150; e2e-coverage:73-75; orm-mount.test.ts:145-160 (root-help group assertions incl. `ref`); cli-engine tests/redirects.test.ts:431 (synthetic fixture string — respell for coherence). +- **migrate:** cli.ts:270; mount-coverage:116; e2e-coverage:63; orm-mount.test.ts:141 (pins redirect next-action "prisma-test migrate --to " — respell to db migrate per the wrap); cli-engine redirects.test.ts + telemetry-payload.test.ts:80 (synthetic fixtures); README.md:90, cli README:81, prisma README:78, packages/cli/AGENTS.md:36; command-principles.md:38, cli-engine-requirements.md:158. +- **format:** cli.ts:265; mount-coverage:109; e2e-coverage:62; READMEs + packages/cli/AGENTS.md:36. cli-engine-requirements.md:196 already describes `contract format` (doc ahead of code — now true). +- **composer:** cli.ts:251-254,:180-182; service/presentation.ts:177 (runCommandAction "composer deploy" → "deploy"); mount-coverage:99-102,176; e2e-coverage:92-99,132; bin.test.ts:448-511; v8-conformance.test.ts:41-43,69; composer-isolation.test.ts (prose); orm-mount.test.ts:15,65; scripts/conformance.ts:34,62; README.md:31, cli README:42,80, prisma README:42,77, packages/cli/AGENTS.md:12,36; examples/*/README.md (next-smoke:22, hello-world:5 — update, they ship in-repo). The composer package's own help already says `{bin} deploy` — correct at root; nothing to change upstream. +- **build logs:** cli.ts:250,:179; commands/build/logs.ts (:101 next action, :158-160 examples); mount-coverage:98; build-logs.test.ts; e2e-coverage:128,145; cli README:79, prisma README:76, packages/cli/AGENTS.md:36. +- **README command tables:** packages/cli/README.md:70-82 and packages/prisma/README.md:67-79 are hand-duplicated — edit both. +- **Pre-existing doc bug in touched copy:** error-conventions.md:275 says `init --format json`; flag was `--config-format`. Moot once init is removed — delete the example with the command. +- **.drive/ hits:** historical records (s2 specs/design docs, parity divergence bodies, command-inventory) stay as history; only the short new divergence entries + regenerated command-review.md change. diff --git a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md index aaacae63..c05b9c6f 100644 --- a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md +++ b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md @@ -1,78 +1,31 @@ # Command grammar cleanup (slice contract) -Status: rev 1 (2026-08-21). One PR into `main`. Repo: prisma-cli only. -Source: the PM command-review brief (2026-08-20/21), reproduced in the -operator's message; this spec adds the grounded design decisions, not -new scope. No new commands; this pass removes, renames and moves -existing ones. +Status: rev 1 (2026-08-21). One PR into `main`. Repo: prisma-cli only. Source: the PM command-review brief (2026-08-20/21), reproduced in the operator's message; this spec adds the grounded design decisions, not new scope. No new commands; this pass removes, renames and moves existing ones. ## At a glance -The shell owns the mount table (`packages/cli/src/cli.ts`), so most of -the work is mount-table edits plus every string that names a command: -help summaries, examples, `run-command` next actions, error copy, -tests, docs. Four behavioural changes ride along: the compute config -(`prisma.compute.ts`/`.json`) and `init` are removed outright; service -commands stop using ambient context (remembered selection, interactive -picker, git-branch inference); six `remove` commands become `delete`; -and the composer/build groups dissolve. +The shell owns the mount table (`packages/cli/src/cli.ts`), so most of the work is mount-table edits plus every string that names a command: help summaries, examples, `run-command` next actions, error copy, tests, docs. Four behavioural changes ride along: the compute config (`prisma.compute.ts`/`.json`) and `init` are removed outright; service commands stop using ambient context (remembered selection, interactive picker, git-branch inference); six `remove` commands become `delete`; and the composer/build groups dissolve. ## Chosen design ### 1. Compute config and `init` removal -- Delete `src/commands/init/` (all files), `src/types/init.ts`, the - `init` mount, its help presence, `tests/init.test.ts`, - `tests/init-agent-setup.test.ts`, and the e2e coverage entry. -- Delete `src/lib/app/compute-config.ts`, `build-settings.ts`, - `deploy-framework.ts`, `build.ts`, and whatever only the config path - reached (follow the import graph; `tests/compute-config.test.ts`, - `tests/service-compute-config.test.ts`, `tests/app-build.test.ts` - and friends go with their subjects). Drop the - `@prisma/compute-sdk/config` import if nothing else needs it. -- `src/commands/service/target.ts`: delete `resolveComputeTarget`, - `resolveComputeManagementContext`, the config-target positional - (`[service]`) on every service command, and the - `SERVICE.COMPUTE_CONFIG_INVALID` / - `SERVICE.COMPUTE_CONFIG_TARGET_UNKNOWN` error codes plus their - helpers (`configTargetRequiresConfigError`, - `computeConfigErrorToCliError` call sites) in `service/errors.ts`. -- `src/lib/agent/setup-status.ts`: remove the compute-config read; - agent status no longer depends on a config file. +- Delete `src/commands/init/` (all files), `src/types/init.ts`, the `init` mount, its help presence, `tests/init.test.ts`, `tests/init-agent-setup.test.ts`, and the e2e coverage entry. +- Delete `src/lib/app/compute-config.ts`, `build-settings.ts`, `deploy-framework.ts`, `build.ts`, and whatever only the config path reached (follow the import graph; `tests/compute-config.test.ts`, `tests/service-compute-config.test.ts`, `tests/app-build.test.ts` and friends go with their subjects). Drop the `@prisma/compute-sdk/config` import if nothing else needs it. +- `src/commands/service/target.ts`: delete `resolveComputeTarget`, `resolveComputeManagementContext`, the config-target positional (`[service]`) on every service command, and the `SERVICE.COMPUTE_CONFIG_INVALID` / `SERVICE.COMPUTE_CONFIG_TARGET_UNKNOWN` error codes plus their helpers (`configTargetRequiresConfigError`, `computeConfigErrorToCliError` call sites) in `service/errors.ts`. +- `src/lib/agent/setup-status.ts`: remove the compute-config read; agent status no longer depends on a config file. ### 2. Service commands take parameters only -The only ambient context a platform command may use is the directory's -project link (`.prisma/local.json`). - -- **Service targeting:** `--service ` (matched by name) or - `PRISMA_SERVICE_ID` (matched by id — the existing domain-flow - mechanics generalized to every service command that targets a - service). Neither present → a structured error naming `--service`, - exit 2, in interactive terminals too. Delete - `resolveExistingServiceSelection`'s saved-selection branch and its - `ctx.prompt.select` picker; delete `rememberSelectedService`, - `LocalStateStore.readSelectedApp` / `setSelectedApp` / - `clearSelectedApp` and the `selectedByProject` state shape - (`service delete` loses its local-state cleanup with it). -- **Branch:** `--branch ` only; when absent, the existing - non-git fallback stands (`"main"` for read flows, `"production"` - for the domain flow). Delete `resolveRequestedBranch`'s git - inference and `src/lib/git/local-branch.ts` + - `tests/local-branch.test.ts` if nothing else imports it. -- **Project:** unchanged (`--project`, `PRISMA_PROJECT_ID`, link - file). `project link` keeps its interactive picker. +The only ambient context a platform command may use is the directory's project link (`.prisma/local.json`). + +- **Service targeting:** `--service ` (matched by name) or `PRISMA_SERVICE_ID` (matched by id — the existing domain-flow mechanics generalized to every service command that targets a service). Neither present → a structured error naming `--service`, exit 2, in interactive terminals too. Delete `resolveExistingServiceSelection`'s saved-selection branch and its `ctx.prompt.select` picker; delete `rememberSelectedService`, `LocalStateStore.readSelectedApp` / `setSelectedApp` / `clearSelectedApp` and the `selectedByProject` state shape (`service delete` loses its local-state cleanup with it). +- **Branch:** `--branch ` only; when absent, the existing non-git fallback stands (`"main"` for read flows, `"production"` for the domain flow). Delete `resolveRequestedBranch`'s git inference and `src/lib/git/local-branch.ts` + `tests/local-branch.test.ts` if nothing else imports it. +- **Project:** unchanged (`--project`, `PRISMA_PROJECT_ID`, link file). `project link` keeps its interactive picker. ### 3. `delete` destroys, `remove` detaches — renames, no aliases -`project remove`→`project delete`, `project env remove`→`project env -delete`, `postgres remove`→`postgres delete`, `postgres connection -remove`→`postgres connection delete`, `service remove`→`service -delete`, `service domain remove`→`service domain delete`. Rename -files, exported symbols, command ids, help, examples, next actions, -error copy, consent questions, and tests (unit + e2e -`describeCommand` markers). `git disconnect`, `auth … logout`, -`bucket delete`, `bucket key delete` unchanged. +`project remove`→`project delete`, `project env remove`→`project env delete`, `postgres remove`→`postgres delete`, `postgres connection remove`→`postgres connection delete`, `service remove`→`service delete`, `service domain remove`→`service domain delete`. Rename files, exported symbols, command ids, help, examples, next actions, error copy, consent questions, and tests (unit + e2e `describeCommand` markers). `git disconnect`, `auth … logout`, `bucket delete`, `bucket key delete` unchanged. ### 4. Moves @@ -85,104 +38,49 @@ error copy, consent questions, and tests (unit + e2e | `composer dev` | `dev` | | `composer deploy` | `deploy` | -No aliases, no redirects for old spellings; an old spelling settles -the engine's unknown-command error. +No aliases, no redirects for old spellings; an old spelling settles the engine's unknown-command error. -**Family wrapping (the shell-side mechanism).** Both external -families are re-wrapped in `cli.ts` with `defineCommandFamily`, -preserving `configSection` and `docsBaseUrl`: +**Family wrapping (the shell-side mechanism).** Both external families are re-wrapped in `cli.ts` with `defineCommandFamily`, preserving `configSection` and `docsBaseUrl`: -- **Composer:** keep only `deploy` and `dev`; `destroy` and `log` are - dropped commands, and dropping them from the wrapped family is what - keeps mount-coverage's "mounts every family command" check honest. -- **ORM:** commands pass through unchanged (the mount table respells - their paths). Redirects are rewritten: the `migration ref` → `ref` - entry is dropped (mounting it as-is would redirect a now-live - spelling), and the `migration apply` entry's replacement - `{bin} migrate --to ` is respelled `{bin} db migrate --to - `. The `migration status` flag redirects name live - commands and pass through. +- **Composer:** keep only `deploy` and `dev`; `destroy` and `log` are dropped commands, and dropping them from the wrapped family is what keeps mount-coverage's "mounts every family command" check honest. +- **ORM:** commands pass through unchanged (the mount table respells their paths). Redirects are rewritten: the `migration ref` → `ref` entry is dropped (mounting it as-is would redirect a now-live spelling), and the `migration apply` entry's replacement `{bin} migrate --to ` is respelled `{bin} db migrate --to `. The `migration status` flag redirects name live commands and pass through. ### 5. Removals -`composer destroy`, `composer log`, the `composer` group and its -brief, and the `build` group (`build logs`, `src/commands/build/`, -`tests/build-logs.test.ts`, the `build` group brief). +`composer destroy`, `composer log`, the `composer` group and its brief, and the `build` group (`build logs`, `src/commands/build/`, `tests/build-logs.test.ts`, the `build` group brief). ### 6. Housekeeping in the same PR -- `tests/mount-coverage.test.ts`: `EXPECTED_MOUNT_PATHS` becomes the - acceptance tree below; `initCommand` leaves `FAMILYLESS`; the - header's `init` ruling note is updated (the 2026-08-12 ruling is - superseded by the 2026-08-21 PM review). -- `tests/e2e-coverage.test.ts` exclusions/backlog entries respelled; - e2e `describeCommand` markers respelled; no command loses its - coverage status silently. -- Root help examples (`cli.ts` `help.examples`): drop `init`; use - live spellings (e.g. `auth login`, `project list`, `deploy`). -- Every `run-command` next action, help example, and error string - naming an old spelling, across `packages/` (the sweep inventory in - the slice plan is the checklist). +- `tests/mount-coverage.test.ts`: `EXPECTED_MOUNT_PATHS` becomes the acceptance tree below; `initCommand` leaves `FAMILYLESS`; the header's `init` ruling note is updated (the 2026-08-12 ruling is superseded by the 2026-08-21 PM review). +- `tests/e2e-coverage.test.ts` exclusions/backlog entries respelled; e2e `describeCommand` markers respelled; no command loses its coverage status silently. +- Root help examples (`cli.ts` `help.examples`): drop `init`; use live spellings (e.g. `auth login`, `project list`, `deploy`). +- Every `run-command` next action, help example, and error string naming an old spelling, across `packages/` (the sweep inventory in the slice plan is the checklist). - README / docs pages that enumerate commands. -- Divergence records under `.drive/projects/prisma-cli-v8/assets/s2/` - get a short entry each for the renames and moves; - `assets/command-review.md` (currently only on branch - `claude/command-review-divergences-17ee99`, commit 76a2c8a) is - brought into this branch and regenerated against the new tree. +- Divergence records under `.drive/projects/prisma-cli-v8/assets/s2/` get a short entry each for the renames and moves; `assets/command-review.md` (currently only on branch `claude/command-review-divergences-17ee99`, commit 76a2c8a) is brought into this branch and regenerated against the new tree. ## Coherence rationale -One reviewer can hold this in one sitting because the change is one -rule applied uniformly: the grammar moves, and every string follows. -The three genuinely behavioural pieces (compute-config removal, -parameter-only service targeting, family wrapping) are each small and -local; the rest is mechanical renaming verified by the grammar -completeness test and the full suite. It rolls back as one unit — a -partial landing would leave the help, the tree, and the docs -disagreeing, which is exactly what the single-PR rule prevents. +One reviewer can hold this in one sitting because the change is one rule applied uniformly: the grammar moves, and every string follows. The three genuinely behavioural pieces (compute-config removal, parameter-only service targeting, family wrapping) are each small and local; the rest is mechanical renaming verified by the grammar completeness test and the full suite. It rolls back as one unit — a partial landing would leave the help, the tree, and the docs disagreeing, which is exactly what the single-PR rule prevents. ## Scope **In:** everything above, in this repo. -**Deliberately out:** new commands (`project unlink`, `service domain -list`, `bucket show`, `contract validate`, `auth token *`); the -`app` → `service` control-plane API rename (CLI keeps calling -`/v1/apps`); any ORM command behaviour change (only mount paths -move); changes to `@prisma/composer-cli` or `@prisma/orm-toolchain` -packages themselves (the shell wraps, upstream cleanups are -follow-ups for those repos). +**Deliberately out:** new commands (`project unlink`, `service domain list`, `bucket show`, `contract validate`, `auth token *`); the `app` → `service` control-plane API rename (CLI keeps calling `/v1/apps`); any ORM command behaviour change (only mount paths move); changes to `@prisma/composer-cli` or `@prisma/orm-toolchain` packages themselves (the shell wraps, upstream cleanups are follow-ups for those repos). ## Pre-investigated edge cases -- The ORM family ships a live redirect table (`cli.mjs`: - `migration apply`, `migration ref`, three `migration status` flag - entries). Mounting it unwrapped would point users at spellings this - PR retires (`migrate`) or capture a spelling it revives - (`migration ref`). Hence the wrap in §4. -- mount-coverage's family-completeness check fails on any family - command the tree does not mount — dropping `composer destroy`/`log` - therefore requires the wrapped composer family, not just mount-table - deletion. -- `PRISMA_SERVICE_ID` matches by id, `--service` by name; the - existing domain-flow behaviour is the model. Do not conflate them. -- `service create` names its service with a flag/argument already and - does not resolve an existing target; the picker removal must not - break it. -- Composer's help examples read `{bin} deploy …`; the root move makes - them correct by itself. Do not "fix" them in the composer package. +- The ORM family ships a live redirect table (`cli.mjs`: `migration apply`, `migration ref`, three `migration status` flag entries). Mounting it unwrapped would point users at spellings this PR retires (`migrate`) or capture a spelling it revives (`migration ref`). Hence the wrap in §4. +- mount-coverage's family-completeness check fails on any family command the tree does not mount — dropping `composer destroy`/`log` therefore requires the wrapped composer family, not just mount-table deletion. +- `PRISMA_SERVICE_ID` matches by id, `--service` by name; the existing domain-flow behaviour is the model. Do not conflate them. +- `service create` names its service with a flag/argument already and does not resolve an existing target; the picker removal must not break it. +- Composer's help examples read `{bin} deploy …`; the root move makes them correct by itself. Do not "fix" them in the composer package. ## Slice-specific done conditions -- The mounted tree equals the acceptance tree below - (mount-coverage green asserts it). -- `grep -rn` across `packages/`, `README.md`, `docs/` finds no - remaining old spelling used as a command reference (excepting - historical records: `.drive/` process artifacts, changelogs). -- Full pre-commit verification per AGENTS.md, including - `pnpm --filter @prisma/cli test:e2e` (credentialed run if the env - provides `PRISMA_E2E_SERVICE_TOKEN`; otherwise the suite's skip is - reported, not hidden). +- The mounted tree equals the acceptance tree below (mount-coverage green asserts it). +- `grep -rn` across `packages/`, `README.md`, `docs/` finds no remaining old spelling used as a command reference (excepting historical records: `.drive/` process artifacts, changelogs). +- Full pre-commit verification per AGENTS.md, including `pnpm --filter @prisma/cli test:e2e` (credentialed run if the env provides `PRISMA_E2E_SERVICE_TOKEN`; otherwise the suite's skip is reported, not hidden). ## Acceptance tree @@ -210,9 +108,4 @@ feedback ## References - Brief: operator message 2026-08-21 (this spec's source of scope). -- `packages/cli/src/cli.ts` (mount table), - `packages/cli/tests/mount-coverage.test.ts` (grammar check), - `packages/cli/src/commands/service/target.ts` (ambient context), - `packages/cli/node_modules/@prisma/orm-toolchain/dist/cli.mjs` - (shipped redirect table), `@prisma/composer-cli/dist/family.mjs` - (composer family shape). +- `packages/cli/src/cli.ts` (mount table), `packages/cli/tests/mount-coverage.test.ts` (grammar check), `packages/cli/src/commands/service/target.ts` (ambient context), `packages/cli/node_modules/@prisma/orm-toolchain/dist/cli.mjs` (shipped redirect table), `@prisma/composer-cli/dist/family.mjs` (composer family shape). diff --git a/README.md b/README.md index 94ad08c1..1cee797e 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,7 @@ pnpm prisma-cli project env add --file .env --role preview pnpm prisma-cli project env list --role preview ``` -Deployments start from pushing the connected repository, the Console, or -`prisma-cli deploy`. +Deployments start from pushing the connected repository, the Console, or `prisma-cli deploy`. ## Local Development @@ -85,12 +84,7 @@ The canonical command shape is: prisma ``` -The package includes project, environment-variable, service and deployment -inspection, promotion, rollback, and deletion commands, plus the Prisma ORM -(`contract`, `db`, `migration`, `orm init`) and Composer -workflows (root `dev` and `deploy`), and the `postgres` and `bucket` -resource groups. The product model intentionally avoids product-specific -namespaces. +The package includes project, environment-variable, service and deployment inspection, promotion, rollback, and deletion commands, plus the Prisma ORM (`contract`, `db`, `migration`, `orm init`) and Composer workflows (root `dev` and `deploy`), and the `postgres` and `bucket` resource groups. The product model intentionally avoids product-specific namespaces. ## Documentation diff --git a/docs/architecture/cli-engine-requirements.md b/docs/architecture/cli-engine-requirements.md index 7cb4fa83..21243f87 100644 --- a/docs/architecture/cli-engine-requirements.md +++ b/docs/architecture/cli-engine-requirements.md @@ -151,13 +151,7 @@ construction: each engine instance builds its tree once at startup from statically defined structure — command definitions are direct function references, and nothing about the tree is discovered at run time. -**Why:** a statically known tree is simpler to reason about, renders complete -help without executing product code, and fails at build time when it is wrong. -The expensive parts — driver stacks, Composer's dependency tree (which imports -the user's own modules and has crashed at import in the past) — stay out of -the startup path, so `prisma db migrate` can never be taken down by a product it -isn't using. The split follows the existing design for isolating heavy -dependency subtrees behind execution-time imports. +**Why:** a statically known tree is simpler to reason about, renders complete help without executing product code, and fails at build time when it is wrong. The expensive parts — driver stacks, Composer's dependency tree (which imports the user's own modules and has crashed at import in the past) — stay out of the startup path, so `prisma db migrate` can never be taken down by a product it isn't using. The split follows the existing design for isolating heavy dependency subtrees behind execution-time imports. ### R10 — One config file, validated by its products, never a crash @@ -211,29 +205,7 @@ dependencies; a command that requires one checks for it at execution time and, when it is absent, returns a structured error naming the dependency and how to install it with the user's own package manager. -Running the user's own package manager is a different act, and a command -may do it: adding dependencies to the project the user is working in, or -running a package's binary once, because the user asked for it. The case -this exists for is a scaffolding command like `prisma orm init`, which -sets up a project and then installs the dependencies it just wrote into -`package.json`. A command -declares that it installs packages, and the work goes through the engine's -package operations, whose terms are what keep the two apart. The manager -is the one the user's project already uses, detected rather than imposed. -The command line is announced before it runs, and the manager's own output -is shown while it runs, so nothing happens the user cannot see — with any -credentials in either stripped out, because being visible to the user must -not mean being visible in a log or a `--json` stream. A failure comes back -as a structured error whose remedy carries that same redacted command, not -a runnable one: whoever put a credential into a package specifier still -holds it and can supply it again, so printing it back to save them that -step buys a moment's convenience and risks writing the credential -permanently into a CI log, where it cannot be taken back. One -unconditional rule is worth more than a rule plus an exception, and an -exception carved into the component whose job is not leaking secrets is -where the leak would live. And the engine composes the command but never -spawns it: execution belongs to the shell, so the engine holds no -process-spawning machinery of its own. +Running the user's own package manager is a different act, and a command may do it: adding dependencies to the project the user is working in, or running a package's binary once, because the user asked for it. The case this exists for is a scaffolding command like `prisma orm init`, which sets up a project and then installs the dependencies it just wrote into `package.json`. A command declares that it installs packages, and the work goes through the engine's package operations, whose terms are what keep the two apart. The manager is the one the user's project already uses, detected rather than imposed. The command line is announced before it runs, and the manager's own output is shown while it runs, so nothing happens the user cannot see — with any credentials in either stripped out, because being visible to the user must not mean being visible in a log or a `--json` stream. A failure comes back as a structured error whose remedy carries that same redacted command, not a runnable one: whoever put a credential into a package specifier still holds it and can supply it again, so printing it back to save them that step buys a moment's convenience and risks writing the credential permanently into a CI log, where it cannot be taken back. One unconditional rule is worth more than a rule plus an exception, and an exception carved into the component whose job is not leaking secrets is where the leak would live. And the engine composes the command but never spawns it: execution belongs to the shell, so the engine holds no process-spawning machinery of its own. **Why:** a previous incarnation of the Prisma CLI installed command submodules on demand into a hidden `node_modules` in the working directory. diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 747c7199..3db182c7 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -99,10 +99,7 @@ Resolve a deployment and show or stream its logs. ### `delete` and `remove` -`delete` destroys a resource; `remove` detaches one thing from another -without destroying it. A command that permanently destroys what it -targets is spelled `delete` (`project delete`, `service delete`, -`postgres delete`); `remove` is reserved for detachment. +`delete` destroys a resource; `remove` detaches one thing from another without destroying it. A command that permanently destroys what it targets is spelled `delete` (`project delete`, `service delete`, `postgres delete`); `remove` is reserved for detachment. ### `wait` diff --git a/packages/cli/README.md b/packages/cli/README.md index 375a55c1..42ab60c4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -38,8 +38,7 @@ npx prisma-cli project create my-app npx prisma-cli git connect git@github.com:owner/repo.git ``` -Deployments start from pushing the connected repository, the Console, or -`prisma-cli deploy`. +Deployments start from pushing the connected repository, the Console, or `prisma-cli deploy`. With `pnpm`: diff --git a/packages/cli/src/adapters/local-state.ts b/packages/cli/src/adapters/local-state.ts index 38cd8869..1a26fab4 100644 --- a/packages/cli/src/adapters/local-state.ts +++ b/packages/cli/src/adapters/local-state.ts @@ -18,9 +18,6 @@ export interface LocalState { branch: { active: string; }; - app: { - knownLiveDeploymentByProject: Record>; - }; agent: { setupPromptDismissedAt: string | null; }; @@ -42,9 +39,6 @@ const DEFAULT_STATE: LocalState = { branch: { active: "preview", }, - app: { - knownLiveDeploymentByProject: {}, - }, agent: { setupPromptDismissedAt: null, }, @@ -85,10 +79,6 @@ export class LocalStateStore { branch: { active: parsed.branch?.active ?? DEFAULT_STATE.branch.active, }, - app: { - knownLiveDeploymentByProject: - parsed.app?.knownLiveDeploymentByProject ?? {}, - }, agent: { setupPromptDismissedAt: parsed.agent?.setupPromptDismissedAt ?? null, }, @@ -182,48 +172,6 @@ export class LocalStateStore { return state; } - async readKnownLiveDeployment( - projectId: string, - appId: string, - ): Promise { - const state = await this.read(); - return state.app.knownLiveDeploymentByProject[projectId]?.[appId] ?? null; - } - - async setKnownLiveDeployment( - projectId: string, - appId: string, - deploymentId: string, - ): Promise { - const state = await this.read(); - state.app.knownLiveDeploymentByProject[projectId] ??= {}; - state.app.knownLiveDeploymentByProject[projectId][appId] = deploymentId; - await this.write(state); - return state; - } - - async clearKnownLiveDeployment( - projectId: string, - appId: string, - ): Promise { - const state = await this.read(); - const projectDeployments = - state.app.knownLiveDeploymentByProject[projectId]; - - if (!projectDeployments || !(appId in projectDeployments)) { - return state; - } - - delete projectDeployments[appId]; - - if (Object.keys(projectDeployments).length === 0) { - delete state.app.knownLiveDeploymentByProject[projectId]; - } - - await this.write(state); - return state; - } - async readAgentSetupPromptDismissedAt(): Promise { const state = await this.read(); return state.agent.setupPromptDismissedAt; diff --git a/packages/cli/src/commands/service/delete.ts b/packages/cli/src/commands/service/delete.ts index f50cfa16..fd4724d1 100644 --- a/packages/cli/src/commands/service/delete.ts +++ b/packages/cli/src/commands/service/delete.ts @@ -1,40 +1,10 @@ import { defineCommand, flag } from "@prisma/cli-engine"; -import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; -import type { LocalStateStore } from "../../adapters/local-state"; -import { - branchValueEmptyError, - deleteFailedError, - userCancelledError, -} from "./errors"; +import { deleteFailedError, userCancelledError } from "./errors"; import { deletePresentations } from "./presentation"; -import { destroyProgressReporter, resolveServiceReleaseState } from "./release"; +import { destroyProgressReporter } from "./release"; import type { ServiceDeleteResult } from "./results"; -import { openServiceStateStore, toServiceSummary } from "./target"; - -function cleanupWarning(target: string, error: unknown): Diagnostic { - const cause = error instanceof Error ? error.message : String(error); - return { - code: "SERVICE.LOCAL_STATE_CLEANUP_FAILED", - severity: "warn", - summary: `The service was deleted remotely, but the local ${target} state could not be cleared: ${cause}`, - nextActions: [], - }; -} - -async function clearDeletedServiceState( - stateStore: LocalStateStore, - projectId: string, - serviceId: string, -): Promise { - const warnings: Diagnostic[] = []; - try { - await stateStore.clearKnownLiveDeployment(projectId, serviceId); - } catch (error) { - warnings.push(cleanupWarning("known live deployment", error)); - } - return warnings; -} +import { resolveServiceReadState, toServiceSummary } from "./target"; export const serviceDeleteCommand = defineCommand({ help: { @@ -59,11 +29,7 @@ export const serviceDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - if (args.flags.branch !== undefined && args.flags.branch.trim() === "") { - throw branchValueEmptyError(); - } - - const state = await resolveServiceReleaseState(ctx, { + const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, branchName: args.flags.branch, @@ -96,23 +62,19 @@ export const serviceDeleteCommand = defineCommand({ }); } catch (error) { ctx.report({ kind: "step-finished", step: "delete", outcome: "failed" }); - throw deleteFailedError("Failed to delete service", error); + throw deleteFailedError( + "Failed to delete service", + error, + state.service.name, + ); } ctx.report({ kind: "step-finished", step: "delete", outcome: "ok" }); - const diagnostics = await clearDeletedServiceState( - openServiceStateStore(ctx), - state.projectId, - deletedService.id, - ); - const result: ServiceDeleteResult = { projectId: state.projectId, service: toServiceSummary(deletedService), deleted: true, }; - return ok( - ctx.present({ data: result, diagnostics }, deletePresentations(result)), - ); + return ok(ctx.present({ data: result }, deletePresentations(result))); }, }); diff --git a/packages/cli/src/commands/service/deployment-delete.ts b/packages/cli/src/commands/service/deployment-delete.ts index d77f1fb1..0ffc1628 100644 --- a/packages/cli/src/commands/service/deployment-delete.ts +++ b/packages/cli/src/commands/service/deployment-delete.ts @@ -6,19 +6,16 @@ import { userCancelledError, } from "./errors"; import { deploymentDeletePresentations } from "./presentation"; -import { - requireDeploymentForService, - resolveServiceReleaseState, -} from "./release"; +import { requireDeploymentForService } from "./release"; import type { ServiceDeploymentDeleteResult } from "./results"; -import { toServiceSummary } from "./target"; +import { resolveServiceReadState, toServiceSummary } from "./target"; export const serviceDeploymentDeleteCommand = defineCommand({ help: { summary: "Delete a deployment and the artifact it holds", examples: [ - "service deployment delete dep_123", - "service deployment delete dep_123 --confirm dep_123", + "service deployment delete dep_123 --service my-service", + "service deployment delete dep_123 --service my-service --confirm dep_123", ], }, args: { @@ -28,6 +25,10 @@ export const serviceDeploymentDeleteCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), }, positionals: { deployment: positional.string({ @@ -38,9 +39,10 @@ export const serviceDeploymentDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const state = await resolveServiceReleaseState(ctx, { + const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, + branchName: args.flags.branch, commandName: "service deployment delete", }); @@ -48,7 +50,10 @@ export const serviceDeploymentDeleteCommand = defineCommand({ .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); }); const targetDeployment = requireDeploymentForService( @@ -78,7 +83,10 @@ export const serviceDeploymentDeleteCommand = defineCommand({ } catch (error) { ctx.report({ kind: "step-finished", step: "delete", outcome: "failed" }); throw deployFailedError("Failed to delete deployment", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); } ctx.report({ kind: "step-finished", step: "delete", outcome: "ok" }); diff --git a/packages/cli/src/commands/service/deployment-list.ts b/packages/cli/src/commands/service/deployment-list.ts index 0a33e0ca..6462f2a2 100644 --- a/packages/cli/src/commands/service/deployment-list.ts +++ b/packages/cli/src/commands/service/deployment-list.ts @@ -15,8 +15,8 @@ export const serviceDeploymentListCommand = defineCommand({ help: { summary: "List deployments for the service", examples: [ - "service deployment list", "service deployment list --service my-service", + "service deployment list --service my-service --branch feature-x", ], }, args: { @@ -29,6 +29,10 @@ export const serviceDeploymentListCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), }, }, needs: { credentials: true }, @@ -36,6 +40,7 @@ export const serviceDeploymentListCommand = defineCommand({ const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, + branchName: args.flags.branch, commandName: "service deployment list", }); diff --git a/packages/cli/src/commands/service/deployment-promote.ts b/packages/cli/src/commands/service/deployment-promote.ts index d0d47aab..82173297 100644 --- a/packages/cli/src/commands/service/deployment-promote.ts +++ b/packages/cli/src/commands/service/deployment-promote.ts @@ -6,18 +6,21 @@ import { promotePresentations } from "./presentation"; import { promoteProgressReporter, requireDeploymentForService, - resolveServiceReleaseState, } from "./release"; import type { ServicePromoteResult } from "./results"; -import { resolveCurrentLiveDeploymentId, toServiceSummary } from "./target"; +import { + resolveCurrentLiveDeploymentId, + resolveServiceReadState, + toServiceSummary, +} from "./target"; export const serviceDeploymentPromoteCommand = defineCommand({ help: { summary: "Promote a deployment to production by rebuilding with production env vars", examples: [ - "service deployment promote dep_123", "service deployment promote dep_123 --service my-service", + "service deployment promote dep_123 --service my-service --branch feature-x", ], }, args: { @@ -27,6 +30,10 @@ export const serviceDeploymentPromoteCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), }, positionals: { deployment: positional.string({ @@ -37,9 +44,10 @@ export const serviceDeploymentPromoteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const state = await resolveServiceReleaseState(ctx, { + const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, + branchName: args.flags.branch, commandName: "service deployment promote", }); @@ -47,7 +55,10 @@ export const serviceDeploymentPromoteCommand = defineCommand({ .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); }); const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( @@ -77,7 +88,10 @@ export const serviceDeploymentPromoteCommand = defineCommand({ outcome: "failed", }); throw deployFailedError("Failed to promote deployment", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); } ctx.report({ kind: "step-finished", step: "promote", outcome: "ok" }); diff --git a/packages/cli/src/commands/service/deployment-rollback.ts b/packages/cli/src/commands/service/deployment-rollback.ts index 574a1f47..19f2b687 100644 --- a/packages/cli/src/commands/service/deployment-rollback.ts +++ b/packages/cli/src/commands/service/deployment-rollback.ts @@ -11,18 +11,21 @@ import { promoteProgressReporter, requireDeploymentForService, resolveRollbackTarget, - resolveServiceReleaseState, } from "./release"; import type { ServiceRollbackResult } from "./results"; -import { resolveCurrentLiveDeploymentId, toServiceSummary } from "./target"; +import { + resolveCurrentLiveDeploymentId, + resolveServiceReadState, + toServiceSummary, +} from "./target"; export const serviceDeploymentRollbackCommand = defineCommand({ help: { summary: "Roll back production to a previous deployment", examples: [ - "service deployment rollback", - "service deployment rollback --to dep_123", - "service deployment rollback --to dep_123 --confirm dep_123", + "service deployment rollback --service my-service", + "service deployment rollback --service my-service --to dep_123", + "service deployment rollback --service my-service --to dep_123 --confirm dep_123", ], }, args: { @@ -32,6 +35,10 @@ export const serviceDeploymentRollbackCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), to: flag.string({ brief: "Deployment id to roll back to (default: the deployment before the live one)", @@ -41,9 +48,10 @@ export const serviceDeploymentRollbackCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const state = await resolveServiceReleaseState(ctx, { + const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, + branchName: args.flags.branch, commandName: "service deployment rollback", }); @@ -51,7 +59,10 @@ export const serviceDeploymentRollbackCommand = defineCommand({ .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); }); const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( @@ -67,6 +78,7 @@ export const serviceDeploymentRollbackCommand = defineCommand({ : resolveRollbackTarget( deploymentsResult.deployments, currentLiveDeploymentId, + state.service.name, ); const granted = await ctx.prompt.consent( @@ -99,7 +111,10 @@ export const serviceDeploymentRollbackCommand = defineCommand({ outcome: "failed", }); throw deployFailedError("Failed to roll back deployment", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); } ctx.report({ kind: "step-finished", step: "rollback", outcome: "ok" }); diff --git a/packages/cli/src/commands/service/deployment-run-state.ts b/packages/cli/src/commands/service/deployment-run-state.ts index 43e3e406..5dcf9662 100644 --- a/packages/cli/src/commands/service/deployment-run-state.ts +++ b/packages/cli/src/commands/service/deployment-run-state.ts @@ -4,13 +4,10 @@ import { deploymentStartPresentations, deploymentStopPresentations, } from "./presentation"; -import { - requireDeploymentForService, - resolveServiceReleaseState, -} from "./release"; +import { requireDeploymentForService } from "./release"; import type { ServiceDeploymentRunStateResult } from "./results"; import type { ServiceContext } from "./target"; -import { toServiceSummary } from "./target"; +import { resolveServiceReadState, toServiceSummary } from "./target"; /** * `start` and `stop` are the same command with the direction reversed, @@ -42,6 +39,7 @@ export interface RunStateArgs { deployment: string; service?: string | undefined; project?: string | undefined; + branch?: string | undefined; } export interface RunStateOutcome { @@ -56,9 +54,10 @@ export async function changeDeploymentRunState( verb: RunStateVerb, ): Promise { const spec = VERBS[verb]; - const state = await resolveServiceReleaseState(ctx, { + const state = await resolveServiceReadState(ctx, { ...(args.service !== undefined ? { serviceName: args.service } : {}), ...(args.project !== undefined ? { projectRef: args.project } : {}), + ...(args.branch !== undefined ? { branchName: args.branch } : {}), commandName: `service deployment ${verb}`, }); @@ -66,7 +65,10 @@ export async function changeDeploymentRunState( .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); }); const targetDeployment = requireDeploymentForService( diff --git a/packages/cli/src/commands/service/deployment-start.ts b/packages/cli/src/commands/service/deployment-start.ts index 218da0b0..02b73238 100644 --- a/packages/cli/src/commands/service/deployment-start.ts +++ b/packages/cli/src/commands/service/deployment-start.ts @@ -6,8 +6,8 @@ export const serviceDeploymentStartCommand = defineCommand({ help: { summary: "Start a stopped deployment", examples: [ - "service deployment start dep_123", "service deployment start dep_123 --service my-service", + "service deployment start dep_123 --service my-service --branch feature-x", ], }, args: { @@ -17,6 +17,10 @@ export const serviceDeploymentStartCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), }, positionals: { deployment: positional.string({ @@ -34,6 +38,7 @@ export const serviceDeploymentStartCommand = defineCommand({ deployment: args.positionals.deployment, service: args.flags.service, project: args.flags.project, + branch: args.flags.branch, }, "start", ); diff --git a/packages/cli/src/commands/service/deployment-stop.ts b/packages/cli/src/commands/service/deployment-stop.ts index e979119a..89a6b0bc 100644 --- a/packages/cli/src/commands/service/deployment-stop.ts +++ b/packages/cli/src/commands/service/deployment-stop.ts @@ -6,8 +6,8 @@ export const serviceDeploymentStopCommand = defineCommand({ help: { summary: "Stop a running deployment", examples: [ - "service deployment stop dep_123", "service deployment stop dep_123 --service my-service", + "service deployment stop dep_123 --service my-service --branch feature-x", ], }, args: { @@ -17,6 +17,10 @@ export const serviceDeploymentStopCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), }, positionals: { deployment: positional.string({ @@ -34,6 +38,7 @@ export const serviceDeploymentStopCommand = defineCommand({ deployment: args.positionals.deployment, service: args.flags.service, project: args.flags.project, + branch: args.flags.branch, }, "stop", ); diff --git a/packages/cli/src/commands/service/domain-add.ts b/packages/cli/src/commands/service/domain-add.ts index ca955e3b..21d9e514 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -13,7 +13,7 @@ import { export const serviceDomainAddCommand = defineCommand({ help: { summary: "Register a custom domain on the service's production branch", - examples: ["service domain add shop.acme.com"], + examples: ["service domain add shop.acme.com --service my-service"], }, args: domainTargetArgs(), needs: { credentials: true }, diff --git a/packages/cli/src/commands/service/domain-delete.ts b/packages/cli/src/commands/service/domain-delete.ts index fbbb7ead..008cb7d6 100644 --- a/packages/cli/src/commands/service/domain-delete.ts +++ b/packages/cli/src/commands/service/domain-delete.ts @@ -14,8 +14,8 @@ export const serviceDomainDeleteCommand = defineCommand({ help: { summary: "Delete a custom domain from the service", examples: [ - "service domain delete shop.acme.com", - "service domain delete shop.acme.com --confirm shop.acme.com", + "service domain delete shop.acme.com --service my-service", + "service domain delete shop.acme.com --service my-service --confirm shop.acme.com", ], }, args: domainTargetArgs(), diff --git a/packages/cli/src/commands/service/domain-retry.ts b/packages/cli/src/commands/service/domain-retry.ts index 9a2c41ad..a19cce4c 100644 --- a/packages/cli/src/commands/service/domain-retry.ts +++ b/packages/cli/src/commands/service/domain-retry.ts @@ -14,7 +14,7 @@ import { export const serviceDomainRetryCommand = defineCommand({ help: { summary: "Retry custom domain DNS verification and TLS provisioning", - examples: ["service domain retry shop.acme.com"], + examples: ["service domain retry shop.acme.com --service my-service"], }, args: domainTargetArgs(), needs: { credentials: true }, diff --git a/packages/cli/src/commands/service/domain-show.ts b/packages/cli/src/commands/service/domain-show.ts index e7e85729..424e1b9e 100644 --- a/packages/cli/src/commands/service/domain-show.ts +++ b/packages/cli/src/commands/service/domain-show.ts @@ -14,7 +14,7 @@ import { export const serviceDomainShowCommand = defineCommand({ help: { summary: "Show custom domain status and certificate details", - examples: ["service domain show shop.acme.com"], + examples: ["service domain show shop.acme.com --service my-service"], }, args: domainTargetArgs(), needs: { credentials: true }, diff --git a/packages/cli/src/commands/service/domain-wait.ts b/packages/cli/src/commands/service/domain-wait.ts index 8ed8522b..cf59c3f3 100644 --- a/packages/cli/src/commands/service/domain-wait.ts +++ b/packages/cli/src/commands/service/domain-wait.ts @@ -78,8 +78,8 @@ export const serviceDomainWaitCommand = defineCommand({ help: { summary: "Wait until a custom domain is active or failed", examples: [ - "service domain wait shop.acme.com", - "service domain wait shop.acme.com --timeout 30m", + "service domain wait shop.acme.com --service my-service", + "service domain wait shop.acme.com --service my-service --timeout 30m", ], }, args: { diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index e1b2f733..d5db2d29 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -171,10 +171,16 @@ export function deployFailedError( export function noDeploymentsError( summary: string, why: string, + serviceName: string, ): CliStructuredError { return new CliStructuredError("SERVICE.NO_DEPLOYMENTS", summary, { why, - nextActions: [runCommandAction("Inspect the service", "service show")], + nextActions: [ + runCommandAction( + "Inspect the service", + `service show --service ${serviceName}`, + ), + ], }); } @@ -189,7 +195,7 @@ export function deploymentNotFoundError( nextActions: [ runCommandAction( "Choose an available deployment id", - "service deployment list", + "service deployment list --service ", ), ], }, @@ -263,7 +269,7 @@ export function deploymentNotFoundForServiceError( nextActions: [ runCommandAction( "Choose an available deployment id", - "service deployment list", + `service deployment list --service ${serviceName}`, ), ], }, @@ -291,7 +297,9 @@ export function serviceTargetRequiredError( ); } -export function noPreviousDeploymentError(): CliStructuredError { +export function noPreviousDeploymentError( + serviceName: string, +): CliStructuredError { return new CliStructuredError( "SERVICE.NO_PREVIOUS_DEPLOYMENT", "No previous deployment available for rollback", @@ -301,7 +309,10 @@ export function noPreviousDeploymentError(): CliStructuredError { adviceAction( "Deploy a second version first, or pass --to for a specific earlier deployment.", ), - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${serviceName}`, + ), ], }, ); @@ -309,7 +320,9 @@ export function noPreviousDeploymentError(): CliStructuredError { /** Rolling back without `--to` needs the live deployment: the default * target is defined relative to it. */ -export function liveDeploymentUnknownError(): CliStructuredError { +export function liveDeploymentUnknownError( + serviceName: string, +): CliStructuredError { return new CliStructuredError( "SERVICE.LIVE_DEPLOYMENT_UNKNOWN", "Cannot determine which deployment is currently live", @@ -318,9 +331,12 @@ export function liveDeploymentUnknownError(): CliStructuredError { nextActions: [ runCommandAction( "Roll back to a named deployment", - "service deployment rollback --to ", + `service deployment rollback --to --service ${serviceName}`, + ), + runCommandAction( + "List deployments", + `service deployment list --service ${serviceName}`, ), - runCommandAction("List deployments", "service deployment list"), ], }, ); @@ -329,46 +345,54 @@ export function liveDeploymentUnknownError(): CliStructuredError { export function deleteFailedError( summary: string, cause: unknown, + serviceName: string, ): CliStructuredError { return new CliStructuredError("SERVICE.DELETE_FAILED", summary, { why: cause instanceof Error ? cause.message : String(cause), nextActions: [ - runCommandAction("Inspect the service", "service show"), - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "Inspect the service", + `service show --service ${serviceName}`, + ), + runCommandAction( + "List deployments", + `service deployment list --service ${serviceName}`, + ), ], cause, }); } -/** A blank `--branch` must never fall back to the inferred (possibly - * production) branch. */ +/** A blank `--branch` names no branch and must never fall through to + * the branch the command targets when the flag is omitted. */ export function branchValueEmptyError(): CliStructuredError { return new CliStructuredError( "SERVICE.BRANCH_INVALID", "The --branch value cannot be empty", { - why: "service delete scopes the deletion to the given branch; an empty --branch would silently fall back to the inferred (possibly production) branch.", + why: "The command scopes its work to the given branch; an empty --branch names none, and omitting the flag targets the default branch instead.", nextActions: [ adviceAction( - "Pass a non-empty branch name, or omit --branch to use the inferred branch.", - ), - runCommandAction( - "Delete on a branch", - "service delete --service --branch ", + "Pass a non-empty branch name, or omit --branch to target the default branch.", ), ], }, ); } -export function liveUrlUnavailableError(): CliStructuredError { +export function liveUrlUnavailableError( + serviceName: string, +): CliStructuredError { return new CliStructuredError( "SERVICE.FEATURE_UNAVAILABLE", "Live URL is not available for this service", { why: "Deployments exist, but the provider does not expose a stable live service URL for this service yet.", nextActions: [ - runCommandAction("Inspect the deployment state", "service show"), + runCommandAction( + "Inspect the deployment state", + `service show --service ${serviceName}`, + ), ], }, ); @@ -388,7 +412,7 @@ export function branchNotDeployableError( ), runCommandAction( "Add on production", - "service domain add --branch production", + "service domain add --service --branch production", ), ], }, @@ -408,7 +432,10 @@ export function domainHostnameInvalidError( "Custom domains must be valid hostnames without protocol, path, wildcard, or port.", nextActions: [ adviceAction("Pass a hostname like shop.acme.com."), - runCommandAction("Add a domain", "service domain add shop.acme.com"), + runCommandAction( + "Add a domain", + "service domain add shop.acme.com --service ", + ), ], }, ); @@ -424,7 +451,10 @@ export function domainNotFoundError(hostname: string): CliStructuredError { adviceAction( "Check the hostname and the service, or add the domain first.", ), - runCommandAction("Add the domain", `service domain add ${hostname}`), + runCommandAction( + "Add the domain", + `service domain add ${hostname} --service `, + ), ], }, ); @@ -471,10 +501,13 @@ export function domainVerificationFailedError( why, nextActions: [ ...(guidance ? [adviceAction(renameAppCopy(guidance))] : []), - runCommandAction("Show the domain", `service domain show ${hostname}`), + runCommandAction( + "Show the domain", + `service domain show ${hostname} --service `, + ), runCommandAction( "Retry verification", - `service domain retry ${hostname}`, + `service domain retry ${hostname} --service `, ), ], }, @@ -491,7 +524,10 @@ export function domainVerificationTimeoutError( { why: `The domain is still "${lastStatus}".`, nextActions: [ - runCommandAction("Show the domain", `service domain show ${hostname}`), + runCommandAction( + "Show the domain", + `service domain show ${hostname} --service `, + ), adviceAction("Retry wait with a longer --timeout."), ], }, @@ -507,7 +543,7 @@ export function timeoutInvalidError(value: string): CliStructuredError { nextActions: [ runCommandAction( "Wait with a valid timeout", - "service domain wait shop.acme.com --timeout 15m", + "service domain wait shop.acme.com --service --timeout 15m", ), ], }, @@ -540,7 +576,10 @@ export function domainCommandError( why: error instanceof Error ? error.message : String(error), meta: debugMeta(error), nextActions: [ - runCommandAction("Show the domain", `service domain show ${hostname}`), + runCommandAction( + "Show the domain", + `service domain show ${hostname} --service `, + ), ], cause: error, }, @@ -603,7 +642,10 @@ function domainHostnameRejectedError( adviceAction( "Pass a valid hostname like shop.acme.com and make sure DNS can be verified.", ), - runCommandAction("Add a domain", "service domain add shop.acme.com"), + runCommandAction( + "Add a domain", + "service domain add shop.acme.com --service ", + ), ], }, ); @@ -620,7 +662,10 @@ function domainQuotaExceededError(error: DomainApiError): CliStructuredError { adviceAction( "Delete an existing custom domain before adding another one.", ), - runCommandAction("Delete a domain", "service domain delete "), + runCommandAction( + "Delete a domain", + "service domain delete --service ", + ), ], }, ); @@ -663,7 +708,10 @@ function domainRequiresDeploymentError( adviceAction( "Promote a deployment on the service's production branch, then add the domain again.", ), - runCommandAction("Add the domain", `service domain add ${hostname}`), + runCommandAction( + "Add the domain", + `service domain add ${hostname} --service `, + ), ], }, ); @@ -683,7 +731,10 @@ function domainRetryNotEligibleError( adviceAction( "Wait for the current verification or TLS step to finish, then rerun retry if the domain fails.", ), - runCommandAction("Show the domain", `service domain show ${hostname}`), + runCommandAction( + "Show the domain", + `service domain show ${hostname} --service `, + ), ], }, ); @@ -730,7 +781,7 @@ function domainDnsNotConfiguredError( ), runCommandAction( "Add the domain", - `service domain add ${hostname}`, + `service domain add ${hostname} --service `, ), ] : [ diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index 57e90b7c..b7d8aa77 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -161,13 +161,16 @@ function logStreamFailedError( function listDeployments( ctx: ServiceContext, provider: AppProvider, - serviceId: string, + service: Pick, ) { return provider - .listDeployments(serviceId, { signal: ctx.signal }) + .listDeployments(service.id, { signal: ctx.signal }) .catch((error): never => { throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${service.name}`, + ), ]); }); } @@ -182,7 +185,7 @@ async function resolveDeploymentInService( const deploymentsResult = await listDeployments( ctx, state.provider, - state.service.id, + state.service, ); const deployment = requireDeploymentForService( deploymentsResult.deployments, @@ -205,7 +208,10 @@ async function resolveGlobalDeployment( .showDeployment(deploymentId, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to show deployment", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + "service deployment list --service ", + ), ]); }); if (!shown) { @@ -237,7 +243,7 @@ async function resolveLiveDeployment( const deploymentsResult = await listDeployments( ctx, state.provider, - state.service.id, + state.service, ); const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( deploymentsResult.app, @@ -257,6 +263,7 @@ async function resolveLiveDeployment( throw noDeploymentsError( "No deployments available to read logs from", `The service "${deploymentsResult.app.name}" does not have a live deployment.`, + deploymentsResult.app.name, ); } return { service: deploymentsResult.app, deployment }; @@ -379,13 +386,57 @@ async function followPages( } } +/** + * A globally-unique deployment id is a complete target on its own, so + * `--deployment` with no service target (neither --service nor + * PRISMA_SERVICE_ID) skips service resolution and checks the deployment + * against the resolved project instead. A named service scopes the + * lookup to that service. + */ +async function resolveLogsTarget( + ctx: ServiceContext, + flags: { + service?: string | undefined; + project?: string | undefined; + branch?: string | undefined; + deployment?: string | undefined; + }, +): Promise<{ state: ServiceProjectState; target: LogTarget }> { + const explicitDeploymentId = flags.deployment; + const serviceRequested = requestedServiceTarget(ctx, flags.service) !== null; + const projectOptions = { + ...(flags.project !== undefined ? { projectRef: flags.project } : {}), + ...(flags.branch !== undefined ? { branchName: flags.branch } : {}), + commandName: "service logs", + }; + + if (explicitDeploymentId !== undefined && !serviceRequested) { + const state = await resolveServiceProjectState(ctx, projectOptions); + return { + state, + target: await resolveGlobalDeployment(ctx, state, explicitDeploymentId), + }; + } + const readState = await resolveServiceReadState(ctx, { + ...(flags.service !== undefined ? { serviceName: flags.service } : {}), + ...projectOptions, + }); + return { + state: readState, + target: + explicitDeploymentId !== undefined + ? await resolveDeploymentInService(ctx, readState, explicitDeploymentId) + : await resolveLiveDeployment(ctx, readState), + }; +} + export const serviceLogsCommand = defineSessionCommand({ help: { summary: "Read logs for a deployment of the service", examples: [ - "service logs", - "service logs --tail 500", - "service logs --follow", + "service logs --service my-service", + "service logs --service my-service --tail 500", + "service logs --service my-service --follow", "service logs --deployment dep_123 --from-start", ], }, @@ -396,6 +447,10 @@ export const serviceLogsCommand = defineSessionCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), deployment: flag.string({ brief: "Deployment id to read (default: the live deployment)", placeholder: "id", @@ -420,43 +475,7 @@ export const serviceLogsCommand = defineSessionCommand({ throw logsRangeConflictError(); } - // A globally-unique deployment id is a complete target on its own, - // so `--deployment` with no service target (neither --service nor - // PRISMA_SERVICE_ID) skips service resolution and checks the - // deployment against the resolved project instead. A named service - // scopes the lookup to that service. - const explicitDeploymentId = args.flags.deployment; - const serviceRequested = - requestedServiceTarget(ctx, args.flags.service) !== null; - const projectOptions = { - ...(args.flags.project !== undefined - ? { projectRef: args.flags.project } - : {}), - commandName: "service logs", - }; - - let state: ServiceProjectState; - let target: LogTarget; - if (explicitDeploymentId !== undefined && !serviceRequested) { - state = await resolveServiceProjectState(ctx, projectOptions); - target = await resolveGlobalDeployment(ctx, state, explicitDeploymentId); - } else { - const readState = await resolveServiceReadState(ctx, { - ...(args.flags.service !== undefined - ? { serviceName: args.flags.service } - : {}), - ...projectOptions, - }); - state = readState; - target = - explicitDeploymentId !== undefined - ? await resolveDeploymentInService( - ctx, - readState, - explicitDeploymentId, - ) - : await resolveLiveDeployment(ctx, readState); - } + const { state, target } = await resolveLogsTarget(ctx, args.flags); const deploymentId = target.deployment.id; for (const line of [ diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index 1ec277a5..3496bac7 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -19,7 +19,10 @@ import { export const serviceOpenCommand = defineCommand({ help: { summary: "Open the service's live URL", - examples: ["service open", "service open --service my-service"], + examples: [ + "service open --service my-service", + "service open --service my-service --branch feature-x", + ], }, args: { flags: { @@ -31,6 +34,10 @@ export const serviceOpenCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), }, }, needs: { credentials: true }, @@ -38,6 +45,7 @@ export const serviceOpenCommand = defineCommand({ const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, + branchName: args.flags.branch, commandName: "service open", }); @@ -45,7 +53,10 @@ export const serviceOpenCommand = defineCommand({ .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to resolve service URL", error, [ - runCommandAction("Inspect the service", "service show"), + runCommandAction( + "Inspect the service", + `service show --service ${state.service.name}`, + ), ]); }); const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( @@ -68,10 +79,11 @@ export const serviceOpenCommand = defineCommand({ throw noDeploymentsError( "No deployments available to open", `The service "${deploymentsResult.app.name}" does not have any deployments yet.`, + deploymentsResult.app.name, ); } if (!deploymentsResult.app.liveUrl) { - throw liveUrlUnavailableError(); + throw liveUrlUnavailableError(deploymentsResult.app.name); } const url = deploymentsResult.app.liveUrl; diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index fcc65249..3804ba04 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -186,7 +186,12 @@ export function createPresentations( export function showPresentations(result: ServiceShowResult): Presentations { const next: NextAction[] = []; if (result.liveUrl) { - next.push(runCommandAction("Open the live URL", "service open")); + next.push( + runCommandAction( + "Open the live URL", + `service open --service ${result.service.name}`, + ), + ); } const inspectable = result.liveDeployment ?? result.recentDeployments[0]; if (inspectable) { @@ -309,7 +314,10 @@ export function openPresentations( ], stdout: () => [result.url], next: () => [ - runCommandAction("Inspect the service", "service show"), + runCommandAction( + "Inspect the service", + `service show --service ${result.service.name}`, + ), runCommandAction( "Show the live deployment", `service deployment show ${liveDeploymentId}`, @@ -318,9 +326,15 @@ export function openPresentations( }; } -function deploymentNextActions(deploymentId: string): NextAction[] { +function deploymentNextActions( + deploymentId: string, + serviceName: string, +): NextAction[] { return [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${serviceName}`, + ), runCommandAction( "Show the deployment", `service deployment show ${deploymentId}`, @@ -351,7 +365,8 @@ export function promotePresentations( : []), ]), ], - next: () => deploymentNextActions(result.deployment.id), + next: () => + deploymentNextActions(result.deployment.id, result.service.name), }; } @@ -382,7 +397,8 @@ export function rollbackPresentations( : []), ]), ], - next: () => deploymentNextActions(result.deployment.id), + next: () => + deploymentNextActions(result.deployment.id, result.service.name), }; } @@ -408,7 +424,8 @@ export function deploymentStartPresentations( : []), ]), ], - next: () => deploymentNextActions(result.deployment.id), + next: () => + deploymentNextActions(result.deployment.id, result.service.name), }; } @@ -431,7 +448,8 @@ export function deploymentStopPresentations( { label: "status", value: result.deployment.status }, ]), ], - next: () => deploymentNextActions(result.deployment.id), + next: () => + deploymentNextActions(result.deployment.id, result.service.name), }; } @@ -451,7 +469,10 @@ export function deploymentDeletePresentations( ]), ], next: () => [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${result.service.name}`, + ), ], }; } @@ -472,9 +493,8 @@ export function deletePresentations( { label: "deleted", value: "yes" }, ]), ], - next: () => [ - runCommandAction("List deployments", "service deployment list"), - ], + // The service is gone, so nothing service-scoped can run next. + next: () => [runCommandAction("List remaining services", "service list")], }; } diff --git a/packages/cli/src/commands/service/release.ts b/packages/cli/src/commands/service/release.ts index 7155e9a2..517aa801 100644 --- a/packages/cli/src/commands/service/release.ts +++ b/packages/cli/src/commands/service/release.ts @@ -1,53 +1,11 @@ import type { DestroyAppProgress, PromoteProgress } from "@prisma/compute-sdk"; -import type { - AppProvider, - AppRecord, - DeploymentRecord, -} from "../../lib/app/app-provider"; +import type { DeploymentRecord } from "../../lib/app/app-provider"; import { deploymentNotFoundForServiceError, liveDeploymentUnknownError, noPreviousDeploymentError, } from "./errors"; import type { ServiceContext } from "./target"; -import { resolveServiceReadState } from "./target"; - -export interface ServiceReleaseState { - provider: AppProvider; - projectId: string; - service: AppRecord; -} - -/** The read flow for the commands that act on a service which must - * already exist: every `service deployment` verb, plus `service - * delete`, which is the one that does not sit under that group. */ -export async function resolveServiceReleaseState( - ctx: ServiceContext, - options: { - serviceName?: string; - projectRef?: string; - branchName?: string; - commandName: string; - }, -): Promise { - const state = await resolveServiceReadState(ctx, { - ...(options.serviceName !== undefined - ? { serviceName: options.serviceName } - : {}), - ...(options.projectRef !== undefined - ? { projectRef: options.projectRef } - : {}), - ...(options.branchName !== undefined - ? { branchName: options.branchName } - : {}), - commandName: options.commandName, - }); - return { - provider: state.provider, - projectId: state.projectId, - service: state.service, - }; -} export function requireDeploymentForService( deployments: DeploymentRecord[], @@ -70,18 +28,19 @@ export function requireDeploymentForService( export function resolveRollbackTarget( deployments: DeploymentRecord[], currentLiveDeploymentId: string | null, + serviceName: string, ): DeploymentRecord { if (deployments.length === 0) { - throw noPreviousDeploymentError(); + throw noPreviousDeploymentError(serviceName); } if (currentLiveDeploymentId === null) { - throw liveDeploymentUnknownError(); + throw liveDeploymentUnknownError(serviceName); } const previousDeployment = deployments.find( (deployment) => deployment.id !== currentLiveDeploymentId, ); if (!previousDeployment) { - throw noPreviousDeploymentError(); + throw noPreviousDeploymentError(serviceName); } return previousDeployment; } diff --git a/packages/cli/src/commands/service/results.ts b/packages/cli/src/commands/service/results.ts index bb32dda3..d41b2b9b 100644 --- a/packages/cli/src/commands/service/results.ts +++ b/packages/cli/src/commands/service/results.ts @@ -1,4 +1,3 @@ -import type { AppDomainDnsRecord, AppDomainStatus } from "../../types/app"; import type { AuthWorkspace } from "../../types/auth"; import type { BranchKind } from "../../types/branch"; import type { ProjectSummary } from "../../types/project"; @@ -102,20 +101,36 @@ export interface ServiceDeleteResult { deleted: true; } +export type ServiceDomainStatus = + | "pending_dns" + | "verifying" + | "verified_routing_blocked" + | "provisioning_tls" + | "active" + | "failed" + | "removing"; + +export interface ServiceDomainDnsRecord { + type: string; + name: string; + value: string; + ttl: number | null; +} + export interface ServiceDomainSummary { id: string; type: "custom-domain"; url: string; hostname: string; serviceId: string; - status: AppDomainStatus; + status: ServiceDomainStatus; foundryStatus: string; failureReason: string | null; failureCategory: "dns" | "acme" | "storage" | "unknown" | null; certExpiresAt: string | null; createdAt: string; updatedAt: string; - dnsRecords: AppDomainDnsRecord[]; + dnsRecords: ServiceDomainDnsRecord[]; } export interface ServiceDomainTarget { @@ -148,6 +163,6 @@ export interface ServiceDomainRetryResult extends ServiceDomainTarget { export interface ServiceDomainWaitResult extends ServiceDomainTarget { hostname: string; - status: AppDomainStatus; + status: ServiceDomainStatus; liveUrl: string; } diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index fbd9e0e1..ef533fcf 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -14,7 +14,10 @@ import { export const serviceShowCommand = defineCommand({ help: { summary: "Show the service and its current deployment", - examples: ["service show", "service show --service my-service"], + examples: [ + "service show --service my-service", + "service show --service my-service --branch feature-x", + ], }, args: { flags: { @@ -26,6 +29,10 @@ export const serviceShowCommand = defineCommand({ brief: "Project id or name", placeholder: "id-or-name", }), + branch: flag.string({ + brief: "Branch the service lives on (default: the default branch)", + placeholder: "name", + }), }, }, needs: { credentials: true }, @@ -33,6 +40,7 @@ export const serviceShowCommand = defineCommand({ const state = await resolveServiceReadState(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, + branchName: args.flags.branch, commandName: "service show", }); @@ -40,7 +48,10 @@ export const serviceShowCommand = defineCommand({ .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { throw deployFailedError("Failed to inspect service", error, [ - runCommandAction("List deployments", "service deployment list"), + runCommandAction( + "List deployments", + `service deployment list --service ${state.service.name}`, + ), ]); }); const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index ef3a9c40..7e9a90fd 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -1,5 +1,4 @@ import type { CommandContext } from "@prisma/cli-engine"; -import { LocalStateStore } from "../../adapters/local-state"; import { type AppProvider, type AppRecord, @@ -15,12 +14,12 @@ import { resolveProjectTarget, sortProjects, } from "../../lib/project/resolution"; -import { resolveStateDir } from "../../state-dir"; import type { AuthWorkspace } from "../../types/auth"; import type { BranchKind } from "../../types/branch"; import type { ProjectResolution, ProjectSummary } from "../../types/project"; import { branchNotDeployableError, + branchValueEmptyError, deployFailedError, domainCommandError, domainHostnameInvalidError, @@ -71,11 +70,6 @@ export interface ResolvedServiceProjectContext { resolution: ProjectResolution; } -export function openServiceStateStore(ctx: ServiceContext): LocalStateStore { - const stateDir = resolveStateDir({ env: ctx.env, cwd: ctx.cwd }); - return new LocalStateStore(stateDir, ctx.signal); -} - /** The workspace the run is acting as, from the credential the engine * is authenticating with. A workspace with no name shows its id * instead: the id is the only other identifier the user can act on, @@ -167,6 +161,14 @@ async function listWorkspaceProjects( ); } +/** A blank `--branch` names no branch and must never fall through to + * the default-branch behavior of omitting the flag. */ +function requireBranchFlagValue(branchName: string | undefined): void { + if (branchName !== undefined && branchName.trim() === "") { + throw branchValueEmptyError(); + } +} + export async function resolveServiceProjectContext( ctx: ServiceContext, explicitProject: string | undefined, @@ -176,6 +178,7 @@ export async function resolveServiceProjectContext( envProjectId?: string; }, ): Promise { + requireBranchFlagValue(options.branchName); const workspace = await requireWorkspace(ctx); // Listed here rather than from inside `resolveProjectTarget`, which // runs its body in a Result generator: a throw in the callback comes @@ -538,7 +541,8 @@ export async function resolveServiceDomainTarget( commandName: string; }, ): Promise { - const branchName = options.branchName?.trim() || "production"; + requireBranchFlagValue(options.branchName); + const branchName = options.branchName?.trim() ?? "production"; if (toBranchKind(branchName) !== "production") { throw branchNotDeployableError(branchName); } diff --git a/packages/cli/src/lib/project/provider.ts b/packages/cli/src/lib/project/provider.ts index 9008feb9..44e71b76 100644 --- a/packages/cli/src/lib/project/provider.ts +++ b/packages/cli/src/lib/project/provider.ts @@ -142,7 +142,9 @@ export function projectDeleteBlockedError( `Project "${projectId}" still has active deployments.`, fix: "Delete the project's services first, then retry the deletion.", exitCode: 1, - nextSteps: [formatPrismaCliCommand(["service", "delete", ""])], + nextSteps: [ + formatPrismaCliCommand(["service", "delete", "--service", ""]), + ], }); } diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts deleted file mode 100644 index 6ebfc8f6..00000000 --- a/packages/cli/src/types/app.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { AuthWorkspace } from "./auth"; -import type { BranchKind } from "./branch"; -import type { ProjectResolution, ProjectSummary } from "./project"; - -export interface AppSummary { - id: string; - name: string; -} - -export interface AppDeploymentSummary { - id: string; - status: string; - url: string | null; - createdAt: string; - live: boolean | null; -} - -export interface AppResolvedContext { - workspace: AuthWorkspace; - project: ProjectSummary; - branch: { - id: string | null; - name: string; - kind: BranchKind; - }; - resolution: ProjectResolution; -} - -export interface AppDeploySettings { - config: { - /** The compute config path when it owns the build settings. */ - path: string | null; - status: "config" | "inferred"; - }; - buildCommand: { - value: string | null; - source: string | null; - }; - outputDirectory: { - value: string; - source: string | null; - }; - framework: { - key: string; - buildType: AppBuildResult["buildType"]; - name: string; - source: string; - }; - entrypoint: string | null; - httpPort: number; - region: string | null; - /** Annotation from the deploy input that produced region (e.g. "set by --region"). Null when the server assigned the region with no explicit input. */ - regionSource: string | null; - envVars: string[]; -} - -export interface AppDeployResult { - workspace: AuthWorkspace; - project: ProjectSummary; - branch: { - id: string | null; - name: string; - kind: BranchKind; - }; - resolution: ProjectResolution; - branchDatabase?: { - status: "created" | "skipped"; - reason?: string; - database?: { - id: string; - name: string; - }; - envVars: string[]; - }; - app: AppSummary; - deployment: { - id: string; - status: string; - url: string | null; - live: boolean; - }; - /** Whether the new deployment was promoted to live. False for --no-promote. */ - promoted: boolean; - deploySettings: AppDeploySettings; - durationMs: number; - localPin?: { - path: string; - written: boolean; - }; -} - -export interface AppDeployAllResult { - /** Aggregate of one full deploy per config target, in declaration order. */ - deployments: Array<{ - target: string; - result: AppDeployResult; - }>; -} - -export interface AppListDeploysResult { - projectId: string; - verboseContext?: AppResolvedContext; - app: AppSummary | null; - deployments: AppDeploymentSummary[]; -} - -export interface AppShowResult { - projectId: string; - verboseContext?: AppResolvedContext; - app: AppSummary | null; - liveDeployment: AppDeploymentSummary | null; - liveUrl: string | null; - recentDeployments: AppDeploymentSummary[]; -} - -export interface AppBuildResult { - directory: string; - entrypoint: string | null; - buildType: - | "bun" - | "nextjs" - | "nuxt" - | "astro" - | "nestjs" - | "tanstack-start" - | "custom"; -} - -export interface AppShowDeployResult { - app: AppSummary | null; - deployment: AppDeploymentSummary; -} - -export interface AppOpenResult { - projectId: string; - verboseContext?: AppResolvedContext; - app: AppSummary; - url: string; - opened: boolean; -} - -export interface AppRunResult { - framework: "bun" | "nextjs"; - entrypoint: string | null; - port: number; - command: string; -} - -export interface AppPromoteResult { - projectId: string; - verboseContext?: AppResolvedContext; - app: AppSummary; - deployment: AppDeploymentSummary; -} - -export interface AppRollbackResult { - projectId: string; - verboseContext?: AppResolvedContext; - app: AppSummary; - deployment: AppDeploymentSummary; - previousLiveDeploymentId: string | null; -} - -export type AppDomainStatus = - | "pending_dns" - | "verifying" - | "verified_routing_blocked" - | "provisioning_tls" - | "active" - | "failed" - | "removing"; - -export type AppDomainFailureCategory = - | "dns" - | "acme" - | "storage" - | "unknown" - | null; - -export interface AppDomainDnsRecord { - type: string; - name: string; - value: string; - ttl: number | null; -} - -export interface AppDomainSummary { - id: string; - type: "custom-domain"; - url: string; - hostname: string; - appId: string; - status: AppDomainStatus; - foundryStatus: string; - failureReason: string | null; - failureCategory: AppDomainFailureCategory; - certExpiresAt: string | null; - createdAt: string; - updatedAt: string; - dnsRecords: AppDomainDnsRecord[]; -} - -export interface AppDomainTarget { - workspace: AuthWorkspace; - project: ProjectSummary; - branch: { - name: string; - kind: BranchKind; - }; - app: AppSummary; -} - -export interface AppDomainAddResult extends AppDomainTarget { - domain: AppDomainSummary; - existing: boolean; -} - -export interface AppDomainShowResult extends AppDomainTarget { - domain: AppDomainSummary; -} - -export interface AppDomainRetryResult extends AppDomainTarget { - domain: AppDomainSummary; -} diff --git a/packages/cli/tests/app-state.test.ts b/packages/cli/tests/app-state.test.ts index 2e11e54c..e6607959 100644 --- a/packages/cli/tests/app-state.test.ts +++ b/packages/cli/tests/app-state.test.ts @@ -1,4 +1,3 @@ -import { readFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -20,53 +19,4 @@ describe("app local state", () => { await expect(store.read()).rejects.toBe(reason); }); - - it("persists known live deployments by project and app id", async () => { - const cwd = await createTempCwd(); - const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME)); - - await store.setKnownLiveDeployment("proj_123", "app_123", "dep_123"); - await store.setKnownLiveDeployment("proj_123", "app_456", "dep_456"); - - expect( - JSON.parse( - await readFile( - path.join(cwd, DEFAULT_STATE_DIR_NAME, "state.json"), - "utf8", - ), - ), - ).toMatchObject({ - app: { - knownLiveDeploymentByProject: { - proj_123: { - app_123: "dep_123", - app_456: "dep_456", - }, - }, - }, - }); - await expect( - store.readKnownLiveDeployment("proj_123", "app_123"), - ).resolves.toBe("dep_123"); - await expect( - store.readKnownLiveDeployment("proj_123", "app_456"), - ).resolves.toBe("dep_456"); - }); - - it("clears known live deployment only for the deleted app", async () => { - const cwd = await createTempCwd(); - const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME)); - - await store.setKnownLiveDeployment("proj_123", "app_123", "dep_123"); - await store.setKnownLiveDeployment("proj_123", "app_456", "dep_456"); - - await store.clearKnownLiveDeployment("proj_123", "app_123"); - - await expect( - store.readKnownLiveDeployment("proj_123", "app_123"), - ).resolves.toBeNull(); - await expect( - store.readKnownLiveDeployment("proj_123", "app_456"), - ).resolves.toBe("dep_456"); - }); }); diff --git a/packages/cli/tests/service-delete.test.ts b/packages/cli/tests/service-delete.test.ts index 1c3c67b2..5531bd37 100644 --- a/packages/cli/tests/service-delete.test.ts +++ b/packages/cli/tests/service-delete.test.ts @@ -1,5 +1,3 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { @@ -43,13 +41,13 @@ describe("prisma-cli service delete", () => { status: "ok", text: "Deleted hello-world and every deployment it owned.", }); - // A deletion used to offer `service deploy`; the binary has no such - // command, so listing deployments is all that is left to suggest. + // The service is gone, so nothing service-scoped can run next; + // listing what remains is all that is left to suggest. expect(result.presented?.presentation.next).toEqual([ { kind: "run-command", - label: "List deployments", - command: "prisma-cli service deployment list", + label: "List remaining services", + command: "prisma-cli service list", }, ]); }); @@ -99,47 +97,6 @@ describe("prisma-cli service delete", () => { }); }); - it("clears the known live deployment from local state", async () => { - const harness = await makeServiceCli({ routes: releaseRoutes() }); - - // Nothing in the service family writes this key any more, but the - // legacy `app` family still does for the same project, so clearing - // it on deletion has real effect until that family retires. Seeded - // here so the assertion below observes a key that was present. - const statePath = path.join(harness.stateDir, "state.json"); - await mkdir(path.dirname(statePath), { recursive: true }); - await writeFile( - statePath, - JSON.stringify({ - app: { knownLiveDeploymentByProject: { proj_1: { svc_1: "dep_2" } } }, - }), - ); - - await harness.cli.run( - [ - "service", - "delete", - "--project", - "acme-app", - "--service", - "hello-world", - ], - { - cwd: harness.cwd, - env: harness.env, - isTty: INTERACTIVE, - answers: ["hello-world"], - }, - ); - - const state = JSON.parse( - await readFile(path.join(harness.stateDir, "state.json"), "utf8"), - ); - expect( - state.app?.knownLiveDeploymentByProject?.proj_1?.svc_1, - ).toBeUndefined(); - }); - it("emits the completed json envelope with commandId service.delete", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); diff --git a/packages/cli/tests/service-deployment-rollback.test.ts b/packages/cli/tests/service-deployment-rollback.test.ts index 04621af4..50fe81d2 100644 --- a/packages/cli/tests/service-deployment-rollback.test.ts +++ b/packages/cli/tests/service-deployment-rollback.test.ts @@ -397,12 +397,13 @@ describe("prisma-cli service deployment rollback", () => { { kind: "run-command", label: "Roll back to a named deployment", - command: "prisma-cli service deployment rollback --to ", + command: + "prisma-cli service deployment rollback --to --service hello-world", }, { kind: "run-command", label: "List deployments", - command: "prisma-cli service deployment list", + command: "prisma-cli service deployment list --service hello-world", }, ]); }); @@ -479,7 +480,7 @@ describe("prisma-cli service deployment rollback", () => { { kind: "run-command", label: "List deployments", - command: "prisma-cli service deployment list", + command: "prisma-cli service deployment list --service hello-world", }, ]); }); diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index 002e5f56..f4dd14a8 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -183,7 +183,7 @@ describe("prisma-cli service domain add", () => { { kind: "run-command", label: "Add the domain", - command: "prisma-cli service domain add shop.acme.com", + command: "prisma-cli service domain add shop.acme.com --service ", }, ]); }); diff --git a/packages/cli/tests/service-open.test.ts b/packages/cli/tests/service-open.test.ts index abf17edb..d6769b0c 100644 --- a/packages/cli/tests/service-open.test.ts +++ b/packages/cli/tests/service-open.test.ts @@ -41,7 +41,7 @@ describe("prisma-cli service open", () => { { kind: "run-command", label: "Inspect the service", - command: "prisma-cli service show", + command: "prisma-cli service show --service hello-world", }, { kind: "run-command", @@ -136,7 +136,7 @@ describe("prisma-cli service open", () => { { kind: "run-command", label: "Inspect the service", - command: "prisma-cli service show", + command: "prisma-cli service show --service hello-world", }, ]); }); diff --git a/packages/cli/tests/service-show.test.ts b/packages/cli/tests/service-show.test.ts index 3438b00f..60c45541 100644 --- a/packages/cli/tests/service-show.test.ts +++ b/packages/cli/tests/service-show.test.ts @@ -49,6 +49,35 @@ describe("prisma-cli service show", () => { }); }); + it("scopes the service lookup to the requested branch", async () => { + const branches: string[] = []; + const harness = await makeServiceCli({ + routes: readFlowRoutes({ + "GET /v1/apps": (init) => { + branches.push(init.params?.query?.branchGitName as string); + return { data: page([SERVICE]) }; + }, + }), + }); + + const result = await harness.cli.run( + [ + "service", + "show", + "--project", + "acme-app", + "--service", + "hello-world", + "--branch", + "staging", + ], + { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, + ); + + expect(result.exitCode).toBe(0); + expect(branches).toEqual(["staging"]); + }); + it("presents no live url while the service has no live deployment", async () => { const neverPromoted = { id: "svc_1", diff --git a/packages/prisma/README.md b/packages/prisma/README.md index 8d6310d1..b403ceb2 100644 --- a/packages/prisma/README.md +++ b/packages/prisma/README.md @@ -38,8 +38,7 @@ npx prisma project create my-app npx prisma git connect git@github.com:owner/repo.git ``` -Deployments start from pushing the connected repository, the Console, or -`prisma deploy`. +Deployments start from pushing the connected repository, the Console, or `prisma deploy`. With `pnpm`: From b5ae8f9684690d6bc96d0e202533c75cf0d9e437 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 14:37:02 +0200 Subject: [PATCH 21/27] Subjects are positional: name the service as an argument, target deployments by id alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling on the review round: any command that operates on a subject resource takes that resource's identifier as its first positional argument — the established CLI convention — and flags only scope or qualify. Recorded as "Subjects are positional" in docs/product/command-principles.md and as a dated amendment in the slice spec. - service show/open/logs/delete and service deployment list/rollback take the service name as an optional positional (PRISMA_SERVICE_ID stays as the env fallback; neither present is still the SERVICE.TARGET_REQUIRED refusal, its copy respelled). - service deployment promote/start/stop/delete and service logs --deployment are targeted by the globally-unique deployment id alone, resolved through the same global lookup service deployment show always used (a shared resolveDeploymentSubject). They take no --service, --project, or --branch, and their results drop projectId, matching deployment show. SERVICE.DEPLOYMENT_OUTSIDE_PROJECT is gone with the project check it belonged to. - project show takes the project as an optional positional instead of --project, with a retryCommand override so the shared project-setup hint stops suggesting a flag the command no longer has. - Domain commands keep --service as a scope flag: their positional is the hostname, and the management API has no global hostname lookup. The API's missing deployment-to-service lookup (each id-targeted run pays a project-by-project scan) is recorded in the deferred ledger. - Help examples, error hints, unit tests, and the e2e suite follow the new grammar. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 3 +- .../specs/command-grammar-cleanup.md | 4 + docs/product/command-principles.md | 4 + packages/cli/e2e/deployed-service.ts | 20 +--- packages/cli/e2e/project-read.e2e.ts | 2 +- packages/cli/e2e/service-deployment.e2e.ts | 17 +-- packages/cli/e2e/service.e2e.ts | 8 +- packages/cli/src/commands/project/show.ts | 19 +-- packages/cli/src/commands/service/delete.ts | 15 ++- .../src/commands/service/deployment-delete.ts | 57 ++------- .../src/commands/service/deployment-list.ts | 18 +-- .../commands/service/deployment-promote.ts | 72 +++--------- .../commands/service/deployment-rollback.ts | 21 ++-- .../commands/service/deployment-run-state.ts | 57 +++------ .../src/commands/service/deployment-start.ts | 29 +---- .../src/commands/service/deployment-stop.ts | 29 +---- packages/cli/src/commands/service/errors.ts | 54 +++------ packages/cli/src/commands/service/logs.ts | 111 ++++++------------ packages/cli/src/commands/service/open.ts | 20 ++-- .../cli/src/commands/service/presentation.ts | 21 +--- packages/cli/src/commands/service/results.ts | 9 +- packages/cli/src/commands/service/show.ts | 20 ++-- packages/cli/src/commands/service/target.ts | 43 +++++-- packages/cli/src/lib/project/resolution.ts | 11 +- packages/cli/tests/project.test.ts | 6 +- packages/cli/tests/service-delete.test.ts | 68 ++--------- .../tests/service-deployment-delete.test.ts | 25 +--- .../cli/tests/service-deployment-list.test.ts | 24 +--- .../tests/service-deployment-promote.test.ts | 85 +++----------- .../tests/service-deployment-rollback.test.ts | 25 +--- .../tests/service-deployment-start.test.ts | 19 ++- .../cli/tests/service-deployment-stop.test.ts | 19 ++- packages/cli/tests/service-domain.test.ts | 6 +- packages/cli/tests/service-logs.test.ts | 43 ++----- packages/cli/tests/service-open.test.ts | 40 ++----- packages/cli/tests/service-session.test.ts | 34 +----- packages/cli/tests/service-show.test.ts | 47 ++------ 37 files changed, 330 insertions(+), 775 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 41f3afcf..6e80875b 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -450,4 +450,5 @@ The cleanup PR removed the compute config and `init`, made service commands para - **Upstream family cleanups.** The shell now wraps both external families: composer still ships `destroy`/`log` commands (and their help) that nothing mounts, and orm-toolchain still keys its family `ref *` and ships the `migration ref` → `ref` redirect the wrapper drops, plus a `migration apply` replacement that says `migrate`. Each repo should retire those surfaces so the wrapper shrinks to a pass-through. - **`PRISMA_PROJECT_ID` is honoured only by the domain commands** (pre-existing): the other service commands take `--project` and the link file but not the env var. Unify or document. - **orm-toolchain's shipped help examples name retired spellings.** Six commands' shipped examples start with the family's own key — `format`, `migrate`, `ref list|set|delete`, and `init` — which the mounts respell to `contract format`, `db migrate`, `migration ref *` and `orm init`. The shell wrapper rewrites the examples (D4-1 ruling) until orm-toolchain updates its own. -- **The deployment-id targeting asymmetry is undocumented.** `service deployment delete|promote|rollback|start|stop` require `--service` even when given a globally-unique deployment id, while `service logs --deployment` without a service target resolves the id globally within the project. Deliberate (per the cleanup plan), but no artifact explains it to a user who meets it. Document or unify. +- ~~**The deployment-id targeting asymmetry is undocumented.**~~ Closed on the PR branch (2026-08-21): every deployment-id command (`promote|start|stop|delete|show`, `logs --deployment`) now resolves the id globally with no service parameter, per the "Subjects are positional" ruling. +- **The management API has no direct deployment→service lookup.** `showDeployment` finds the owning service via `findAppForDeployment`, a scan of every project's service list and each service's deployments. Every id-targeted command pays that scan per run. An API endpoint answering "which app owns deployment X" would collapse it to one call. diff --git a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md index c05b9c6f..da08f3b8 100644 --- a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md +++ b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md @@ -109,3 +109,7 @@ feedback - Brief: operator message 2026-08-21 (this spec's source of scope). - `packages/cli/src/cli.ts` (mount table), `packages/cli/tests/mount-coverage.test.ts` (grammar check), `packages/cli/src/commands/service/target.ts` (ambient context), `packages/cli/node_modules/@prisma/orm-toolchain/dist/cli.mjs` (shipped redirect table), `@prisma/composer-cli/dist/family.mjs` (composer family shape). + +## Amendment (2026-08-21, operator ruling on PR #218) + +§2's `--service ` targeting is superseded: any command that operates on a subject resource takes that resource's identifier as its first positional argument (recorded as "Subjects are positional" in `docs/product/command-principles.md`). Concretely: `service show|open|logs|delete ` and `service deployment list|rollback ` take the service name as an optional positional (PRISMA_SERVICE_ID stays as the env fallback; neither present is still the SERVICE.TARGET_REQUIRED refusal). `service deployment promote|start|stop|delete ` and `service logs --deployment ` are targeted by the globally-unique deployment id alone, resolved the way `service deployment show` always was — they take no `--service`, `--project`, or `--branch`, and their results carry no `projectId`. `project show [id-or-name]` follows the same rule. Domain commands keep `--service` as a scope flag: their positional is the hostname, and the management API has no global hostname lookup. diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 3db182c7..ee8c5b27 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -101,6 +101,10 @@ Resolve a deployment and show or stream its logs. `delete` destroys a resource; `remove` detaches one thing from another without destroying it. A command that permanently destroys what it targets is spelled `delete` (`project delete`, `service delete`, `postgres delete`); `remove` is reserved for detachment. +### Subjects are positional + +A command that operates on a subject resource takes that resource's identifier as its first positional argument (`service show my-api`, `postgres delete db_123`, `service deployment promote dep_123`) — the established convention across CLIs. Flags never name the subject; they scope or qualify it (`--project`, `--branch`, `--role`). When the subject's identifier is globally unique — a deployment id, a bucket id — the id alone is the complete target, and the command asks for no redundant parent scope. + ### `wait` Block until a remote resource reaches a terminal state. diff --git a/packages/cli/e2e/deployed-service.ts b/packages/cli/e2e/deployed-service.ts index 8230c794..f1da62df 100644 --- a/packages/cli/e2e/deployed-service.ts +++ b/packages/cli/e2e/deployed-service.ts @@ -148,22 +148,8 @@ export async function deployService( // failure would strand the whole scratch project, not just this // service. try { - await cli.run([ - "service", - "deployment", - "start", - deploymentId, - "--service", - serviceName, - ]); - await cli.run([ - "service", - "deployment", - "promote", - deploymentId, - "--service", - serviceName, - ]); + await cli.run(["service", "deployment", "start", deploymentId]); + await cli.run(["service", "deployment", "promote", deploymentId]); } catch (failure) { await deleteDeployment(cli, { id: deploymentId, serviceName }); throw failure; @@ -192,8 +178,6 @@ export async function deleteDeployment( "deployment", "delete", deployment.id, - "--service", - deployment.serviceName, "--confirm", deployment.id, ], diff --git a/packages/cli/e2e/project-read.e2e.ts b/packages/cli/e2e/project-read.e2e.ts index 218fc61d..41d295cd 100644 --- a/packages/cli/e2e/project-read.e2e.ts +++ b/packages/cli/e2e/project-read.e2e.ts @@ -41,7 +41,7 @@ describeCommand("project show", () => { it("shows a project resolved by id, and says how it resolved it", async () => { const target = scratch.project(); - const run = await scratch.run(["project", "show", "--project", target.id]); + const run = await scratch.run(["project", "show", target.id]); const shown = run.envelope.result as { readonly project: { readonly id: string; readonly name: string }; readonly resolution: { readonly projectSource: string }; diff --git a/packages/cli/e2e/service-deployment.e2e.ts b/packages/cli/e2e/service-deployment.e2e.ts index b1206b0e..b5a40573 100644 --- a/packages/cli/e2e/service-deployment.e2e.ts +++ b/packages/cli/e2e/service-deployment.e2e.ts @@ -84,8 +84,6 @@ describeCommand("service deployment start", () => { "deployment", "start", existing.deploymentId, - "--service", - existing.serviceName, ]); const started = run.envelope.result as { readonly deployment: DeploymentRow; @@ -105,7 +103,6 @@ describeCommand("service deployment list", () => { "service", "deployment", "list", - "--service", existing.serviceName, ]); const listed = run.envelope.result as { @@ -151,12 +148,7 @@ describeCommand("service open", () => { // No browser and no TTY in CI, so the command reports the URL it // would have opened. That it declined to open is part of the // contract, not an incidental detail. - const run = await scratch.run([ - "service", - "open", - "--service", - existing.serviceName, - ]); + const run = await scratch.run(["service", "open", existing.serviceName]); const opened = run.envelope.result as { readonly service: { readonly id: string }; readonly url: string; @@ -177,8 +169,6 @@ describeCommand("service deployment stop", () => { "deployment", "stop", existing.deploymentId, - "--service", - existing.serviceName, ]); const stopped = run.envelope.result as { readonly deployment: DeploymentRow; @@ -201,18 +191,14 @@ describeCommand("service deployment delete", () => { "deployment", "delete", existing.deploymentId, - "--service", - existing.serviceName, "--confirm", existing.deploymentId, ]); const removed = run.envelope.result as { - readonly projectId: string; readonly deploymentId: string; readonly deleted: boolean; }; - expect(removed.projectId).toBe(scratch.project().id); expect(removed.deploymentId).toBe(existing.deploymentId); expect(removed.deleted).toBe(true); // Teardown has nothing left to remove. @@ -222,7 +208,6 @@ describeCommand("service deployment delete", () => { "service", "deployment", "list", - "--service", existing.serviceName, ]); const remaining = after.envelope.result as { diff --git a/packages/cli/e2e/service.e2e.ts b/packages/cli/e2e/service.e2e.ts index 935d4d70..4fa05e79 100644 --- a/packages/cli/e2e/service.e2e.ts +++ b/packages/cli/e2e/service.e2e.ts @@ -98,12 +98,7 @@ describeCommand("service list", () => { describeCommand("service show", () => { it("shows the created service, with nothing deployed to it", async () => { const existing = requireService(); - const run = await scratch.run([ - "service", - "show", - "--service", - existing.name, - ]); + const run = await scratch.run(["service", "show", existing.name]); const shown = run.envelope.result as { readonly projectId: string; readonly service: { readonly id: string; readonly name: string }; @@ -130,7 +125,6 @@ describeCommand("service delete", () => { const deletion = await scratch.run([ "service", "delete", - "--service", existing.name, "--confirm", existing.name, diff --git a/packages/cli/src/commands/project/show.ts b/packages/cli/src/commands/project/show.ts index 7ad7cc2f..25648624 100644 --- a/packages/cli/src/commands/project/show.ts +++ b/packages/cli/src/commands/project/show.ts @@ -1,5 +1,9 @@ /** The `project show` command. */ -import { defineCommand, flag, type Presentations } from "@prisma/cli-engine"; +import { + defineCommand, + type Presentations, + positional, +} from "@prisma/cli-engine"; import { notOk, ok } from "@prisma/cli-engine/protocol"; import { shortenHomePath } from "../../lib/fs/home-path"; import { @@ -85,7 +89,7 @@ function showPresentations( status: "info", text: result.resolution.projectSource === "explicit" - ? "Showing the project named by --project (this directory's own link, if any, is unchanged)." + ? "Showing the named project (this directory's own link, if any, is unchanged)." : "This directory is linked to the following platform project.", }, { kind: "fields", rows }, @@ -97,6 +101,7 @@ function showPresentations( ? toNextActions( buildProjectSetupNextActions({ commandName: "project show", + retryCommand: "prisma-cli project show ", suggestedProjectName: result.suggestedProjectName, reason: "This directory is not linked to a Prisma Project. Package and directory names can suggest setup defaults, but they do not select a Project.", @@ -108,16 +113,16 @@ function showPresentations( export const projectShowCommand = defineCommand({ args: { - flags: { - project: flag.string({ - brief: "Project id or name", + positionals: { + project: positional.optionalString({ + brief: "Project id or name (default: the linked project)", placeholder: "id-or-name", }), }, }, help: { summary: "Show this directory's Project binding", - examples: ["project show", "project show --project proj_123 --json"], + examples: ["project show", "project show proj_123 --json"], }, needs: { credentials: true }, handler: async (args, ctx) => { @@ -126,7 +131,7 @@ export const projectShowCommand = defineCommand({ const inspected = await inspectProjectBinding({ context: legacyOperationContext(ctx), workspace, - explicitProject: args.flags.project, + explicitProject: args.positionals.project, listProjects: () => listWorkspaceProjects(ctx), commandName: "project show", }); diff --git a/packages/cli/src/commands/service/delete.ts b/packages/cli/src/commands/service/delete.ts index fd4724d1..b1dd7884 100644 --- a/packages/cli/src/commands/service/delete.ts +++ b/packages/cli/src/commands/service/delete.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag } from "@prisma/cli-engine"; +import { defineCommand, flag, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deleteFailedError, userCancelledError } from "./errors"; import { deletePresentations } from "./presentation"; @@ -10,13 +10,12 @@ export const serviceDeleteCommand = defineCommand({ help: { summary: "Delete the service from the resolved branch", examples: [ - "service delete --service my-service", - "service delete --service my-service --confirm my-service", + "service delete my-service", + "service delete my-service --confirm my-service", ], }, args: { flags: { - service: flag.string({ brief: "Service name", placeholder: "name" }), project: flag.string({ brief: "Project id or name", placeholder: "id-or-name", @@ -26,11 +25,17 @@ export const serviceDeleteCommand = defineCommand({ placeholder: "name", }), }, + positionals: { + service: positional.optionalString({ + brief: "Service name", + placeholder: "service", + }), + }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { - serviceName: args.flags.service, + serviceName: args.positionals.service, projectRef: args.flags.project, branchName: args.flags.branch, commandName: "service delete", diff --git a/packages/cli/src/commands/service/deployment-delete.ts b/packages/cli/src/commands/service/deployment-delete.ts index 0ffc1628..5e6c8931 100644 --- a/packages/cli/src/commands/service/deployment-delete.ts +++ b/packages/cli/src/commands/service/deployment-delete.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, @@ -6,30 +6,18 @@ import { userCancelledError, } from "./errors"; import { deploymentDeletePresentations } from "./presentation"; -import { requireDeploymentForService } from "./release"; import type { ServiceDeploymentDeleteResult } from "./results"; -import { resolveServiceReadState, toServiceSummary } from "./target"; +import { resolveDeploymentSubject, toServiceSummary } from "./target"; export const serviceDeploymentDeleteCommand = defineCommand({ help: { summary: "Delete a deployment and the artifact it holds", examples: [ - "service deployment delete dep_123 --service my-service", - "service deployment delete dep_123 --service my-service --confirm dep_123", + "service deployment delete dep_123", + "service deployment delete dep_123 --confirm dep_123", ], }, args: { - flags: { - service: flag.string({ brief: "Service name", placeholder: "name" }), - project: flag.string({ - brief: "Project id or name", - placeholder: "id-or-name", - }), - branch: flag.string({ - brief: "Branch the service lives on (default: the default branch)", - placeholder: "name", - }), - }, positionals: { deployment: positional.string({ brief: "Deployment id to delete", @@ -39,32 +27,14 @@ export const serviceDeploymentDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const state = await resolveServiceReadState(ctx, { - serviceName: args.flags.service, - projectRef: args.flags.project, - branchName: args.flags.branch, - commandName: "service deployment delete", - }); - - const deploymentsResult = await state.provider - .listDeployments(state.service.id, { signal: ctx.signal }) - .catch((error) => { - throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction( - "List deployments", - `service deployment list --service ${state.service.name}`, - ), - ]); - }); - const targetDeployment = requireDeploymentForService( - deploymentsResult.deployments, + const { provider, service, deployment } = await resolveDeploymentSubject( + ctx, args.positionals.deployment, - state.service.name, ); const granted = await ctx.prompt.consent( - `Delete deployment "${targetDeployment.id}" from Service "${state.service.name}"?`, - { token: targetDeployment.id }, + `Delete deployment "${deployment.id}" from Service "${service.name}"?`, + { token: deployment.id }, ); // A token consent resolves to true or throws (mismatch, or the // engine's consent-required error), so this guard only fires if that @@ -76,8 +46,8 @@ export const serviceDeploymentDeleteCommand = defineCommand({ ctx.report({ kind: "step-started", step: "delete" }); try { - await state.provider.deleteDeployment({ - deploymentId: targetDeployment.id, + await provider.deleteDeployment({ + deploymentId: deployment.id, signal: ctx.signal, }); } catch (error) { @@ -85,16 +55,15 @@ export const serviceDeploymentDeleteCommand = defineCommand({ throw deployFailedError("Failed to delete deployment", error, [ runCommandAction( "List deployments", - `service deployment list --service ${state.service.name}`, + `service deployment list ${service.name}`, ), ]); } ctx.report({ kind: "step-finished", step: "delete", outcome: "ok" }); const result: ServiceDeploymentDeleteResult = { - projectId: state.projectId, - service: toServiceSummary(deploymentsResult.app), - deploymentId: targetDeployment.id, + service: toServiceSummary(service), + deploymentId: deployment.id, deleted: true, }; return ok( diff --git a/packages/cli/src/commands/service/deployment-list.ts b/packages/cli/src/commands/service/deployment-list.ts index 6462f2a2..7e61eba9 100644 --- a/packages/cli/src/commands/service/deployment-list.ts +++ b/packages/cli/src/commands/service/deployment-list.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag } from "@prisma/cli-engine"; +import { defineCommand, flag, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError } from "./errors"; import { deploymentListPresentations } from "./presentation"; @@ -15,16 +15,12 @@ export const serviceDeploymentListCommand = defineCommand({ help: { summary: "List deployments for the service", examples: [ - "service deployment list --service my-service", - "service deployment list --service my-service --branch feature-x", + "service deployment list my-service", + "service deployment list my-service --branch feature-x", ], }, args: { flags: { - service: flag.string({ - brief: "Service name", - placeholder: "name", - }), project: flag.string({ brief: "Project id or name", placeholder: "id-or-name", @@ -34,11 +30,17 @@ export const serviceDeploymentListCommand = defineCommand({ placeholder: "name", }), }, + positionals: { + service: positional.optionalString({ + brief: "Service name", + placeholder: "service", + }), + }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { - serviceName: args.flags.service, + serviceName: args.positionals.service, projectRef: args.flags.project, branchName: args.flags.branch, commandName: "service deployment list", diff --git a/packages/cli/src/commands/service/deployment-promote.ts b/packages/cli/src/commands/service/deployment-promote.ts index 82173297..d926812d 100644 --- a/packages/cli/src/commands/service/deployment-promote.ts +++ b/packages/cli/src/commands/service/deployment-promote.ts @@ -1,40 +1,19 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, positional } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, runCommandAction } from "./errors"; import { promotePresentations } from "./presentation"; -import { - promoteProgressReporter, - requireDeploymentForService, -} from "./release"; +import { promoteProgressReporter } from "./release"; import type { ServicePromoteResult } from "./results"; -import { - resolveCurrentLiveDeploymentId, - resolveServiceReadState, - toServiceSummary, -} from "./target"; +import { resolveDeploymentSubject, toServiceSummary } from "./target"; export const serviceDeploymentPromoteCommand = defineCommand({ help: { summary: "Promote a deployment to production by rebuilding with production env vars", - examples: [ - "service deployment promote dep_123 --service my-service", - "service deployment promote dep_123 --service my-service --branch feature-x", - ], + examples: ["service deployment promote dep_123"], }, args: { - flags: { - service: flag.string({ brief: "Service name", placeholder: "name" }), - project: flag.string({ - brief: "Project id or name", - placeholder: "id-or-name", - }), - branch: flag.string({ - brief: "Branch the service lives on (default: the default branch)", - placeholder: "name", - }), - }, positionals: { deployment: positional.string({ brief: "Deployment id to promote", @@ -44,42 +23,20 @@ export const serviceDeploymentPromoteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const state = await resolveServiceReadState(ctx, { - serviceName: args.flags.service, - projectRef: args.flags.project, - branchName: args.flags.branch, - commandName: "service deployment promote", - }); - - const deploymentsResult = await state.provider - .listDeployments(state.service.id, { signal: ctx.signal }) - .catch((error) => { - throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction( - "List deployments", - `service deployment list --service ${state.service.name}`, - ), - ]); - }); - const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( - deploymentsResult.app, - deploymentsResult.deployments, - ); - const targetDeployment = requireDeploymentForService( - deploymentsResult.deployments, + const { provider, service, deployment } = await resolveDeploymentSubject( + ctx, args.positionals.deployment, - state.service.name, ); - const alreadyLive = currentLiveDeploymentId === targetDeployment.id; + const alreadyLive = service.liveDeploymentId === deployment.id; if (!alreadyLive) { ctx.report({ kind: "step-started", step: "promote" }); try { - await state.provider.promoteDeployment({ - appId: state.service.id, - deploymentId: targetDeployment.id, + await provider.promoteDeployment({ + appId: service.id, + deploymentId: deployment.id, signal: ctx.signal, - progress: promoteProgressReporter(ctx, targetDeployment.id), + progress: promoteProgressReporter(ctx, deployment.id), }); } catch (error) { ctx.report({ @@ -90,7 +47,7 @@ export const serviceDeploymentPromoteCommand = defineCommand({ throw deployFailedError("Failed to promote deployment", error, [ runCommandAction( "List deployments", - `service deployment list --service ${state.service.name}`, + `service deployment list ${service.name}`, ), ]); } @@ -98,9 +55,8 @@ export const serviceDeploymentPromoteCommand = defineCommand({ } const result: ServicePromoteResult = { - projectId: state.projectId, - service: toServiceSummary(deploymentsResult.app), - deployment: { ...targetDeployment, status: "running", live: true }, + service: toServiceSummary(service), + deployment: { ...deployment, status: "running", live: true }, }; const diagnostics: Diagnostic[] = alreadyLive ? [ diff --git a/packages/cli/src/commands/service/deployment-rollback.ts b/packages/cli/src/commands/service/deployment-rollback.ts index 19f2b687..ea3db7ed 100644 --- a/packages/cli/src/commands/service/deployment-rollback.ts +++ b/packages/cli/src/commands/service/deployment-rollback.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag } from "@prisma/cli-engine"; +import { defineCommand, flag, positional } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { ok } from "@prisma/cli-engine/protocol"; import { @@ -23,14 +23,13 @@ export const serviceDeploymentRollbackCommand = defineCommand({ help: { summary: "Roll back production to a previous deployment", examples: [ - "service deployment rollback --service my-service", - "service deployment rollback --service my-service --to dep_123", - "service deployment rollback --service my-service --to dep_123 --confirm dep_123", + "service deployment rollback my-service", + "service deployment rollback my-service --to dep_123", + "service deployment rollback my-service --to dep_123 --confirm dep_123", ], }, args: { flags: { - service: flag.string({ brief: "Service name", placeholder: "name" }), project: flag.string({ brief: "Project id or name", placeholder: "id-or-name", @@ -45,11 +44,17 @@ export const serviceDeploymentRollbackCommand = defineCommand({ placeholder: "deployment", }), }, + positionals: { + service: positional.optionalString({ + brief: "Service name", + placeholder: "service", + }), + }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { - serviceName: args.flags.service, + serviceName: args.positionals.service, projectRef: args.flags.project, branchName: args.flags.branch, commandName: "service deployment rollback", @@ -61,7 +66,7 @@ export const serviceDeploymentRollbackCommand = defineCommand({ throw deployFailedError("Failed to list service deployments", error, [ runCommandAction( "List deployments", - `service deployment list --service ${state.service.name}`, + `service deployment list ${state.service.name}`, ), ]); }); @@ -113,7 +118,7 @@ export const serviceDeploymentRollbackCommand = defineCommand({ throw deployFailedError("Failed to roll back deployment", error, [ runCommandAction( "List deployments", - `service deployment list --service ${state.service.name}`, + `service deployment list ${state.service.name}`, ), ]); } diff --git a/packages/cli/src/commands/service/deployment-run-state.ts b/packages/cli/src/commands/service/deployment-run-state.ts index 5dcf9662..1623a133 100644 --- a/packages/cli/src/commands/service/deployment-run-state.ts +++ b/packages/cli/src/commands/service/deployment-run-state.ts @@ -4,10 +4,9 @@ import { deploymentStartPresentations, deploymentStopPresentations, } from "./presentation"; -import { requireDeploymentForService } from "./release"; import type { ServiceDeploymentRunStateResult } from "./results"; import type { ServiceContext } from "./target"; -import { resolveServiceReadState, toServiceSummary } from "./target"; +import { resolveDeploymentSubject, toServiceSummary } from "./target"; /** * `start` and `stop` are the same command with the direction reversed, @@ -35,13 +34,6 @@ const VERBS = { export type RunStateVerb = keyof typeof VERBS; -export interface RunStateArgs { - deployment: string; - service?: string | undefined; - project?: string | undefined; - branch?: string | undefined; -} - export interface RunStateOutcome { result: ServiceDeploymentRunStateResult; diagnostics: Diagnostic[]; @@ -50,35 +42,17 @@ export interface RunStateOutcome { export async function changeDeploymentRunState( ctx: ServiceContext, - args: RunStateArgs, + deploymentId: string, verb: RunStateVerb, ): Promise { const spec = VERBS[verb]; - const state = await resolveServiceReadState(ctx, { - ...(args.service !== undefined ? { serviceName: args.service } : {}), - ...(args.project !== undefined ? { projectRef: args.project } : {}), - ...(args.branch !== undefined ? { branchName: args.branch } : {}), - commandName: `service deployment ${verb}`, - }); - - const deploymentsResult = await state.provider - .listDeployments(state.service.id, { signal: ctx.signal }) - .catch((error) => { - throw deployFailedError("Failed to list service deployments", error, [ - runCommandAction( - "List deployments", - `service deployment list --service ${state.service.name}`, - ), - ]); - }); - const targetDeployment = requireDeploymentForService( - deploymentsResult.deployments, - args.deployment, - state.service.name, + const { provider, service, deployment } = await resolveDeploymentSubject( + ctx, + deploymentId, ); - const alreadyInState = targetDeployment.status === spec.settledStatus; + const alreadyInState = deployment.status === spec.settledStatus; - let observed = targetDeployment; + let observed = deployment; if (!alreadyInState) { ctx.report({ kind: "step-started", step: verb }); try { @@ -87,19 +61,19 @@ export async function changeDeploymentRunState( // is carried through, rather than the CLI guessing at the // precondition itself. await (verb === "start" - ? state.provider.startDeployment({ - deploymentId: targetDeployment.id, + ? provider.startDeployment({ + deploymentId: deployment.id, signal: ctx.signal, }) - : state.provider.stopDeployment({ - deploymentId: targetDeployment.id, + : provider.stopDeployment({ + deploymentId: deployment.id, signal: ctx.signal, })); // The start and stop endpoints answer with nothing, so the status // is read back rather than assumed. A deployment still coming up // reports whatever state it is actually in. - observed = await state.provider.readDeployment({ - deploymentId: targetDeployment.id, + observed = await provider.readDeployment({ + deploymentId: deployment.id, signal: ctx.signal, }); } catch (error) { @@ -107,7 +81,7 @@ export async function changeDeploymentRunState( throw deployFailedError(spec.failureSummary, error, [ runCommandAction( "Show the deployment", - `service deployment show ${targetDeployment.id}`, + `service deployment show ${deployment.id}`, ), ]); } @@ -115,8 +89,7 @@ export async function changeDeploymentRunState( } const result: ServiceDeploymentRunStateResult = { - projectId: state.projectId, - service: toServiceSummary(deploymentsResult.app), + service: toServiceSummary(service), deployment: observed, alreadyInState, }; diff --git a/packages/cli/src/commands/service/deployment-start.ts b/packages/cli/src/commands/service/deployment-start.ts index 02b73238..c10596af 100644 --- a/packages/cli/src/commands/service/deployment-start.ts +++ b/packages/cli/src/commands/service/deployment-start.ts @@ -1,27 +1,13 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { changeDeploymentRunState } from "./deployment-run-state"; export const serviceDeploymentStartCommand = defineCommand({ help: { summary: "Start a stopped deployment", - examples: [ - "service deployment start dep_123 --service my-service", - "service deployment start dep_123 --service my-service --branch feature-x", - ], + examples: ["service deployment start dep_123"], }, args: { - flags: { - service: flag.string({ brief: "Service name", placeholder: "name" }), - project: flag.string({ - brief: "Project id or name", - placeholder: "id-or-name", - }), - branch: flag.string({ - brief: "Branch the service lives on (default: the default branch)", - placeholder: "name", - }), - }, positionals: { deployment: positional.string({ brief: "Deployment id to start", @@ -32,16 +18,7 @@ export const serviceDeploymentStartCommand = defineCommand({ needs: { credentials: true }, handler: async (args, ctx) => { const { result, diagnostics, presentations } = - await changeDeploymentRunState( - ctx, - { - deployment: args.positionals.deployment, - service: args.flags.service, - project: args.flags.project, - branch: args.flags.branch, - }, - "start", - ); + await changeDeploymentRunState(ctx, args.positionals.deployment, "start"); return ok(ctx.present({ data: result, diagnostics }, presentations)); }, }); diff --git a/packages/cli/src/commands/service/deployment-stop.ts b/packages/cli/src/commands/service/deployment-stop.ts index 89a6b0bc..96b2989c 100644 --- a/packages/cli/src/commands/service/deployment-stop.ts +++ b/packages/cli/src/commands/service/deployment-stop.ts @@ -1,27 +1,13 @@ -import { defineCommand, flag, positional } from "@prisma/cli-engine"; +import { defineCommand, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { changeDeploymentRunState } from "./deployment-run-state"; export const serviceDeploymentStopCommand = defineCommand({ help: { summary: "Stop a running deployment", - examples: [ - "service deployment stop dep_123 --service my-service", - "service deployment stop dep_123 --service my-service --branch feature-x", - ], + examples: ["service deployment stop dep_123"], }, args: { - flags: { - service: flag.string({ brief: "Service name", placeholder: "name" }), - project: flag.string({ - brief: "Project id or name", - placeholder: "id-or-name", - }), - branch: flag.string({ - brief: "Branch the service lives on (default: the default branch)", - placeholder: "name", - }), - }, positionals: { deployment: positional.string({ brief: "Deployment id to stop", @@ -32,16 +18,7 @@ export const serviceDeploymentStopCommand = defineCommand({ needs: { credentials: true }, handler: async (args, ctx) => { const { result, diagnostics, presentations } = - await changeDeploymentRunState( - ctx, - { - deployment: args.positionals.deployment, - service: args.flags.service, - project: args.flags.project, - branch: args.flags.branch, - }, - "stop", - ); + await changeDeploymentRunState(ctx, args.positionals.deployment, "stop"); return ok(ctx.present({ data: result, diagnostics }, presentations)); }, }); diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index d5db2d29..57603eff 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -119,7 +119,7 @@ export function serviceSelectionInvalidError( { why: `The service "${serviceName}" could not be found in resolved project "${projectId}".`, nextActions: [ - adviceAction("Pass --service with the name of an existing service."), + adviceAction("Pass the name of an existing service."), // Not `service deployment list`: that command has to resolve a // service before it can list anything, so it fails the same way. runCommandAction("List services", "service list"), @@ -176,10 +176,7 @@ export function noDeploymentsError( return new CliStructuredError("SERVICE.NO_DEPLOYMENTS", summary, { why, nextActions: [ - runCommandAction( - "Inspect the service", - `service show --service ${serviceName}`, - ), + runCommandAction("Inspect the service", `service show ${serviceName}`), ], }); } @@ -195,7 +192,7 @@ export function deploymentNotFoundError( nextActions: [ runCommandAction( "Choose an available deployment id", - "service deployment list --service ", + "service deployment list ", ), ], }, @@ -219,26 +216,8 @@ export function logsRangeConflictError(): CliStructuredError { ); } -/** A deployment id resolves globally, so one that exists but belongs to - * another project is its own failure — not "not found". */ -export function deploymentOutsideProjectError( - deploymentId: string, -): CliStructuredError { - return new CliStructuredError( - "SERVICE.DEPLOYMENT_OUTSIDE_PROJECT", - `Deployment "${deploymentId}" belongs to another project`, - { - why: "The deployment exists, but the service that owns it is not in the resolved project.", - nextActions: [ - adviceAction("Pass --project for the project that owns it."), - runCommandAction("List services", "service list"), - ], - }, - ); -} - -/** The deployment exists but names no owning service, so there is no - * project to check it against and nothing to scope logs by. */ +/** The deployment exists but names no owning service, so there is + * nothing to report or act on it as. */ export function deploymentDetachedError( deploymentId: string, ): CliStructuredError { @@ -269,7 +248,7 @@ export function deploymentNotFoundForServiceError( nextActions: [ runCommandAction( "Choose an available deployment id", - `service deployment list --service ${serviceName}`, + `service deployment list ${serviceName}`, ), ], }, @@ -283,11 +262,11 @@ export function serviceTargetRequiredError( ): CliStructuredError { return new CliStructuredError( "SERVICE.TARGET_REQUIRED", - `Command "${commandName}" requires --service`, + `Command "${commandName}" requires a service`, { why: "Service commands act only on an explicitly named service, and this run named none.", nextActions: [ - adviceAction("Pass --service ."), + adviceAction("Pass the service name as the first argument."), adviceAction("Or set PRISMA_SERVICE_ID to a service id."), // Not `service deployment list`: it resolves a service first, so // it cannot help a run that could not resolve one. @@ -311,7 +290,7 @@ export function noPreviousDeploymentError( ), runCommandAction( "List deployments", - `service deployment list --service ${serviceName}`, + `service deployment list ${serviceName}`, ), ], }, @@ -331,11 +310,11 @@ export function liveDeploymentUnknownError( nextActions: [ runCommandAction( "Roll back to a named deployment", - `service deployment rollback --to --service ${serviceName}`, + `service deployment rollback ${serviceName} --to `, ), runCommandAction( "List deployments", - `service deployment list --service ${serviceName}`, + `service deployment list ${serviceName}`, ), ], }, @@ -350,13 +329,10 @@ export function deleteFailedError( return new CliStructuredError("SERVICE.DELETE_FAILED", summary, { why: cause instanceof Error ? cause.message : String(cause), nextActions: [ - runCommandAction( - "Inspect the service", - `service show --service ${serviceName}`, - ), + runCommandAction("Inspect the service", `service show ${serviceName}`), runCommandAction( "List deployments", - `service deployment list --service ${serviceName}`, + `service deployment list ${serviceName}`, ), ], cause, @@ -391,7 +367,7 @@ export function liveUrlUnavailableError( nextActions: [ runCommandAction( "Inspect the deployment state", - `service show --service ${serviceName}`, + `service show ${serviceName}`, ), ], }, @@ -471,7 +447,7 @@ export function selectedServiceMissingError( { why: `The service "${serviceId}" from ${envVarName} could not be found in resolved project "${projectId}".`, nextActions: [ - adviceAction(`Unset ${envVarName}, or pass --service .`), + adviceAction(`Unset ${envVarName}, or pass a service name.`), runCommandAction("List services", "service list"), ], }, diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index b7d8aa77..8e283774 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -1,31 +1,24 @@ import type { CommandContext } from "@prisma/cli-engine"; -import { defineSessionCommand, flag } from "@prisma/cli-engine"; +import { defineSessionCommand, flag, positional } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import type { AppProvider, AppRecord } from "../../lib/app/app-provider"; import { forEachNdjsonRecord } from "../../lib/ndjson"; import { adviceAction, deployFailedError, - deploymentDetachedError, deploymentNotFoundError, - deploymentOutsideProjectError, logsRangeConflictError, noDeploymentsError, runCommandAction, } from "./errors"; import { requireDeploymentForService } from "./release"; import type { ServiceDeploymentSummary } from "./results"; -import type { - ServiceContext, - ServiceProjectState, - ServiceReadState, -} from "./target"; +import type { ServiceContext, ServiceReadState } from "./target"; import { applyLiveDeploymentHint, - listServices, requestedServiceTarget, resolveCurrentLiveDeploymentId, - resolveServiceProjectState, + resolveDeploymentSubject, resolveServiceReadState, } from "./target"; @@ -169,7 +162,7 @@ function listDeployments( throw deployFailedError("Failed to list service deployments", error, [ runCommandAction( "List deployments", - `service deployment list --service ${service.name}`, + `service deployment list ${service.name}`, ), ]); }); @@ -195,46 +188,6 @@ async function resolveDeploymentInService( return { service: deploymentsResult.app, deployment }; } -/** `--deployment ` without a service target: the id is global, so - * it is resolved directly and then checked against the resolved - * project — a deployment that exists but belongs elsewhere is reported - * as its own failure. */ -async function resolveGlobalDeployment( - ctx: ServiceContext, - state: ServiceProjectState, - deploymentId: string, -): Promise { - const shown = await state.provider - .showDeployment(deploymentId, { signal: ctx.signal }) - .catch((error) => { - throw deployFailedError("Failed to show deployment", error, [ - runCommandAction( - "List deployments", - "service deployment list --service ", - ), - ]); - }); - if (!shown) { - throw deploymentNotFoundError(deploymentId); - } - if (!shown.app) { - throw deploymentDetachedError(deploymentId); - } - - const services = await listServices( - ctx, - state.provider, - state.projectId, - state.target.branch.name, - ); - const owning = services.find((service) => service.id === shown.app?.id); - if (!owning) { - throw deploymentOutsideProjectError(deploymentId); - } - - return { service: owning, deployment: shown.deployment }; -} - /** No `--deployment`: read whatever is live for the resolved service. */ async function resolveLiveDeployment( ctx: ServiceContext, @@ -388,41 +341,39 @@ async function followPages( /** * A globally-unique deployment id is a complete target on its own, so - * `--deployment` with no service target (neither --service nor - * PRISMA_SERVICE_ID) skips service resolution and checks the deployment - * against the resolved project instead. A named service scopes the + * `--deployment` with no service target (neither a service argument nor + * PRISMA_SERVICE_ID) resolves it directly, the way `service deployment + * show` does — no project resolution at all. A named service scopes the * lookup to that service. */ async function resolveLogsTarget( ctx: ServiceContext, - flags: { + options: { service?: string | undefined; project?: string | undefined; branch?: string | undefined; deployment?: string | undefined; }, -): Promise<{ state: ServiceProjectState; target: LogTarget }> { - const explicitDeploymentId = flags.deployment; - const serviceRequested = requestedServiceTarget(ctx, flags.service) !== null; - const projectOptions = { - ...(flags.project !== undefined ? { projectRef: flags.project } : {}), - ...(flags.branch !== undefined ? { branchName: flags.branch } : {}), - commandName: "service logs", - }; +): Promise<{ projectId: string | null; target: LogTarget }> { + const explicitDeploymentId = options.deployment; + const serviceRequested = + requestedServiceTarget(ctx, options.service) !== null; if (explicitDeploymentId !== undefined && !serviceRequested) { - const state = await resolveServiceProjectState(ctx, projectOptions); + const subject = await resolveDeploymentSubject(ctx, explicitDeploymentId); return { - state, - target: await resolveGlobalDeployment(ctx, state, explicitDeploymentId), + projectId: null, + target: { service: subject.service, deployment: subject.deployment }, }; } const readState = await resolveServiceReadState(ctx, { - ...(flags.service !== undefined ? { serviceName: flags.service } : {}), - ...projectOptions, + ...(options.service !== undefined ? { serviceName: options.service } : {}), + ...(options.project !== undefined ? { projectRef: options.project } : {}), + ...(options.branch !== undefined ? { branchName: options.branch } : {}), + commandName: "service logs", }); return { - state: readState, + projectId: readState.projectId, target: explicitDeploymentId !== undefined ? await resolveDeploymentInService(ctx, readState, explicitDeploymentId) @@ -434,15 +385,20 @@ export const serviceLogsCommand = defineSessionCommand({ help: { summary: "Read logs for a deployment of the service", examples: [ - "service logs --service my-service", - "service logs --service my-service --tail 500", - "service logs --service my-service --follow", + "service logs my-service", + "service logs my-service --tail 500", + "service logs my-service --follow", "service logs --deployment dep_123 --from-start", ], }, args: { + positionals: { + service: positional.optionalString({ + brief: "Service name", + placeholder: "service", + }), + }, flags: { - service: flag.string({ brief: "Service name", placeholder: "name" }), project: flag.string({ brief: "Project id or name", placeholder: "id-or-name", @@ -475,11 +431,16 @@ export const serviceLogsCommand = defineSessionCommand({ throw logsRangeConflictError(); } - const { state, target } = await resolveLogsTarget(ctx, args.flags); + const { projectId, target } = await resolveLogsTarget(ctx, { + service: args.positionals.service, + ...args.flags, + }); const deploymentId = target.deployment.id; for (const line of [ - `project: ${state.projectId}`, + // A run targeted purely by deployment id resolves no project, so + // there is none to report. + ...(projectId === null ? [] : [`project: ${projectId}`]), `service: ${target.service.name}`, `deployment: ${deploymentId}`, ]) { diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index 3496bac7..5039f3dd 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag } from "@prisma/cli-engine"; +import { defineCommand, flag, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, @@ -20,16 +20,12 @@ export const serviceOpenCommand = defineCommand({ help: { summary: "Open the service's live URL", examples: [ - "service open --service my-service", - "service open --service my-service --branch feature-x", + "service open my-service", + "service open my-service --branch feature-x", ], }, args: { flags: { - service: flag.string({ - brief: "Service name", - placeholder: "name", - }), project: flag.string({ brief: "Project id or name", placeholder: "id-or-name", @@ -39,11 +35,17 @@ export const serviceOpenCommand = defineCommand({ placeholder: "name", }), }, + positionals: { + service: positional.optionalString({ + brief: "Service name", + placeholder: "service", + }), + }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { - serviceName: args.flags.service, + serviceName: args.positionals.service, projectRef: args.flags.project, branchName: args.flags.branch, commandName: "service open", @@ -55,7 +57,7 @@ export const serviceOpenCommand = defineCommand({ throw deployFailedError("Failed to resolve service URL", error, [ runCommandAction( "Inspect the service", - `service show --service ${state.service.name}`, + `service show ${state.service.name}`, ), ]); }); diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index 3804ba04..4c28dfb3 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -128,12 +128,7 @@ export function listPresentations(result: ServiceListResult): Presentations { next: () => { const first = result.services[0]; return first - ? [ - runCommandAction( - "Show a service", - `service show --service ${first.name}`, - ), - ] + ? [runCommandAction("Show a service", `service show ${first.name}`)] : // Not a run-command: `command` is executed verbatim, and // `service create ` would make a service literally // called "". Naming it is the user's choice, which is @@ -177,7 +172,7 @@ export function createPresentations( runCommandAction("Deploy to the service", "deploy"), runCommandAction( "Show the service", - `service show --service ${result.service.name}`, + `service show ${result.service.name}`, ), ], }; @@ -189,7 +184,7 @@ export function showPresentations(result: ServiceShowResult): Presentations { next.push( runCommandAction( "Open the live URL", - `service open --service ${result.service.name}`, + `service open ${result.service.name}`, ), ); } @@ -316,7 +311,7 @@ export function openPresentations( next: () => [ runCommandAction( "Inspect the service", - `service show --service ${result.service.name}`, + `service show ${result.service.name}`, ), runCommandAction( "Show the live deployment", @@ -333,7 +328,7 @@ function deploymentNextActions( return [ runCommandAction( "List deployments", - `service deployment list --service ${serviceName}`, + `service deployment list ${serviceName}`, ), runCommandAction( "Show the deployment", @@ -356,7 +351,6 @@ export function promotePresentations( : `Promoted ${result.deployment.id} to production.`, ), fields([ - { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, { label: "deployment", value: result.deployment.id }, { label: "status", value: result.deployment.status }, @@ -415,7 +409,6 @@ export function deploymentStartPresentations( : `Started ${result.deployment.id}.`, ), fields([ - { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, { label: "deployment", value: result.deployment.id }, { label: "status", value: result.deployment.status }, @@ -442,7 +435,6 @@ export function deploymentStopPresentations( : `Stopped ${result.deployment.id}.`, ), fields([ - { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, { label: "deployment", value: result.deployment.id }, { label: "status", value: result.deployment.status }, @@ -462,7 +454,6 @@ export function deploymentDeletePresentations( human: () => [ completed(`Deleted ${result.deploymentId} from ${result.service.name}.`), fields([ - { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, { label: "deployment", value: result.deploymentId }, { label: "deleted", value: "yes" }, @@ -471,7 +462,7 @@ export function deploymentDeletePresentations( next: () => [ runCommandAction( "List deployments", - `service deployment list --service ${result.service.name}`, + `service deployment list ${result.service.name}`, ), ], }; diff --git a/packages/cli/src/commands/service/results.ts b/packages/cli/src/commands/service/results.ts index d41b2b9b..7f7fa7de 100644 --- a/packages/cli/src/commands/service/results.ts +++ b/packages/cli/src/commands/service/results.ts @@ -68,13 +68,16 @@ export interface ServiceOpenResult { opened: boolean; } +/** Targeted by deployment id alone, so no project is resolved. */ export interface ServicePromoteResult { - projectId: string; service: ServiceSummary; deployment: ServiceDeploymentSummary; } -export interface ServiceRollbackResult extends ServicePromoteResult { +export interface ServiceRollbackResult { + projectId: string; + service: ServiceSummary; + deployment: ServiceDeploymentSummary; previousLiveDeploymentId: string | null; } @@ -82,14 +85,12 @@ export interface ServiceRollbackResult extends ServicePromoteResult { * is true when the deployment already had the status the command asks * for, so the run made no call. */ export interface ServiceDeploymentRunStateResult { - projectId: string; service: ServiceSummary; deployment: ServiceDeploymentSummary; alreadyInState: boolean; } export interface ServiceDeploymentDeleteResult { - projectId: string; service: ServiceSummary; deploymentId: string; deleted: true; diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index ef533fcf..831f1ebe 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -1,4 +1,4 @@ -import { defineCommand, flag } from "@prisma/cli-engine"; +import { defineCommand, flag, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, runCommandAction } from "./errors"; import { showPresentations } from "./presentation"; @@ -15,16 +15,12 @@ export const serviceShowCommand = defineCommand({ help: { summary: "Show the service and its current deployment", examples: [ - "service show --service my-service", - "service show --service my-service --branch feature-x", + "service show my-service", + "service show my-service --branch feature-x", ], }, args: { flags: { - service: flag.string({ - brief: "Service name", - placeholder: "name", - }), project: flag.string({ brief: "Project id or name", placeholder: "id-or-name", @@ -34,11 +30,17 @@ export const serviceShowCommand = defineCommand({ placeholder: "name", }), }, + positionals: { + service: positional.optionalString({ + brief: "Service name", + placeholder: "service", + }), + }, }, needs: { credentials: true }, handler: async (args, ctx) => { const state = await resolveServiceReadState(ctx, { - serviceName: args.flags.service, + serviceName: args.positionals.service, projectRef: args.flags.project, branchName: args.flags.branch, commandName: "service show", @@ -50,7 +52,7 @@ export const serviceShowCommand = defineCommand({ throw deployFailedError("Failed to inspect service", error, [ runCommandAction( "List deployments", - `service deployment list --service ${state.service.name}`, + `service deployment list ${state.service.name}`, ), ]); }); diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 7e9a90fd..5077fa32 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -3,6 +3,7 @@ import { type AppProvider, type AppRecord, createAppProvider, + type DeploymentRecord, type DomainRecord, } from "../../lib/app/app-provider"; import { resolveReadBranch } from "../../lib/app/read-branch"; @@ -21,6 +22,8 @@ import { branchNotDeployableError, branchValueEmptyError, deployFailedError, + deploymentDetachedError, + deploymentNotFoundError, domainCommandError, domainHostnameInvalidError, domainNotFoundError, @@ -269,8 +272,8 @@ export interface RequestedServiceTarget { value: string; } -/** The service target the run was given, if any: `--service ` - * wins, then PRISMA_SERVICE_ID (a service id). */ +/** The service target the run was given, if any: the service name + * argument wins, then PRISMA_SERVICE_ID (a service id). */ export function requestedServiceTarget( ctx: ServiceContext, explicitServiceName: string | undefined, @@ -324,6 +327,34 @@ export function matchRequestedService( return matched; } +export interface DeploymentSubject { + provider: AppProvider; + service: AppRecord; + deployment: DeploymentRecord; +} + +/** Resolve a deployment by its globally-unique id. The id alone names + * the subject — no service, project, or branch parameter is consulted, + * the same way `service deployment show` resolves it. */ +export async function resolveDeploymentSubject( + ctx: ServiceContext, + deploymentId: string, +): Promise { + const provider = serviceProvider(ctx); + const shown = await provider + .showDeployment(deploymentId, { signal: ctx.signal }) + .catch((error) => { + throw deployFailedError("Failed to show deployment", error, []); + }); + if (!shown) { + throw deploymentNotFoundError(deploymentId); + } + if (!shown.app) { + throw deploymentDetachedError(deploymentId); + } + return { provider, service: shown.app, deployment: shown.deployment }; +} + /** The live deployment is the one the service record names as its latest * deployment. Nothing else decides it — local CLI state never does. */ export function resolveCurrentLiveDeploymentId( @@ -467,7 +498,7 @@ export async function resolveDomainByHostname( throw domainNotFoundError(hostname); } -export interface ServiceProjectState { +interface ServiceProjectState { provider: AppProvider; target: ResolvedServiceProjectContext; projectId: string; @@ -477,10 +508,8 @@ export interface ServiceReadState extends ServiceProjectState { service: AppRecord; } -/** Project + branch resolution without a service target. For the - * callers that resolve their subject by a globally-unique deployment - * id and never need a service parameter. */ -export async function resolveServiceProjectState( +/** Project + branch resolution, before the service match. */ +async function resolveServiceProjectState( ctx: ServiceContext, options: { projectRef?: string; diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 4d316a8f..351cdd28 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -429,13 +429,20 @@ export function buildProjectSetupNextActions( suggestedProjectName?: string; createCommand?: string; reason?: string; + /** The explicit-target retry line, for commands whose grammar the + * generic `--project ` template does not fit. */ + retryCommand?: string; } = {}, ): NextAction[] { const recoveryCommands = buildProjectRecoveryCommands(options.commandName); const linkCommand = recoveryCommands[0] ?? "prisma-cli project link "; - const retryCommand = recoveryCommands[1]; - const commands = ["prisma-cli project list", ...recoveryCommands]; + const retryCommand = options.retryCommand ?? recoveryCommands[1]; + const commands = [ + "prisma-cli project list", + linkCommand, + ...(retryCommand ? [retryCommand] : []), + ]; const actions: NextAction[] = [ { diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 58ad1677..008ddd97 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -396,7 +396,7 @@ describe("prisma-cli project show", () => { expect(result.presented?.presentation.next?.at(-1)).toEqual({ kind: "run-command", label: "Retry with an explicit Project", - command: "prisma-cli project show --project ", + command: "prisma-cli project show ", }); // "Not linked" is prose for a reader; stdout leaves the field empty. expect(result.presented?.presentation.stdout).toEqual([ @@ -408,7 +408,7 @@ describe("prisma-cli project show", () => { it("maps an unknown --project to PROJECT.NOT_FOUND", async () => { const result = await makeCli(fakeClient()).run( - ["project", "show", "--project", "nope", "--json"], + ["project", "show", "nope", "--json"], { cwd: await tempCwd() }, ); @@ -429,7 +429,7 @@ describe("prisma-cli project show", () => { { ...API_PROJECTS[0], id: "proj_b", name: "Billing" }, ]; const result = await makeCli(fakeClient({ projects: duplicates })).run( - ["project", "show", "--project", "Billing", "--json"], + ["project", "show", "Billing", "--json"], { cwd: await tempCwd() }, ); diff --git a/packages/cli/tests/service-delete.test.ts b/packages/cli/tests/service-delete.test.ts index 5531bd37..cceb7da6 100644 --- a/packages/cli/tests/service-delete.test.ts +++ b/packages/cli/tests/service-delete.test.ts @@ -14,14 +14,7 @@ describe("prisma-cli service delete", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "delete", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "delete", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, @@ -56,14 +49,7 @@ describe("prisma-cli service delete", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "delete", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "delete", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, @@ -101,15 +87,7 @@ describe("prisma-cli service delete", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "delete", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "delete", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env, @@ -131,14 +109,7 @@ describe("prisma-cli service delete", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "delete", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "delete", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, @@ -155,15 +126,7 @@ describe("prisma-cli service delete", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "delete", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "delete", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -184,7 +147,6 @@ describe("prisma-cli service delete", () => { "delete", "--project", "acme-app", - "--service", "hello-world", "--confirm", "hello-world", @@ -205,7 +167,6 @@ describe("prisma-cli service delete", () => { "delete", "--project", "acme-app", - "--service", "hello-world", "--confirm", "hello-world", @@ -232,7 +193,6 @@ describe("prisma-cli service delete", () => { "delete", "--project", "acme-app", - "--service", "hello-world", "--confirm", "some-other-service", @@ -261,7 +221,6 @@ describe("prisma-cli service delete", () => { "delete", "--project", "acme-app", - "--service", "hello-world", "--yes", "--json", @@ -286,7 +245,6 @@ describe("prisma-cli service delete", () => { "delete", "--project", "acme-app", - "--service", "hello-world", "--branch", "", @@ -314,15 +272,7 @@ describe("prisma-cli service delete", () => { }); const result = await harness.cli.run( - [ - "service", - "delete", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "delete", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env, @@ -344,7 +294,7 @@ describe("prisma-cli service delete", () => { expect(frame.envelope.error.code).toBe("SERVICE.DELETE_FAILED"); }); - it("requires --service or PRISMA_SERVICE_ID, interactive terminals included", async () => { + it("requires a service or PRISMA_SERVICE_ID, interactive terminals included", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -361,12 +311,12 @@ describe("prisma-cli service delete", () => { } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); expect(frame.envelope.error.summary).toBe( - 'Command "service delete" requires --service', + 'Command "service delete" requires a service', ); expect(frame.envelope.nextActions).toEqual([ { kind: "user-choice", - label: "Pass --service .", + label: "Pass the service name as the first argument.", }, { kind: "user-choice", diff --git a/packages/cli/tests/service-deployment-delete.test.ts b/packages/cli/tests/service-deployment-delete.test.ts index e6bddef6..c09498a5 100644 --- a/packages/cli/tests/service-deployment-delete.test.ts +++ b/packages/cli/tests/service-deployment-delete.test.ts @@ -8,7 +8,6 @@ import { } from "./service-testkit"; const INTERACTIVE = { stdin: true, stdout: true, stderr: true }; -const TARGET = ["--project", "acme-app", "--service", "hello-world"]; function blocks(presented: unknown) { const value = presented as @@ -42,7 +41,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1", ...TARGET], + ["service", "deployment", "delete", "dep_1"], { cwd: harness.cwd, env: harness.env, @@ -60,7 +59,6 @@ describe("prisma-cli service deployment delete", () => { outcome: "ok", }); expect(result.presented?.data).toEqual({ - projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, deploymentId: "dep_1", deleted: true, @@ -75,7 +73,6 @@ describe("prisma-cli service deployment delete", () => { ).toEqual({ kind: "fields", rows: [ - { label: "project", value: "proj_1" }, { label: "service", value: "hello-world" }, { label: "deployment", value: "dep_1" }, { label: "deleted", value: "yes" }, @@ -93,7 +90,6 @@ describe("prisma-cli service deployment delete", () => { "deployment", "delete", "dep_1", - ...TARGET, "--confirm", "dep_1", "--json", @@ -121,7 +117,6 @@ describe("prisma-cli service deployment delete", () => { "deployment", "delete", "dep_1", - ...TARGET, "--confirm", "dep_2", "--json", @@ -146,7 +141,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1", ...TARGET], + ["service", "deployment", "delete", "dep_1"], { cwd: harness.cwd, env: harness.env, @@ -165,7 +160,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1", ...TARGET, "--json"], + ["service", "deployment", "delete", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -190,15 +185,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - [ - "service", - "deployment", - "delete", - "dep_2", - ...TARGET, - "--confirm", - "dep_2", - ], + ["service", "deployment", "delete", "dep_2", "--confirm", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -229,7 +216,6 @@ describe("prisma-cli service deployment delete", () => { "deployment", "delete", "dep_2", - ...TARGET, "--confirm", "dep_2", "--json", @@ -261,7 +247,6 @@ describe("prisma-cli service deployment delete", () => { "deployment", "delete", "dep_missing", - ...TARGET, "--confirm", "dep_missing", "--json", @@ -286,7 +271,7 @@ describe("prisma-cli service deployment delete", () => { }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1", ...TARGET], + ["service", "deployment", "delete", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: INTERACTIVE }, ); diff --git a/packages/cli/tests/service-deployment-list.test.ts b/packages/cli/tests/service-deployment-list.test.ts index f3b7f9af..171be111 100644 --- a/packages/cli/tests/service-deployment-list.test.ts +++ b/packages/cli/tests/service-deployment-list.test.ts @@ -15,15 +15,7 @@ describe("prisma-cli service deployment list", () => { const harness = await makeServiceCli(); const result = await harness.cli.run( - [ - "service", - "deployment", - "list", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "deployment", "list", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -70,15 +62,7 @@ describe("prisma-cli service deployment list", () => { ); const result = await harness.cli.run( - [ - "service", - "deployment", - "list", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "deployment", "list", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -107,7 +91,7 @@ describe("prisma-cli service deployment list", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); - expect(frame.envelope.error.summary).toContain("--service"); + expect(frame.envelope.error.summary).toContain("requires a service"); }); it("emits the completed json envelope with commandId service.deployment.list", async () => { @@ -120,7 +104,6 @@ describe("prisma-cli service deployment list", () => { "list", "--project", "acme-app", - "--service", "hello-world", "--json", ], @@ -156,7 +139,6 @@ describe("prisma-cli service deployment list", () => { "list", "--project", "acme-app", - "--service", "hello-world", "--json", ], diff --git a/packages/cli/tests/service-deployment-promote.test.ts b/packages/cli/tests/service-deployment-promote.test.ts index 3ddc72e0..add1dece 100644 --- a/packages/cli/tests/service-deployment-promote.test.ts +++ b/packages/cli/tests/service-deployment-promote.test.ts @@ -14,22 +14,12 @@ describe("prisma-cli service deployment promote", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "deployment", - "promote", - "dep_1", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "deployment", "promote", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, deployment: { id: "dep_1", status: "running", live: true }, }); @@ -44,16 +34,7 @@ describe("prisma-cli service deployment promote", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "deployment", - "promote", - "dep_1", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "deployment", "promote", "dep_1"], { cwd: harness.cwd, env: harness.env }, ); @@ -85,19 +66,10 @@ describe("prisma-cli service deployment promote", () => { it("writes no local selection or live-deployment state", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); - await harness.cli.run( - [ - "service", - "deployment", - "promote", - "dep_1", - "--project", - "acme-app", - "--service", - "hello-world", - ], - { cwd: harness.cwd, env: harness.env }, - ); + await harness.cli.run(["service", "deployment", "promote", "dep_1"], { + cwd: harness.cwd, + env: harness.env, + }); await expect( readFile(path.join(harness.stateDir, "state.json"), "utf8"), @@ -108,16 +80,7 @@ describe("prisma-cli service deployment promote", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - [ - "service", - "deployment", - "promote", - "dep_2", - "--project", - "acme-app", - "--service", - "hello-world", - ], + ["service", "deployment", "promote", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -147,10 +110,7 @@ describe("prisma-cli service deployment promote", () => { "deployment", "promote", "dep_1", - "--project", - "acme-app", - "--service", - "hello-world", + "--json", ], { cwd: harness.cwd, env: harness.env }, @@ -177,10 +137,7 @@ describe("prisma-cli service deployment promote", () => { "deployment", "promote", "dep_missing", - "--project", - "acme-app", - "--service", - "hello-world", + "--json", ], { cwd: harness.cwd, env: harness.env }, @@ -210,10 +167,7 @@ describe("prisma-cli service deployment promote", () => { "deployment", "promote", "dep_1", - "--project", - "acme-app", - "--service", - "hello-world", + "--json", ], { cwd: harness.cwd, env: harness.env }, @@ -232,21 +186,13 @@ describe("prisma-cli service deployment promote", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); }); - it("requires --service or PRISMA_SERVICE_ID", async () => { + it("settles a deployment with no owning service as SERVICE.DEPLOYMENT_DETACHED", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); const result = await harness.cli.run( - [ - "service", - "deployment", - "promote", - "dep_1", - "--project", - "acme-app", - "--json", - ], + ["service", "deployment", "promote", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -255,10 +201,7 @@ describe("prisma-cli service deployment promote", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); - expect(frame.envelope.error.summary).toBe( - 'Command "service deployment promote" requires --service', - ); + expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_DETACHED"); }); it("fails early with the engine sign-in error when unauthenticated", async () => { @@ -268,7 +211,7 @@ describe("prisma-cli service deployment promote", () => { }); const result = await harness.cli.run( - ["service", "deployment", "promote", "dep_1", "--project", "acme-app"], + ["service", "deployment", "promote", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/cli/tests/service-deployment-rollback.test.ts b/packages/cli/tests/service-deployment-rollback.test.ts index 50fe81d2..3815b761 100644 --- a/packages/cli/tests/service-deployment-rollback.test.ts +++ b/packages/cli/tests/service-deployment-rollback.test.ts @@ -50,7 +50,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--confirm", "dep_1", @@ -83,7 +82,6 @@ describe("prisma-cli service deployment rollback", () => { "dep_2", "--project", "acme-app", - "--service", "hello-world", "--confirm", "dep_2", @@ -120,7 +118,6 @@ describe("prisma-cli service deployment rollback", () => { "dep_1", "--project", "acme-app", - "--service", "hello-world", "--confirm", "dep_1", @@ -144,7 +141,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--confirm", "dep_1", @@ -183,7 +179,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--confirm", "dep_1", @@ -214,7 +209,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", ], { @@ -244,7 +238,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", ], { @@ -272,7 +265,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--json", ], @@ -306,7 +298,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--confirm", "hello-world", @@ -337,7 +328,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--yes", "--json", @@ -367,7 +357,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--json", ], @@ -398,12 +387,12 @@ describe("prisma-cli service deployment rollback", () => { kind: "run-command", label: "Roll back to a named deployment", command: - "prisma-cli service deployment rollback --to --service hello-world", + "prisma-cli service deployment rollback hello-world --to ", }, { kind: "run-command", label: "List deployments", - command: "prisma-cli service deployment list --service hello-world", + command: "prisma-cli service deployment list hello-world", }, ]); }); @@ -422,7 +411,6 @@ describe("prisma-cli service deployment rollback", () => { "dep_1", "--project", "acme-app", - "--service", "hello-world", "--confirm", "dep_1", @@ -457,7 +445,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--json", ], @@ -480,7 +467,7 @@ describe("prisma-cli service deployment rollback", () => { { kind: "run-command", label: "List deployments", - command: "prisma-cli service deployment list --service hello-world", + command: "prisma-cli service deployment list hello-world", }, ]); }); @@ -499,7 +486,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--json", ], @@ -533,7 +519,6 @@ describe("prisma-cli service deployment rollback", () => { "rollback", "--project", "acme-app", - "--service", "hello-world", "--confirm", "dep_1", @@ -555,7 +540,7 @@ describe("prisma-cli service deployment rollback", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); }); - it("requires --service or PRISMA_SERVICE_ID", async () => { + it("requires a service or PRISMA_SERVICE_ID", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -572,7 +557,7 @@ describe("prisma-cli service deployment rollback", () => { } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); expect(frame.envelope.error.summary).toBe( - 'Command "service deployment rollback" requires --service', + 'Command "service deployment rollback" requires a service', ); }); diff --git a/packages/cli/tests/service-deployment-start.test.ts b/packages/cli/tests/service-deployment-start.test.ts index 80a775c6..d7449354 100644 --- a/packages/cli/tests/service-deployment-start.test.ts +++ b/packages/cli/tests/service-deployment-start.test.ts @@ -64,16 +64,13 @@ function startRoutes( }), }; } - -const TARGET = ["--project", "acme-app", "--service", "hello-world"]; - describe("prisma-cli service deployment start", () => { it("starts a stopped deployment and reports it running", async () => { const start = startRoutes(); const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1", ...TARGET], + ["service", "deployment", "start", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -86,7 +83,6 @@ describe("prisma-cli service deployment start", () => { outcome: "ok", }); expect(result.presented?.data).toMatchObject({ - projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, deployment: { id: "dep_1", status: "running" }, alreadyInState: false, @@ -101,7 +97,6 @@ describe("prisma-cli service deployment start", () => { ).toEqual({ kind: "fields", rows: [ - { label: "project", value: "proj_1" }, { label: "service", value: "hello-world" }, { label: "deployment", value: "dep_1" }, { label: "status", value: "running" }, @@ -122,7 +117,7 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1", ...TARGET], + ["service", "deployment", "start", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -139,7 +134,7 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_2", ...TARGET], + ["service", "deployment", "start", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -175,7 +170,7 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1", ...TARGET, "--json"], + ["service", "deployment", "start", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -198,7 +193,7 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_missing", ...TARGET, "--json"], + ["service", "deployment", "start", "dep_missing", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -216,7 +211,7 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1", ...TARGET, "--json"], + ["service", "deployment", "start", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -239,7 +234,7 @@ describe("prisma-cli service deployment start", () => { }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1", ...TARGET], + ["service", "deployment", "start", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/cli/tests/service-deployment-stop.test.ts b/packages/cli/tests/service-deployment-stop.test.ts index c13618b5..4e857de9 100644 --- a/packages/cli/tests/service-deployment-stop.test.ts +++ b/packages/cli/tests/service-deployment-stop.test.ts @@ -65,16 +65,13 @@ function stopRoutes( }), }; } - -const TARGET = ["--project", "acme-app", "--service", "hello-world"]; - describe("prisma-cli service deployment stop", () => { it("stops a running deployment and reports it stopped", async () => { const stop = stopRoutes(); const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2", ...TARGET], + ["service", "deployment", "stop", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -87,7 +84,6 @@ describe("prisma-cli service deployment stop", () => { outcome: "ok", }); expect(result.presented?.data).toMatchObject({ - projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, deployment: { id: "dep_2", status: "stopped" }, alreadyInState: false, @@ -103,7 +99,6 @@ describe("prisma-cli service deployment stop", () => { ).toEqual({ kind: "fields", rows: [ - { label: "project", value: "proj_1" }, { label: "service", value: "hello-world" }, { label: "deployment", value: "dep_2" }, { label: "status", value: "stopped" }, @@ -121,7 +116,7 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2", ...TARGET], + ["service", "deployment", "stop", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -138,7 +133,7 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_1", ...TARGET], + ["service", "deployment", "stop", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -170,7 +165,7 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2", ...TARGET, "--json"], + ["service", "deployment", "stop", "dep_2", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -188,7 +183,7 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_missing", ...TARGET, "--json"], + ["service", "deployment", "stop", "dep_missing", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -206,7 +201,7 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2", ...TARGET, "--json"], + ["service", "deployment", "stop", "dep_2", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -229,7 +224,7 @@ describe("prisma-cli service deployment stop", () => { }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2", ...TARGET], + ["service", "deployment", "stop", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index f4dd14a8..da6be25c 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -278,7 +278,7 @@ describe("prisma-cli service domain add", () => { expect(frame.envelope.nextActions).toEqual([ { kind: "user-choice", - label: "Unset PRISMA_SERVICE_ID, or pass --service .", + label: "Unset PRISMA_SERVICE_ID, or pass a service name.", }, { kind: "run-command", @@ -288,7 +288,7 @@ describe("prisma-cli service domain add", () => { ]); }); - it("requires --service or PRISMA_SERVICE_ID", async () => { + it("requires a service or PRISMA_SERVICE_ID", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -312,7 +312,7 @@ describe("prisma-cli service domain add", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); - expect(frame.envelope.error.summary).toContain("--service"); + expect(frame.envelope.error.summary).toContain("requires a service"); expect(frame.envelope.nextActions).toContainEqual({ kind: "run-command", label: "List services", diff --git a/packages/cli/tests/service-logs.test.ts b/packages/cli/tests/service-logs.test.ts index 4c88b0bb..0f2cb6df 100644 --- a/packages/cli/tests/service-logs.test.ts +++ b/packages/cli/tests/service-logs.test.ts @@ -103,7 +103,7 @@ function dataLines(events: readonly { kind: string }[]): string[] { .map((output) => output.line); } -const TARGET = ["--project", "acme-app", "--service", "hello-world"]; +const TARGET = ["--project", "acme-app", "hello-world"]; /** Polling is instant so a follow test does not wait on the 2s default. */ const FAST_POLL = { PRISMA_CLI_SERVICE_LOGS_POLL_MS: "0" }; @@ -225,7 +225,7 @@ describe("prisma-cli service logs", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); - expect(frame.envelope.error.summary).toContain("--service"); + expect(frame.envelope.error.summary).toContain("requires a service"); }); it("resolves --deployment within the named service", async () => { @@ -327,42 +327,25 @@ describe("prisma-cli service logs", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); }); - it("settles a deployment owned by another project as its own failure", async () => { + it("resolves --deployment purely by id, with no project resolution", async () => { const queries: Array | undefined> = []; const harness = await makeServiceCli({ - routes: { - ...logRoutes([[end(null)]], queries), - // The deployment's owning service is found by the global scan - // (no branch scope), but the resolved project's own listing is - // branch-scoped and does not contain it. - "GET /v1/apps": (init) => ({ - data: init.params?.query?.branchGitName ? page([]) : page([SERVICE]), - }), - }, + routes: logRoutes([[log("from dep_1"), end("7")]], queries), }); const result = await harness.cli.run( - [ - "service", - "logs", - "--deployment", - "dep_1", - "--project", - "acme-app", - "--json", - ], + ["service", "logs", "--deployment", "dep_1"], { cwd: harness.cwd, env: harness.env }, ); - expect(result.exitCode).toBe(2); - const frame = result.json[result.json.length - 1]; - if (frame?.kind !== "result" || frame.envelope.ok) { - throw new Error("expected an errored envelope"); - } - expect(frame.envelope.error.code).toBe( - "SERVICE.DEPLOYMENT_OUTSIDE_PROJECT", - ); - expect(queries).toEqual([]); + expect(result.exitCode).toBe(0); + expect(dataLines(result.events)).toEqual(["from dep_1"]); + // The run resolved no project, so the header names none. + expect( + outputs(result.events).some((output) => + String(output.line).startsWith("project:"), + ), + ).toBe(false); }); it("settles a service with no live deployment as SERVICE.NO_DEPLOYMENTS", async () => { diff --git a/packages/cli/tests/service-open.test.ts b/packages/cli/tests/service-open.test.ts index d6769b0c..b0af6721 100644 --- a/packages/cli/tests/service-open.test.ts +++ b/packages/cli/tests/service-open.test.ts @@ -14,7 +14,7 @@ describe("prisma-cli service open", () => { const harness = await makeServiceCli({ openUrl: opener }); const result = await harness.cli.run( - ["service", "open", "--project", "acme-app", "--service", "hello-world"], + ["service", "open", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -41,7 +41,7 @@ describe("prisma-cli service open", () => { { kind: "run-command", label: "Inspect the service", - command: "prisma-cli service show --service hello-world", + command: "prisma-cli service show hello-world", }, { kind: "run-command", @@ -56,7 +56,7 @@ describe("prisma-cli service open", () => { const harness = await makeServiceCli({ openUrl: opener }); const result = await harness.cli.run( - ["service", "open", "--project", "acme-app", "--service", "hello-world"], + ["service", "open", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, @@ -87,7 +87,7 @@ describe("prisma-cli service open", () => { }); const result = await harness.cli.run( - ["service", "open", "--project", "acme-app", "--service", "hello-world"], + ["service", "open", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, @@ -113,15 +113,7 @@ describe("prisma-cli service open", () => { }); const result = await harness.cli.run( - [ - "service", - "open", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "open", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -136,7 +128,7 @@ describe("prisma-cli service open", () => { { kind: "run-command", label: "Inspect the service", - command: "prisma-cli service show --service hello-world", + command: "prisma-cli service show hello-world", }, ]); }); @@ -151,15 +143,7 @@ describe("prisma-cli service open", () => { }); const result = await harness.cli.run( - [ - "service", - "open", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "open", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -175,15 +159,7 @@ describe("prisma-cli service open", () => { const harness = await makeServiceCli(); const result = await harness.cli.run( - [ - "service", - "open", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "open", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); diff --git a/packages/cli/tests/service-session.test.ts b/packages/cli/tests/service-session.test.ts index f8ffdf8f..2cf65c6a 100644 --- a/packages/cli/tests/service-session.test.ts +++ b/packages/cli/tests/service-session.test.ts @@ -90,7 +90,7 @@ describe("prisma-cli service — the workspace comes from the engine session", ( const harness = await makeServiceCli({ routes: workspaceRoutes() }); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "hello-world"], + ["service", "show", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env }, ); @@ -110,7 +110,7 @@ describe("prisma-cli service — the workspace comes from the engine session", ( const harness = await makeServiceCli({ routes: prefixedWorkspaceRoutes() }); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "hello-world"], + ["service", "show", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env }, ); @@ -134,15 +134,7 @@ describe("prisma-cli service — the workspace comes from the engine session", ( }); const result = await harness.cli.run( - [ - "service", - "show", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "show", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -160,15 +152,7 @@ describe("prisma-cli service — the workspace comes from the engine session", ( const harness = await makeServiceCli({ routes: workspaceRoutes() }); const result = await harness.cli.run( - [ - "service", - "show", - "--project", - "no-such-app", - "--service", - "hello-world", - "--json", - ], + ["service", "show", "--project", "no-such-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -300,15 +284,7 @@ describe("prisma-cli service — the workspace comes from the engine session", ( }); const result = await harness.cli.run( - [ - "service", - "show", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "show", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); diff --git a/packages/cli/tests/service-show.test.ts b/packages/cli/tests/service-show.test.ts index 60c45541..2b6d121a 100644 --- a/packages/cli/tests/service-show.test.ts +++ b/packages/cli/tests/service-show.test.ts @@ -14,7 +14,7 @@ describe("prisma-cli service show", () => { const harness = await makeServiceCli(); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "hello-world"], + ["service", "show", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -66,7 +66,6 @@ describe("prisma-cli service show", () => { "show", "--project", "acme-app", - "--service", "hello-world", "--branch", "staging", @@ -97,7 +96,7 @@ describe("prisma-cli service show", () => { }); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "hello-world"], + ["service", "show", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -112,7 +111,7 @@ describe("prisma-cli service show", () => { const harness = await makeServiceCli(); await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "hello-world"], + ["service", "show", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env }, ); @@ -142,15 +141,7 @@ describe("prisma-cli service show", () => { const harness = await makeServiceCli(); const result = await harness.cli.run( - [ - "service", - "show", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "show", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -181,15 +172,7 @@ describe("prisma-cli service show", () => { }); const result = await harness.cli.run( - [ - "service", - "show", - "--project", - "acme-app", - "--service", - "hello-world", - "--json", - ], + ["service", "show", "--project", "acme-app", "hello-world", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -214,19 +197,11 @@ describe("prisma-cli service show", () => { expect(result.stderr).toContain("CLI.CREDENTIALS_REQUIRED"); }); - it("rejects an unknown --service name as SERVICE.SELECTION_INVALID", async () => { + it("rejects an unknown service name as SERVICE.SELECTION_INVALID", async () => { const harness = await makeServiceCli(); const result = await harness.cli.run( - [ - "service", - "show", - "--project", - "acme-app", - "--service", - "nope", - "--json", - ], + ["service", "show", "--project", "acme-app", "nope", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -269,7 +244,7 @@ describe("prisma-cli service show", () => { }); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "--service", "api"], + ["service", "show", "--project", "acme-app", "api"], { cwd: harness.cwd, env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, @@ -297,10 +272,10 @@ describe("prisma-cli service show", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain("SERVICE.TARGET_REQUIRED"); - expect(result.stderr).toContain("--service"); + expect(result.stderr).toContain("requires a service"); }); - it("names --service in the structured missing-target error", async () => { + it("names the service argument in the structured missing-target error", async () => { const harness = await makeServiceCli(); const result = await harness.cli.run( @@ -314,7 +289,7 @@ describe("prisma-cli service show", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); - expect(frame.envelope.error.summary).toContain("--service"); + expect(frame.envelope.error.summary).toContain("requires a service"); expect(frame.envelope.nextActions).toContainEqual({ kind: "run-command", label: "List services", From 3cae5906038d284c61118e5c7cb4a09ab599815c Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 15:29:04 +0200 Subject: [PATCH 22/27] project env list never infers scope from the git checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling: extend parameters-only targeting to project env. A bare `project env list` now lists the overview of every scope instead of resolving the checkout's branch against the platform; --role and --branch are the only scope selectors, as they already were for add/update/delete. readLocalGitBranch and lib/git/local-branch.ts are deleted — nothing else imported them — and the "local-git" target source is gone from the list result shape. Also sharpened the deferred-ledger entry on deployment lookups with what the API actually returns: GET /v1/deployments/{id} omits the parent appId, which is why the CLI scans for the owner. The fix belongs in pdp-control-plane's deployment representation. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 4 +- packages/cli/src/commands/project/env-list.ts | 2 +- packages/cli/src/controllers/app-env.ts | 62 +-------- packages/cli/src/lib/git/local-branch.ts | 82 ------------ packages/cli/src/types/app-env.ts | 2 +- packages/cli/tests/local-branch.test.ts | 68 ---------- packages/cli/tests/project.test.ts | 126 +----------------- 7 files changed, 12 insertions(+), 334 deletions(-) delete mode 100644 packages/cli/src/lib/git/local-branch.ts delete mode 100644 packages/cli/tests/local-branch.test.ts diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 6e80875b..0c8db015 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -445,10 +445,10 @@ Still open: The cleanup PR removed the compute config and `init`, made service commands parameter-only, renamed the six destructive `remove` commands to `delete`, moved `postgres restore`/`ref *`/`migrate`/`format`/`composer dev|deploy`, and dropped `composer destroy|log` and the `build` group. Deliberately left behind: -- **`project env` still infers scope from the current git branch.** `controllers/app-env.ts` imports `readLocalGitBranch` — the same ambient context the brief removed from the service commands, but the brief enumerated service commands only. Extending "parameters only" to `project env` is a product call. +- ~~**`project env` still infers scope from the current git branch.**~~ Closed on the PR branch (2026-08-21, operator ruling): `project env list` with no `--role`/`--branch` lists the overview instead of inferring from the checkout; `readLocalGitBranch` and `lib/git/local-branch.ts` are deleted. - ~~**`knownLiveDeploymentByProject` has no writer.**~~ Closed on the PR branch (2026-08-21): the local-state shape, its store methods, and `service delete`'s cleanup pass were deleted. - **Upstream family cleanups.** The shell now wraps both external families: composer still ships `destroy`/`log` commands (and their help) that nothing mounts, and orm-toolchain still keys its family `ref *` and ships the `migration ref` → `ref` redirect the wrapper drops, plus a `migration apply` replacement that says `migrate`. Each repo should retire those surfaces so the wrapper shrinks to a pass-through. - **`PRISMA_PROJECT_ID` is honoured only by the domain commands** (pre-existing): the other service commands take `--project` and the link file but not the env var. Unify or document. - **orm-toolchain's shipped help examples name retired spellings.** Six commands' shipped examples start with the family's own key — `format`, `migrate`, `ref list|set|delete`, and `init` — which the mounts respell to `contract format`, `db migrate`, `migration ref *` and `orm init`. The shell wrapper rewrites the examples (D4-1 ruling) until orm-toolchain updates its own. - ~~**The deployment-id targeting asymmetry is undocumented.**~~ Closed on the PR branch (2026-08-21): every deployment-id command (`promote|start|stop|delete|show`, `logs --deployment`) now resolves the id globally with no service parameter, per the "Subjects are positional" ruling. -- **The management API has no direct deployment→service lookup.** `showDeployment` finds the owning service via `findAppForDeployment`, a scan of every project's service list and each service's deployments. Every id-targeted command pays that scan per run. An API endpoint answering "which app owns deployment X" would collapse it to one call. +- **`GET /v1/deployments/{id}` omits the parent `appId`.** Verified against `@prisma/management-api-sdk@1.55.0`: the response carries id/status/url/previewDomain/envVars/createdAt and no owning-app pointer, so `showDeployment` finds the owner via `findAppForDeployment` — a scan of every project's service list and each service's deployments — and every id-targeted command pays it per run. The fix is in pdp-control-plane: include `appId` in the deployment representation; the CLI then swaps the scan for one `GET /v1/apps/{appId}`. diff --git a/packages/cli/src/commands/project/env-list.ts b/packages/cli/src/commands/project/env-list.ts index 14d848a7..5b2efd75 100644 --- a/packages/cli/src/commands/project/env-list.ts +++ b/packages/cli/src/commands/project/env-list.ts @@ -112,7 +112,7 @@ export const projectEnvListCommand = defineCommand({ ctx.api, projectId, explicit ?? undefined, - { cwd: ctx.cwd, signal: ctx.signal }, + { signal: ctx.signal }, ); const rows = diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index f7b31aa7..4db65f1b 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -11,7 +11,6 @@ import { type EnvFileAssignment, readEnvFileAssignments, } from "../lib/app/env-file"; -import { readLocalGitBranch } from "../lib/git/local-branch"; import type { EnvListTarget, EnvScopeDescriptor } from "../types/app-env"; import { apiCallError, @@ -178,11 +177,13 @@ export async function resolveScopeToApi( }; } +/** No explicit scope lists the overview of every scope. Nothing is + * inferred from ambient context: what runs is what was named. */ export async function resolveListScopeToApi( client: ManagementApiClient, projectId: string, explicit: EnvScope | undefined, - options: { cwd: string; signal: AbortSignal }, + options: { signal: AbortSignal }, ): Promise { if (explicit) { const resolved = await resolveScopeToApi(client, projectId, explicit, { @@ -198,63 +199,6 @@ export async function resolveListScopeToApi( }; } - const gitBranch = await readLocalGitBranch(options.cwd, options.signal); - if (gitBranch) { - const branch = ( - await listBranchesByName(client, projectId, gitBranch, options.signal) - )[0]; - if (!branch) { - return { - kind: "scoped", - descriptor: { kind: "role", role: "preview" }, - target: { - source: "local-git", - branchName: gitBranch, - branchExists: false, - envMap: "preview", - }, - apiTarget: { class: "preview", branchId: null }, - addScope: { kind: "branch", branchName: gitBranch }, - }; - } - - if (branch.role === "production") { - return { - kind: "scoped", - descriptor: { kind: "role", role: "production" }, - target: { - source: "local-git", - branchName: branch.gitName, - branchId: branch.id, - branchRole: branch.role, - branchExists: true, - envMap: "production", - }, - apiTarget: { class: "production", branchId: null }, - addScope: { kind: "role", role: "production" }, - }; - } - - return { - kind: "scoped", - descriptor: { - kind: "branch", - branchName: branch.gitName, - branchId: branch.id, - }, - target: { - source: "local-git", - branchName: branch.gitName, - branchId: branch.id, - branchRole: branch.role, - branchExists: true, - envMap: "preview", - }, - apiTarget: { class: "preview", branchId: branch.id }, - addScope: { kind: "branch", branchName: branch.gitName }, - }; - } - return { kind: "overview", descriptor: { kind: "overview" }, diff --git a/packages/cli/src/lib/git/local-branch.ts b/packages/cli/src/lib/git/local-branch.ts deleted file mode 100644 index 8e97533e..00000000 --- a/packages/cli/src/lib/git/local-branch.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { access, readFile } from "node:fs/promises"; -import path from "node:path"; - -/** - * Resolves the checked-out branch the way git does: the nearest `.git` - * (directory or worktree file) from `cwd` upward owns the answer, so - * monorepo commands run from inside a package see the repository branch. - * Returns null for detached HEAD or when no repository contains `cwd`. - */ -export async function readLocalGitBranch( - cwd: string, - signal: AbortSignal, -): Promise { - for (let directory = path.resolve(cwd); ; ) { - // biome-ignore lint/performance/noAwaitInLoops: the walk stops at the first directory that holds a `.git`, and which directory that is decides whether the parent is looked at at all. - const headPath = await resolveGitHeadPath( - path.join(directory, ".git"), - signal, - ); - if (headPath) { - // This repository owns cwd; never walk past it to an outer repository. - return readBranchFromHead(headPath, signal); - } - - const parent = path.dirname(directory); - if (parent === directory) { - return null; - } - directory = parent; - } -} - -async function readBranchFromHead( - headPath: string, - signal: AbortSignal, -): Promise { - try { - const head = ( - await readFile(headPath, { encoding: "utf8", signal }) - ).trim(); - const refPrefix = "ref: refs/heads/"; - if (head.startsWith(refPrefix)) { - return head.slice(refPrefix.length); - } - } catch (error) { - if (signal.aborted) throw error; - } - - return null; -} - -async function resolveGitHeadPath( - gitPath: string, - signal: AbortSignal, -): Promise { - signal.throwIfAborted(); - try { - const raw = await readFile(gitPath, { encoding: "utf8", signal }); - const prefix = "gitdir:"; - if (raw.startsWith(prefix)) { - return path.join( - path.resolve(path.dirname(gitPath), raw.slice(prefix.length).trim()), - "HEAD", - ); - } - } catch (error) { - if (signal.aborted) throw error; - // Fall through to try the normal .git directory shape below. - // Common cases: EISDIR (normal git repo), EACCES, ENOENT. - } - - signal.throwIfAborted(); - try { - // access does not accept AbortSignal; check before and after the filesystem boundary. - await access(path.join(gitPath, "HEAD")); - signal.throwIfAborted(); - return path.join(gitPath, "HEAD"); - } catch (error) { - if (signal.aborted) throw error; - return null; - } -} diff --git a/packages/cli/src/types/app-env.ts b/packages/cli/src/types/app-env.ts index 77d2298c..970d1583 100644 --- a/packages/cli/src/types/app-env.ts +++ b/packages/cli/src/types/app-env.ts @@ -13,7 +13,7 @@ export type EnvScopeDescriptor = | { kind: "overview" }; export interface EnvListTarget { - source: "explicit" | "local-git" | "overview"; + source: "explicit" | "overview"; envMap: "production" | "preview" | "overview"; branchName?: string; branchId?: string; diff --git a/packages/cli/tests/local-branch.test.ts b/packages/cli/tests/local-branch.test.ts deleted file mode 100644 index 02f5798c..00000000 --- a/packages/cli/tests/local-branch.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { readLocalGitBranch } from "../src/lib/git/local-branch"; -import { createTempCwd } from "./helpers"; - -const signal = new AbortController().signal; - -async function writeGitHead(repoDir: string, head: string): Promise { - await mkdir(path.join(repoDir, ".git"), { recursive: true }); - await writeFile(path.join(repoDir, ".git", "HEAD"), `${head}\n`, "utf8"); -} - -describe("readLocalGitBranch", () => { - it("reads the branch from the repository at cwd", async () => { - const repo = await createTempCwd(); - await writeGitHead(repo, "ref: refs/heads/feat/api"); - - expect(await readLocalGitBranch(repo, signal)).toBe("feat/api"); - }); - - it("walks up to the repository root from inside a monorepo package", async () => { - const repo = await createTempCwd(); - await writeGitHead(repo, "ref: refs/heads/feat/compute"); - const packageDir = path.join(repo, "apps", "api", "src"); - await mkdir(packageDir, { recursive: true }); - - expect(await readLocalGitBranch(packageDir, signal)).toBe("feat/compute"); - }); - - it("treats the nearest repository as the boundary even when its HEAD is detached", async () => { - const outer = await createTempCwd(); - await writeGitHead(outer, "ref: refs/heads/outer-branch"); - const inner = path.join(outer, "vendored"); - await writeGitHead(inner, "0123456789abcdef0123456789abcdef01234567"); - - expect(await readLocalGitBranch(inner, signal)).toBeNull(); - }); - - it("supports worktree-style .git files pointing at the real git directory", async () => { - const base = await createTempCwd(); - const gitDir = path.join(base, "real-git"); - await mkdir(gitDir, { recursive: true }); - await writeFile( - path.join(gitDir, "HEAD"), - "ref: refs/heads/worktree-branch\n", - "utf8", - ); - const worktree = path.join(base, "tree"); - await mkdir(worktree, { recursive: true }); - await writeFile( - path.join(worktree, ".git"), - `gitdir: ${path.join("..", "real-git")}\n`, - "utf8", - ); - - const nested = path.join(worktree, "apps", "web"); - await mkdir(nested, { recursive: true }); - expect(await readLocalGitBranch(nested, signal)).toBe("worktree-branch"); - }); - - it("returns null when no repository contains cwd", async () => { - const dir = await createTempCwd(); - expect(await readLocalGitBranch(dir, signal)).toBeNull(); - }); -}); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 008ddd97..28fac1dd 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -2102,7 +2102,7 @@ describe("prisma-cli project env list", () => { }); }); - it("labels a local git branch that the platform does not know yet", async () => { + it("ignores the local git branch and lists the overview when no scope is passed", async () => { const cwd = await pinnedCwd(); await mkdir(path.join(cwd, ".git"), { recursive: true }); await writeFile( @@ -2115,32 +2115,12 @@ describe("prisma-cli project env list", () => { { cwd, isTty: { stdout: true } }, ); + // Nothing is inferred from ambient context: the checkout branch + // never selects a scope. expect(result.presented?.data).toMatchObject({ - target: { - source: "local-git", - branchName: "feature/foo", - branchExists: false, - envMap: "preview", - }, - }); - expect( - blocks(result.presented).find((block) => block.kind === "fields"), - ).toEqual({ - kind: "fields", - rows: [ - { - label: "target", - value: "branch:feature/foo -> preview (not created yet)", - }, - ], + scope: { kind: "overview" }, + target: { source: "overview", envMap: "overview" }, }); - expect(result.presented?.presentation.next).toEqual([ - { - kind: "run-command", - label: "prisma-cli project env add KEY=value --branch feature/foo", - command: "prisma-cli project env add KEY=value --branch feature/foo", - }, - ]); }); it("suggests adding a variable when the scope is empty", async () => { @@ -2215,102 +2195,6 @@ describe("prisma-cli project env list", () => { error: { code: "CLI.CREDENTIALS_REQUIRED" }, }); }); - - it("targets the preview overrides of a local branch the platform knows", async () => { - const cwd = await pinnedCwd(); - await mkdir(path.join(cwd, ".git"), { recursive: true }); - await writeFile( - path.join(cwd, ".git", "HEAD"), - "ref: refs/heads/feature/foo\n", - "utf8", - ); - const result = await makeCli( - envClient({ - branches: [ - { - id: "br_feature", - gitName: "feature/foo", - role: "preview", - isDefault: false, - }, - ], - variables: [ - envRow({ id: "env_role", key: "SHARED", class: "preview" }), - envRow({ - id: "env_branch", - key: "SHARED", - class: "preview", - branchId: "br_feature", - }), - ], - }), - ).run(["project", "env", "list"], { cwd, isTty: { stdout: true } }); - - expect(result.exitCode).toBe(0); - expect(result.presented?.data).toMatchObject({ - scope: { - kind: "branch", - branchName: "feature/foo", - branchId: "br_feature", - }, - target: { - source: "local-git", - branchName: "feature/foo", - branchId: "br_feature", - branchRole: "preview", - branchExists: true, - envMap: "preview", - }, - variables: [{ id: "env_branch", source: "branch:feature/foo" }], - }); - expect( - blocks(result.presented).find((block) => block.kind === "fields"), - ).toEqual({ - kind: "fields", - rows: [{ label: "target", value: "branch:feature/foo -> preview" }], - }); - }); - - it("targets production when the local branch is the production branch", async () => { - const cwd = await pinnedCwd(); - await mkdir(path.join(cwd, ".git"), { recursive: true }); - await writeFile( - path.join(cwd, ".git", "HEAD"), - "ref: refs/heads/main\n", - "utf8", - ); - const result = await makeCli( - envClient({ - branches: [ - { - id: "br_main", - gitName: "main", - role: "production", - isDefault: true, - }, - ], - variables: [envRow()], - }), - ).run(["project", "env", "list"], { cwd, isTty: { stdout: true } }); - - expect(result.exitCode).toBe(0); - expect(result.presented?.data).toMatchObject({ - scope: { kind: "role", role: "production" }, - target: { - source: "local-git", - branchName: "main", - branchRole: "production", - branchExists: true, - envMap: "production", - }, - }); - expect( - blocks(result.presented).find((block) => block.kind === "fields"), - ).toEqual({ - kind: "fields", - rows: [{ label: "target", value: "branch:main -> production" }], - }); - }); }); describe("prisma-cli project env delete", () => { From c891a7b37c333943b93af866dc7ee6037791b521 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 15:39:09 +0200 Subject: [PATCH 23/27] Remove PRISMA_PROJECT_ID Operator ruling: the env var existed for the headless app deploy flow, which this PR already deleted; the domain commands were its only surviving reader, by accident rather than decision. Project targeting is --project and the link file. The envProjectId option and the allowEnvProjectId split between resolveProjectTarget and inspectProjectBinding collapse with it, and the "env" project source leaves the result types. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 2 +- packages/cli/src/commands/service/target.ts | 9 ---- packages/cli/src/lib/project/resolution.ts | 45 +++---------------- packages/cli/src/types/project.ts | 2 - packages/cli/tests/project-resolution.test.ts | 37 --------------- 5 files changed, 6 insertions(+), 89 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 0c8db015..f0be940c 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -448,7 +448,7 @@ The cleanup PR removed the compute config and `init`, made service commands para - ~~**`project env` still infers scope from the current git branch.**~~ Closed on the PR branch (2026-08-21, operator ruling): `project env list` with no `--role`/`--branch` lists the overview instead of inferring from the checkout; `readLocalGitBranch` and `lib/git/local-branch.ts` are deleted. - ~~**`knownLiveDeploymentByProject` has no writer.**~~ Closed on the PR branch (2026-08-21): the local-state shape, its store methods, and `service delete`'s cleanup pass were deleted. - **Upstream family cleanups.** The shell now wraps both external families: composer still ships `destroy`/`log` commands (and their help) that nothing mounts, and orm-toolchain still keys its family `ref *` and ships the `migration ref` → `ref` redirect the wrapper drops, plus a `migration apply` replacement that says `migrate`. Each repo should retire those surfaces so the wrapper shrinks to a pass-through. -- **`PRISMA_PROJECT_ID` is honoured only by the domain commands** (pre-existing): the other service commands take `--project` and the link file but not the env var. Unify or document. +- ~~**`PRISMA_PROJECT_ID` is honoured only by the domain commands**~~ Closed on the PR branch (2026-08-21, operator ruling): the env var served the deleted `app deploy` headless flow and survived only in the domain commands by accident; it is removed entirely. Project targeting is `--project` and the link file. - **orm-toolchain's shipped help examples name retired spellings.** Six commands' shipped examples start with the family's own key — `format`, `migrate`, `ref list|set|delete`, and `init` — which the mounts respell to `contract format`, `db migrate`, `migration ref *` and `orm init`. The shell wrapper rewrites the examples (D4-1 ruling) until orm-toolchain updates its own. - ~~**The deployment-id targeting asymmetry is undocumented.**~~ Closed on the PR branch (2026-08-21): every deployment-id command (`promote|start|stop|delete|show`, `logs --deployment`) now resolves the id globally with no service parameter, per the "Subjects are positional" ruling. - **`GET /v1/deployments/{id}` omits the parent `appId`.** Verified against `@prisma/management-api-sdk@1.55.0`: the response carries id/status/url/previewDomain/envVars/createdAt and no owning-app pointer, so `showDeployment` finds the owner via `findAppForDeployment` — a scan of every project's service list and each service's deployments — and every id-targeted command pays it per run. The fix is in pdp-control-plane: include `appId` in the deployment representation; the CLI then swaps the scan for one `GET /v1/apps/{appId}`. diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 5077fa32..5770ff6d 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -43,8 +43,6 @@ import type { ServiceSummary, } from "./results"; -const PRISMA_PROJECT_ID_ENV_VAR = "PRISMA_PROJECT_ID"; - /** A hostname's optional root dot, and one DNS label. */ const TRAILING_DOT = /\.$/; const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; @@ -178,7 +176,6 @@ export async function resolveServiceProjectContext( options: { commandName: string; branchName?: string; - envProjectId?: string; }, ): Promise { requireBranchFlagValue(options.branchName); @@ -192,9 +189,6 @@ export async function resolveServiceProjectContext( context: resolutionContext(ctx), workspace, ...(explicitProject !== undefined ? { explicitProject } : {}), - ...(options.envProjectId !== undefined - ? { envProjectId: options.envProjectId } - : {}), listProjects: () => Promise.resolve(projects), commandName: options.commandName, }); @@ -582,13 +576,10 @@ export async function resolveServiceDomainTarget( options.commandName, ); - const envProjectId = readServiceEnvOverride(ctx, PRISMA_PROJECT_ID_ENV_VAR); - const provider = serviceProvider(ctx); const target = await resolveServiceProjectContext(ctx, options.projectRef, { commandName: options.commandName, branchName, - ...(envProjectId !== undefined ? { envProjectId } : {}), }); const projectId = target.project.id; const services = await listServices( diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 351cdd28..7a116673 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -156,7 +156,6 @@ export interface ResolveProjectOptions { context: ProjectResolutionContext; workspace: AuthWorkspace; explicitProject?: string; - envProjectId?: string; commandName?: string; listProjects(): Promise; } @@ -165,15 +164,10 @@ export async function resolveProjectTarget( options: ResolveProjectOptions, ): Promise> { return Result.gen(async function* () { - const localPin = yield* Result.await( - readImplicitLocalPin(options, { allowEnvProjectId: true }), - ); + const localPin = yield* Result.await(readImplicitLocalPin(options)); const projects = await options.listProjects(); const target = yield* Result.await( - resolveBoundProjectTarget(options, projects, { - allowEnvProjectId: true, - localPin, - }), + resolveBoundProjectTarget(options, projects, { localPin }), ); if (target) { @@ -195,15 +189,10 @@ export async function inspectProjectBinding( options: ResolveProjectOptions, ): Promise> { return Result.gen(async function* () { - const localPin = yield* Result.await( - readImplicitLocalPin(options, { allowEnvProjectId: false }), - ); + const localPin = yield* Result.await(readImplicitLocalPin(options)); const projects = await options.listProjects(); const target = yield* Result.await( - resolveBoundProjectTarget(options, projects, { - allowEnvProjectId: false, - localPin, - }), + resolveBoundProjectTarget(options, projects, { localPin }), ); if (target) { @@ -595,7 +584,6 @@ async function resolveBoundProjectTarget( options: ResolveProjectOptions, projects: ProjectCandidate[], settings: { - allowEnvProjectId: boolean; localPin: LocalResolutionPinReadResult | null; }, ): Promise> { @@ -616,23 +604,6 @@ async function resolveBoundProjectTarget( ); } - if (settings.allowEnvProjectId && options.envProjectId) { - const project = projects.find( - (candidate) => candidate.id === options.envProjectId, - ); - if (!project) { - return Result.err( - new ProjectNotFoundError(options.envProjectId, options.workspace), - ); - } - return Result.ok( - resolvedTarget(options.workspace, project, "env", { - targetName: options.envProjectId, - targetNameSource: "env", - }), - ); - } - const localPin = settings.localPin; if (!localPin) { return Result.ok(null); @@ -681,16 +652,10 @@ async function resolveBoundProjectTarget( async function readImplicitLocalPin( options: ResolveProjectOptions, - settings: { - allowEnvProjectId: boolean; - }, ): Promise< Result > { - if ( - options.explicitProject || - (settings.allowEnvProjectId && options.envProjectId) - ) { + if (options.explicitProject) { return Result.ok(null); } diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index 25fc6d82..f7b0eda4 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -9,7 +9,6 @@ export interface ProjectSummary { export type ProjectSource = | "explicit" - | "env" | "local-pin" | "platform-mapping" | "created" @@ -21,7 +20,6 @@ export interface ProjectResolution { targetName?: string | null; targetNameSource?: | "explicit" - | "env" | "local-pin" | "package-name" | "directory-name" diff --git a/packages/cli/tests/project-resolution.test.ts b/packages/cli/tests/project-resolution.test.ts index d6282c05..b6f265e1 100644 --- a/packages/cli/tests/project-resolution.test.ts +++ b/packages/cli/tests/project-resolution.test.ts @@ -115,43 +115,6 @@ describe("project resolution", () => { expect(listProjects).toHaveBeenCalledTimes(1); }); - it("lets PRISMA_PROJECT_ID bypass a mismatched local pin", async () => { - const cwd = await createTempCwd(); - await writeLocalPin(cwd, { - workspaceId: "ws_other", - projectId: "proj_123", - }); - const { context } = await createTestCommandContext({ cwd }); - const listProjects = vi.fn( - async (): Promise => [ - { - id: "proj_env", - name: "Env Project", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - ], - ); - - const result = await resolveProjectTarget({ - context, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - envProjectId: "proj_env", - listProjects, - commandName: "app deploy", - }); - - const resolved = expectOk(result); - expect(resolved.resolution.projectSource).toBe("env"); - expect(resolved.project.id).toBe("proj_env"); - expect(listProjects).toHaveBeenCalledTimes(1); - }); - it("returns LOCAL_STATE_STALE for invalid local pin JSON before listing projects", async () => { const cwd = await createTempCwd(); await writeLocalPinContent(cwd, "{ nope"); From 1d44bf715715895bc281431e3e88231a2cbf8c97 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 17:26:19 +0200 Subject: [PATCH 24/27] Adopt ADR-012 vocabulary: a deploy produces a version, and the CLI says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdp-control-plane ADR-012 retires "Deployment" as a noun — a deploy produces a Version — and the CLI adopts it now, pre-rc, before any release teaches users the retired word. - Commands: service deployment list|show|promote|rollback|start|stop| delete mount as service version …; service logs --deployment becomes --version-id (the engine reserves the flag name "version" for the shared --version flag). - JSON contract: deployment→version, deployments→versions, deploymentId→versionId, liveDeployment→liveVersion, recentDeployments→recentVersions, previousLiveDeploymentId→ previousLiveVersionId, and liveDeploymentId→liveVersionId on list entries. Progress steps stop-deployments/delete-deployments become stop-versions/delete-versions. - Error codes: SERVICE.DEPLOYMENT_*→SERVICE.VERSION_*, NO_DEPLOYMENTS→NO_VERSIONS, NO_PREVIOUS_DEPLOYMENT→ NO_PREVIOUS_VERSION, LIVE_DEPLOYMENT_UNKNOWN→LIVE_VERSION_UNKNOWN. - Copy: help and error prose says "service version" (qualified, per the ADR); example ids use the real cpv_ prefix. - The wire layer deliberately keeps platform vocabulary until the platform's own coordinated rename: /v1/deployments paths, compute-sdk names, appId, and the adapter in lib/app/app-provider.ts, which is the seam where the two vocabularies meet. Recorded in the ledger. - docs/product/command-principles.md's noun table now says service and version; READMEs, the slice-spec amendment, tests, and the e2e suite follow. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 2 + .../specs/command-grammar-cleanup.md | 4 + README.md | 4 +- docs/product/command-principles.md | 10 +- packages/cli/README.md | 6 +- packages/cli/e2e/deployed-service.ts | 6 +- ...ployment.e2e.ts => service-version.e2e.ts} | 98 +++++----- packages/cli/src/cli.ts | 46 ++--- packages/cli/src/commands/service/delete.ts | 2 +- .../src/commands/service/deployment-show.ts | 56 ------ .../src/commands/service/deployment-start.ts | 24 --- .../src/commands/service/deployment-stop.ts | 24 --- packages/cli/src/commands/service/errors.ts | 95 +++++----- packages/cli/src/commands/service/logs.ts | 96 +++++----- packages/cli/src/commands/service/open.ts | 20 +-- .../cli/src/commands/service/presentation.ts | 169 ++++++++---------- packages/cli/src/commands/service/release.ts | 24 +-- packages/cli/src/commands/service/results.ts | 38 ++-- packages/cli/src/commands/service/show.ts | 26 +-- packages/cli/src/commands/service/target.ts | 44 ++--- ...deployment-delete.ts => version-delete.ts} | 42 ++--- .../{deployment-list.ts => version-list.ts} | 40 ++--- ...ployment-promote.ts => version-promote.ts} | 37 ++-- ...oyment-rollback.ts => version-rollback.ts} | 57 +++--- ...ment-run-state.ts => version-run-state.ts} | 60 +++---- .../cli/src/commands/service/version-show.ts | 54 ++++++ .../cli/src/commands/service/version-start.ts | 27 +++ .../cli/src/commands/service/version-stop.ts | 27 +++ packages/cli/tests/e2e-coverage.test.ts | 4 +- packages/cli/tests/mount-coverage.test.ts | 14 +- packages/cli/tests/service-create.test.ts | 2 +- packages/cli/tests/service-delete.test.ts | 6 +- packages/cli/tests/service-domain.test.ts | 6 +- packages/cli/tests/service-list.test.ts | 4 +- packages/cli/tests/service-logs.test.ts | 40 ++--- packages/cli/tests/service-open.test.ts | 8 +- packages/cli/tests/service-show.test.ts | 6 +- packages/cli/tests/service-testkit.ts | 2 +- ...test.ts => service-version-delete.test.ts} | 58 ++---- ...t.test.ts => service-version-list.test.ts} | 24 +-- ...est.ts => service-version-promote.test.ts} | 38 ++-- ...st.ts => service-version-rollback.test.ts} | 88 ++++----- ...w.test.ts => service-version-show.test.ts} | 38 ++-- ....test.ts => service-version-start.test.ts} | 38 ++-- ...p.test.ts => service-version-stop.test.ts} | 38 ++-- packages/prisma/README.md | 6 +- 46 files changed, 759 insertions(+), 799 deletions(-) rename packages/cli/e2e/{service-deployment.e2e.ts => service-version.e2e.ts} (63%) delete mode 100644 packages/cli/src/commands/service/deployment-show.ts delete mode 100644 packages/cli/src/commands/service/deployment-start.ts delete mode 100644 packages/cli/src/commands/service/deployment-stop.ts rename packages/cli/src/commands/service/{deployment-delete.ts => version-delete.ts} (52%) rename packages/cli/src/commands/service/{deployment-list.ts => version-list.ts} (60%) rename packages/cli/src/commands/service/{deployment-promote.ts => version-promote.ts} (58%) rename packages/cli/src/commands/service/{deployment-rollback.ts => version-rollback.ts} (65%) rename packages/cli/src/commands/service/{deployment-run-state.ts => version-run-state.ts} (58%) create mode 100644 packages/cli/src/commands/service/version-show.ts create mode 100644 packages/cli/src/commands/service/version-start.ts create mode 100644 packages/cli/src/commands/service/version-stop.ts rename packages/cli/tests/{service-deployment-delete.test.ts => service-version-delete.test.ts} (86%) rename packages/cli/tests/{service-deployment-list.test.ts => service-version-list.test.ts} (87%) rename packages/cli/tests/{service-deployment-promote.test.ts => service-version-promote.test.ts} (83%) rename packages/cli/tests/{service-deployment-rollback.test.ts => service-version-rollback.test.ts} (87%) rename packages/cli/tests/{service-deployment-show.test.ts => service-version-show.test.ts} (82%) rename packages/cli/tests/{service-deployment-start.test.ts => service-version-start.test.ts} (85%) rename packages/cli/tests/{service-deployment-stop.test.ts => service-version-stop.test.ts} (84%) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index f0be940c..59b8f3ce 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -445,6 +445,8 @@ Still open: The cleanup PR removed the compute config and `init`, made service commands parameter-only, renamed the six destructive `remove` commands to `delete`, moved `postgres restore`/`ref *`/`migrate`/`format`/`composer dev|deploy`, and dropped `composer destroy|log` and the `build` group. Deliberately left behind: +- **The wire layer still speaks App/Deployment.** The CLI surface says Service/Version (ADR-012), while the adapter (`packages/cli/src/lib/app/app-provider.ts`), compute-sdk names, `/v1/deployments` paths, and `appId` keep platform vocabulary. They rename in pdp-control-plane's coordinated all-surfaces pass, and the adapter is the one file where both vocabularies are allowed to meet until then. + - ~~**`project env` still infers scope from the current git branch.**~~ Closed on the PR branch (2026-08-21, operator ruling): `project env list` with no `--role`/`--branch` lists the overview instead of inferring from the checkout; `readLocalGitBranch` and `lib/git/local-branch.ts` are deleted. - ~~**`knownLiveDeploymentByProject` has no writer.**~~ Closed on the PR branch (2026-08-21): the local-state shape, its store methods, and `service delete`'s cleanup pass were deleted. - **Upstream family cleanups.** The shell now wraps both external families: composer still ships `destroy`/`log` commands (and their help) that nothing mounts, and orm-toolchain still keys its family `ref *` and ships the `migration ref` → `ref` redirect the wrapper drops, plus a `migration apply` replacement that says `migrate`. Each repo should retire those surfaces so the wrapper shrinks to a pass-through. diff --git a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md index da08f3b8..af85afa5 100644 --- a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md +++ b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md @@ -113,3 +113,7 @@ feedback ## Amendment (2026-08-21, operator ruling on PR #218) §2's `--service ` targeting is superseded: any command that operates on a subject resource takes that resource's identifier as its first positional argument (recorded as "Subjects are positional" in `docs/product/command-principles.md`). Concretely: `service show|open|logs|delete ` and `service deployment list|rollback ` take the service name as an optional positional (PRISMA_SERVICE_ID stays as the env fallback; neither present is still the SERVICE.TARGET_REQUIRED refusal). `service deployment promote|start|stop|delete ` and `service logs --deployment ` are targeted by the globally-unique deployment id alone, resolved the way `service deployment show` always was — they take no `--service`, `--project`, or `--branch`, and their results carry no `projectId`. `project show [id-or-name]` follows the same rule. Domain commands keep `--service` as a scope flag: their positional is the hostname, and the management API has no global hostname lookup. + +## Amendment (2026-08-21, ADR-012 vocabulary) + +pdp-control-plane ADR-012 retires "Deployment" as a noun (a deploy produces a Version) and renames App to Service across every surface. The CLI adopts it now, pre-rc, on this branch: `service deployment *` mounts become `service version *`, `service logs --deployment` becomes `--version-id` (`version` is an engine-reserved flag name), JSON result fields respell (`deployment`→`version`, `deploymentId`→`versionId`, `liveDeployment`→`liveVersion`, `recentDeployments`→`recentVersions`, `previousLiveDeploymentId`→`previousLiveVersionId`, `liveDeploymentId`→`liveVersionId` on list entries), error codes respell (`SERVICE.DEPLOYMENT_*`→`SERVICE.VERSION_*`, `NO_DEPLOYMENTS`→`NO_VERSIONS`, `NO_PREVIOUS_DEPLOYMENT`→`NO_PREVIOUS_VERSION`, `LIVE_DEPLOYMENT_UNKNOWN`→`LIVE_VERSION_UNKNOWN`), progress steps respell (`stop-deployments`→`stop-versions`, `delete-deployments`→`delete-versions`), and all help/error prose says "service version" (qualified, per the ADR; example ids use the real `cpv_` prefix). The wire layer deliberately keeps platform vocabulary until the platform's own coordinated rename: `/v1/deployments` paths, compute-sdk names, `appId`, and the adapter in `packages/cli/src/lib/app/app-provider.ts`, which is the seam where the two vocabularies meet. diff --git a/README.md b/README.md index 1cee797e..0e121237 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Beta of the unified Prisma CLI. This repository contains the unified Prisma command-line experience: one binary for the ORM, Composer, and the Prisma Developer Platform — projects, -branches, services, deployments, environment variables, and the Prisma ORM +branches, services, service versions, environment variables, and the Prisma ORM schema and migration workflow. The 8.0.0 release candidates publish as `prisma` (binary `prisma`) and @@ -84,7 +84,7 @@ The canonical command shape is: prisma ``` -The package includes project, environment-variable, service and deployment inspection, promotion, rollback, and deletion commands, plus the Prisma ORM (`contract`, `db`, `migration`, `orm init`) and Composer workflows (root `dev` and `deploy`), and the `postgres` and `bucket` resource groups. The product model intentionally avoids product-specific namespaces. +The package includes project, environment-variable, service and service-version inspection, promotion, rollback, and deletion commands, plus the Prisma ORM (`contract`, `db`, `migration`, `orm init`) and Composer workflows (root `dev` and `deploy`), and the `postgres` and `bucket` resource groups. The product model intentionally avoids product-specific namespaces. ## Documentation diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index ee8c5b27..2816cc83 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -50,8 +50,8 @@ The CLI should keep the meaning of these nouns stable: - `schema` - `database` - `bucket` -- `app` -- `deployment` +- `service` +- `version` (always qualified in prose: a service version) - `domain` If a noun means one thing in docs and a different thing in commands or output, the model is already drifting. @@ -95,7 +95,7 @@ Build and release an app into a target branch. ### `logs` -Resolve a deployment and show or stream its logs. +Resolve a service version and show or stream its logs. ### `delete` and `remove` @@ -103,7 +103,7 @@ Resolve a deployment and show or stream its logs. ### Subjects are positional -A command that operates on a subject resource takes that resource's identifier as its first positional argument (`service show my-api`, `postgres delete db_123`, `service deployment promote dep_123`) — the established convention across CLIs. Flags never name the subject; they scope or qualify it (`--project`, `--branch`, `--role`). When the subject's identifier is globally unique — a deployment id, a bucket id — the id alone is the complete target, and the command asks for no redundant parent scope. +A command that operates on a subject resource takes that resource's identifier as its first positional argument (`service show my-api`, `postgres delete db_123`, `service version promote cpv_123`) — the established convention across CLIs. Flags never name the subject; they scope or qualify it (`--project`, `--branch`, `--role`). When the subject's identifier is globally unique — a service version id, a bucket id — the id alone is the complete target, and the command asks for no redundant parent scope. ### `wait` @@ -121,7 +121,7 @@ In the MVP, that means preview source plus production rebuild. ### `rollback` -Return traffic to a previous healthy deployment without rebuilding. +Return traffic to a previous healthy service version without rebuilding. In the MVP, that means production rollback only. diff --git a/packages/cli/README.md b/packages/cli/README.md index 42ab60c4..4d747a38 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -16,7 +16,7 @@ Prisma Developer Platform. It is one binary for the ORM, Composer, and the Prisma Developer -Platform: projects, branches, services, deployments, environment +Platform: projects, branches, services, service versions, environment variables, and the Prisma ORM schema and migration workflow. --- @@ -74,7 +74,7 @@ The beta package exposes `prisma-cli` so it can coexist with the existing | `branch` | List Prisma branches for the resolved project. | | `postgres` | Create, inspect, back up, restore, and delete Prisma Postgres databases and their connections. | | `bucket` | Create, list, and delete object-store buckets and their access keys. | -| `service` | Inspect services: deployments, logs, domains, promote, roll back, delete. | +| `service` | Inspect services: versions, logs, domains, promote, roll back, delete. | | `dev`, `deploy` | Run a Composer app locally; deploy it to the platform. | | `contract`, `db`, `migration`, `orm init`, `lsp` | The Prisma ORM workflow. | @@ -86,7 +86,7 @@ npx prisma-cli auth whoami npx prisma-cli project show npx prisma-cli branch list npx prisma-cli service list -npx prisma-cli service deployment promote DEPLOYMENT_ID +npx prisma-cli service version promote VERSION_ID ``` ### Built for humans, CI, and agents diff --git a/packages/cli/e2e/deployed-service.ts b/packages/cli/e2e/deployed-service.ts index f1da62df..1ab51701 100644 --- a/packages/cli/e2e/deployed-service.ts +++ b/packages/cli/e2e/deployed-service.ts @@ -148,8 +148,8 @@ export async function deployService( // failure would strand the whole scratch project, not just this // service. try { - await cli.run(["service", "deployment", "start", deploymentId]); - await cli.run(["service", "deployment", "promote", deploymentId]); + await cli.run(["service", "version", "start", deploymentId]); + await cli.run(["service", "version", "promote", deploymentId]); } catch (failure) { await deleteDeployment(cli, { id: deploymentId, serviceName }); throw failure; @@ -175,7 +175,7 @@ export async function deleteDeployment( const removal = await cli.run( [ "service", - "deployment", + "version", "delete", deployment.id, "--confirm", diff --git a/packages/cli/e2e/service-deployment.e2e.ts b/packages/cli/e2e/service-version.e2e.ts similarity index 63% rename from packages/cli/e2e/service-deployment.e2e.ts rename to packages/cli/e2e/service-version.e2e.ts index b5a40573..29b010f4 100644 --- a/packages/cli/e2e/service-deployment.e2e.ts +++ b/packages/cli/e2e/service-version.e2e.ts @@ -1,14 +1,14 @@ /** - * The deployment verbs, against a service this file deploys to. + * The version verbs, against a service this file deploys to. * - * Every command here needs a deployment to act on, which is why they + * Every command here needs a version to act on, which is why they * had no coverage: the CLI cannot make one, and only Composer does. * `deployed-service.ts` does what Composer does through the management * API, so these commands can finally be run rather than reasoned about. * * The blocks run in file order and share one service: it is deployed * once, read by the middle blocks, then stopped and deleted at the end. - * Teardown must delete the deployment before the scratch project can go. + * Teardown must delete the version before the scratch project can go. */ import { afterAll, expect, it } from "vitest"; @@ -19,7 +19,7 @@ import { describeCommand } from "./suite"; const HTTPS_URL = /^https:\/\//; -const scratch = useScratchProject("service-deployment"); +const scratch = useScratchProject("service-version"); let deployed: | { serviceId: string; serviceName: string; deploymentId: string } @@ -31,7 +31,7 @@ function requireDeployed(): { deploymentId: string; } { if (deployed === undefined) { - throw new Error("the deployment fixture did not run"); + throw new Error("the version fixture did not run"); } return deployed; } @@ -53,67 +53,67 @@ afterAll(async () => { } }); -describeCommand("service deployment promote", () => { - it("deploys a service and promotes the deployment live", async () => { - // `deployService` runs `service deployment start` and then - // `service deployment promote`; both are commands under test, so a +describeCommand("service version promote", () => { + it("deploys a service and promotes the version live", async () => { + // `deployService` runs `service version start` and then + // `service version promote`; both are commands under test, so a // failure in either fails here rather than somewhere downstream. deployed = await deployService(scratch, scratchName("dep")); const run = await scratch.run([ "service", - "deployment", + "version", "show", deployed.deploymentId, ]); - const shown = run.envelope.result as { deployment: DeploymentRow }; + const shown = run.envelope.result as { version: DeploymentRow }; - expect(shown.deployment.id).toBe(deployed.deploymentId); - expect(shown.deployment.live).toBe(true); - expect(shown.deployment.status).toBe("running"); + expect(shown.version.id).toBe(deployed.deploymentId); + expect(shown.version.live).toBe(true); + expect(shown.version.status).toBe("running"); }); }); -describeCommand("service deployment start", () => { - it("reports the deployment the fixture started as running", async () => { +describeCommand("service version start", () => { + it("reports the version the fixture started as running", async () => { const existing = requireDeployed(); - // Starting an already-running deployment is the idempotent answer, + // Starting an already-running version is the idempotent answer, // which is the only start this file can make twice. const run = await scratch.run([ "service", - "deployment", + "version", "start", existing.deploymentId, ]); const started = run.envelope.result as { - readonly deployment: DeploymentRow; + readonly version: DeploymentRow; readonly alreadyInState: boolean; }; - expect(started.deployment.id).toBe(existing.deploymentId); - expect(started.deployment.status).toBe("running"); + expect(started.version.id).toBe(existing.deploymentId); + expect(started.version.status).toBe("running"); expect(started.alreadyInState).toBe(true); }); }); -describeCommand("service deployment list", () => { - it("lists the deployment, and marks it live", async () => { +describeCommand("service version list", () => { + it("lists the version, and marks it live", async () => { const existing = requireDeployed(); const run = await scratch.run([ "service", - "deployment", + "version", "list", existing.serviceName, ]); const listed = run.envelope.result as { readonly projectId: string; readonly service: { readonly id: string }; - readonly deployments: readonly DeploymentRow[]; + readonly versions: readonly DeploymentRow[]; }; expect(listed.projectId).toBe(scratch.project().id); expect(listed.service.id).toBe(existing.serviceId); - const found = listed.deployments.find( + const found = listed.versions.find( (deployment) => deployment.id === existing.deploymentId, ); expect(found?.live).toBe(true); @@ -121,24 +121,24 @@ describeCommand("service deployment list", () => { }); }); -describeCommand("service deployment show", () => { - it("shows the deployment and the service it belongs to", async () => { +describeCommand("service version show", () => { + it("shows the version and the service it belongs to", async () => { const existing = requireDeployed(); const run = await scratch.run([ "service", - "deployment", + "version", "show", existing.deploymentId, ]); const shown = run.envelope.result as { readonly service: { readonly id: string; readonly name: string }; - readonly deployment: DeploymentRow; + readonly version: DeploymentRow; }; expect(shown.service.id).toBe(existing.serviceId); expect(shown.service.name).toBe(existing.serviceName); - expect(shown.deployment.id).toBe(existing.deploymentId); - expect(Date.parse(shown.deployment.createdAt)).not.toBeNaN(); + expect(shown.version.id).toBe(existing.deploymentId); + expect(Date.parse(shown.version.createdAt)).not.toBeNaN(); }); }); @@ -161,60 +161,60 @@ describeCommand("service open", () => { }); }); -describeCommand("service deployment stop", () => { - it("stops the running deployment", async () => { +describeCommand("service version stop", () => { + it("stops the running version", async () => { const existing = requireDeployed(); const run = await scratch.run([ "service", - "deployment", + "version", "stop", existing.deploymentId, ]); const stopped = run.envelope.result as { - readonly deployment: DeploymentRow; + readonly version: DeploymentRow; readonly alreadyInState: boolean; }; - expect(stopped.deployment.id).toBe(existing.deploymentId); - expect(stopped.deployment.status).toBe("stopped"); + expect(stopped.version.id).toBe(existing.deploymentId); + expect(stopped.version.status).toBe("stopped"); expect(stopped.alreadyInState).toBe(false); // Stopping takes it out of service, so it is no longer the live one. - expect(stopped.deployment.live).toBeNull(); + expect(stopped.version.live).toBeNull(); }); }); -describeCommand("service deployment delete", () => { - it("deletes the deployment, and the listing no longer reports it", async () => { +describeCommand("service version delete", () => { + it("deletes the version, and the listing no longer reports it", async () => { const existing = requireDeployed(); const run = await scratch.run([ "service", - "deployment", + "version", "delete", existing.deploymentId, "--confirm", existing.deploymentId, ]); const removed = run.envelope.result as { - readonly deploymentId: string; + readonly versionId: string; readonly deleted: boolean; }; - expect(removed.deploymentId).toBe(existing.deploymentId); + expect(removed.versionId).toBe(existing.deploymentId); expect(removed.deleted).toBe(true); // Teardown has nothing left to remove. deployed = undefined; const after = await scratch.run([ "service", - "deployment", + "version", "list", existing.serviceName, ]); const remaining = after.envelope.result as { - readonly deployments: readonly DeploymentRow[]; + readonly versions: readonly DeploymentRow[]; }; - expect( - remaining.deployments.map((deployment) => deployment.id), - ).not.toContain(existing.deploymentId); + expect(remaining.versions.map((deployment) => deployment.id)).not.toContain( + existing.deploymentId, + ); }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 09324a9d..f8cf4484 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -54,13 +54,6 @@ import { projectShowCommand } from "./commands/project/show"; import { projectTransferCommand } from "./commands/project/transfer"; import { serviceCreateCommand } from "./commands/service/create"; import { serviceDeleteCommand } from "./commands/service/delete"; -import { serviceDeploymentDeleteCommand } from "./commands/service/deployment-delete"; -import { serviceDeploymentListCommand } from "./commands/service/deployment-list"; -import { serviceDeploymentPromoteCommand } from "./commands/service/deployment-promote"; -import { serviceDeploymentRollbackCommand } from "./commands/service/deployment-rollback"; -import { serviceDeploymentShowCommand } from "./commands/service/deployment-show"; -import { serviceDeploymentStartCommand } from "./commands/service/deployment-start"; -import { serviceDeploymentStopCommand } from "./commands/service/deployment-stop"; import { serviceDomainAddCommand } from "./commands/service/domain-add"; import { serviceDomainDeleteCommand } from "./commands/service/domain-delete"; import { serviceDomainRetryCommand } from "./commands/service/domain-retry"; @@ -70,6 +63,13 @@ import { serviceListCommand } from "./commands/service/list"; import { serviceLogsCommand } from "./commands/service/logs"; import { serviceOpenCommand } from "./commands/service/open"; import { serviceShowCommand } from "./commands/service/show"; +import { serviceVersionDeleteCommand } from "./commands/service/version-delete"; +import { serviceVersionListCommand } from "./commands/service/version-list"; +import { serviceVersionPromoteCommand } from "./commands/service/version-promote"; +import { serviceVersionRollbackCommand } from "./commands/service/version-rollback"; +import { serviceVersionShowCommand } from "./commands/service/version-show"; +import { serviceVersionStartCommand } from "./commands/service/version-start"; +import { serviceVersionStopCommand } from "./commands/service/version-stop"; import { getCliVersion } from "./lib/version"; export const platformCommandFamily: CommandFamily = defineCommandFamily({ @@ -116,13 +116,13 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ serviceCreate: serviceCreateCommand, serviceShow: serviceShowCommand, serviceOpen: serviceOpenCommand, - serviceDeploymentList: serviceDeploymentListCommand, - serviceDeploymentShow: serviceDeploymentShowCommand, - serviceDeploymentPromote: serviceDeploymentPromoteCommand, - serviceDeploymentRollback: serviceDeploymentRollbackCommand, - serviceDeploymentStart: serviceDeploymentStartCommand, - serviceDeploymentStop: serviceDeploymentStopCommand, - serviceDeploymentDelete: serviceDeploymentDeleteCommand, + serviceDeploymentList: serviceVersionListCommand, + serviceDeploymentShow: serviceVersionShowCommand, + serviceDeploymentPromote: serviceVersionPromoteCommand, + serviceDeploymentRollback: serviceVersionRollbackCommand, + serviceDeploymentStart: serviceVersionStartCommand, + serviceDeploymentStop: serviceVersionStopCommand, + serviceDeploymentDelete: serviceVersionDeleteCommand, serviceDelete: serviceDeleteCommand, serviceDomainAdd: serviceDomainAddCommand, serviceDomainShow: serviceDomainShowCommand, @@ -284,9 +284,9 @@ export const cliGroups: Readonly< "bucket key": { brief: "Manage access keys for an object-store bucket" }, branch: { brief: "View your Platform branches" }, git: { brief: "Manage Git repository connections for a project" }, - service: { brief: "Manage services and deployments for a project" }, + service: { brief: "Manage services and their versions for a project" }, "service domain": { brief: "Manage custom domains for a service" }, - "service deployment": { brief: "Manage deployments for a service" }, + "service version": { brief: "Manage the versions of a service" }, agent: { brief: "Manage Prisma skills for AI coding agents" }, "auth workspace": { brief: "Manage local workspace sessions" }, contract: { brief: "Define and emit your application data contract" }, @@ -340,13 +340,13 @@ export const mountedCommands: Readonly> = { "service create": serviceCreateCommand, "service show": serviceShowCommand, "service open": serviceOpenCommand, - "service deployment list": serviceDeploymentListCommand, - "service deployment show": serviceDeploymentShowCommand, - "service deployment promote": serviceDeploymentPromoteCommand, - "service deployment rollback": serviceDeploymentRollbackCommand, - "service deployment start": serviceDeploymentStartCommand, - "service deployment stop": serviceDeploymentStopCommand, - "service deployment delete": serviceDeploymentDeleteCommand, + "service version list": serviceVersionListCommand, + "service version show": serviceVersionShowCommand, + "service version promote": serviceVersionPromoteCommand, + "service version rollback": serviceVersionRollbackCommand, + "service version start": serviceVersionStartCommand, + "service version stop": serviceVersionStopCommand, + "service version delete": serviceVersionDeleteCommand, "service delete": serviceDeleteCommand, "service domain add": serviceDomainAddCommand, "service domain show": serviceDomainShowCommand, diff --git a/packages/cli/src/commands/service/delete.ts b/packages/cli/src/commands/service/delete.ts index b1dd7884..e5fdd657 100644 --- a/packages/cli/src/commands/service/delete.ts +++ b/packages/cli/src/commands/service/delete.ts @@ -42,7 +42,7 @@ export const serviceDeleteCommand = defineCommand({ }); const granted = await ctx.prompt.consent( - `Delete Service "${state.service.name}" and every deployment it owns?`, + `Delete Service "${state.service.name}" and every version it owns?`, { token: state.service.name }, ); // A token consent resolves to true or throws (mismatch, or the diff --git a/packages/cli/src/commands/service/deployment-show.ts b/packages/cli/src/commands/service/deployment-show.ts deleted file mode 100644 index c36f9492..00000000 --- a/packages/cli/src/commands/service/deployment-show.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { defineCommand, positional } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; -import { - deployFailedError, - deploymentNotFoundError, - runCommandAction, -} from "./errors"; -import { deploymentShowPresentations } from "./presentation"; -import type { ServiceDeploymentShowResult } from "./results"; -import { serviceProvider, toServiceSummary } from "./target"; - -export const serviceDeploymentShowCommand = defineCommand({ - help: { - summary: "Show a deployment in detail", - examples: ["service deployment show dep_123"], - }, - args: { - positionals: { - deployment: positional.string({ - brief: "Deployment id", - placeholder: "deployment", - }), - }, - }, - needs: { credentials: true }, - handler: async (args, ctx) => { - const deploymentId = args.positionals.deployment; - const provider = serviceProvider(ctx); - const deployment = await provider - .showDeployment(deploymentId, { signal: ctx.signal }) - .catch((error) => { - throw deployFailedError("Failed to show deployment", error, [ - runCommandAction("List deployments", "service deployment list"), - ]); - }); - - if (!deployment) { - throw deploymentNotFoundError(deploymentId); - } - - const result: ServiceDeploymentShowResult = { - service: deployment.app ? toServiceSummary(deployment.app) : null, - deployment: { - ...deployment.deployment, - // Without the owning service record there is nothing that names - // the live deployment, so the flag stays unknown. - live: deployment.app - ? deployment.app.liveDeploymentId === deployment.deployment.id - : null, - }, - }; - return ok( - ctx.present({ data: result }, deploymentShowPresentations(result)), - ); - }, -}); diff --git a/packages/cli/src/commands/service/deployment-start.ts b/packages/cli/src/commands/service/deployment-start.ts deleted file mode 100644 index c10596af..00000000 --- a/packages/cli/src/commands/service/deployment-start.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { defineCommand, positional } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; -import { changeDeploymentRunState } from "./deployment-run-state"; - -export const serviceDeploymentStartCommand = defineCommand({ - help: { - summary: "Start a stopped deployment", - examples: ["service deployment start dep_123"], - }, - args: { - positionals: { - deployment: positional.string({ - brief: "Deployment id to start", - placeholder: "deployment", - }), - }, - }, - needs: { credentials: true }, - handler: async (args, ctx) => { - const { result, diagnostics, presentations } = - await changeDeploymentRunState(ctx, args.positionals.deployment, "start"); - return ok(ctx.present({ data: result, diagnostics }, presentations)); - }, -}); diff --git a/packages/cli/src/commands/service/deployment-stop.ts b/packages/cli/src/commands/service/deployment-stop.ts deleted file mode 100644 index 96b2989c..00000000 --- a/packages/cli/src/commands/service/deployment-stop.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { defineCommand, positional } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; -import { changeDeploymentRunState } from "./deployment-run-state"; - -export const serviceDeploymentStopCommand = defineCommand({ - help: { - summary: "Stop a running deployment", - examples: ["service deployment stop dep_123"], - }, - args: { - positionals: { - deployment: positional.string({ - brief: "Deployment id to stop", - placeholder: "deployment", - }), - }, - }, - needs: { credentials: true }, - handler: async (args, ctx) => { - const { result, diagnostics, presentations } = - await changeDeploymentRunState(ctx, args.positionals.deployment, "stop"); - return ok(ctx.present({ data: result, diagnostics }, presentations)); - }, -}); diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 57603eff..f1f3dd34 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -120,7 +120,7 @@ export function serviceSelectionInvalidError( why: `The service "${serviceName}" could not be found in resolved project "${projectId}".`, nextActions: [ adviceAction("Pass the name of an existing service."), - // Not `service deployment list`: that command has to resolve a + // Not `service version list`: that command has to resolve a // service before it can list anything, so it fails the same way. runCommandAction("List services", "service list"), ], @@ -168,12 +168,12 @@ export function deployFailedError( }); } -export function noDeploymentsError( +export function noVersionsError( summary: string, why: string, serviceName: string, ): CliStructuredError { - return new CliStructuredError("SERVICE.NO_DEPLOYMENTS", summary, { + return new CliStructuredError("SERVICE.NO_VERSIONS", summary, { why, nextActions: [ runCommandAction("Inspect the service", `service show ${serviceName}`), @@ -181,18 +181,16 @@ export function noDeploymentsError( }); } -export function deploymentNotFoundError( - deploymentId: string, -): CliStructuredError { +export function versionNotFoundError(deploymentId: string): CliStructuredError { return new CliStructuredError( - "SERVICE.DEPLOYMENT_NOT_FOUND", - `Deployment "${deploymentId}" not found`, + "SERVICE.VERSION_NOT_FOUND", + `Version "${deploymentId}" not found`, { - why: "The requested deployment does not exist or is no longer available.", + why: "The requested service version does not exist or is no longer available.", nextActions: [ runCommandAction( - "Choose an available deployment id", - "service deployment list ", + "Choose an available version id", + "service version list ", ), ], }, @@ -216,39 +214,37 @@ export function logsRangeConflictError(): CliStructuredError { ); } -/** The deployment exists but names no owning service, so there is +/** The version exists but names no owning service, so there is * nothing to report or act on it as. */ -export function deploymentDetachedError( - deploymentId: string, -): CliStructuredError { +export function versionDetachedError(deploymentId: string): CliStructuredError { return new CliStructuredError( - "SERVICE.DEPLOYMENT_DETACHED", - `Deployment "${deploymentId}" has no owning service`, + "SERVICE.VERSION_DETACHED", + `Version "${deploymentId}" has no owning service`, { - why: "The Management API returned the deployment without a service, so it cannot be scoped to a project.", + why: "The Management API returned the version without a service, so there is nothing to report or act on it as.", nextActions: [ runCommandAction( - "Show the deployment", - `service deployment show ${deploymentId}`, + "Show the version", + `service version show ${deploymentId}`, ), ], }, ); } -export function deploymentNotFoundForServiceError( +export function versionNotFoundForServiceError( deploymentId: string, serviceName: string, ): CliStructuredError { return new CliStructuredError( - "SERVICE.DEPLOYMENT_NOT_FOUND", - `Deployment "${deploymentId}" not found for service "${serviceName}"`, + "SERVICE.VERSION_NOT_FOUND", + `Version "${deploymentId}" not found for service "${serviceName}"`, { - why: "The requested deployment does not belong to the resolved service or is no longer available.", + why: "The requested version does not belong to the resolved service or is no longer available.", nextActions: [ runCommandAction( - "Choose an available deployment id", - `service deployment list ${serviceName}`, + "Choose an available version id", + `service version list ${serviceName}`, ), ], }, @@ -268,7 +264,7 @@ export function serviceTargetRequiredError( nextActions: [ adviceAction("Pass the service name as the first argument."), adviceAction("Or set PRISMA_SERVICE_ID to a service id."), - // Not `service deployment list`: it resolves a service first, so + // Not `service version list`: it resolves a service first, so // it cannot help a run that could not resolve one. runCommandAction("List services", "service list"), ], @@ -276,21 +272,21 @@ export function serviceTargetRequiredError( ); } -export function noPreviousDeploymentError( +export function noPreviousVersionError( serviceName: string, ): CliStructuredError { return new CliStructuredError( - "SERVICE.NO_PREVIOUS_DEPLOYMENT", - "No previous deployment available for rollback", + "SERVICE.NO_PREVIOUS_VERSION", + "No previous version available for rollback", { - why: "The service does not have an earlier deployment to switch back to.", + why: "The service does not have an earlier version to switch back to.", nextActions: [ adviceAction( - "Deploy a second version first, or pass --to for a specific earlier deployment.", + "Deploy a second version first, or pass --to for a specific earlier version.", ), runCommandAction( - "List deployments", - `service deployment list ${serviceName}`, + "List versions", + `service version list ${serviceName}`, ), ], }, @@ -299,22 +295,22 @@ export function noPreviousDeploymentError( /** Rolling back without `--to` needs the live deployment: the default * target is defined relative to it. */ -export function liveDeploymentUnknownError( +export function liveVersionUnknownError( serviceName: string, ): CliStructuredError { return new CliStructuredError( - "SERVICE.LIVE_DEPLOYMENT_UNKNOWN", - "Cannot determine which deployment is currently live", + "SERVICE.LIVE_VERSION_UNKNOWN", + "Cannot determine which version is currently live", { - why: "The service record does not name a live deployment, so the deployment to roll back to cannot be chosen without guessing what production is serving.", + why: "The service record does not name a live version, so the version to roll back to cannot be chosen without guessing what production is serving.", nextActions: [ runCommandAction( - "Roll back to a named deployment", - `service deployment rollback ${serviceName} --to `, + "Roll back to a named version", + `service version rollback ${serviceName} --to `, ), runCommandAction( - "List deployments", - `service deployment list ${serviceName}`, + "List versions", + `service version list ${serviceName}`, ), ], }, @@ -330,10 +326,7 @@ export function deleteFailedError( why: cause instanceof Error ? cause.message : String(cause), nextActions: [ runCommandAction("Inspect the service", `service show ${serviceName}`), - runCommandAction( - "List deployments", - `service deployment list ${serviceName}`, - ), + runCommandAction("List versions", `service version list ${serviceName}`), ], cause, }); @@ -363,10 +356,10 @@ export function liveUrlUnavailableError( "SERVICE.FEATURE_UNAVAILABLE", "Live URL is not available for this service", { - why: "Deployments exist, but the provider does not expose a stable live service URL for this service yet.", + why: "Versions exist, but the provider does not expose a stable live service URL for this service yet.", nextActions: [ runCommandAction( - "Inspect the deployment state", + "Inspect the service state", `service show ${serviceName}`, ), ], @@ -671,8 +664,8 @@ function domainRequiresDeploymentError( error: DomainApiError, ): CliStructuredError { return new CliStructuredError( - "SERVICE.NO_DEPLOYMENTS", - "Custom domain requires a live production deployment", + "SERVICE.NO_VERSIONS", + "Custom domain requires a live production version", { why: "The selected production service does not have a promoted version that can receive a custom domain.", meta: debugMeta(error), @@ -682,7 +675,7 @@ function domainRequiresDeploymentError( // with the dropped command; without the advice the only thing // left told the user to rerun what had just failed. adviceAction( - "Promote a deployment on the service's production branch, then add the domain again.", + "Promote a version on the service's production branch, then add the domain again.", ), runCommandAction( "Add the domain", diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index 8e283774..2865fb51 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -6,20 +6,20 @@ import { forEachNdjsonRecord } from "../../lib/ndjson"; import { adviceAction, deployFailedError, - deploymentNotFoundError, logsRangeConflictError, - noDeploymentsError, + noVersionsError, runCommandAction, + versionNotFoundError, } from "./errors"; -import { requireDeploymentForService } from "./release"; -import type { ServiceDeploymentSummary } from "./results"; +import { requireVersionForService } from "./release"; +import type { ServiceVersionSummary } from "./results"; import type { ServiceContext, ServiceReadState } from "./target"; import { - applyLiveDeploymentHint, + applyLiveVersionHint, requestedServiceTarget, - resolveCurrentLiveDeploymentId, - resolveDeploymentSubject, + resolveCurrentLiveVersionId, resolveServiceReadState, + resolveVersionSubject, } from "./target"; const TRAILING_NEWLINE = /\n$/; @@ -82,7 +82,7 @@ type TerminalRecord = Extract; interface LogTarget { service: AppRecord; - deployment: ServiceDeploymentSummary; + version: ServiceVersionSummary; } function logsFailedError( @@ -100,8 +100,8 @@ function logsFailedError( "Retry the command, or rerun with --log-level verbose for more detail.", ), runCommandAction( - "Show the deployment", - `service deployment show ${deploymentId}`, + "Show the version", + `service version show ${deploymentId}`, ), ], }, @@ -143,8 +143,8 @@ function logStreamFailedError( }, nextActions: [ runCommandAction( - "Show the deployment", - `service deployment show ${deploymentId}`, + "Show the version", + `service version show ${deploymentId}`, ), ], }, @@ -159,18 +159,18 @@ function listDeployments( return provider .listDeployments(service.id, { signal: ctx.signal }) .catch((error): never => { - throw deployFailedError("Failed to list service deployments", error, [ + throw deployFailedError("Failed to list service versions", error, [ runCommandAction( - "List deployments", - `service deployment list ${service.name}`, + "List versions", + `service version list ${service.name}`, ), ]); }); } -/** `--deployment ` with a service target: the id must belong to +/** `--version-id ` with a service target: the id must belong to * the resolved service. */ -async function resolveDeploymentInService( +async function resolveVersionInService( ctx: ServiceContext, state: ServiceReadState, deploymentId: string, @@ -180,16 +180,16 @@ async function resolveDeploymentInService( state.provider, state.service, ); - const deployment = requireDeploymentForService( + const deployment = requireVersionForService( deploymentsResult.deployments, deploymentId, state.service.name, ); - return { service: deploymentsResult.app, deployment }; + return { service: deploymentsResult.app, version: deployment }; } -/** No `--deployment`: read whatever is live for the resolved service. */ -async function resolveLiveDeployment( +/** No `--version-id`: read whatever is live for the resolved service. */ +async function resolveLiveVersion( ctx: ServiceContext, state: ServiceReadState, ): Promise { @@ -198,11 +198,11 @@ async function resolveLiveDeployment( state.provider, state.service, ); - const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( + const currentLiveDeploymentId = resolveCurrentLiveVersionId( deploymentsResult.app, deploymentsResult.deployments, ); - const deployments = applyLiveDeploymentHint( + const deployments = applyLiveVersionHint( deploymentsResult.deployments, currentLiveDeploymentId, ); @@ -213,13 +213,13 @@ async function resolveLiveDeployment( : null; if (!deployment) { - throw noDeploymentsError( - "No deployments available to read logs from", - `The service "${deploymentsResult.app.name}" does not have a live deployment.`, + throw noVersionsError( + "No versions available to read logs from", + `The service "${deploymentsResult.app.name}" does not have a live version.`, deploymentsResult.app.name, ); } - return { service: deploymentsResult.app, deployment }; + return { service: deploymentsResult.app, version: deployment }; } /** @@ -250,7 +250,7 @@ async function readPage( if (!response.ok || !body) { await body?.cancel().catch(() => undefined); throw response.status === 404 - ? deploymentNotFoundError(deploymentId) + ? versionNotFoundError(deploymentId) : logsFailedError(deploymentId, response.status); } @@ -295,7 +295,7 @@ function requireResumeCursor( why: "The log page ended without a resume cursor, so there is no point to continue reading from.", nextActions: [ adviceAction( - "Rerun without --follow to read the page, or retry if the deployment is still starting.", + "Rerun without --follow to read the page, or retry if the version is still starting.", ), ], }, @@ -341,8 +341,8 @@ async function followPages( /** * A globally-unique deployment id is a complete target on its own, so - * `--deployment` with no service target (neither a service argument nor - * PRISMA_SERVICE_ID) resolves it directly, the way `service deployment + * `--version-id` with no service target (neither a service argument nor + * PRISMA_SERVICE_ID) resolves it directly, the way `service version * show` does — no project resolution at all. A named service scopes the * lookup to that service. */ @@ -352,18 +352,18 @@ async function resolveLogsTarget( service?: string | undefined; project?: string | undefined; branch?: string | undefined; - deployment?: string | undefined; + versionId?: string | undefined; }, ): Promise<{ projectId: string | null; target: LogTarget }> { - const explicitDeploymentId = options.deployment; + const explicitVersionId = options.versionId; const serviceRequested = requestedServiceTarget(ctx, options.service) !== null; - if (explicitDeploymentId !== undefined && !serviceRequested) { - const subject = await resolveDeploymentSubject(ctx, explicitDeploymentId); + if (explicitVersionId !== undefined && !serviceRequested) { + const subject = await resolveVersionSubject(ctx, explicitVersionId); return { projectId: null, - target: { service: subject.service, deployment: subject.deployment }, + target: { service: subject.service, version: subject.version }, }; } const readState = await resolveServiceReadState(ctx, { @@ -375,20 +375,20 @@ async function resolveLogsTarget( return { projectId: readState.projectId, target: - explicitDeploymentId !== undefined - ? await resolveDeploymentInService(ctx, readState, explicitDeploymentId) - : await resolveLiveDeployment(ctx, readState), + explicitVersionId !== undefined + ? await resolveVersionInService(ctx, readState, explicitVersionId) + : await resolveLiveVersion(ctx, readState), }; } export const serviceLogsCommand = defineSessionCommand({ help: { - summary: "Read logs for a deployment of the service", + summary: "Read logs for a version of the service", examples: [ "service logs my-service", "service logs my-service --tail 500", "service logs my-service --follow", - "service logs --deployment dep_123 --from-start", + "service logs --version-id cpv_123 --from-start", ], }, args: { @@ -407,8 +407,8 @@ export const serviceLogsCommand = defineSessionCommand({ brief: "Branch the service lives on (default: the default branch)", placeholder: "name", }), - deployment: flag.string({ - brief: "Deployment id to read (default: the live deployment)", + versionId: flag.string({ + brief: "Service version id to read (default: the live version)", placeholder: "id", }), tail: flag.number({ @@ -435,14 +435,14 @@ export const serviceLogsCommand = defineSessionCommand({ service: args.positionals.service, ...args.flags, }); - const deploymentId = target.deployment.id; + const versionId = target.version.id; for (const line of [ // A run targeted purely by deployment id resolves no project, so // there is none to report. ...(projectId === null ? [] : [`project: ${projectId}`]), `service: ${target.service.name}`, - `deployment: ${deploymentId}`, + `version: ${versionId}`, ]) { ctx.report({ kind: "output", @@ -457,9 +457,9 @@ export const serviceLogsCommand = defineSessionCommand({ ? { from_start: "true" } : { tail: args.flags.tail ?? DEFAULT_TAIL }; - const terminal = await readPage(ctx, deploymentId, firstPageQuery); + const terminal = await readPage(ctx, versionId, firstPageQuery); if (terminal.kind === "error") { - throw logStreamFailedError(deploymentId, terminal); + throw logStreamFailedError(versionId, terminal); } if (!args.flags.follow) { // The routine terminal record ends the page. Its cursor is the @@ -467,6 +467,6 @@ export const serviceLogsCommand = defineSessionCommand({ return ok(undefined); } - return followPages(ctx, deploymentId, terminal.cursor); + return followPages(ctx, versionId, terminal.cursor); }, }); diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index 5039f3dd..887138af 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -3,16 +3,16 @@ import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError, liveUrlUnavailableError, - noDeploymentsError, + noVersionsError, runCommandAction, } from "./errors"; import { openPresentations } from "./presentation"; import type { ServiceOpenResult } from "./results"; import { - applyLiveDeploymentHint, - resolveCurrentLiveDeploymentId, + applyLiveVersionHint, + resolveCurrentLiveVersionId, resolveServiceReadState, - sortDeploymentsNewestFirst, + sortVersionsNewestFirst, toServiceSummary, } from "./target"; @@ -61,12 +61,12 @@ export const serviceOpenCommand = defineCommand({ ), ]); }); - const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( + const currentLiveDeploymentId = resolveCurrentLiveVersionId( deploymentsResult.app, deploymentsResult.deployments, ); - const deployments = sortDeploymentsNewestFirst( - applyLiveDeploymentHint( + const deployments = sortVersionsNewestFirst( + applyLiveVersionHint( deploymentsResult.deployments, currentLiveDeploymentId, ), @@ -78,9 +78,9 @@ export const serviceOpenCommand = defineCommand({ : null; if (!liveDeployment) { - throw noDeploymentsError( - "No deployments available to open", - `The service "${deploymentsResult.app.name}" does not have any deployments yet.`, + throw noVersionsError( + "No versions available to open", + `The service "${deploymentsResult.app.name}" does not have any versions yet.`, deploymentsResult.app.name, ); } diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index 4c28dfb3..d1d7d912 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -4,11 +4,6 @@ import { adviceAction, runCommandAction } from "./errors"; import type { ServiceCreateResult, ServiceDeleteResult, - ServiceDeploymentDeleteResult, - ServiceDeploymentListResult, - ServiceDeploymentRunStateResult, - ServiceDeploymentShowResult, - ServiceDeploymentSummary, ServiceDomainAddResult, ServiceDomainDeleteResult, ServiceDomainRetryResult, @@ -21,6 +16,11 @@ import type { ServicePromoteResult, ServiceRollbackResult, ServiceShowResult, + ServiceVersionDeleteResult, + ServiceVersionListResult, + ServiceVersionRunStateResult, + ServiceVersionShowResult, + ServiceVersionSummary, } from "./results"; type FieldRow = { label: string; value: string }; @@ -40,9 +40,7 @@ function completed(text: string): Block { return { kind: "summary", status: "ok", text }; } -function formatRecentDeployments( - deployments: ServiceDeploymentSummary[], -): string { +function formatRecentDeployments(deployments: ServiceVersionSummary[]): string { if (deployments.length === 0) { return "none"; } @@ -160,7 +158,7 @@ export function createPresentations( { label: "service", value: result.service.name }, { label: "id", value: result.service.id }, { label: "region", value: result.service.region ?? "" }, - // A service with no deployment has no address that resolves, so + // A service with no version has no address that resolves, so // it reports what it needs next instead of a dead URL. { label: "live url", @@ -188,12 +186,12 @@ export function showPresentations(result: ServiceShowResult): Presentations { ), ); } - const inspectable = result.liveDeployment ?? result.recentDeployments[0]; + const inspectable = result.liveVersion ?? result.recentVersions[0]; if (inspectable) { next.push( runCommandAction( - "Show the deployment", - `service deployment show ${inspectable.id}`, + "Show the version", + `service version show ${inspectable.id}`, ), ); } @@ -206,13 +204,13 @@ export function showPresentations(result: ServiceShowResult): Presentations { { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, { - label: "live deployment", - value: result.liveDeployment?.id ?? "", + label: "live version", + value: result.liveVersion?.id ?? "", }, { label: "live url", value: result.liveUrl ?? "unavailable" }, { - label: "recent deployments", - value: formatRecentDeployments(result.recentDeployments), + label: "recent versions", + value: formatRecentDeployments(result.recentVersions), }, ]), ], @@ -220,28 +218,28 @@ export function showPresentations(result: ServiceShowResult): Presentations { }; } -export function deploymentListPresentations( - result: ServiceDeploymentListResult, +export function versionListPresentations( + result: ServiceVersionListResult, ): Presentations { return { stdout: () => [], json: () => result, human: () => [ - title(`Listing deployments for service ${result.service.name}.`), + title(`Listing versions of service ${result.service.name}.`), fields([ { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, ]), - result.deployments.length === 0 + result.versions.length === 0 ? { kind: "summary", status: "info", - text: "No deployments found.", + text: "No versions found.", } : { kind: "table", - columns: ["deployment", "status", "created", "live"], - rows: result.deployments.map((deployment) => [ + columns: ["version", "status", "created", "live"], + rows: result.versions.map((deployment) => [ deployment.id, deployment.status, deployment.createdAt, @@ -250,12 +248,12 @@ export function deploymentListPresentations( }, ], next: () => { - const newest = result.deployments[0]; + const newest = result.versions[0]; return newest ? [ runCommandAction( - "Show the newest deployment", - `service deployment show ${newest.id}`, + "Show the newest version", + `service version show ${newest.id}`, ), ] : []; @@ -263,28 +261,28 @@ export function deploymentListPresentations( }; } -export function deploymentShowPresentations( - result: ServiceDeploymentShowResult, +export function versionShowPresentations( + result: ServiceVersionShowResult, ): Presentations { return { stdout: () => [], json: () => result, next: () => [], human: () => [ - title("Showing deployment details."), + title("Showing service version details."), fields([ ...(result.service ? [{ label: "service", value: result.service.name }] : []), - { label: "deployment", value: result.deployment.id }, - { label: "status", value: result.deployment.status }, - ...(result.deployment.url - ? [{ label: "url", value: result.deployment.url }] + { label: "version", value: result.version.id }, + { label: "status", value: result.version.status }, + ...(result.version.url + ? [{ label: "url", value: result.version.url }] : []), - ...(result.deployment.live === null + ...(result.version.live === null ? [] - : [{ label: "live", value: result.deployment.live ? "yes" : "no" }]), - { label: "created", value: result.deployment.createdAt }, + : [{ label: "live", value: result.version.live ? "yes" : "no" }]), + { label: "created", value: result.version.createdAt }, ]), ], }; @@ -314,25 +312,22 @@ export function openPresentations( `service show ${result.service.name}`, ), runCommandAction( - "Show the live deployment", - `service deployment show ${liveDeploymentId}`, + "Show the live version", + `service version show ${liveDeploymentId}`, ), ], }; } -function deploymentNextActions( +function versionNextActions( deploymentId: string, serviceName: string, ): NextAction[] { return [ + runCommandAction("List versions", `service version list ${serviceName}`), runCommandAction( - "List deployments", - `service deployment list ${serviceName}`, - ), - runCommandAction( - "Show the deployment", - `service deployment show ${deploymentId}`, + "Show the version", + `service version show ${deploymentId}`, ), ]; } @@ -347,20 +342,19 @@ export function promotePresentations( human: () => [ completed( alreadyLive - ? `${result.deployment.id} was already live for ${result.service.name}.` - : `Promoted ${result.deployment.id} to production.`, + ? `${result.version.id} was already live for ${result.service.name}.` + : `Promoted ${result.version.id} to production.`, ), fields([ { label: "service", value: result.service.name }, - { label: "deployment", value: result.deployment.id }, - { label: "status", value: result.deployment.status }, - ...(result.deployment.url - ? [{ label: "url", value: result.deployment.url }] + { label: "version", value: result.version.id }, + { label: "status", value: result.version.status }, + ...(result.version.url + ? [{ label: "url", value: result.version.url }] : []), ]), ], - next: () => - deploymentNextActions(result.deployment.id, result.service.name), + next: () => versionNextActions(result.version.id, result.service.name), }; } @@ -374,30 +368,29 @@ export function rollbackPresentations( human: () => [ completed( alreadyLive - ? `${result.deployment.id} was already live for ${result.service.name}.` - : `Rolled ${result.service.name} back to ${result.deployment.id}.`, + ? `${result.version.id} was already live for ${result.service.name}.` + : `Rolled ${result.service.name} back to ${result.version.id}.`, ), fields([ { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, - { label: "deployment", value: result.deployment.id }, - { label: "status", value: result.deployment.status }, + { label: "version", value: result.version.id }, + { label: "status", value: result.version.status }, { - label: "previous live deployment", - value: result.previousLiveDeploymentId ?? "unknown", + label: "previous live version", + value: result.previousLiveVersionId ?? "unknown", }, - ...(result.deployment.url - ? [{ label: "url", value: result.deployment.url }] + ...(result.version.url + ? [{ label: "url", value: result.version.url }] : []), ]), ], - next: () => - deploymentNextActions(result.deployment.id, result.service.name), + next: () => versionNextActions(result.version.id, result.service.name), }; } -export function deploymentStartPresentations( - result: ServiceDeploymentRunStateResult, +export function versionStartPresentations( + result: ServiceVersionRunStateResult, ): Presentations { return { stdout: () => [], @@ -405,25 +398,24 @@ export function deploymentStartPresentations( human: () => [ completed( result.alreadyInState - ? `${result.deployment.id} was already running.` - : `Started ${result.deployment.id}.`, + ? `${result.version.id} was already running.` + : `Started ${result.version.id}.`, ), fields([ { label: "service", value: result.service.name }, - { label: "deployment", value: result.deployment.id }, - { label: "status", value: result.deployment.status }, - ...(result.deployment.url - ? [{ label: "url", value: result.deployment.url }] + { label: "version", value: result.version.id }, + { label: "status", value: result.version.status }, + ...(result.version.url + ? [{ label: "url", value: result.version.url }] : []), ]), ], - next: () => - deploymentNextActions(result.deployment.id, result.service.name), + next: () => versionNextActions(result.version.id, result.service.name), }; } -export function deploymentStopPresentations( - result: ServiceDeploymentRunStateResult, +export function versionStopPresentations( + result: ServiceVersionRunStateResult, ): Presentations { return { stdout: () => [], @@ -431,38 +423,37 @@ export function deploymentStopPresentations( human: () => [ completed( result.alreadyInState - ? `${result.deployment.id} was already stopped.` - : `Stopped ${result.deployment.id}.`, + ? `${result.version.id} was already stopped.` + : `Stopped ${result.version.id}.`, ), fields([ { label: "service", value: result.service.name }, - { label: "deployment", value: result.deployment.id }, - { label: "status", value: result.deployment.status }, + { label: "version", value: result.version.id }, + { label: "status", value: result.version.status }, ]), ], - next: () => - deploymentNextActions(result.deployment.id, result.service.name), + next: () => versionNextActions(result.version.id, result.service.name), }; } -export function deploymentDeletePresentations( - result: ServiceDeploymentDeleteResult, +export function versionDeletePresentations( + result: ServiceVersionDeleteResult, ): Presentations { return { stdout: () => [], json: () => result, human: () => [ - completed(`Deleted ${result.deploymentId} from ${result.service.name}.`), + completed(`Deleted ${result.versionId} from ${result.service.name}.`), fields([ { label: "service", value: result.service.name }, - { label: "deployment", value: result.deploymentId }, + { label: "version", value: result.versionId }, { label: "deleted", value: "yes" }, ]), ], next: () => [ runCommandAction( - "List deployments", - `service deployment list ${result.service.name}`, + "List versions", + `service version list ${result.service.name}`, ), ], }; @@ -475,9 +466,7 @@ export function deletePresentations( stdout: () => [], json: () => result, human: () => [ - completed( - `Deleted ${result.service.name} and every deployment it owned.`, - ), + completed(`Deleted ${result.service.name} and every version it owned.`), fields([ { label: "project", value: result.projectId }, { label: "service", value: result.service.name }, diff --git a/packages/cli/src/commands/service/release.ts b/packages/cli/src/commands/service/release.ts index 517aa801..1635865d 100644 --- a/packages/cli/src/commands/service/release.ts +++ b/packages/cli/src/commands/service/release.ts @@ -1,13 +1,13 @@ import type { DestroyAppProgress, PromoteProgress } from "@prisma/compute-sdk"; import type { DeploymentRecord } from "../../lib/app/app-provider"; import { - deploymentNotFoundForServiceError, - liveDeploymentUnknownError, - noPreviousDeploymentError, + liveVersionUnknownError, + noPreviousVersionError, + versionNotFoundForServiceError, } from "./errors"; import type { ServiceContext } from "./target"; -export function requireDeploymentForService( +export function requireVersionForService( deployments: DeploymentRecord[], deploymentId: string, serviceName: string, @@ -16,7 +16,7 @@ export function requireDeploymentForService( (candidate) => candidate.id === deploymentId, ); if (!deployment) { - throw deploymentNotFoundForServiceError(deploymentId, serviceName); + throw versionNotFoundForServiceError(deploymentId, serviceName); } return deployment; } @@ -31,16 +31,16 @@ export function resolveRollbackTarget( serviceName: string, ): DeploymentRecord { if (deployments.length === 0) { - throw noPreviousDeploymentError(serviceName); + throw noPreviousVersionError(serviceName); } if (currentLiveDeploymentId === null) { - throw liveDeploymentUnknownError(serviceName); + throw liveVersionUnknownError(serviceName); } const previousDeployment = deployments.find( (deployment) => deployment.id !== currentLiveDeploymentId, ); if (!previousDeployment) { - throw noPreviousDeploymentError(serviceName); + throw noPreviousVersionError(serviceName); } return previousDeployment; } @@ -115,7 +115,7 @@ export function destroyProgressReporter( stopping = deploymentIds.length; ctx.report({ kind: "progress", - step: "stop-deployments", + step: "stop-versions", completed: 0, total: stopping, }); @@ -124,7 +124,7 @@ export function destroyProgressReporter( stopped += 1; ctx.report({ kind: "progress", - step: "stop-deployments", + step: "stop-versions", completed: stopped, total: stopping, }); @@ -133,7 +133,7 @@ export function destroyProgressReporter( deleting = deploymentIds.length; ctx.report({ kind: "progress", - step: "delete-deployments", + step: "delete-versions", completed: 0, total: deleting, }); @@ -142,7 +142,7 @@ export function destroyProgressReporter( deleted += 1; ctx.report({ kind: "progress", - step: "delete-deployments", + step: "delete-versions", completed: deleted, total: deleting, }); diff --git a/packages/cli/src/commands/service/results.ts b/packages/cli/src/commands/service/results.ts index 7f7fa7de..a9fb7de8 100644 --- a/packages/cli/src/commands/service/results.ts +++ b/packages/cli/src/commands/service/results.ts @@ -7,7 +7,7 @@ export interface ServiceSummary { name: string; } -export interface ServiceDeploymentSummary { +export interface ServiceVersionSummary { id: string; status: string; url: string | null; @@ -16,13 +16,13 @@ export interface ServiceDeploymentSummary { } /** A service as `service list` and `service create` report it. `liveUrl` - * is null until a deployment is promoted: the endpoint domain a service + * is null until a version is promoted: the endpoint domain a service * carries before that does not resolve. */ export interface ServiceListEntry { id: string; name: string; region: string | null; - liveDeploymentId: string | null; + liveVersionId: string | null; liveUrl: string | null; } @@ -45,20 +45,20 @@ export interface ServiceCreateResult { export interface ServiceShowResult { projectId: string; service: ServiceSummary; - liveDeployment: ServiceDeploymentSummary | null; + liveVersion: ServiceVersionSummary | null; liveUrl: string | null; - recentDeployments: ServiceDeploymentSummary[]; + recentVersions: ServiceVersionSummary[]; } -export interface ServiceDeploymentListResult { +export interface ServiceVersionListResult { projectId: string; service: ServiceSummary; - deployments: ServiceDeploymentSummary[]; + versions: ServiceVersionSummary[]; } -export interface ServiceDeploymentShowResult { +export interface ServiceVersionShowResult { service: ServiceSummary | null; - deployment: ServiceDeploymentSummary; + version: ServiceVersionSummary; } export interface ServiceOpenResult { @@ -68,31 +68,31 @@ export interface ServiceOpenResult { opened: boolean; } -/** Targeted by deployment id alone, so no project is resolved. */ +/** Targeted by version id alone, so no project is resolved. */ export interface ServicePromoteResult { service: ServiceSummary; - deployment: ServiceDeploymentSummary; + version: ServiceVersionSummary; } export interface ServiceRollbackResult { projectId: string; service: ServiceSummary; - deployment: ServiceDeploymentSummary; - previousLiveDeploymentId: string | null; + version: ServiceVersionSummary; + previousLiveVersionId: string | null; } -/** What `service deployment start` and `stop` report. `alreadyInState` - * is true when the deployment already had the status the command asks +/** What `service version start` and `stop` report. `alreadyInState` + * is true when the version already had the status the command asks * for, so the run made no call. */ -export interface ServiceDeploymentRunStateResult { +export interface ServiceVersionRunStateResult { service: ServiceSummary; - deployment: ServiceDeploymentSummary; + version: ServiceVersionSummary; alreadyInState: boolean; } -export interface ServiceDeploymentDeleteResult { +export interface ServiceVersionDeleteResult { service: ServiceSummary; - deploymentId: string; + versionId: string; deleted: true; } diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index 831f1ebe..a46b2b1d 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -4,16 +4,16 @@ import { deployFailedError, runCommandAction } from "./errors"; import { showPresentations } from "./presentation"; import type { ServiceShowResult } from "./results"; import { - applyLiveDeploymentHint, - resolveCurrentLiveDeploymentId, + applyLiveVersionHint, + resolveCurrentLiveVersionId, resolveServiceReadState, - sortDeploymentsNewestFirst, + sortVersionsNewestFirst, toServiceSummary, } from "./target"; export const serviceShowCommand = defineCommand({ help: { - summary: "Show the service and its current deployment", + summary: "Show the service and its current version", examples: [ "service show my-service", "service show my-service --branch feature-x", @@ -51,22 +51,22 @@ export const serviceShowCommand = defineCommand({ .catch((error) => { throw deployFailedError("Failed to inspect service", error, [ runCommandAction( - "List deployments", - `service deployment list ${state.service.name}`, + "List versions", + `service version list ${state.service.name}`, ), ]); }); - const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( + const currentLiveDeploymentId = resolveCurrentLiveVersionId( deploymentsResult.app, deploymentsResult.deployments, ); - const deployments = sortDeploymentsNewestFirst( - applyLiveDeploymentHint( + const deployments = sortVersionsNewestFirst( + applyLiveVersionHint( deploymentsResult.deployments, currentLiveDeploymentId, ), ); - const liveDeployment = currentLiveDeploymentId + const liveVersion = currentLiveDeploymentId ? (deployments.find( (deployment) => deployment.id === currentLiveDeploymentId, ) ?? null) @@ -75,12 +75,12 @@ export const serviceShowCommand = defineCommand({ const result: ServiceShowResult = { projectId: state.projectId, service: toServiceSummary(deploymentsResult.app), - liveDeployment, + liveVersion, // A service that was never promoted still carries an endpoint // domain, and that domain does not resolve. Only a service with a // live deployment has a URL to show. - liveUrl: liveDeployment ? deploymentsResult.app.liveUrl : null, - recentDeployments: deployments.slice(0, 5), + liveUrl: liveVersion ? deploymentsResult.app.liveUrl : null, + recentVersions: deployments.slice(0, 5), }; return ok(ctx.present({ data: result }, showPresentations(result))); }, diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 5770ff6d..3ed14ada 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -22,8 +22,6 @@ import { branchNotDeployableError, branchValueEmptyError, deployFailedError, - deploymentDetachedError, - deploymentNotFoundError, domainCommandError, domainHostnameInvalidError, domainNotFoundError, @@ -33,14 +31,16 @@ import { selectedServiceMissingError, serviceSelectionInvalidError, serviceTargetRequiredError, + versionDetachedError, + versionNotFoundError, workspaceRequiredError, } from "./errors"; import type { - ServiceDeploymentSummary, ServiceDomainSummary, ServiceDomainTarget, ServiceListEntry, ServiceSummary, + ServiceVersionSummary, } from "./results"; /** A hostname's optional root dot, and one DNS label. */ @@ -321,39 +321,39 @@ export function matchRequestedService( return matched; } -export interface DeploymentSubject { +export interface VersionSubject { provider: AppProvider; service: AppRecord; - deployment: DeploymentRecord; + version: DeploymentRecord; } -/** Resolve a deployment by its globally-unique id. The id alone names +/** Resolve a service version by its globally-unique id. The id alone names * the subject — no service, project, or branch parameter is consulted, - * the same way `service deployment show` resolves it. */ -export async function resolveDeploymentSubject( + * the same way `service version show` resolves it. */ +export async function resolveVersionSubject( ctx: ServiceContext, deploymentId: string, -): Promise { +): Promise { const provider = serviceProvider(ctx); const shown = await provider .showDeployment(deploymentId, { signal: ctx.signal }) .catch((error) => { - throw deployFailedError("Failed to show deployment", error, []); + throw deployFailedError("Failed to show version", error, []); }); if (!shown) { - throw deploymentNotFoundError(deploymentId); + throw versionNotFoundError(deploymentId); } if (!shown.app) { - throw deploymentDetachedError(deploymentId); + throw versionDetachedError(deploymentId); } - return { provider, service: shown.app, deployment: shown.deployment }; + return { provider, service: shown.app, version: shown.deployment }; } /** The live deployment is the one the service record names as its latest * deployment. Nothing else decides it — local CLI state never does. */ -export function resolveCurrentLiveDeploymentId( +export function resolveCurrentLiveVersionId( service: Pick, - deployments: ServiceDeploymentSummary[], + deployments: ServiceVersionSummary[], ): string | null { if ( service.liveDeploymentId && @@ -364,10 +364,10 @@ export function resolveCurrentLiveDeploymentId( return null; } -export function applyLiveDeploymentHint( - deployments: ServiceDeploymentSummary[], +export function applyLiveVersionHint( + deployments: ServiceVersionSummary[], currentLiveDeploymentId: string | null, -): ServiceDeploymentSummary[] { +): ServiceVersionSummary[] { if (!currentLiveDeploymentId) { return deployments.map((deployment) => ({ ...deployment, @@ -380,9 +380,9 @@ export function applyLiveDeploymentHint( })); } -export function sortDeploymentsNewestFirst( - deployments: ServiceDeploymentSummary[], -): ServiceDeploymentSummary[] { +export function sortVersionsNewestFirst( + deployments: ServiceVersionSummary[], +): ServiceVersionSummary[] { return deployments .slice() .sort( @@ -407,7 +407,7 @@ export function toServiceListEntry(service: AppRecord): ServiceListEntry { id: service.id, name: service.name, region: service.region, - liveDeploymentId: service.liveDeploymentId, + liveVersionId: service.liveDeploymentId, liveUrl: service.liveDeploymentId ? service.liveUrl : null, }; } diff --git a/packages/cli/src/commands/service/deployment-delete.ts b/packages/cli/src/commands/service/version-delete.ts similarity index 52% rename from packages/cli/src/commands/service/deployment-delete.ts rename to packages/cli/src/commands/service/version-delete.ts index 5e6c8931..31047033 100644 --- a/packages/cli/src/commands/service/deployment-delete.ts +++ b/packages/cli/src/commands/service/version-delete.ts @@ -5,36 +5,36 @@ import { runCommandAction, userCancelledError, } from "./errors"; -import { deploymentDeletePresentations } from "./presentation"; -import type { ServiceDeploymentDeleteResult } from "./results"; -import { resolveDeploymentSubject, toServiceSummary } from "./target"; +import { versionDeletePresentations } from "./presentation"; +import type { ServiceVersionDeleteResult } from "./results"; +import { resolveVersionSubject, toServiceSummary } from "./target"; -export const serviceDeploymentDeleteCommand = defineCommand({ +export const serviceVersionDeleteCommand = defineCommand({ help: { - summary: "Delete a deployment and the artifact it holds", + summary: "Delete a service version and the artifact it holds", examples: [ - "service deployment delete dep_123", - "service deployment delete dep_123 --confirm dep_123", + "service version delete cpv_123", + "service version delete cpv_123 --confirm cpv_123", ], }, args: { positionals: { - deployment: positional.string({ - brief: "Deployment id to delete", - placeholder: "deployment", + version: positional.string({ + brief: "Version id to delete", + placeholder: "version", }), }, }, needs: { credentials: true }, handler: async (args, ctx) => { - const { provider, service, deployment } = await resolveDeploymentSubject( + const { provider, service, version } = await resolveVersionSubject( ctx, - args.positionals.deployment, + args.positionals.version, ); const granted = await ctx.prompt.consent( - `Delete deployment "${deployment.id}" from Service "${service.name}"?`, - { token: deployment.id }, + `Delete version "${version.id}" from Service "${service.name}"?`, + { token: version.id }, ); // A token consent resolves to true or throws (mismatch, or the // engine's consent-required error), so this guard only fires if that @@ -47,27 +47,27 @@ export const serviceDeploymentDeleteCommand = defineCommand({ ctx.report({ kind: "step-started", step: "delete" }); try { await provider.deleteDeployment({ - deploymentId: deployment.id, + deploymentId: version.id, signal: ctx.signal, }); } catch (error) { ctx.report({ kind: "step-finished", step: "delete", outcome: "failed" }); - throw deployFailedError("Failed to delete deployment", error, [ + throw deployFailedError("Failed to delete version", error, [ runCommandAction( - "List deployments", - `service deployment list ${service.name}`, + "List versions", + `service version list ${service.name}`, ), ]); } ctx.report({ kind: "step-finished", step: "delete", outcome: "ok" }); - const result: ServiceDeploymentDeleteResult = { + const result: ServiceVersionDeleteResult = { service: toServiceSummary(service), - deploymentId: deployment.id, + versionId: version.id, deleted: true, }; return ok( - ctx.present({ data: result }, deploymentDeletePresentations(result)), + ctx.present({ data: result }, versionDeletePresentations(result)), ); }, }); diff --git a/packages/cli/src/commands/service/deployment-list.ts b/packages/cli/src/commands/service/version-list.ts similarity index 60% rename from packages/cli/src/commands/service/deployment-list.ts rename to packages/cli/src/commands/service/version-list.ts index 7e61eba9..c2705213 100644 --- a/packages/cli/src/commands/service/deployment-list.ts +++ b/packages/cli/src/commands/service/version-list.ts @@ -1,22 +1,22 @@ import { defineCommand, flag, positional } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { deployFailedError } from "./errors"; -import { deploymentListPresentations } from "./presentation"; -import type { ServiceDeploymentListResult } from "./results"; +import { versionListPresentations } from "./presentation"; +import type { ServiceVersionListResult } from "./results"; import { - applyLiveDeploymentHint, - resolveCurrentLiveDeploymentId, + applyLiveVersionHint, + resolveCurrentLiveVersionId, resolveServiceReadState, - sortDeploymentsNewestFirst, + sortVersionsNewestFirst, toServiceSummary, } from "./target"; -export const serviceDeploymentListCommand = defineCommand({ +export const serviceVersionListCommand = defineCommand({ help: { - summary: "List deployments for the service", + summary: "List versions of the service", examples: [ - "service deployment list my-service", - "service deployment list my-service --branch feature-x", + "service version list my-service", + "service version list my-service --branch feature-x", ], }, args: { @@ -43,36 +43,30 @@ export const serviceDeploymentListCommand = defineCommand({ serviceName: args.positionals.service, projectRef: args.flags.project, branchName: args.flags.branch, - commandName: "service deployment list", + commandName: "service version list", }); const deploymentsResult = await state.provider .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { - throw deployFailedError( - "Failed to list service deployments", - error, - [], - ); + throw deployFailedError("Failed to list service versions", error, []); }); - const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( + const currentLiveDeploymentId = resolveCurrentLiveVersionId( deploymentsResult.app, deploymentsResult.deployments, ); - const deployments = sortDeploymentsNewestFirst( - applyLiveDeploymentHint( + const deployments = sortVersionsNewestFirst( + applyLiveVersionHint( deploymentsResult.deployments, currentLiveDeploymentId, ), ); - const result: ServiceDeploymentListResult = { + const result: ServiceVersionListResult = { projectId: state.projectId, service: toServiceSummary(deploymentsResult.app), - deployments, + versions: deployments, }; - return ok( - ctx.present({ data: result }, deploymentListPresentations(result)), - ); + return ok(ctx.present({ data: result }, versionListPresentations(result))); }, }); diff --git a/packages/cli/src/commands/service/deployment-promote.ts b/packages/cli/src/commands/service/version-promote.ts similarity index 58% rename from packages/cli/src/commands/service/deployment-promote.ts rename to packages/cli/src/commands/service/version-promote.ts index d926812d..d218c83a 100644 --- a/packages/cli/src/commands/service/deployment-promote.ts +++ b/packages/cli/src/commands/service/version-promote.ts @@ -5,38 +5,38 @@ import { deployFailedError, runCommandAction } from "./errors"; import { promotePresentations } from "./presentation"; import { promoteProgressReporter } from "./release"; import type { ServicePromoteResult } from "./results"; -import { resolveDeploymentSubject, toServiceSummary } from "./target"; +import { resolveVersionSubject, toServiceSummary } from "./target"; -export const serviceDeploymentPromoteCommand = defineCommand({ +export const serviceVersionPromoteCommand = defineCommand({ help: { summary: - "Promote a deployment to production by rebuilding with production env vars", - examples: ["service deployment promote dep_123"], + "Promote a service version to production by rebuilding with production env vars", + examples: ["service version promote cpv_123"], }, args: { positionals: { - deployment: positional.string({ - brief: "Deployment id to promote", - placeholder: "deployment", + version: positional.string({ + brief: "Version id to promote", + placeholder: "version", }), }, }, needs: { credentials: true }, handler: async (args, ctx) => { - const { provider, service, deployment } = await resolveDeploymentSubject( + const { provider, service, version } = await resolveVersionSubject( ctx, - args.positionals.deployment, + args.positionals.version, ); - const alreadyLive = service.liveDeploymentId === deployment.id; + const alreadyLive = service.liveDeploymentId === version.id; if (!alreadyLive) { ctx.report({ kind: "step-started", step: "promote" }); try { await provider.promoteDeployment({ appId: service.id, - deploymentId: deployment.id, + deploymentId: version.id, signal: ctx.signal, - progress: promoteProgressReporter(ctx, deployment.id), + progress: promoteProgressReporter(ctx, version.id), }); } catch (error) { ctx.report({ @@ -44,10 +44,10 @@ export const serviceDeploymentPromoteCommand = defineCommand({ step: "promote", outcome: "failed", }); - throw deployFailedError("Failed to promote deployment", error, [ + throw deployFailedError("Failed to promote version", error, [ runCommandAction( - "List deployments", - `service deployment list ${service.name}`, + "List versions", + `service version list ${service.name}`, ), ]); } @@ -56,15 +56,14 @@ export const serviceDeploymentPromoteCommand = defineCommand({ const result: ServicePromoteResult = { service: toServiceSummary(service), - deployment: { ...deployment, status: "running", live: true }, + version: { ...version, status: "running", live: true }, }; const diagnostics: Diagnostic[] = alreadyLive ? [ { - code: "SERVICE.DEPLOYMENT_ALREADY_LIVE", + code: "SERVICE.VERSION_ALREADY_LIVE", severity: "warn", - summary: - "The selected deployment is already live for this service.", + summary: "The selected version is already live for this service.", nextActions: [], }, ] diff --git a/packages/cli/src/commands/service/deployment-rollback.ts b/packages/cli/src/commands/service/version-rollback.ts similarity index 65% rename from packages/cli/src/commands/service/deployment-rollback.ts rename to packages/cli/src/commands/service/version-rollback.ts index ea3db7ed..c689fcea 100644 --- a/packages/cli/src/commands/service/deployment-rollback.ts +++ b/packages/cli/src/commands/service/version-rollback.ts @@ -9,23 +9,23 @@ import { import { rollbackPresentations } from "./presentation"; import { promoteProgressReporter, - requireDeploymentForService, + requireVersionForService, resolveRollbackTarget, } from "./release"; import type { ServiceRollbackResult } from "./results"; import { - resolveCurrentLiveDeploymentId, + resolveCurrentLiveVersionId, resolveServiceReadState, toServiceSummary, } from "./target"; -export const serviceDeploymentRollbackCommand = defineCommand({ +export const serviceVersionRollbackCommand = defineCommand({ help: { - summary: "Roll back production to a previous deployment", + summary: "Roll back production to a previous service version", examples: [ - "service deployment rollback my-service", - "service deployment rollback my-service --to dep_123", - "service deployment rollback my-service --to dep_123 --confirm dep_123", + "service version rollback my-service", + "service version rollback my-service --to cpv_123", + "service version rollback my-service --to cpv_123 --confirm cpv_123", ], }, args: { @@ -40,8 +40,8 @@ export const serviceDeploymentRollbackCommand = defineCommand({ }), to: flag.string({ brief: - "Deployment id to roll back to (default: the deployment before the live one)", - placeholder: "deployment", + "Version id to roll back to (default: the version before the live one)", + placeholder: "version", }), }, positionals: { @@ -57,25 +57,25 @@ export const serviceDeploymentRollbackCommand = defineCommand({ serviceName: args.positionals.service, projectRef: args.flags.project, branchName: args.flags.branch, - commandName: "service deployment rollback", + commandName: "service version rollback", }); const deploymentsResult = await state.provider .listDeployments(state.service.id, { signal: ctx.signal }) .catch((error) => { - throw deployFailedError("Failed to list service deployments", error, [ + throw deployFailedError("Failed to list service versions", error, [ runCommandAction( - "List deployments", - `service deployment list ${state.service.name}`, + "List versions", + `service version list ${state.service.name}`, ), ]); }); - const currentLiveDeploymentId = resolveCurrentLiveDeploymentId( + const currentLiveDeploymentId = resolveCurrentLiveVersionId( deploymentsResult.app, deploymentsResult.deployments, ); - const targetDeployment = args.flags.to - ? requireDeploymentForService( + const targetVersion = args.flags.to + ? requireVersionForService( deploymentsResult.deployments, args.flags.to, state.service.name, @@ -87,8 +87,8 @@ export const serviceDeploymentRollbackCommand = defineCommand({ ); const granted = await ctx.prompt.consent( - `Roll back Service "${state.service.name}" to deployment ${targetDeployment.id} and make it live?`, - { token: targetDeployment.id }, + `Roll back Service "${state.service.name}" to version ${targetVersion.id} and make it live?`, + { token: targetVersion.id }, ); // A token consent resolves to true or throws (mismatch, or the // engine's consent-required error), so this guard only fires if that @@ -98,16 +98,16 @@ export const serviceDeploymentRollbackCommand = defineCommand({ throw userCancelledError("Service rollback canceled"); } - const alreadyLive = currentLiveDeploymentId === targetDeployment.id; + const alreadyLive = currentLiveDeploymentId === targetVersion.id; if (!alreadyLive) { ctx.report({ kind: "step-started", step: "rollback" }); try { await state.provider.promoteDeployment({ appId: state.service.id, - deploymentId: targetDeployment.id, + deploymentId: targetVersion.id, signal: ctx.signal, - progress: promoteProgressReporter(ctx, targetDeployment.id), + progress: promoteProgressReporter(ctx, targetVersion.id), }); } catch (error) { ctx.report({ @@ -115,10 +115,10 @@ export const serviceDeploymentRollbackCommand = defineCommand({ step: "rollback", outcome: "failed", }); - throw deployFailedError("Failed to roll back deployment", error, [ + throw deployFailedError("Failed to roll back version", error, [ runCommandAction( - "List deployments", - `service deployment list ${state.service.name}`, + "List versions", + `service version list ${state.service.name}`, ), ]); } @@ -128,16 +128,15 @@ export const serviceDeploymentRollbackCommand = defineCommand({ const result: ServiceRollbackResult = { projectId: state.projectId, service: toServiceSummary(deploymentsResult.app), - deployment: { ...targetDeployment, status: "running", live: true }, - previousLiveDeploymentId: currentLiveDeploymentId, + version: { ...targetVersion, status: "running", live: true }, + previousLiveVersionId: currentLiveDeploymentId, }; const diagnostics: Diagnostic[] = alreadyLive ? [ { - code: "SERVICE.DEPLOYMENT_ALREADY_LIVE", + code: "SERVICE.VERSION_ALREADY_LIVE", severity: "warn", - summary: - "The selected deployment is already live for this service.", + summary: "The selected version is already live for this service.", nextActions: [], }, ] diff --git a/packages/cli/src/commands/service/deployment-run-state.ts b/packages/cli/src/commands/service/version-run-state.ts similarity index 58% rename from packages/cli/src/commands/service/deployment-run-state.ts rename to packages/cli/src/commands/service/version-run-state.ts index 1623a133..139a3dad 100644 --- a/packages/cli/src/commands/service/deployment-run-state.ts +++ b/packages/cli/src/commands/service/version-run-state.ts @@ -1,12 +1,12 @@ import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { deployFailedError, runCommandAction } from "./errors"; import { - deploymentStartPresentations, - deploymentStopPresentations, + versionStartPresentations, + versionStopPresentations, } from "./presentation"; -import type { ServiceDeploymentRunStateResult } from "./results"; +import type { ServiceVersionRunStateResult } from "./results"; import type { ServiceContext } from "./target"; -import { resolveDeploymentSubject, toServiceSummary } from "./target"; +import { resolveVersionSubject, toServiceSummary } from "./target"; /** * `start` and `stop` are the same command with the direction reversed, @@ -16,81 +16,81 @@ import { resolveDeploymentSubject, toServiceSummary } from "./target"; */ const VERBS = { start: { - /** The status the API reports once the deployment is up. */ + /** The status the API reports once the version is up. */ settledStatus: "running", - diagnosticCode: "SERVICE.DEPLOYMENT_ALREADY_RUNNING", - diagnosticSummary: "The selected deployment is already running.", - failureSummary: "Failed to start deployment", - presentations: deploymentStartPresentations, + diagnosticCode: "SERVICE.VERSION_ALREADY_RUNNING", + diagnosticSummary: "The selected version is already running.", + failureSummary: "Failed to start version", + presentations: versionStartPresentations, }, stop: { settledStatus: "stopped", - diagnosticCode: "SERVICE.DEPLOYMENT_ALREADY_STOPPED", - diagnosticSummary: "The selected deployment is already stopped.", - failureSummary: "Failed to stop deployment", - presentations: deploymentStopPresentations, + diagnosticCode: "SERVICE.VERSION_ALREADY_STOPPED", + diagnosticSummary: "The selected version is already stopped.", + failureSummary: "Failed to stop version", + presentations: versionStopPresentations, }, } as const; export type RunStateVerb = keyof typeof VERBS; export interface RunStateOutcome { - result: ServiceDeploymentRunStateResult; + result: ServiceVersionRunStateResult; diagnostics: Diagnostic[]; - presentations: ReturnType; + presentations: ReturnType; } -export async function changeDeploymentRunState( +export async function changeVersionRunState( ctx: ServiceContext, - deploymentId: string, + versionId: string, verb: RunStateVerb, ): Promise { const spec = VERBS[verb]; - const { provider, service, deployment } = await resolveDeploymentSubject( + const { provider, service, version } = await resolveVersionSubject( ctx, - deploymentId, + versionId, ); - const alreadyInState = deployment.status === spec.settledStatus; + const alreadyInState = version.status === spec.settledStatus; - let observed = deployment; + let observed = version; if (!alreadyInState) { ctx.report({ kind: "step-started", step: verb }); try { - // The API requires a deployment's artifact to be uploaded before + // The API requires a version's artifact to be uploaded before // it will start. That refusal is the API's to make and its message // is carried through, rather than the CLI guessing at the // precondition itself. await (verb === "start" ? provider.startDeployment({ - deploymentId: deployment.id, + deploymentId: version.id, signal: ctx.signal, }) : provider.stopDeployment({ - deploymentId: deployment.id, + deploymentId: version.id, signal: ctx.signal, })); // The start and stop endpoints answer with nothing, so the status - // is read back rather than assumed. A deployment still coming up + // is read back rather than assumed. A version still coming up // reports whatever state it is actually in. observed = await provider.readDeployment({ - deploymentId: deployment.id, + deploymentId: version.id, signal: ctx.signal, }); } catch (error) { ctx.report({ kind: "step-finished", step: verb, outcome: "failed" }); throw deployFailedError(spec.failureSummary, error, [ runCommandAction( - "Show the deployment", - `service deployment show ${deployment.id}`, + "Show the version", + `service version show ${version.id}`, ), ]); } ctx.report({ kind: "step-finished", step: verb, outcome: "ok" }); } - const result: ServiceDeploymentRunStateResult = { + const result: ServiceVersionRunStateResult = { service: toServiceSummary(service), - deployment: observed, + version: observed, alreadyInState, }; const diagnostics: Diagnostic[] = alreadyInState diff --git a/packages/cli/src/commands/service/version-show.ts b/packages/cli/src/commands/service/version-show.ts new file mode 100644 index 00000000..ee05855e --- /dev/null +++ b/packages/cli/src/commands/service/version-show.ts @@ -0,0 +1,54 @@ +import { defineCommand, positional } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { + deployFailedError, + runCommandAction, + versionNotFoundError, +} from "./errors"; +import { versionShowPresentations } from "./presentation"; +import type { ServiceVersionShowResult } from "./results"; +import { serviceProvider, toServiceSummary } from "./target"; + +export const serviceVersionShowCommand = defineCommand({ + help: { + summary: "Show a service version in detail", + examples: ["service version show cpv_123"], + }, + args: { + positionals: { + version: positional.string({ + brief: "Version id", + placeholder: "version", + }), + }, + }, + needs: { credentials: true }, + handler: async (args, ctx) => { + const versionId = args.positionals.version; + const provider = serviceProvider(ctx); + const shown = await provider + .showDeployment(versionId, { signal: ctx.signal }) + .catch((error) => { + throw deployFailedError("Failed to show version", error, [ + runCommandAction("List versions", "service version list "), + ]); + }); + + if (!shown) { + throw versionNotFoundError(versionId); + } + + const result: ServiceVersionShowResult = { + service: shown.app ? toServiceSummary(shown.app) : null, + version: { + ...shown.deployment, + // Without the owning service record there is nothing that names + // the live version, so the flag stays unknown. + live: shown.app + ? shown.app.liveDeploymentId === shown.deployment.id + : null, + }, + }; + return ok(ctx.present({ data: result }, versionShowPresentations(result))); + }, +}); diff --git a/packages/cli/src/commands/service/version-start.ts b/packages/cli/src/commands/service/version-start.ts new file mode 100644 index 00000000..5617b845 --- /dev/null +++ b/packages/cli/src/commands/service/version-start.ts @@ -0,0 +1,27 @@ +import { defineCommand, positional } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { changeVersionRunState } from "./version-run-state"; + +export const serviceVersionStartCommand = defineCommand({ + help: { + summary: "Start a stopped service version", + examples: ["service version start cpv_123"], + }, + args: { + positionals: { + version: positional.string({ + brief: "Version id to start", + placeholder: "version", + }), + }, + }, + needs: { credentials: true }, + handler: async (args, ctx) => { + const { result, diagnostics, presentations } = await changeVersionRunState( + ctx, + args.positionals.version, + "start", + ); + return ok(ctx.present({ data: result, diagnostics }, presentations)); + }, +}); diff --git a/packages/cli/src/commands/service/version-stop.ts b/packages/cli/src/commands/service/version-stop.ts new file mode 100644 index 00000000..50e06b18 --- /dev/null +++ b/packages/cli/src/commands/service/version-stop.ts @@ -0,0 +1,27 @@ +import { defineCommand, positional } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { changeVersionRunState } from "./version-run-state"; + +export const serviceVersionStopCommand = defineCommand({ + help: { + summary: "Stop a running service version", + examples: ["service version stop cpv_123"], + }, + args: { + positionals: { + version: positional.string({ + brief: "Version id to stop", + placeholder: "version", + }), + }, + }, + needs: { credentials: true }, + handler: async (args, ctx) => { + const { result, diagnostics, presentations } = await changeVersionRunState( + ctx, + args.positionals.version, + "stop", + ); + return ok(ctx.present({ data: result, diagnostics }, presentations)); + }, +}); diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index cbe30b5e..6ff92845 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -108,7 +108,7 @@ const EXCLUSIONS: Readonly> = { * starts and promotes it through the CLI. That covered seven commands, * and what is left needs something the deployment alone does not give. * - * `service deployment rollback` needs a SECOND promoted deployment to + * `service version rollback` needs a SECOND promoted deployment to * roll back from. The fixture makes one; making two and promoting them * in order is more run time and more teardown, and is the next thing to * write. @@ -128,7 +128,7 @@ const EXCLUSIONS: Readonly> = { * than the fixture does today. */ const AWAITING_COVERAGE: readonly string[] = [ - "service deployment rollback", + "service version rollback", "service logs", "service domain add", "service domain show", diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index 739cc927..1ae2cc09 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -143,13 +143,6 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "project transfer", "service create", "service delete", - "service deployment delete", - "service deployment list", - "service deployment promote", - "service deployment rollback", - "service deployment show", - "service deployment start", - "service deployment stop", "service domain add", "service domain delete", "service domain retry", @@ -159,6 +152,13 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ "service logs", "service open", "service show", + "service version delete", + "service version list", + "service version promote", + "service version rollback", + "service version show", + "service version start", + "service version stop", "telemetry disable", "telemetry enable", "telemetry status", diff --git a/packages/cli/tests/service-create.test.ts b/packages/cli/tests/service-create.test.ts index 1191fc3c..25843eeb 100644 --- a/packages/cli/tests/service-create.test.ts +++ b/packages/cli/tests/service-create.test.ts @@ -71,7 +71,7 @@ describe("prisma-cli service create", () => { id: "svc_new", name: "worker", region: "eu-central-1", - liveDeploymentId: null, + liveVersionId: null, liveUrl: null, }, existing: false, diff --git a/packages/cli/tests/service-delete.test.ts b/packages/cli/tests/service-delete.test.ts index cceb7da6..6cb5d9df 100644 --- a/packages/cli/tests/service-delete.test.ts +++ b/packages/cli/tests/service-delete.test.ts @@ -32,7 +32,7 @@ describe("prisma-cli service delete", () => { expect(presentedSummary(result.presented)).toEqual({ kind: "summary", status: "ok", - text: "Deleted hello-world and every deployment it owned.", + text: "Deleted hello-world and every version it owned.", }); // The service is gone, so nothing service-scoped can run next; // listing what remains is all that is left to suggest. @@ -66,7 +66,7 @@ describe("prisma-cli service delete", () => { }); expect(result.events).toContainEqual({ kind: "progress", - step: "delete-deployments", + step: "delete-versions", completed: 2, total: 2, }); @@ -322,7 +322,7 @@ describe("prisma-cli service delete", () => { kind: "user-choice", label: "Or set PRISMA_SERVICE_ID to a service id.", }, - // Not `service deployment list`: that command resolves a service + // Not `service version list`: that command resolves a service // before it lists anything, so it fails the same way this did. { kind: "run-command", diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index da6be25c..a201d89b 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -150,7 +150,7 @@ describe("prisma-cli service domain add", () => { }); }); - it("maps a 422 without a live production deployment to SERVICE.NO_DEPLOYMENTS", async () => { + it("maps a 422 without a live production deployment to SERVICE.NO_VERSIONS", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ "POST /v1/apps/{appId}/domains": () => ({ @@ -170,7 +170,7 @@ describe("prisma-cli service domain add", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.NO_DEPLOYMENTS"); + expect(frame.envelope.error.code).toBe("SERVICE.NO_VERSIONS"); // No action suggests `service deploy --branch production`: the binary // does not answer to it. The advice carries what the user has to do // first, so the retry action is not the only thing offered. @@ -178,7 +178,7 @@ describe("prisma-cli service domain add", () => { { kind: "user-choice", label: - "Promote a deployment on the service's production branch, then add the domain again.", + "Promote a version on the service's production branch, then add the domain again.", }, { kind: "run-command", diff --git a/packages/cli/tests/service-list.test.ts b/packages/cli/tests/service-list.test.ts index 204bbb5b..185520e8 100644 --- a/packages/cli/tests/service-list.test.ts +++ b/packages/cli/tests/service-list.test.ts @@ -53,14 +53,14 @@ describe("prisma-cli service list", () => { id: "svc_1", name: "hello-world", region: "eu-central-1", - liveDeploymentId: "dep_2", + liveVersionId: "dep_2", liveUrl: "https://hello.prisma.app", }, { id: "svc_2", name: "worker", region: "us-east-1", - liveDeploymentId: null, + liveVersionId: null, liveUrl: null, }, ], diff --git a/packages/cli/tests/service-logs.test.ts b/packages/cli/tests/service-logs.test.ts index 0f2cb6df..40113730 100644 --- a/packages/cli/tests/service-logs.test.ts +++ b/packages/cli/tests/service-logs.test.ts @@ -126,7 +126,7 @@ describe("prisma-cli service logs", () => { // The service's latest deployment is dep_2, so that is what is read. expect(outputs(result.events)).toContainEqual({ channel: "diagnostic", - line: "deployment: dep_2", + line: "version: dep_2", }); expect(dataLines(result.events)).toEqual(["first line", "second line"]); // One page only: no follow, so no second request. @@ -190,21 +190,21 @@ describe("prisma-cli service logs", () => { expect(queries).toEqual([]); }); - it("reads an explicit --deployment resolved globally", async () => { + it("reads an explicit --version resolved globally", async () => { const queries: Array | undefined> = []; const harness = await makeServiceCli({ routes: logRoutes([[log("from dep_1"), end("7")]], queries), }); const result = await harness.cli.run( - ["service", "logs", "--deployment", "dep_1", "--project", "acme-app"], + ["service", "logs", "--version-id", "dep_1", "--project", "acme-app"], { cwd: harness.cwd, env: harness.env }, ); expect(result.exitCode).toBe(0); expect(outputs(result.events)).toContainEqual({ channel: "diagnostic", - line: "deployment: dep_1", + line: "version: dep_1", }); expect(dataLines(result.events)).toEqual(["from dep_1"]); }); @@ -228,14 +228,14 @@ describe("prisma-cli service logs", () => { expect(frame.envelope.error.summary).toContain("requires a service"); }); - it("resolves --deployment within the named service", async () => { + it("resolves --version within the named service", async () => { const queries: Array | undefined> = []; const harness = await makeServiceCli({ routes: logRoutes([[log("from dep_1"), end("7")]], queries), }); const result = await harness.cli.run( - ["service", "logs", "--deployment", "dep_1", ...TARGET], + ["service", "logs", "--version-id", "dep_1", ...TARGET], { cwd: harness.cwd, env: harness.env }, ); @@ -246,18 +246,18 @@ describe("prisma-cli service logs", () => { }); expect(outputs(result.events)).toContainEqual({ channel: "diagnostic", - line: "deployment: dep_1", + line: "version: dep_1", }); expect(dataLines(result.events)).toEqual(["from dep_1"]); }); - it("refuses a --deployment the named service does not own", async () => { + it("refuses a --version the named service does not own", async () => { const harness = await makeServiceCli({ routes: logRoutes([[end(null)]], []), }); const result = await harness.cli.run( - ["service", "logs", "--deployment", "dep_missing", ...TARGET, "--json"], + ["service", "logs", "--version-id", "dep_missing", ...TARGET, "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -266,12 +266,12 @@ describe("prisma-cli service logs", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); // The service-scoped refusal, not the global lookup's 404. expect(frame.envelope.error.summary).toContain('for service "hello-world"'); }); - it("scopes --deployment to the PRISMA_SERVICE_ID service like --service", async () => { + it("scopes --version to the PRISMA_SERVICE_ID service like --service", async () => { const harness = await makeServiceCli({ routes: logRoutes([[end(null)]], []), }); @@ -280,7 +280,7 @@ describe("prisma-cli service logs", () => { [ "service", "logs", - "--deployment", + "--version-id", "dep_missing", "--project", "acme-app", @@ -297,11 +297,11 @@ describe("prisma-cli service logs", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); expect(frame.envelope.error.summary).toContain('for service "hello-world"'); }); - it("settles an unknown --deployment as SERVICE.DEPLOYMENT_NOT_FOUND", async () => { + it("settles an unknown --version as SERVICE.VERSION_NOT_FOUND", async () => { const harness = await makeServiceCli({ routes: logRoutes([[end(null)]], []), }); @@ -310,7 +310,7 @@ describe("prisma-cli service logs", () => { [ "service", "logs", - "--deployment", + "--version-id", "dep_missing", "--project", "acme-app", @@ -324,17 +324,17 @@ describe("prisma-cli service logs", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); }); - it("resolves --deployment purely by id, with no project resolution", async () => { + it("resolves --version purely by id, with no project resolution", async () => { const queries: Array | undefined> = []; const harness = await makeServiceCli({ routes: logRoutes([[log("from dep_1"), end("7")]], queries), }); const result = await harness.cli.run( - ["service", "logs", "--deployment", "dep_1"], + ["service", "logs", "--version-id", "dep_1"], { cwd: harness.cwd, env: harness.env }, ); @@ -348,7 +348,7 @@ describe("prisma-cli service logs", () => { ).toBe(false); }); - it("settles a service with no live deployment as SERVICE.NO_DEPLOYMENTS", async () => { + it("settles a service with no live deployment as SERVICE.NO_VERSIONS", async () => { const harness = await makeServiceCli({ routes: { ...logRoutes([[end(null)]], []), @@ -371,7 +371,7 @@ describe("prisma-cli service logs", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.NO_DEPLOYMENTS"); + expect(frame.envelope.error.code).toBe("SERVICE.NO_VERSIONS"); }); it("settles an error terminal record as a structured failure", async () => { diff --git a/packages/cli/tests/service-open.test.ts b/packages/cli/tests/service-open.test.ts index b0af6721..0811f417 100644 --- a/packages/cli/tests/service-open.test.ts +++ b/packages/cli/tests/service-open.test.ts @@ -45,8 +45,8 @@ describe("prisma-cli service open", () => { }, { kind: "run-command", - label: "Show the live deployment", - command: "prisma-cli service deployment show dep_2", + label: "Show the live version", + command: "prisma-cli service version show dep_2", }, ]); }); @@ -99,7 +99,7 @@ describe("prisma-cli service open", () => { expect(result.presented?.data).toMatchObject({ opened: false }); }); - it("settles a service with no deployments as SERVICE.NO_DEPLOYMENTS", async () => { + it("settles a service with no deployments as SERVICE.NO_VERSIONS", async () => { const harness = await makeServiceCli({ routes: readFlowRoutes({ "GET /v1/apps": () => ({ @@ -122,7 +122,7 @@ describe("prisma-cli service open", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.NO_DEPLOYMENTS"); + expect(frame.envelope.error.code).toBe("SERVICE.NO_VERSIONS"); // No action suggests `service deploy`: the binary does not answer to it. expect(frame.envelope.nextActions).toEqual([ { diff --git a/packages/cli/tests/service-show.test.ts b/packages/cli/tests/service-show.test.ts index 2b6d121a..c4ac16c9 100644 --- a/packages/cli/tests/service-show.test.ts +++ b/packages/cli/tests/service-show.test.ts @@ -22,7 +22,7 @@ describe("prisma-cli service show", () => { expect(result.presented?.data).toEqual({ projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, - liveDeployment: { + liveVersion: { id: "dep_2", status: "running", createdAt: "2026-08-02T00:00:00.000Z", @@ -30,7 +30,7 @@ describe("prisma-cli service show", () => { live: true, }, liveUrl: "https://hello.prisma.app", - recentDeployments: [ + recentVersions: [ { id: "dep_2", status: "running", @@ -102,7 +102,7 @@ describe("prisma-cli service show", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - liveDeployment: null, + liveVersion: null, liveUrl: null, }); }); diff --git a/packages/cli/tests/service-testkit.ts b/packages/cli/tests/service-testkit.ts index a972cfe7..908baf83 100644 --- a/packages/cli/tests/service-testkit.ts +++ b/packages/cli/tests/service-testkit.ts @@ -288,7 +288,7 @@ export function releaseRoutes(overrides: Routes = {}): Routes { const SERVICE_GROUPS = { service: { brief: "Manage services and deployments for a project" }, "service domain": { brief: "Manage custom domains for a service" }, - "service deployment": { brief: "Manage deployments for a service" }, + "service version": { brief: "Manage deployments for a service" }, build: { brief: "Inspect builds created by a git push or Console" }, }; diff --git a/packages/cli/tests/service-deployment-delete.test.ts b/packages/cli/tests/service-version-delete.test.ts similarity index 86% rename from packages/cli/tests/service-deployment-delete.test.ts rename to packages/cli/tests/service-version-delete.test.ts index c09498a5..9d92f7c6 100644 --- a/packages/cli/tests/service-deployment-delete.test.ts +++ b/packages/cli/tests/service-version-delete.test.ts @@ -35,13 +35,13 @@ function deleteRoutes(overrides: Routes = {}): { }; } -describe("prisma-cli service deployment delete", () => { +describe("prisma-cli service version delete", () => { it("deletes the deployment once consent is typed back", async () => { const removal = deleteRoutes(); const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1"], + ["service", "version", "delete", "dep_1"], { cwd: harness.cwd, env: harness.env, @@ -60,7 +60,7 @@ describe("prisma-cli service deployment delete", () => { }); expect(result.presented?.data).toEqual({ service: { id: "svc_1", name: "hello-world" }, - deploymentId: "dep_1", + versionId: "dep_1", deleted: true, }); expect(presentedSummary(result.presented)).toEqual({ @@ -74,7 +74,7 @@ describe("prisma-cli service deployment delete", () => { kind: "fields", rows: [ { label: "service", value: "hello-world" }, - { label: "deployment", value: "dep_1" }, + { label: "version", value: "dep_1" }, { label: "deleted", value: "yes" }, ], }); @@ -85,15 +85,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - [ - "service", - "deployment", - "delete", - "dep_1", - "--confirm", - "dep_1", - "--json", - ], + ["service", "version", "delete", "dep_1", "--confirm", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -103,7 +95,7 @@ describe("prisma-cli service deployment delete", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.deployment.delete"); + expect(frame.envelope.commandId).toBe("service.version.delete"); expect(frame.envelope.result).toMatchObject({ deleted: true }); }); @@ -112,15 +104,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - [ - "service", - "deployment", - "delete", - "dep_1", - "--confirm", - "dep_2", - "--json", - ], + ["service", "version", "delete", "dep_1", "--confirm", "dep_2", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -141,7 +125,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1"], + ["service", "version", "delete", "dep_1"], { cwd: harness.cwd, env: harness.env, @@ -160,7 +144,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1", "--json"], + ["service", "version", "delete", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -185,14 +169,14 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_2", "--confirm", "dep_2"], + ["service", "version", "delete", "dep_2", "--confirm", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(removal.deleted).toEqual(["dep_2"]); expect(result.presented?.data).toMatchObject({ - deploymentId: "dep_2", + versionId: "dep_2", deleted: true, }); }); @@ -211,15 +195,7 @@ describe("prisma-cli service deployment delete", () => { const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( - [ - "service", - "deployment", - "delete", - "dep_2", - "--confirm", - "dep_2", - "--json", - ], + ["service", "version", "delete", "dep_2", "--confirm", "dep_2", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -229,7 +205,7 @@ describe("prisma-cli service deployment delete", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); - expect(frame.envelope.error.summary).toBe("Failed to delete deployment"); + expect(frame.envelope.error.summary).toBe("Failed to delete version"); // The CLI does not stop the deployment first or invent the // precondition; the API states it and the user reads what it said. expect(frame.envelope.error.why).toContain( @@ -237,14 +213,14 @@ describe("prisma-cli service deployment delete", () => { ); }); - it("settles an unknown deployment id as SERVICE.DEPLOYMENT_NOT_FOUND", async () => { + it("settles an unknown deployment id as SERVICE.VERSION_NOT_FOUND", async () => { const removal = deleteRoutes(); const harness = await makeServiceCli({ routes: removal.routes }); const result = await harness.cli.run( [ "service", - "deployment", + "version", "delete", "dep_missing", "--confirm", @@ -260,7 +236,7 @@ describe("prisma-cli service deployment delete", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); }); it("fails early with the engine sign-in error when unauthenticated", async () => { @@ -271,7 +247,7 @@ describe("prisma-cli service deployment delete", () => { }); const result = await harness.cli.run( - ["service", "deployment", "delete", "dep_1"], + ["service", "version", "delete", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: INTERACTIVE }, ); diff --git a/packages/cli/tests/service-deployment-list.test.ts b/packages/cli/tests/service-version-list.test.ts similarity index 87% rename from packages/cli/tests/service-deployment-list.test.ts rename to packages/cli/tests/service-version-list.test.ts index 171be111..aae76adb 100644 --- a/packages/cli/tests/service-deployment-list.test.ts +++ b/packages/cli/tests/service-version-list.test.ts @@ -10,12 +10,12 @@ import { SERVICE_DETAIL, } from "./service-testkit"; -describe("prisma-cli service deployment list", () => { +describe("prisma-cli service version list", () => { it("lists deployments newest first with the live hint applied", async () => { const harness = await makeServiceCli(); const result = await harness.cli.run( - ["service", "deployment", "list", "--project", "acme-app", "hello-world"], + ["service", "version", "list", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -23,7 +23,7 @@ describe("prisma-cli service deployment list", () => { expect(result.presented?.data).toEqual({ projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, - deployments: [ + versions: [ { id: "dep_2", status: "running", @@ -62,13 +62,13 @@ describe("prisma-cli service deployment list", () => { ); const result = await harness.cli.run( - ["service", "deployment", "list", "--project", "acme-app", "hello-world"], + ["service", "version", "list", "--project", "acme-app", "hello-world"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - deployments: [ + versions: [ { id: "dep_2", live: null }, { id: "dep_1", live: null }, ], @@ -81,7 +81,7 @@ describe("prisma-cli service deployment list", () => { }); const result = await harness.cli.run( - ["service", "deployment", "list", "--project", "acme-app", "--json"], + ["service", "version", "list", "--project", "acme-app", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -94,13 +94,13 @@ describe("prisma-cli service deployment list", () => { expect(frame.envelope.error.summary).toContain("requires a service"); }); - it("emits the completed json envelope with commandId service.deployment.list", async () => { + it("emits the completed json envelope with commandId service.version.list", async () => { const harness = await makeServiceCli(); const result = await harness.cli.run( [ "service", - "deployment", + "version", "list", "--project", "acme-app", @@ -115,7 +115,7 @@ describe("prisma-cli service deployment list", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.deployment.list"); + expect(frame.envelope.commandId).toBe("service.version.list"); expect(frame.envelope.result).toMatchObject({ projectId: "proj_1", service: { id: "svc_1", name: "hello-world" }, @@ -135,7 +135,7 @@ describe("prisma-cli service deployment list", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "list", "--project", "acme-app", @@ -152,7 +152,7 @@ describe("prisma-cli service deployment list", () => { } expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); expect(frame.envelope.error.summary).toBe( - "Failed to list service deployments", + "Failed to list service versions", ); expect(frame.envelope.nextActions).toEqual([]); }); @@ -161,7 +161,7 @@ describe("prisma-cli service deployment list", () => { const harness = await makeServiceCli({ authenticated: false }); const result = await harness.cli.run( - ["service", "deployment", "list", "--project", "acme-app"], + ["service", "version", "list", "--project", "acme-app"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/cli/tests/service-deployment-promote.test.ts b/packages/cli/tests/service-version-promote.test.ts similarity index 83% rename from packages/cli/tests/service-deployment-promote.test.ts rename to packages/cli/tests/service-version-promote.test.ts index add1dece..7b0dced0 100644 --- a/packages/cli/tests/service-deployment-promote.test.ts +++ b/packages/cli/tests/service-version-promote.test.ts @@ -9,19 +9,19 @@ import { releaseRoutes, } from "./service-testkit"; -describe("prisma-cli service deployment promote", () => { +describe("prisma-cli service version promote", () => { it("promotes the requested deployment and reports it as the live one", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "promote", "dep_1"], + ["service", "version", "promote", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ service: { id: "svc_1", name: "hello-world" }, - deployment: { id: "dep_1", status: "running", live: true }, + version: { id: "dep_1", status: "running", live: true }, }); expect(presentedSummary(result.presented)).toEqual({ kind: "summary", @@ -34,7 +34,7 @@ describe("prisma-cli service deployment promote", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "promote", "dep_1"], + ["service", "version", "promote", "dep_1"], { cwd: harness.cwd, env: harness.env }, ); @@ -66,7 +66,7 @@ describe("prisma-cli service deployment promote", () => { it("writes no local selection or live-deployment state", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); - await harness.cli.run(["service", "deployment", "promote", "dep_1"], { + await harness.cli.run(["service", "version", "promote", "dep_1"], { cwd: harness.cwd, env: harness.env, }); @@ -80,16 +80,16 @@ describe("prisma-cli service deployment promote", () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "promote", "dep_2"], + ["service", "version", "promote", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.diagnostics).toEqual([ { - code: "SERVICE.DEPLOYMENT_ALREADY_LIVE", + code: "SERVICE.VERSION_ALREADY_LIVE", severity: "warn", - summary: "The selected deployment is already live for this service.", + summary: "The selected version is already live for this service.", nextActions: [], }, ]); @@ -101,13 +101,13 @@ describe("prisma-cli service deployment promote", () => { }); }); - it("emits the completed json envelope with commandId service.deployment.promote", async () => { + it("emits the completed json envelope with commandId service.version.promote", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "deployment", + "version", "promote", "dep_1", @@ -121,10 +121,10 @@ describe("prisma-cli service deployment promote", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.deployment.promote"); + expect(frame.envelope.commandId).toBe("service.version.promote"); expect(frame.envelope.result).toMatchObject({ service: { id: "svc_1", name: "hello-world" }, - deployment: { id: "dep_1" }, + version: { id: "dep_1" }, }); }); @@ -134,7 +134,7 @@ describe("prisma-cli service deployment promote", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "promote", "dep_missing", @@ -148,7 +148,7 @@ describe("prisma-cli service deployment promote", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); }); it("settles a failing promote call as SERVICE.DEPLOY_FAILED after a failed step", async () => { @@ -164,7 +164,7 @@ describe("prisma-cli service deployment promote", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "promote", "dep_1", @@ -186,13 +186,13 @@ describe("prisma-cli service deployment promote", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); }); - it("settles a deployment with no owning service as SERVICE.DEPLOYMENT_DETACHED", async () => { + it("settles a deployment with no owning service as SERVICE.VERSION_DETACHED", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); const result = await harness.cli.run( - ["service", "deployment", "promote", "dep_1", "--json"], + ["service", "version", "promote", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -201,7 +201,7 @@ describe("prisma-cli service deployment promote", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_DETACHED"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_DETACHED"); }); it("fails early with the engine sign-in error when unauthenticated", async () => { @@ -211,7 +211,7 @@ describe("prisma-cli service deployment promote", () => { }); const result = await harness.cli.run( - ["service", "deployment", "promote", "dep_1"], + ["service", "version", "promote", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/cli/tests/service-deployment-rollback.test.ts b/packages/cli/tests/service-version-rollback.test.ts similarity index 87% rename from packages/cli/tests/service-deployment-rollback.test.ts rename to packages/cli/tests/service-version-rollback.test.ts index 3815b761..9bf85fe1 100644 --- a/packages/cli/tests/service-deployment-rollback.test.ts +++ b/packages/cli/tests/service-version-rollback.test.ts @@ -39,14 +39,14 @@ function unknownLiveDeploymentRoutes(overrides: Routes = {}): Routes { }); } -describe("prisma-cli service deployment rollback", () => { +describe("prisma-cli service version rollback", () => { it("rolls back to the deployment before the live one by default", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -60,8 +60,8 @@ describe("prisma-cli service deployment rollback", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ service: { id: "svc_1", name: "hello-world" }, - deployment: { id: "dep_1", status: "running", live: true }, - previousLiveDeploymentId: "dep_2", + version: { id: "dep_1", status: "running", live: true }, + previousLiveVersionId: "dep_2", }); expect(presentedSummary(result.presented)).toEqual({ kind: "summary", @@ -76,7 +76,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--to", "dep_2", @@ -92,9 +92,9 @@ describe("prisma-cli service deployment rollback", () => { expect(result.exitCode).toBe(0); expect(result.presented?.diagnostics).toEqual([ { - code: "SERVICE.DEPLOYMENT_ALREADY_LIVE", + code: "SERVICE.VERSION_ALREADY_LIVE", severity: "warn", - summary: "The selected deployment is already live for this service.", + summary: "The selected version is already live for this service.", nextActions: [], }, ]); @@ -112,7 +112,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--to", "dep_1", @@ -127,7 +127,7 @@ describe("prisma-cli service deployment rollback", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_1" }, + version: { id: "dep_1" }, }); }); @@ -137,7 +137,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -169,13 +169,13 @@ describe("prisma-cli service deployment rollback", () => { ]); }); - it("emits the completed json envelope with commandId service.deployment.rollback", async () => { + it("emits the completed json envelope with commandId service.version.rollback", async () => { const harness = await makeServiceCli({ routes: releaseRoutes() }); const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -192,10 +192,10 @@ describe("prisma-cli service deployment rollback", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.deployment.rollback"); + expect(frame.envelope.commandId).toBe("service.version.rollback"); expect(frame.envelope.result).toMatchObject({ - deployment: { id: "dep_1" }, - previousLiveDeploymentId: "dep_2", + version: { id: "dep_1" }, + previousLiveVersionId: "dep_2", }); }); @@ -205,7 +205,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -224,7 +224,7 @@ describe("prisma-cli service deployment rollback", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_1" }, + version: { id: "dep_1" }, }); }); @@ -234,7 +234,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -261,7 +261,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -280,7 +280,7 @@ describe("prisma-cli service deployment rollback", () => { } expect(frame.envelope.error.code).toBe("CLI.CONSENT_REQUIRED"); expect(frame.envelope.error.summary).toContain( - 'Roll back Service "hello-world" to deployment dep_1 and make it live?', + 'Roll back Service "hello-world" to version dep_1 and make it live?', ); expect(frame.envelope.error.summary).toContain("--confirm dep_1"); expect(frame.envelope.error.meta).toMatchObject({ @@ -294,7 +294,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -324,7 +324,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -353,7 +353,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -381,18 +381,18 @@ describe("prisma-cli service deployment rollback", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.LIVE_DEPLOYMENT_UNKNOWN"); + expect(frame.envelope.error.code).toBe("SERVICE.LIVE_VERSION_UNKNOWN"); expect(frame.envelope.nextActions).toEqual([ { kind: "run-command", - label: "Roll back to a named deployment", + label: "Roll back to a named version", command: - "prisma-cli service deployment rollback hello-world --to ", + "prisma-cli service version rollback hello-world --to ", }, { kind: "run-command", - label: "List deployments", - command: "prisma-cli service deployment list hello-world", + label: "List versions", + command: "prisma-cli service version list hello-world", }, ]); }); @@ -405,7 +405,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--to", "dep_1", @@ -420,8 +420,8 @@ describe("prisma-cli service deployment rollback", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_1" }, - previousLiveDeploymentId: null, + version: { id: "dep_1" }, + previousLiveVersionId: null, }); expect(result.events.at(-1)).toEqual({ kind: "step-finished", @@ -430,7 +430,7 @@ describe("prisma-cli service deployment rollback", () => { }); }); - it("reports SERVICE.NO_PREVIOUS_DEPLOYMENT when only the live deployment exists", async () => { + it("reports SERVICE.NO_PREVIOUS_VERSION when only the live deployment exists", async () => { const [, live] = DEPLOYMENTS; const harness = await makeServiceCli({ routes: releaseRoutes({ @@ -441,7 +441,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -456,23 +456,23 @@ describe("prisma-cli service deployment rollback", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.NO_PREVIOUS_DEPLOYMENT"); + expect(frame.envelope.error.code).toBe("SERVICE.NO_PREVIOUS_VERSION"); // The advice stays; the `service deploy` action is gone with the command. expect(frame.envelope.nextActions).toEqual([ { kind: "user-choice", label: - "Deploy a second version first, or pass --to for a specific earlier deployment.", + "Deploy a second version first, or pass --to for a specific earlier version.", }, { kind: "run-command", - label: "List deployments", - command: "prisma-cli service deployment list hello-world", + label: "List versions", + command: "prisma-cli service version list hello-world", }, ]); }); - it("reports SERVICE.NO_PREVIOUS_DEPLOYMENT for a service with no deployments at all", async () => { + it("reports SERVICE.NO_PREVIOUS_VERSION for a service with no deployments at all", async () => { const harness = await makeServiceCli({ routes: unknownLiveDeploymentRoutes({ "GET /v1/apps/{appId}/deployments": () => ({ data: page([]) }), @@ -482,7 +482,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -499,7 +499,7 @@ describe("prisma-cli service deployment rollback", () => { } // An empty listing has no live deployment either, and the emptiness // is the more useful answer of the two. - expect(frame.envelope.error.code).toBe("SERVICE.NO_PREVIOUS_DEPLOYMENT"); + expect(frame.envelope.error.code).toBe("SERVICE.NO_PREVIOUS_VERSION"); }); it("settles a failing promote call as SERVICE.DEPLOY_FAILED after a failed step", async () => { @@ -515,7 +515,7 @@ describe("prisma-cli service deployment rollback", () => { const result = await harness.cli.run( [ "service", - "deployment", + "version", "rollback", "--project", "acme-app", @@ -546,7 +546,7 @@ describe("prisma-cli service deployment rollback", () => { }); const result = await harness.cli.run( - ["service", "deployment", "rollback", "--project", "acme-app", "--json"], + ["service", "version", "rollback", "--project", "acme-app", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -557,7 +557,7 @@ describe("prisma-cli service deployment rollback", () => { } expect(frame.envelope.error.code).toBe("SERVICE.TARGET_REQUIRED"); expect(frame.envelope.error.summary).toBe( - 'Command "service deployment rollback" requires a service', + 'Command "service version rollback" requires a service', ); }); @@ -568,7 +568,7 @@ describe("prisma-cli service deployment rollback", () => { }); const result = await harness.cli.run( - ["service", "deployment", "rollback", "--project", "acme-app"], + ["service", "version", "rollback", "--project", "acme-app"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/cli/tests/service-deployment-show.test.ts b/packages/cli/tests/service-version-show.test.ts similarity index 82% rename from packages/cli/tests/service-deployment-show.test.ts rename to packages/cli/tests/service-version-show.test.ts index cc9d4a7a..6ddfa0b8 100644 --- a/packages/cli/tests/service-deployment-show.test.ts +++ b/packages/cli/tests/service-version-show.test.ts @@ -48,12 +48,12 @@ async function seedRememberedLiveDeployment( ); } -describe("prisma-cli service deployment show", () => { +describe("prisma-cli service version show", () => { it("presents the promoted service url and takes the live flag from the service's latest deployment", async () => { const harness = await makeServiceCli({ routes: showDeployRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_2"], + ["service", "version", "show", "dep_2"], { cwd: harness.cwd, env: harness.env, @@ -64,7 +64,7 @@ describe("prisma-cli service deployment show", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ service: { id: "svc_1", name: "hello-world" }, - deployment: { + version: { id: "dep_2", status: "running", createdAt: "2026-08-02T00:00:00.000Z", @@ -79,13 +79,13 @@ describe("prisma-cli service deployment show", () => { await seedRememberedLiveDeployment(harness); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_1"], + ["service", "version", "show", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_1", live: false }, + version: { id: "dep_1", live: false }, }); }); @@ -93,13 +93,13 @@ describe("prisma-cli service deployment show", () => { const harness = await makeServiceCli({ routes: neverPromotedRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_1"], + ["service", "version", "show", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_1", url: "https://dep1.prisma.app" }, + version: { id: "dep_1", url: "https://dep1.prisma.app" }, }); }); @@ -107,21 +107,21 @@ describe("prisma-cli service deployment show", () => { const harness = await makeServiceCli({ routes: showDeployRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_1"], + ["service", "version", "show", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_1", url: "https://dep1.prisma.app", live: false }, + version: { id: "dep_1", url: "https://dep1.prisma.app", live: false }, }); }); - it("settles an unknown deployment id as SERVICE.DEPLOYMENT_NOT_FOUND with exit 2", async () => { + it("settles an unknown deployment id as SERVICE.VERSION_NOT_FOUND with exit 2", async () => { const harness = await makeServiceCli({ routes: showDeployRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_missing", "--json"], + ["service", "version", "show", "dep_missing", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -130,9 +130,9 @@ describe("prisma-cli service deployment show", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); expect(frame.envelope.error.summary).toBe( - 'Deployment "dep_missing" not found', + 'Version "dep_missing" not found', ); }); @@ -147,7 +147,7 @@ describe("prisma-cli service deployment show", () => { }); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_2", "--json"], + ["service", "version", "show", "dep_2", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -159,11 +159,11 @@ describe("prisma-cli service deployment show", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); }); - it("emits the completed json envelope with commandId service.deployment.show", async () => { + it("emits the completed json envelope with commandId service.version.show", async () => { const harness = await makeServiceCli({ routes: showDeployRoutes() }); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_1", "--json"], + ["service", "version", "show", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -172,9 +172,9 @@ describe("prisma-cli service deployment show", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.deployment.show"); + expect(frame.envelope.commandId).toBe("service.version.show"); expect(frame.envelope.result).toMatchObject({ - deployment: { id: "dep_1", live: false }, + version: { id: "dep_1", live: false }, }); }); @@ -185,7 +185,7 @@ describe("prisma-cli service deployment show", () => { }); const result = await harness.cli.run( - ["service", "deployment", "show", "dep_1"], + ["service", "version", "show", "dep_1"], { cwd: harness.cwd, env: harness.env, diff --git a/packages/cli/tests/service-deployment-start.test.ts b/packages/cli/tests/service-version-start.test.ts similarity index 85% rename from packages/cli/tests/service-deployment-start.test.ts rename to packages/cli/tests/service-version-start.test.ts index d7449354..fd84bf0b 100644 --- a/packages/cli/tests/service-deployment-start.test.ts +++ b/packages/cli/tests/service-version-start.test.ts @@ -64,13 +64,13 @@ function startRoutes( }), }; } -describe("prisma-cli service deployment start", () => { +describe("prisma-cli service version start", () => { it("starts a stopped deployment and reports it running", async () => { const start = startRoutes(); const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1"], + ["service", "version", "start", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -84,7 +84,7 @@ describe("prisma-cli service deployment start", () => { }); expect(result.presented?.data).toMatchObject({ service: { id: "svc_1", name: "hello-world" }, - deployment: { id: "dep_1", status: "running" }, + version: { id: "dep_1", status: "running" }, alreadyInState: false, }); expect(presentedSummary(result.presented)).toEqual({ @@ -98,7 +98,7 @@ describe("prisma-cli service deployment start", () => { kind: "fields", rows: [ { label: "service", value: "hello-world" }, - { label: "deployment", value: "dep_1" }, + { label: "version", value: "dep_1" }, { label: "status", value: "running" }, // releaseRoutes serves each deployment's detail as // ".prisma.app", and the listing reads those details. @@ -117,14 +117,14 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1"], + ["service", "version", "start", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(start.started).toEqual(["dep_1"]); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_1", status: "starting" }, + version: { id: "dep_1", status: "starting" }, alreadyInState: false, }); }); @@ -134,7 +134,7 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_2"], + ["service", "version", "start", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -142,9 +142,9 @@ describe("prisma-cli service deployment start", () => { expect(start.started).toEqual([]); expect(result.presented?.diagnostics).toEqual([ { - code: "SERVICE.DEPLOYMENT_ALREADY_RUNNING", + code: "SERVICE.VERSION_ALREADY_RUNNING", severity: "warn", - summary: "The selected deployment is already running.", + summary: "The selected version is already running.", nextActions: [], }, ]); @@ -170,7 +170,7 @@ describe("prisma-cli service deployment start", () => { const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1", "--json"], + ["service", "version", "start", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -180,7 +180,7 @@ describe("prisma-cli service deployment start", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); - expect(frame.envelope.error.summary).toBe("Failed to start deployment"); + expect(frame.envelope.error.summary).toBe("Failed to start version"); // The CLI invents no precondition of its own: what the user reads is // the message the API sent back. expect(frame.envelope.error.why).toContain( @@ -188,12 +188,12 @@ describe("prisma-cli service deployment start", () => { ); }); - it("settles an unknown deployment id as SERVICE.DEPLOYMENT_NOT_FOUND", async () => { + it("settles an unknown deployment id as SERVICE.VERSION_NOT_FOUND", async () => { const start = startRoutes(); const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_missing", "--json"], + ["service", "version", "start", "dep_missing", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -202,16 +202,16 @@ describe("prisma-cli service deployment start", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); expect(start.started).toEqual([]); }); - it("emits the completed json envelope with commandId service.deployment.start", async () => { + it("emits the completed json envelope with commandId service.version.start", async () => { const start = startRoutes(); const harness = await makeServiceCli({ routes: start.routes }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1", "--json"], + ["service", "version", "start", "dep_1", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -220,9 +220,9 @@ describe("prisma-cli service deployment start", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.deployment.start"); + expect(frame.envelope.commandId).toBe("service.version.start"); expect(frame.envelope.result).toMatchObject({ - deployment: { id: "dep_1", status: "running" }, + version: { id: "dep_1", status: "running" }, }); }); @@ -234,7 +234,7 @@ describe("prisma-cli service deployment start", () => { }); const result = await harness.cli.run( - ["service", "deployment", "start", "dep_1"], + ["service", "version", "start", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/cli/tests/service-deployment-stop.test.ts b/packages/cli/tests/service-version-stop.test.ts similarity index 84% rename from packages/cli/tests/service-deployment-stop.test.ts rename to packages/cli/tests/service-version-stop.test.ts index 4e857de9..71d5877b 100644 --- a/packages/cli/tests/service-deployment-stop.test.ts +++ b/packages/cli/tests/service-version-stop.test.ts @@ -65,13 +65,13 @@ function stopRoutes( }), }; } -describe("prisma-cli service deployment stop", () => { +describe("prisma-cli service version stop", () => { it("stops a running deployment and reports it stopped", async () => { const stop = stopRoutes(); const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2"], + ["service", "version", "stop", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -85,7 +85,7 @@ describe("prisma-cli service deployment stop", () => { }); expect(result.presented?.data).toMatchObject({ service: { id: "svc_1", name: "hello-world" }, - deployment: { id: "dep_2", status: "stopped" }, + version: { id: "dep_2", status: "stopped" }, alreadyInState: false, }); expect(presentedSummary(result.presented)).toEqual({ @@ -100,7 +100,7 @@ describe("prisma-cli service deployment stop", () => { kind: "fields", rows: [ { label: "service", value: "hello-world" }, - { label: "deployment", value: "dep_2" }, + { label: "version", value: "dep_2" }, { label: "status", value: "stopped" }, ], }); @@ -116,14 +116,14 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2"], + ["service", "version", "stop", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(stop.stopped).toEqual(["dep_2"]); expect(result.presented?.data).toMatchObject({ - deployment: { id: "dep_2", status: "stopping" }, + version: { id: "dep_2", status: "stopping" }, alreadyInState: false, }); }); @@ -133,7 +133,7 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_1"], + ["service", "version", "stop", "dep_1"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); @@ -141,9 +141,9 @@ describe("prisma-cli service deployment stop", () => { expect(stop.stopped).toEqual([]); expect(result.presented?.diagnostics).toEqual([ { - code: "SERVICE.DEPLOYMENT_ALREADY_STOPPED", + code: "SERVICE.VERSION_ALREADY_STOPPED", severity: "warn", - summary: "The selected deployment is already stopped.", + summary: "The selected version is already stopped.", nextActions: [], }, ]); @@ -165,7 +165,7 @@ describe("prisma-cli service deployment stop", () => { const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2", "--json"], + ["service", "version", "stop", "dep_2", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -175,15 +175,15 @@ describe("prisma-cli service deployment stop", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); - expect(frame.envelope.error.summary).toBe("Failed to stop deployment"); + expect(frame.envelope.error.summary).toBe("Failed to stop version"); }); - it("settles an unknown deployment id as SERVICE.DEPLOYMENT_NOT_FOUND", async () => { + it("settles an unknown deployment id as SERVICE.VERSION_NOT_FOUND", async () => { const stop = stopRoutes(); const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_missing", "--json"], + ["service", "version", "stop", "dep_missing", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -192,16 +192,16 @@ describe("prisma-cli service deployment stop", () => { if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.DEPLOYMENT_NOT_FOUND"); + expect(frame.envelope.error.code).toBe("SERVICE.VERSION_NOT_FOUND"); expect(stop.stopped).toEqual([]); }); - it("emits the completed json envelope with commandId service.deployment.stop", async () => { + it("emits the completed json envelope with commandId service.version.stop", async () => { const stop = stopRoutes(); const harness = await makeServiceCli({ routes: stop.routes }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2", "--json"], + ["service", "version", "stop", "dep_2", "--json"], { cwd: harness.cwd, env: harness.env }, ); @@ -210,9 +210,9 @@ describe("prisma-cli service deployment stop", () => { if (frame?.kind !== "result" || !frame.envelope.ok) { throw new Error("expected a completed envelope"); } - expect(frame.envelope.commandId).toBe("service.deployment.stop"); + expect(frame.envelope.commandId).toBe("service.version.stop"); expect(frame.envelope.result).toMatchObject({ - deployment: { id: "dep_2", status: "stopped" }, + version: { id: "dep_2", status: "stopped" }, }); }); @@ -224,7 +224,7 @@ describe("prisma-cli service deployment stop", () => { }); const result = await harness.cli.run( - ["service", "deployment", "stop", "dep_2"], + ["service", "version", "stop", "dep_2"], { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); diff --git a/packages/prisma/README.md b/packages/prisma/README.md index b403ceb2..b3e59335 100644 --- a/packages/prisma/README.md +++ b/packages/prisma/README.md @@ -16,7 +16,7 @@ Prisma Developer Platform. It is one binary for the ORM, Composer, and the Prisma Developer -Platform: projects, branches, services, deployments, environment +Platform: projects, branches, services, service versions, environment variables, and the Prisma ORM schema and migration workflow. --- @@ -71,7 +71,7 @@ npx prisma project env list --role preview | `branch` | List Prisma branches for the resolved project. | | `postgres` | Create, inspect, back up, restore, and delete Prisma Postgres databases and their connections. | | `bucket` | Create, list, and delete object-store buckets and their access keys. | -| `service` | Inspect services: deployments, logs, domains, promote, roll back, delete. | +| `service` | Inspect services: versions, logs, domains, promote, roll back, delete. | | `dev`, `deploy` | Run a Composer app locally; deploy it to the platform. | | `contract`, `db`, `migration`, `orm init`, `lsp` | The Prisma ORM workflow. | @@ -83,7 +83,7 @@ npx prisma auth whoami npx prisma project show npx prisma branch list npx prisma service list -npx prisma service deployment promote DEPLOYMENT_ID +npx prisma service version promote VERSION_ID ``` ### Built for humans, CI, and agents From 840c748a5ad52bb95989f0bb51e56910c7759a9a Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 17:31:09 +0200 Subject: [PATCH 25/27] e2e follows the version-field rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create/show e2e asserted liveDeploymentId, liveDeployment, and recentDeployments, which the ADR-012 rename respelled to liveVersionId, liveVersion, and recentVersions — caught by the credentialed CI run, which the local suite cannot reach. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/e2e/service.e2e.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/e2e/service.e2e.ts b/packages/cli/e2e/service.e2e.ts index 4fa05e79..e8d19b3e 100644 --- a/packages/cli/e2e/service.e2e.ts +++ b/packages/cli/e2e/service.e2e.ts @@ -31,7 +31,7 @@ interface ServiceRow { readonly id: string; readonly name: string; readonly region: string | null; - readonly liveDeploymentId: string | null; + readonly liveVersionId: string | null; readonly liveUrl: string | null; } @@ -57,7 +57,7 @@ describeCommand("service create", () => { expect(created.branch).toBe("main"); // Nothing has been deployed, so the endpoint domain the service // already carries is not presented as a live URL. - expect(created.service.liveDeploymentId).toBeNull(); + expect(created.service.liveVersionId).toBeNull(); expect(created.service.liveUrl).toBeNull(); serviceId = created.service.id; @@ -102,9 +102,9 @@ describeCommand("service show", () => { const shown = run.envelope.result as { readonly projectId: string; readonly service: { readonly id: string; readonly name: string }; - readonly liveDeployment: unknown; + readonly liveVersion: unknown; readonly liveUrl: string | null; - readonly recentDeployments: readonly unknown[]; + readonly recentVersions: readonly unknown[]; }; expect(shown.projectId).toBe(scratch.project().id); @@ -113,9 +113,9 @@ describeCommand("service show", () => { // `service create` does not deploy, so these three state the same // fact three ways, and each is a separate chance to invent one: no // promoted deployment, so no live URL, and no history to show. - expect(shown.liveDeployment).toBeNull(); + expect(shown.liveVersion).toBeNull(); expect(shown.liveUrl).toBeNull(); - expect(shown.recentDeployments).toEqual([]); + expect(shown.recentVersions).toEqual([]); }); }); From 761dd915c506de04fb68cb5f77c90f0a72ca90e8 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 22 Aug 2026 09:16:02 +0200 Subject: [PATCH 26/27] Remove PRISMA_SERVICE_ID: the argument targets by id first, name second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling: no env-var targeting, and no hidden second targeting mode. PRISMA_SERVICE_ID matched by id while the argument matched by name — the same string meant different things depending on where you wrote it — and the workflow it served (the headless deploy pipeline) no longer exists. The service argument itself now carries the capability with one visible rule: the stable platform id is primary, the display name is the secondary fallback, and an id match always wins, so a service named like another service's id can never shadow it. The project and database matchers get the same precedence (both previously reported a name-vs-id collision as ambiguous or first-match). The rule is recorded under "Subjects are positional" in docs/product/command-principles.md, and the spec carries a dated amendment superseding the env-fallback clause. The env-specific SERVICE.SELECTION_INVALID variant and the "set PRISMA_SERVICE_ID" advice are gone; briefs read "Service id or name". Signed-off-by: willbot Signed-off-by: Will Madden --- .../specs/command-grammar-cleanup.md | 4 + docs/product/command-principles.md | 2 +- packages/cli/src/commands/service/delete.ts | 2 +- .../cli/src/commands/service/domain-shared.ts | 2 +- packages/cli/src/commands/service/errors.ts | 23 +----- packages/cli/src/commands/service/logs.ts | 23 +++--- packages/cli/src/commands/service/open.ts | 2 +- packages/cli/src/commands/service/show.ts | 2 +- packages/cli/src/commands/service/target.ts | 78 +++++-------------- .../cli/src/commands/service/version-list.ts | 2 +- .../src/commands/service/version-rollback.ts | 2 +- packages/cli/src/controllers/database.ts | 11 ++- packages/cli/src/lib/project/resolution.ts | 11 ++- packages/cli/tests/service-delete.test.ts | 8 +- packages/cli/tests/service-domain.test.ts | 31 ++++---- packages/cli/tests/service-logs.test.ts | 10 +-- packages/cli/tests/service-show.test.ts | 44 ++++------- .../cli/tests/service-version-list.test.ts | 2 +- .../tests/service-version-rollback.test.ts | 2 +- 19 files changed, 97 insertions(+), 164 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md index af85afa5..98572525 100644 --- a/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md +++ b/.drive/projects/prisma-cli-v8/specs/command-grammar-cleanup.md @@ -117,3 +117,7 @@ feedback ## Amendment (2026-08-21, ADR-012 vocabulary) pdp-control-plane ADR-012 retires "Deployment" as a noun (a deploy produces a Version) and renames App to Service across every surface. The CLI adopts it now, pre-rc, on this branch: `service deployment *` mounts become `service version *`, `service logs --deployment` becomes `--version-id` (`version` is an engine-reserved flag name), JSON result fields respell (`deployment`→`version`, `deploymentId`→`versionId`, `liveDeployment`→`liveVersion`, `recentDeployments`→`recentVersions`, `previousLiveDeploymentId`→`previousLiveVersionId`, `liveDeploymentId`→`liveVersionId` on list entries), error codes respell (`SERVICE.DEPLOYMENT_*`→`SERVICE.VERSION_*`, `NO_DEPLOYMENTS`→`NO_VERSIONS`, `NO_PREVIOUS_DEPLOYMENT`→`NO_PREVIOUS_VERSION`, `LIVE_DEPLOYMENT_UNKNOWN`→`LIVE_VERSION_UNKNOWN`), progress steps respell (`stop-deployments`→`stop-versions`, `delete-deployments`→`delete-versions`), and all help/error prose says "service version" (qualified, per the ADR; example ids use the real `cpv_` prefix). The wire layer deliberately keeps platform vocabulary until the platform's own coordinated rename: `/v1/deployments` paths, compute-sdk names, `appId`, and the adapter in `packages/cli/src/lib/app/app-provider.ts`, which is the seam where the two vocabularies meet. + +## Amendment (2026-08-22, operator ruling: no env-var targeting, ids primary) + +§2's `PRISMA_SERVICE_ID` mechanism is removed, superseding the earlier subjects-positional amendment's "env fallback" clause. The env var was a hidden second targeting mode — it matched by id while the argument matched by name — and the workflow it served (the headless deploy pipeline) no longer exists. Instead, the service argument itself accepts an id or a name with one visible rule: the stable platform id is primary, the name is the secondary fallback, and an id match always wins. The same precedence now applies to the project and database matchers, and the general rule is recorded under "Subjects are positional" in `docs/product/command-principles.md`. `SERVICE.SELECTION_INVALID` is the one not-found error for either form; the env-specific error and advice copy are gone. diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 2816cc83..050e5da4 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -103,7 +103,7 @@ Resolve a service version and show or stream its logs. ### Subjects are positional -A command that operates on a subject resource takes that resource's identifier as its first positional argument (`service show my-api`, `postgres delete db_123`, `service version promote cpv_123`) — the established convention across CLIs. Flags never name the subject; they scope or qualify it (`--project`, `--branch`, `--role`). When the subject's identifier is globally unique — a service version id, a bucket id — the id alone is the complete target, and the command asks for no redundant parent scope. +A command that operates on a subject resource takes that resource's identifier as its first positional argument (`service show my-api`, `postgres delete db_123`, `service version promote cpv_123`) — the established convention across CLIs. Flags never name the subject; they scope or qualify it (`--project`, `--branch`, `--role`). The argument primarily targets the stable platform id, with the display name as a secondary fallback: an id match always wins, and a resource named like another resource's id can never shadow it. Environment variables never target a subject. When the subject's identifier is globally unique — a service version id, a bucket id — the id alone is the complete target, and the command asks for no redundant parent scope. ### `wait` diff --git a/packages/cli/src/commands/service/delete.ts b/packages/cli/src/commands/service/delete.ts index e5fdd657..a5189c6d 100644 --- a/packages/cli/src/commands/service/delete.ts +++ b/packages/cli/src/commands/service/delete.ts @@ -27,7 +27,7 @@ export const serviceDeleteCommand = defineCommand({ }, positionals: { service: positional.optionalString({ - brief: "Service name", + brief: "Service id or name", placeholder: "service", }), }, diff --git a/packages/cli/src/commands/service/domain-shared.ts b/packages/cli/src/commands/service/domain-shared.ts index cde956c9..ef708503 100644 --- a/packages/cli/src/commands/service/domain-shared.ts +++ b/packages/cli/src/commands/service/domain-shared.ts @@ -5,7 +5,7 @@ export function domainTargetArgs() { return { flags: { service: flag.string({ - brief: "Service name", + brief: "Service id or name", placeholder: "name", }), project: flag.string({ diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index f1f3dd34..eccecb93 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -119,7 +119,7 @@ export function serviceSelectionInvalidError( { why: `The service "${serviceName}" could not be found in resolved project "${projectId}".`, nextActions: [ - adviceAction("Pass the name of an existing service."), + adviceAction("Pass the id or name of an existing service."), // Not `service version list`: that command has to resolve a // service before it can list anything, so it fails the same way. runCommandAction("List services", "service list"), @@ -262,8 +262,7 @@ export function serviceTargetRequiredError( { why: "Service commands act only on an explicitly named service, and this run named none.", nextActions: [ - adviceAction("Pass the service name as the first argument."), - adviceAction("Or set PRISMA_SERVICE_ID to a service id."), + adviceAction("Pass the service id or name as the first argument."), // Not `service version list`: it resolves a service first, so // it cannot help a run that could not resolve one. runCommandAction("List services", "service list"), @@ -429,24 +428,6 @@ export function domainNotFoundError(hostname: string): CliStructuredError { ); } -export function selectedServiceMissingError( - envVarName: string, - serviceId: string, - projectId: string, -): CliStructuredError { - return new CliStructuredError( - "SERVICE.SELECTION_INVALID", - "The requested service does not exist in the resolved project", - { - why: `The service "${serviceId}" from ${envVarName} could not be found in resolved project "${projectId}".`, - nextActions: [ - adviceAction(`Unset ${envVarName}, or pass a service name.`), - runCommandAction("List services", "service list"), - ], - }, - ); -} - function formatDomainFailureWhy(domain: DomainRecord): string { if (!domain.failureReason) { return "The platform reported a terminal failed state for this custom domain."; diff --git a/packages/cli/src/commands/service/logs.ts b/packages/cli/src/commands/service/logs.ts index 2865fb51..fe34f59f 100644 --- a/packages/cli/src/commands/service/logs.ts +++ b/packages/cli/src/commands/service/logs.ts @@ -16,7 +16,6 @@ import type { ServiceVersionSummary } from "./results"; import type { ServiceContext, ServiceReadState } from "./target"; import { applyLiveVersionHint, - requestedServiceTarget, resolveCurrentLiveVersionId, resolveServiceReadState, resolveVersionSubject, @@ -91,7 +90,7 @@ function logsFailedError( ): CliStructuredError { return new CliStructuredError( "SERVICE.LOGS_FAILED", - `Failed to read logs for deployment ${deploymentId}`, + `Failed to read logs for version ${deploymentId}`, { why: `The Management API returned HTTP ${status}.`, meta: { status }, @@ -117,7 +116,7 @@ function logsFailedError( function logsIncompleteError(deploymentId: string): CliStructuredError { return new CliStructuredError( "SERVICE.LOGS_INCOMPLETE", - `Incomplete log page for deployment ${deploymentId}`, + `Incomplete log page for version ${deploymentId}`, { why: "The response ended without the record that closes a page, so the lines shown may be only part of it.", nextActions: [adviceAction("Rerun the command to read the page again.")], @@ -133,7 +132,7 @@ function logStreamFailedError( ): CliStructuredError { return new CliStructuredError( "SERVICE.LOGS_FAILED", - `Log stream failed for deployment ${deploymentId}`, + `Log stream failed for version ${deploymentId}`, { why: record.message, meta: { @@ -290,7 +289,7 @@ function requireResumeCursor( if (cursor === null) { throw new CliStructuredError( "SERVICE.LOGS_NO_CURSOR", - `Cannot follow logs for deployment ${deploymentId}`, + `Cannot follow logs for version ${deploymentId}`, { why: "The log page ended without a resume cursor, so there is no point to continue reading from.", nextActions: [ @@ -340,11 +339,10 @@ async function followPages( } /** - * A globally-unique deployment id is a complete target on its own, so - * `--version-id` with no service target (neither a service argument nor - * PRISMA_SERVICE_ID) resolves it directly, the way `service version - * show` does — no project resolution at all. A named service scopes the - * lookup to that service. + * A globally-unique version id is a complete target on its own, so + * `--version-id` with no service argument resolves it directly, the + * way `service version show` does — no project resolution at all. A + * service argument scopes the lookup to that service. */ async function resolveLogsTarget( ctx: ServiceContext, @@ -356,8 +354,7 @@ async function resolveLogsTarget( }, ): Promise<{ projectId: string | null; target: LogTarget }> { const explicitVersionId = options.versionId; - const serviceRequested = - requestedServiceTarget(ctx, options.service) !== null; + const serviceRequested = options.service !== undefined; if (explicitVersionId !== undefined && !serviceRequested) { const subject = await resolveVersionSubject(ctx, explicitVersionId); @@ -394,7 +391,7 @@ export const serviceLogsCommand = defineSessionCommand({ args: { positionals: { service: positional.optionalString({ - brief: "Service name", + brief: "Service id or name", placeholder: "service", }), }, diff --git a/packages/cli/src/commands/service/open.ts b/packages/cli/src/commands/service/open.ts index 887138af..d2141d9d 100644 --- a/packages/cli/src/commands/service/open.ts +++ b/packages/cli/src/commands/service/open.ts @@ -37,7 +37,7 @@ export const serviceOpenCommand = defineCommand({ }, positionals: { service: positional.optionalString({ - brief: "Service name", + brief: "Service id or name", placeholder: "service", }), }, diff --git a/packages/cli/src/commands/service/show.ts b/packages/cli/src/commands/service/show.ts index a46b2b1d..214791f9 100644 --- a/packages/cli/src/commands/service/show.ts +++ b/packages/cli/src/commands/service/show.ts @@ -32,7 +32,7 @@ export const serviceShowCommand = defineCommand({ }, positionals: { service: positional.optionalString({ - brief: "Service name", + brief: "Service id or name", placeholder: "service", }), }, diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 3ed14ada..d2e1a665 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -28,7 +28,6 @@ import { fromLegacyCliError, projectNotFoundError, runCommandAction, - selectedServiceMissingError, serviceSelectionInvalidError, serviceTargetRequiredError, versionDetachedError, @@ -46,7 +45,6 @@ import type { /** A hostname's optional root dot, and one DNS label. */ const TRAILING_DOT = /\.$/; const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; -const PRISMA_SERVICE_ID_ENV_VAR = "PRISMA_SERVICE_ID"; export type ServiceContext = Pick< CommandContext, @@ -91,14 +89,6 @@ export async function requireWorkspace( }; } -function readServiceEnvOverride( - ctx: ServiceContext, - name: string, -): string | undefined { - const value = ctx.env[name]?.trim(); - return value ? value : undefined; -} - /** What project resolution reads: where the command was invoked, and * the run's abort signal. */ function resolutionContext(ctx: ServiceContext): ProjectResolutionContext { @@ -261,62 +251,32 @@ export async function listServices( }); } -export interface RequestedServiceTarget { - kind: "name" | "id"; - value: string; -} - -/** The service target the run was given, if any: the service name - * argument wins, then PRISMA_SERVICE_ID (a service id). */ -export function requestedServiceTarget( - ctx: ServiceContext, - explicitServiceName: string | undefined, -): RequestedServiceTarget | null { - if (explicitServiceName) { - return { kind: "name", value: explicitServiceName }; - } - const envServiceId = readServiceEnvOverride(ctx, PRISMA_SERVICE_ID_ENV_VAR); - if (envServiceId) { - return { kind: "id", value: envServiceId }; - } - return null; -} - -/** As `requestedServiceTarget`, but no target refuses — service - * commands never infer, remember, or prompt for one. */ -export function requireRequestedServiceTarget( - ctx: ServiceContext, - explicitServiceName: string | undefined, +/** The service argument, required: service commands never infer, + * remember, or prompt for a target. */ +export function requireServiceArgument( + serviceRef: string | undefined, commandName: string, -): RequestedServiceTarget { - const requested = requestedServiceTarget(ctx, explicitServiceName); - if (!requested) { +): string { + if (!serviceRef) { throw serviceTargetRequiredError(commandName); } - return requested; + return serviceRef; } +/** Matches the service argument against the branch's services: the + * stable platform id is primary, the name is the fallback. An id + * match always wins, so a service named like another service's id + * cannot shadow it. */ export function matchRequestedService( - requested: RequestedServiceTarget, + serviceRef: string, services: AppRecord[], projectId: string, ): AppRecord { - if (requested.kind === "name") { - const matched = services.find( - (service) => service.name === requested.value, - ); - if (!matched) { - throw serviceSelectionInvalidError(requested.value, projectId); - } - return matched; - } - const matched = services.find((service) => service.id === requested.value); + const matched = + services.find((service) => service.id === serviceRef) ?? + services.find((service) => service.name === serviceRef); if (!matched) { - throw selectedServiceMissingError( - PRISMA_SERVICE_ID_ENV_VAR, - requested.value, - projectId, - ); + throw serviceSelectionInvalidError(serviceRef, projectId); } return matched; } @@ -533,8 +493,7 @@ export async function resolveServiceReadState( commandName: string; }, ): Promise { - const requested = requireRequestedServiceTarget( - ctx, + const requested = requireServiceArgument( options.serviceName, options.commandName, ); @@ -570,8 +529,7 @@ export async function resolveServiceDomainTarget( throw branchNotDeployableError(branchName); } - const requested = requireRequestedServiceTarget( - ctx, + const requested = requireServiceArgument( options.serviceName, options.commandName, ); diff --git a/packages/cli/src/commands/service/version-list.ts b/packages/cli/src/commands/service/version-list.ts index c2705213..a9fa2975 100644 --- a/packages/cli/src/commands/service/version-list.ts +++ b/packages/cli/src/commands/service/version-list.ts @@ -32,7 +32,7 @@ export const serviceVersionListCommand = defineCommand({ }, positionals: { service: positional.optionalString({ - brief: "Service name", + brief: "Service id or name", placeholder: "service", }), }, diff --git a/packages/cli/src/commands/service/version-rollback.ts b/packages/cli/src/commands/service/version-rollback.ts index c689fcea..fcbcfb77 100644 --- a/packages/cli/src/commands/service/version-rollback.ts +++ b/packages/cli/src/commands/service/version-rollback.ts @@ -46,7 +46,7 @@ export const serviceVersionRollbackCommand = defineCommand({ }, positionals: { service: positional.optionalString({ - brief: "Service name", + brief: "Service id or name", placeholder: "service", }), }, diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts index fd02c0b9..10852dde 100644 --- a/packages/cli/src/controllers/database.ts +++ b/packages/cli/src/controllers/database.ts @@ -120,9 +120,14 @@ export async function resolveDatabase( branchName, signal, }); - const matches = databases.filter( - (database) => database.id === ref || database.name === ref, - ); + // The stable id is primary and the name is the fallback, so a + // database named like another database's id can never shadow it or + // make it ambiguous. + const byId = databases.filter((database) => database.id === ref); + const matches = + byId.length > 0 + ? byId + : databases.filter((database) => database.name === ref); if (matches.length === 0) { throw databaseNotFoundError(ref, target.project.name, branchName); diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 7a116673..019fd9d4 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -553,9 +553,14 @@ function resolveExplicitProject( projects: ProjectCandidate[], workspace: AuthWorkspace, ): Result { - const matches = projects.filter( - (project) => project.id === projectRef || project.name === projectRef, - ); + // The stable id is primary and the name is the fallback, so a project + // named like another project's id can never shadow it or make it + // ambiguous. + const byId = projects.filter((project) => project.id === projectRef); + const matches = + byId.length > 0 + ? byId + : projects.filter((project) => project.name === projectRef); if (matches.length === 1) { return Result.ok(matches[0]); } diff --git a/packages/cli/tests/service-delete.test.ts b/packages/cli/tests/service-delete.test.ts index 6cb5d9df..46d4ef2c 100644 --- a/packages/cli/tests/service-delete.test.ts +++ b/packages/cli/tests/service-delete.test.ts @@ -294,7 +294,7 @@ describe("prisma-cli service delete", () => { expect(frame.envelope.error.code).toBe("SERVICE.DELETE_FAILED"); }); - it("requires a service or PRISMA_SERVICE_ID, interactive terminals included", async () => { + it("requires a service argument, interactive terminals included", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); @@ -316,11 +316,7 @@ describe("prisma-cli service delete", () => { expect(frame.envelope.nextActions).toEqual([ { kind: "user-choice", - label: "Pass the service name as the first argument.", - }, - { - kind: "user-choice", - label: "Or set PRISMA_SERVICE_ID to a service id.", + label: "Pass the service id or name as the first argument.", }, // Not `service version list`: that command resolves a service // before it lists anything, so it fails the same way this did. diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index a201d89b..a636e8e4 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -229,7 +229,7 @@ describe("prisma-cli service domain add", () => { expect(frame.envelope.error.code).toBe("SERVICE.BRANCH_NOT_DEPLOYABLE"); }); - it("honors the PRISMA_SERVICE_ID environment override", async () => { + it("accepts a service id in --service", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ "POST /v1/apps/{appId}/domains": (init) => { @@ -240,17 +240,23 @@ describe("prisma-cli service domain add", () => { }); const result = await harness.cli.run( - ["service", "domain", "add", "shop.acme.com", "--project", "acme-app"], - { - cwd: harness.cwd, - env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, - }, + [ + "service", + "domain", + "add", + "shop.acme.com", + "--service", + "svc_1", + "--project", + "acme-app", + ], + { cwd: harness.cwd, env: harness.env }, ); expect(result.exitCode).toBe(0); }); - it("rejects a PRISMA_SERVICE_ID the project does not have as SERVICE.SELECTION_INVALID", async () => { + it("rejects a service id the project does not have as SERVICE.SELECTION_INVALID", async () => { const harness = await makeServiceCli({ routes: domainRoutes() }); const result = await harness.cli.run( @@ -259,14 +265,13 @@ describe("prisma-cli service domain add", () => { "domain", "add", "shop.acme.com", + "--service", + "svc_missing", "--project", "acme-app", "--json", ], - { - cwd: harness.cwd, - env: { ...harness.env, PRISMA_SERVICE_ID: "svc_missing" }, - }, + { cwd: harness.cwd, env: harness.env }, ); expect(result.exitCode).toBe(2); @@ -278,7 +283,7 @@ describe("prisma-cli service domain add", () => { expect(frame.envelope.nextActions).toEqual([ { kind: "user-choice", - label: "Unset PRISMA_SERVICE_ID, or pass a service name.", + label: "Pass the id or name of an existing service.", }, { kind: "run-command", @@ -288,7 +293,7 @@ describe("prisma-cli service domain add", () => { ]); }); - it("requires a service or PRISMA_SERVICE_ID", async () => { + it("requires a service", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); diff --git a/packages/cli/tests/service-logs.test.ts b/packages/cli/tests/service-logs.test.ts index 40113730..3ac7218d 100644 --- a/packages/cli/tests/service-logs.test.ts +++ b/packages/cli/tests/service-logs.test.ts @@ -209,7 +209,7 @@ describe("prisma-cli service logs", () => { expect(dataLines(result.events)).toEqual(["from dep_1"]); }); - it("refuses without --service or PRISMA_SERVICE_ID as SERVICE.TARGET_REQUIRED", async () => { + it("refuses without a service argument as SERVICE.TARGET_REQUIRED", async () => { const harness = await makeServiceCli({ routes: logRoutes([[end(null)]], []), }); @@ -271,7 +271,7 @@ describe("prisma-cli service logs", () => { expect(frame.envelope.error.summary).toContain('for service "hello-world"'); }); - it("scopes --version to the PRISMA_SERVICE_ID service like --service", async () => { + it("scopes --version-id to the service named by an id argument", async () => { const harness = await makeServiceCli({ routes: logRoutes([[end(null)]], []), }); @@ -280,16 +280,14 @@ describe("prisma-cli service logs", () => { [ "service", "logs", + "svc_1", "--version-id", "dep_missing", "--project", "acme-app", "--json", ], - { - cwd: harness.cwd, - env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, - }, + { cwd: harness.cwd, env: harness.env }, ); expect(result.exitCode).toBe(2); diff --git a/packages/cli/tests/service-show.test.ts b/packages/cli/tests/service-show.test.ts index c4ac16c9..218988bb 100644 --- a/packages/cli/tests/service-show.test.ts +++ b/packages/cli/tests/service-show.test.ts @@ -7,6 +7,7 @@ import { page, readFlowRoutes, SERVICE, + SERVICE_DETAIL, } from "./service-testkit"; describe("prisma-cli service show", () => { @@ -120,15 +121,12 @@ describe("prisma-cli service show", () => { ).rejects.toMatchObject({ code: "ENOENT" }); }); - it("resolves the service by id from PRISMA_SERVICE_ID", async () => { + it("resolves the service by its stable id as the argument", async () => { const harness = await makeServiceCli(); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app"], - { - cwd: harness.cwd, - env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, - }, + ["service", "show", "--project", "acme-app", "svc_1"], + { cwd: harness.cwd, env: harness.env }, ); expect(result.exitCode).toBe(0); @@ -213,11 +211,13 @@ describe("prisma-cli service show", () => { expect(frame.envelope.error.code).toBe("SERVICE.SELECTION_INVALID"); }); - it("prefers --service over PRISMA_SERVICE_ID", async () => { + it("prefers an id match over a name match for the same argument", async () => { + // A service named exactly like another service's id: the stable id + // must win, so the argument "svc_1" targets svc_1, not the impostor. const second = { ...SERVICE, id: "svc_2", - name: "api", + name: "svc_1", latestDeploymentId: null, appEndpointDomain: null, }; @@ -225,40 +225,24 @@ describe("prisma-cli service show", () => { routes: readFlowRoutes({ "GET /v1/apps": () => ({ data: page([SERVICE, second]) }), "GET /v1/apps/{appId}": (init) => { - expect(init.params?.path?.appId).toBe("svc_2"); - return { - data: { - data: { - id: "svc_2", - name: "api", - projectId: "proj_1", - region: { id: null }, - latestDeploymentId: null, - appEndpointDomain: null, - }, - }, - }; + expect(init.params?.path?.appId).toBe("svc_1"); + return { data: { data: SERVICE_DETAIL } }; }, - "GET /v1/apps/{appId}/deployments": () => ({ data: page([]) }), }), }); const result = await harness.cli.run( - ["service", "show", "--project", "acme-app", "api"], - { - cwd: harness.cwd, - env: { ...harness.env, PRISMA_SERVICE_ID: "svc_1" }, - isTty: { stdout: true }, - }, + ["service", "show", "--project", "acme-app", "svc_1"], + { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); expect(result.exitCode).toBe(0); expect(result.presented?.data).toMatchObject({ - service: { id: "svc_2", name: "api" }, + service: { id: "svc_1", name: "hello-world" }, }); }); - it("refuses without --service or PRISMA_SERVICE_ID, interactive terminals included", async () => { + it("refuses without a service argument, interactive terminals included", async () => { const harness = await makeServiceCli(); const result = await harness.cli.run( diff --git a/packages/cli/tests/service-version-list.test.ts b/packages/cli/tests/service-version-list.test.ts index aae76adb..2394dd16 100644 --- a/packages/cli/tests/service-version-list.test.ts +++ b/packages/cli/tests/service-version-list.test.ts @@ -75,7 +75,7 @@ describe("prisma-cli service version list", () => { }); }); - it("refuses without --service or PRISMA_SERVICE_ID", async () => { + it("refuses without a service argument", async () => { const harness = await makeServiceCli({ routes: readFlowRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); diff --git a/packages/cli/tests/service-version-rollback.test.ts b/packages/cli/tests/service-version-rollback.test.ts index 9bf85fe1..1a25e304 100644 --- a/packages/cli/tests/service-version-rollback.test.ts +++ b/packages/cli/tests/service-version-rollback.test.ts @@ -540,7 +540,7 @@ describe("prisma-cli service version rollback", () => { expect(frame.envelope.error.code).toBe("SERVICE.DEPLOY_FAILED"); }); - it("requires a service or PRISMA_SERVICE_ID", async () => { + it("requires a service argument", async () => { const harness = await makeServiceCli({ routes: releaseRoutes({ "GET /v1/apps": () => ({ data: page([]) }) }), }); From 8a988748154f29f4e337800c16d2899910c6788a Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 22 Aug 2026 13:26:35 +0200 Subject: [PATCH 27/27] Mount the composer and ORM families as shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composer-cli 0.12.0 retired destroy/log (composer#253) and orm-toolchain 8.0.0-rc.5 rekeyed its family to the mount paths and fixed its redirect table (prisma#30102), so the shell's re-wrapping — the destroy/log subtraction, the help-example respelling, and the redirect filter/rewrite — is deleted. Both families mount exactly as their packages ship them: the mount table reads the upstream keys, and wrapCommandFamily, toRedirectSpec, COMPOSER_DROPPED_COMMANDS, ORM_MOUNT_RESPELLINGS, respellHelpExamples, and respellMovedOrmCommands are gone. orm-mount.test.ts's example and redirect assertions keep their strings and flip their meaning: they now prove upstream stays clean rather than that a wrapper repaired it. The platform family's stale serviceDeployment* internal keys are renamed serviceVersion* in passing. Both new pins peer @prisma/cli-engine 0.2.0, matching the workspace engine. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 4 +- packages/cli/package.json | 4 +- packages/cli/src/cli.ts | 162 +--- packages/cli/tests/orm-mount.test.ts | 2 +- pnpm-lock.yaml | 851 +++++++++++++++++++++- pnpm-workspace.yaml | 12 + 6 files changed, 889 insertions(+), 146 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 59b8f3ce..ecb03191 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -449,8 +449,8 @@ The cleanup PR removed the compute config and `init`, made service commands para - ~~**`project env` still infers scope from the current git branch.**~~ Closed on the PR branch (2026-08-21, operator ruling): `project env list` with no `--role`/`--branch` lists the overview instead of inferring from the checkout; `readLocalGitBranch` and `lib/git/local-branch.ts` are deleted. - ~~**`knownLiveDeploymentByProject` has no writer.**~~ Closed on the PR branch (2026-08-21): the local-state shape, its store methods, and `service delete`'s cleanup pass were deleted. -- **Upstream family cleanups.** The shell now wraps both external families: composer still ships `destroy`/`log` commands (and their help) that nothing mounts, and orm-toolchain still keys its family `ref *` and ships the `migration ref` → `ref` redirect the wrapper drops, plus a `migration apply` replacement that says `migrate`. Each repo should retire those surfaces so the wrapper shrinks to a pass-through. +- ~~**Upstream family cleanups.**~~ Closed (2026-08-22): composer#253 retired `destroy`/`log` and prisma#30102 rekeyed the ORM family to the mount paths and fixed its redirects; the shell now mounts both families as shipped and the wrapper arithmetic is deleted. Both pins are on released versions: composer-cli 0.12.0, orm-toolchain 8.0.0-rc.5. - ~~**`PRISMA_PROJECT_ID` is honoured only by the domain commands**~~ Closed on the PR branch (2026-08-21, operator ruling): the env var served the deleted `app deploy` headless flow and survived only in the domain commands by accident; it is removed entirely. Project targeting is `--project` and the link file. -- **orm-toolchain's shipped help examples name retired spellings.** Six commands' shipped examples start with the family's own key — `format`, `migrate`, `ref list|set|delete`, and `init` — which the mounts respell to `contract format`, `db migrate`, `migration ref *` and `orm init`. The shell wrapper rewrites the examples (D4-1 ruling) until orm-toolchain updates its own. +- ~~**orm-toolchain's shipped help examples name retired spellings.**~~ Closed (2026-08-22) by prisma#30102: the family keys are the mount paths and the examples follow; `tests/orm-mount.test.ts` now asserts upstream stays clean. - ~~**The deployment-id targeting asymmetry is undocumented.**~~ Closed on the PR branch (2026-08-21): every deployment-id command (`promote|start|stop|delete|show`, `logs --deployment`) now resolves the id globally with no service parameter, per the "Subjects are positional" ruling. - **`GET /v1/deployments/{id}` omits the parent `appId`.** Verified against `@prisma/management-api-sdk@1.55.0`: the response carries id/status/url/previewDomain/envVars/createdAt and no owning-app pointer, so `showDeployment` finds the owner via `findAppForDeployment` — a scan of every project's service list and each service's deployments — and every id-targeted command pays it per run. The fix is in pdp-control-plane: include `appId` in the deployment representation; the CLI then swaps the scan for one `GET /v1/apps/{appId}`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 4e20defd..b27a94fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,11 +49,11 @@ }, "dependencies": { "@prisma/cli-engine": "workspace:0.2.0", - "@prisma/composer-cli": "0.11.0", + "@prisma/composer-cli": "0.12.0", "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "1.55.0", - "@prisma/orm-toolchain": "8.0.0-rc.4", + "@prisma/orm-toolchain": "8.0.0-rc.5", "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", "dotenv": "^17.4.2", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f8cf4484..2ab7bdd4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,10 +2,8 @@ import { type AnyCommand, type Cli, type CommandFamily, - type CommandRedirect, createCli, defineCommandFamily, - type RedirectSpec, telemetryCommandGroup, } from "@prisma/cli-engine"; import { createComposerFamily } from "@prisma/composer-cli/family"; @@ -116,13 +114,13 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ serviceCreate: serviceCreateCommand, serviceShow: serviceShowCommand, serviceOpen: serviceOpenCommand, - serviceDeploymentList: serviceVersionListCommand, - serviceDeploymentShow: serviceVersionShowCommand, - serviceDeploymentPromote: serviceVersionPromoteCommand, - serviceDeploymentRollback: serviceVersionRollbackCommand, - serviceDeploymentStart: serviceVersionStartCommand, - serviceDeploymentStop: serviceVersionStopCommand, - serviceDeploymentDelete: serviceVersionDeleteCommand, + serviceVersionList: serviceVersionListCommand, + serviceVersionShow: serviceVersionShowCommand, + serviceVersionPromote: serviceVersionPromoteCommand, + serviceVersionRollback: serviceVersionRollbackCommand, + serviceVersionStart: serviceVersionStartCommand, + serviceVersionStop: serviceVersionStopCommand, + serviceVersionDelete: serviceVersionDeleteCommand, serviceDelete: serviceDeleteCommand, serviceDomainAdd: serviceDomainAddCommand, serviceDomainShow: serviceDomainShowCommand, @@ -132,134 +130,24 @@ export const platformCommandFamily: CommandFamily = defineCommandFamily({ }, }); -/** A normalized redirect, re-spelled as the input shape - * `defineCommandFamily` takes (optional fields instead of - * `| undefined`). */ -function toRedirectSpec(redirect: CommandRedirect): RedirectSpec { - return { - from: redirect.from, - ...(redirect.flag !== undefined ? { flag: redirect.flag } : {}), - replacement: redirect.replacement, - ...(redirect.reason !== undefined ? { reason: redirect.reason } : {}), - }; -} - -/** A family re-wrapped by the shell: the same configSection and docs - * base, but the shell's choice of commands and redirects. */ -function wrapCommandFamily( - family: CommandFamily, - commands: Readonly>, - redirects: readonly RedirectSpec[], -): CommandFamily { - return defineCommandFamily({ - ...(family.configSection !== undefined - ? { configSection: family.configSection } - : {}), - commands, - ...(family.docsBaseUrl !== undefined - ? { docsBaseUrl: family.docsBaseUrl } - : {}), - redirects, - }); -} - /** * Composer's commands, contributed by composer's own package and run by - * this process. Only the command definitions and their handler entry - * functions load here; the alchemy and effect constellation stays behind - * composer's dynamic executor imports, so mounting costs an unrelated - * command nothing. - * - * Re-wrapped without `destroy` and `log`, which were dropped by the - * 2026-08-21 PM review. Subtraction, not selection: a command composer - * adds upstream enters the wrapped family, so mount-coverage's - * family-completeness check flags it until the shell mounts it. + * this process, mounted as shipped. Only the command definitions and + * their handler entry functions load here; the alchemy and effect + * constellation stays behind composer's dynamic executor imports, so + * mounting costs an unrelated command nothing. */ -const COMPOSER_DROPPED_COMMANDS = new Set(["destroy", "log"]); -const composerFamilySource = createComposerFamily(); -export const composerCommandFamily: CommandFamily = wrapCommandFamily( - composerFamilySource, - Object.fromEntries( - Object.entries(composerFamilySource.commands).filter( - ([key]) => !COMPOSER_DROPPED_COMMANDS.has(key), - ), - ), - composerFamilySource.redirects.map(toRedirectSpec), -); - -/** The ORM commands whose mount path differs from the family's own - * key. Their shipped help examples spell the family key, so the wrap - * respells them to the mounted path. */ -const ORM_MOUNT_RESPELLINGS: Readonly> = { - format: "contract format", - init: "orm init", - migrate: "db migrate", - "ref list": "migration ref list", - "ref set": "migration ref set", - "ref delete": "migration ref delete", -}; - -function respellHelpExamples( - command: AnyCommand, - from: string, - to: string, -): AnyCommand { - return { - ...command, - help: { - ...command.help, - examples: command.help.examples.map((example) => - example === from || example.startsWith(`${from} `) - ? `${to}${example.slice(from.length)}` - : example, - ), - }, - } as AnyCommand; -} - -function respellMovedOrmCommands( - commands: Readonly>, -): Readonly> { - return Object.fromEntries( - Object.entries(commands).map(([key, command]) => { - const mountPath = ORM_MOUNT_RESPELLINGS[key]; - return [ - key, - mountPath ? respellHelpExamples(command, key, mountPath) : command, - ]; - }), - ); -} +export const composerCommandFamily: CommandFamily = createComposerFamily(); /** - * The ORM commands, contributed by orm-toolchain's own package. The - * family object carries its `orm` config section, its docs base and its - * redirect table, so nothing here is wired per command. Unlike - * composer's, this family's entry module imports esbuild and arktype - * statically, so every invocation of this bin pays that import; fixing - * that is orm-toolchain's move. - * - * Re-wrapped to rewrite the shipped redirects for this shell's tree: - * the `migration ref` entry is dropped (that spelling is live again as - * `migration ref list|set|delete`, and mounting it with the redirect in - * place fails buildCli's collision check), `migration apply`'s - * replacement is respelled to the `db migrate` mount, and the moved - * commands' help examples are respelled to their mounted paths. + * The ORM commands, contributed by orm-toolchain's own package, mounted + * as shipped: the family keys are the mount paths, so the shell adds + * nothing. The family object carries its `orm` config section, its docs + * base and its redirect table. Unlike composer's, this family's entry + * module imports esbuild and arktype statically, so every invocation of + * this bin pays that import; fixing that is orm-toolchain's move. */ -export const ormCommandFamily: CommandFamily = wrapCommandFamily( - ormToolchainFamily, - respellMovedOrmCommands(ormToolchainFamily.commands), - ormToolchainFamily.redirects - .filter((redirect) => redirect.from !== "migration ref") - .map((redirect) => - redirect.from === "migration apply" - ? toRedirectSpec({ - ...redirect, - replacement: "{bin} db migrate --to ", - }) - : toRedirectSpec(redirect), - ), -); +export const ormCommandFamily: CommandFamily = ormToolchainFamily; /** The engine ships the three telemetry commands and the group help * text that belongs to them; both halves are spread in below. */ @@ -366,11 +254,11 @@ export const mountedCommands: Readonly> = { "db sign": ormCommandFamily.commands["db sign"], "db update": ormCommandFamily.commands["db update"], "db verify": ormCommandFamily.commands["db verify"], - "db migrate": ormCommandFamily.commands.migrate, - "contract format": ormCommandFamily.commands.format, + "db migrate": ormCommandFamily.commands["db migrate"], + "contract format": ormCommandFamily.commands["contract format"], // `orm init` keeps this path: only the top-level `init` (the compute // config wizard) was removed, by the 2026-08-21 PM review. - "orm init": ormCommandFamily.commands.init, + "orm init": ormCommandFamily.commands["orm init"], lsp: ormCommandFamily.commands.lsp, "migration check": ormCommandFamily.commands["migration check"], "migration graph": ormCommandFamily.commands["migration graph"], @@ -380,9 +268,9 @@ export const mountedCommands: Readonly> = { "migration plan": ormCommandFamily.commands["migration plan"], "migration show": ormCommandFamily.commands["migration show"], "migration status": ormCommandFamily.commands["migration status"], - "migration ref delete": ormCommandFamily.commands["ref delete"], - "migration ref list": ormCommandFamily.commands["ref list"], - "migration ref set": ormCommandFamily.commands["ref set"], + "migration ref delete": ormCommandFamily.commands["migration ref delete"], + "migration ref list": ormCommandFamily.commands["migration ref list"], + "migration ref set": ormCommandFamily.commands["migration ref set"], // Local utilities: no owning package, no config section, no API. "agent install": agentInstallCommand, "agent update": agentUpdateCommand, diff --git a/packages/cli/tests/orm-mount.test.ts b/packages/cli/tests/orm-mount.test.ts index c48677c2..e9403562 100644 --- a/packages/cli/tests/orm-mount.test.ts +++ b/packages/cli/tests/orm-mount.test.ts @@ -58,7 +58,7 @@ function descriptor(kind: string) { }; } -/** The retired spellings the ORM family's own examples carry. */ +/** Retired spellings upstream must not ship in its help examples. */ const RETIRED_ORM_SPELLING = /prisma-test (format|migrate|ref|init)(\s|$)/; function shell(config?: Readonly>) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b141acae..9616a863 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: workspace:0.2.0 version: link:../cli-engine '@prisma/composer-cli': - specifier: 0.11.0 - version: 0.11.0(@prisma/cli-engine@packages+cli-engine)(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0) + specifier: 0.12.0 + version: 0.12.0(@prisma/cli-engine@packages+cli-engine)(@types/node@22.19.19)(@vercel/nft@1.10.2(rollup@4.62.2))(magicast@0.5.3)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1107.0))(mysql2@3.23.3(@types/node@22.19.19))(pg@8.23.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(ws@8.21.0) '@prisma/compute-sdk': specifier: 0.39.0 version: 0.39.0(@prisma/management-api-sdk@1.55.0)(rollup@4.62.2) @@ -39,8 +39,8 @@ importers: specifier: 1.55.0 version: 1.55.0 '@prisma/orm-toolchain': - specifier: 8.0.0-rc.4 - version: 8.0.0-rc.4(@prisma/cli-engine@packages+cli-engine)(magicast@0.5.3)(typanion@3.14.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 8.0.0-rc.5 + version: 8.0.0-rc.5(@prisma/cli-engine@packages+cli-engine)(magicast@0.5.3)(typanion@3.14.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.4 @@ -255,9 +255,36 @@ packages: resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@alchemy.run/cloudflare-runtime@2.0.0-beta.74': + resolution: {integrity: sha512-P4eICKlw1TgnY6QjM3mztIuZcKXPdT4MmbatxWvzSp7ecueWcts9vnXn21dC0KR/vtRBRdopyuEbhmQ1CB0TfQ==} + peerDependencies: + '@distilled.cloud/cloudflare': 1.0.0-rc.6 + '@effect/platform-bun': '>=4.0.0-rc.110 || >=4.0.0' + '@effect/platform-node': '>=4.0.0-rc.110 || >=4.0.0' + effect: '>=4.0.0-rc.110 || >=4.0.0' + rolldown: 1.1.5 + vite: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@effect/platform-bun': + optional: true + '@effect/platform-node': + optional: true + rolldown: + optional: true + vite: + optional: true + + '@alchemy.run/floci@2.0.0-beta.74': + resolution: {integrity: sha512-JxQ1d1N8TzRXliJxI9HEM1DVIIONPB/NpPYx0S3EjGC6m0qM5Q5NzLJBXozxUL85mDTWba4cAodrpMF9oDIdsg==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + '@alchemy.run/node-utils@0.0.5': resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} + '@alchemy.run/node-utils@2.0.0-beta.74': + resolution: {integrity: sha512-UH7mbmF0qZWL0b5xURFaRbKR2qNXdvxEjIq86FdqSKacnH0lsHSgGzNKFbDSKFAY1u6Kq3unHFmgRk7UAD/YTQ==} + '@ark/schema@0.56.2': resolution: {integrity: sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==} @@ -504,16 +531,29 @@ packages: '@cloudflare/workers-types@5.20260810.1': resolution: {integrity: sha512-aD0hEU6HaiG4wy4hDoPoLXMH963nHD4MsIY566pGkWO2dL/yeYEiMN7SeJ24t+wBmLDnh16yQ7IPos5opGkIoA==} + '@cloudflare/workers-types@5.20260821.1': + resolution: {integrity: sha512-jZiTISghTPeytJ7eDt5K1AV7df998xGECMgT8oy838VfFLJLROvdLzDaJb0/Jv4WfxO00keNoWt1d6olL7URRg==} + '@distilled.cloud/aws@0.30.3': resolution: {integrity: sha512-6U/wO+fLNnqBlRnqFpF79edS5t6njDl/6UmnCVUfWpIQ4n23X0VY1ft0lzsaBa506xRtF4vRhMELR9FIp5DKUA==} peerDependencies: effect: '>=4.0.0-beta.100 || >=4.0.0' + '@distilled.cloud/aws@1.0.0-rc.6': + resolution: {integrity: sha512-WI0KK4mCdIvclKH7kbK/geY9iIRiSN6v067OdvuLUOEHA19GdQj88lUBSbtwdmIG0sm61zm2JKHwXO+RKseuIg==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + '@distilled.cloud/axiom@0.30.3': resolution: {integrity: sha512-U4YvXsvz/TDYfIbZNRVAv8Yx1mnbms/rBQ/RHnRQWhL0JzxZOZOQvToxkB4kh/oa8KT+QbjTkDxRJP/f6P0M1g==} peerDependencies: effect: '>=4.0.0-beta.100 || >=4.0.0' + '@distilled.cloud/axiom@1.0.0-rc.6': + resolution: {integrity: sha512-np94ilGVgnsOjM8mMpfmSRMvtQBH5qvZdBJ1K9MG+NxCmP5yo+F57TTiWgw1Ta++jT8TDYuFPg/EITO2BAMkRA==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0': resolution: {integrity: sha512-cBVl4Ck4Prf9/dPSvyQeGW+2CDLIKs+xbyUm/MgNOSyYDZYOLbsCnDePO4YwdvaxsT0oOZZIkSDDki2ve9DCHA==} peerDependencies: @@ -558,32 +598,78 @@ packages: peerDependencies: effect: '>=4.0.0-beta.100 || >=4.0.0' + '@distilled.cloud/cloudflare@1.0.0-rc.6': + resolution: {integrity: sha512-5nN5MuHo2UgQIHswt7J+4B/nUg8LvMiODTgPNL33ui4aqgAVj7H0S1+JsHIl03nh1d2twVaKglGtQbwZDXLf/w==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + '@distilled.cloud/core@0.30.3': resolution: {integrity: sha512-RupX597cPmceEiOY6csihFbzH2WhDkfbwGCMk+Izx8+f16u+aLm4mgyrYoxj1/dzIHsyIfw8sFKgNPzAg0Rx2Q==} peerDependencies: effect: '>=4.0.0-beta.100 || >=4.0.0' + '@distilled.cloud/core@1.0.0-rc.6': + resolution: {integrity: sha512-nNKbsNmlRNMgXaXZdPGBqMrKLFpql2CGW7mWcGR5M3csA4WpdRJnwG5mkDcI+uw1VqxRrzOM1Ijiab0AzQCrLg==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + + '@distilled.cloud/fly-io@1.0.0-rc.6': + resolution: {integrity: sha512-023LsogDqQilo/wBCM+ASbD26DIZi3PInJKNLS42hB/eY2SH3D3QYiNpkKW72Qj+ZOoyT8AvNVHRKOQHXm/C8A==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + + '@distilled.cloud/hetzner@1.0.0-rc.6': + resolution: {integrity: sha512-k0uaPNFdl5l1rj0qlOt5y8NkfdSb70+nUa4p7Xt4PGMPJl9xsUCoPanOwaI+LbtWebWQfC6zhhdPEUXLNAr/5g==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + '@distilled.cloud/neon@0.30.3': resolution: {integrity: sha512-4iW6lNvrJ/BuraKQ572bTQPNNtK/pFMqALoKjgozXR5jAZXbohRrD8/3QHLZW9cOAPenaHugwk4YIm0/+u4j5Q==} peerDependencies: effect: '>=4.0.0-beta.100 || >=4.0.0' + '@distilled.cloud/neon@1.0.0-rc.6': + resolution: {integrity: sha512-yn+4GhQ8Gu5GVcx3g1zxVCheBSmaQE5FAA5TzzXnlIiKUwmMLVTB/ucwJpDYhoUxjWK7bnv1eVZReoibYMHX4g==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + '@distilled.cloud/planetscale@0.30.3': resolution: {integrity: sha512-0ZcPqoXKl5uhJ6Ytw9UpgHa2t2pjwsuQFZn4OlnjZl0F/lToO/8TdXp4iKyw7Wog5tKTkfRYrQXVzRbaPKP3QQ==} peerDependencies: effect: '>=4.0.0-beta.100 || >=4.0.0' + '@distilled.cloud/planetscale@1.0.0-rc.6': + resolution: {integrity: sha512-6lIFwZAXiQCJ9u5QMIyY72qTdYbWmGAt7+HbONFEvmihwvecFuRo1i1QYkx5j9EN8c54KeO7CcEBq7HXpvwIhQ==} + peerDependencies: + effect: '>=4.0.0-rc.110 || >=4.0.0' + '@effect/sql-d1@4.0.0-beta.107': resolution: {integrity: sha512-GN0RMZRQ+Kc6t4hiIbGBFMdYj2UssX27w0n1oyV+s9OZcWOdD42C2vDxUwnnKLstPDdeOMBAFX7dMekOiF2rUQ==} peerDependencies: effect: ^4.0.0-beta.107 + '@effect/sql-d1@4.0.0-rc.111': + resolution: {integrity: sha512-hjIoVS59gAvP1DjB+R5hwL9n/UbS1598/qcT1Hp3Tn3ksuJUOPTXfsCCiAQT3Ueecfkbiy32fxrCbzD0Z1YJLA==} + peerDependencies: + effect: ^4.0.0-rc.111 + + '@effect/sql-sqlite-do@4.0.0-rc.111': + resolution: {integrity: sha512-DZSv45s/XLIzpa9sM3qmEOb4uKp4T6kq53TVHsSFVJzoRt8GJdai427QIZhxjOoJzAkRt0UwmhPjvAb5d98jFg==} + peerDependencies: + effect: ^4.0.0-rc.111 + '@effect/vitest@4.0.0-beta.103': resolution: {integrity: sha512-Kz3gemVuJNAZ3e4V6A7BwAP87x2Av8LyHOlCjy9jbZzlncwJXO7Olk1Sje3hdKaet3wEl51A/0/89xJ2IYSgNg==} peerDependencies: effect: ^4.0.0-beta.103 vitest: ^4.1.0 + '@effect/vitest@4.0.0-rc.111': + resolution: {integrity: sha512-YDaEVT+grREBVMzykRNFtJwGxy02achzT0WXZYwMJA4ukzuB9krkgQ5roc3N6zhC3NQq/j4IyM32/Pcov7AhHw==} + peerDependencies: + effect: ^4.0.0-rc.111 + vitest: '>=4.1.0 <5.0.0' + '@electric-sql/pglite-socket@0.0.20': resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} hasBin: true @@ -934,6 +1020,168 @@ packages: peerDependencies: hono: ^4 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1161,10 +1409,21 @@ packages: peerDependencies: '@prisma/cli-engine': 0.2.0 + '@prisma/composer-cli@0.12.0': + resolution: {integrity: sha512-s/EU+dUJOrDxIZYcNoDjOlLSj1xhpTw5IcVsCr8DZxY2p84h3J50FfvJ6IyLJBUHKz0w4gZ3evqvk5oJIFwx+g==} + engines: {node: '>=22.18.0'} + hasBin: true + peerDependencies: + '@prisma/cli-engine': 0.2.0 + '@prisma/composer@0.11.0': resolution: {integrity: sha512-NP4Ds9qrHQdtitj2pBRdUkuHs6Crtx+uCHHKyUfAaIeKW3b0vRrOibJUhaYXZ/dE9YrbYRmtpddLO0ZTL1h1yA==} engines: {node: '>=22.18.0'} + '@prisma/composer@0.12.0': + resolution: {integrity: sha512-IJFbkDUQtIY1cmSfnuCbeNGQsYvBUaKRENNL0OLF0IqX7D/xmu+4T7R2ljnebiTlquiACv7ONkBh89EUiNjXEA==} + engines: {node: '>=22.18.0'} + '@prisma/compute-sdk@0.39.0': resolution: {integrity: sha512-Ir4yuCiqyv7XjhqsqolKZjXzzMCFiZJ4vvk1jl0dn6MjZx1j6puojD+1BLbCE8ty0NakaOyhWmXWyO63YeUf/Q==} engines: {node: '>=18.0.0'} @@ -1197,6 +1456,14 @@ packages: typescript: optional: true + '@prisma/orm-framework@8.0.0-rc.5': + resolution: {integrity: sha512-4yfndcDIOGagmtH1GAIzHF1dvFvZDG6F1uoBzrB4D1UBZ8PHDJO0854MQZp/oe7YdW+dbht+G83GaYt/8YMCPw==} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true + '@prisma/orm-toolchain@8.0.0-rc.4': resolution: {integrity: sha512-YufxTbj0jB8f6iSCbo/KEgWwPP6Y1C2XFEg5N6YnVLHV7nP92NfR51qlUJrtvj153mltPZjKpGdAdtxvf0Besw==} peerDependencies: @@ -1209,9 +1476,34 @@ packages: vite: optional: true + '@prisma/orm-toolchain@8.0.0-rc.5': + resolution: {integrity: sha512-VJ6+yX31je7AaDFS8JBIG4hYkTuzkoliW4WzjQVqL+PmQ4y+lYRBfz+pEhM8e8Sy0pagR38L4DZvPCCIiG8s7g==} + peerDependencies: + '@prisma/cli-engine': 0.2.0 + typescript: '>=5.9' + vite: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + vite: + optional: true + '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + '@puppeteer/browsers@3.2.1': + resolution: {integrity: sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + proxy-agent: '>=8.0.1' + yauzl: ^2.10.0 || ^3.4.0 + peerDependenciesMeta: + proxy-agent: + optional: true + yauzl: + optional: true + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1766,6 +2058,55 @@ packages: ws: optional: true + alchemy@2.0.0-beta.74: + resolution: {integrity: sha512-Dpy6lZxk1SS5sZeV8vA79c0RaMLcniFCKewai1jEEamzFz0dR+yi2Ckmgi59ubMLAKvp9LNGNnhvRXfF0ETbkA==} + hasBin: true + peerDependencies: + '@alchemy.run/frontend-frameworks': 2.0.0-beta.74 + '@aws/durable-execution-sdk-js': ^2.1.0 + '@effect/platform-bun': '>=4.0.0-rc.110 || >=4.0.0' + '@effect/platform-node': '>=4.0.0-rc.110 || >=4.0.0' + '@effect/sql-mysql2': '>=4.0.0-rc.110 || >=4.0.0' + '@effect/sql-pg': '>=4.0.0-rc.110 || >=4.0.0' + '@vercel/nft': ^1.10.2 + drizzle-kit: 1.0.0-rc.5-ab785fc + drizzle-orm: 1.0.0-rc.5-ab785fc + effect: '>=4.0.0-rc.110 || >=4.0.0' + mongodb: ^6.10.0 + mysql2: ^3.23.2 + pg: ^8.22.0 + vite: ^8.0.7 + ws: ^8.20.0 + peerDependenciesMeta: + '@alchemy.run/frontend-frameworks': + optional: true + '@aws/durable-execution-sdk-js': + optional: true + '@effect/platform-bun': + optional: true + '@effect/platform-node': + optional: true + '@effect/sql-mysql2': + optional: true + '@effect/sql-pg': + optional: true + '@vercel/nft': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + pg: + optional: true + vite: + optional: true + ws: + optional: true + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1909,6 +2250,15 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + capnp-es@0.0.14: + resolution: {integrity: sha512-8lWj4GJISiqRSlAJGkWpI4Azib7QY5UDIkqxeHcI7aAnXVk9SFuWxl1Fme+2HhzNANV0WTJqwUZvvXvloc3sBA==} + hasBin: true + peerDependencies: + typescript: ^5.7.3 + peerDependenciesMeta: + typescript: + optional: true + capnweb@0.6.1: resolution: {integrity: sha512-fmhV26QPd1ewf5R74h55oVZnGwIcSaRMzbfLQUy8+zOBjuTmT3KXoT8wxHvnp1m9Ht9BoUUS5ZwNLoVLfQTyBg==} @@ -1952,6 +2302,10 @@ packages: peerDependencies: typanion: '*' + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + closest-match@1.3.3: resolution: {integrity: sha512-RSdHrZwNOvt2uMQgqJDJdM/I+5MlJ1tQJEXYrbRjSMXWiCRo06g2hwObJ7+WKt2J9ySK9/pJ0Q2vbL+BPkofDA==} @@ -2038,6 +2392,9 @@ packages: effect@4.0.0-beta.103: resolution: {integrity: sha512-pE8TxF4m2tQzVI+77dIlm3s+81TACV1AiX1JEkvY+zVuxgQQ8aGSkqXNJF6b/ST+coCSk5cUdbULpQ7sm4oHyw==} + effect@4.0.0-rc.111: + resolution: {integrity: sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2065,6 +2422,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -2154,6 +2515,10 @@ packages: generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -2394,6 +2759,10 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + modern-tar@0.8.4: + resolution: {integrity: sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==} + engines: {node: '>=18.0.0'} + mongodb-connection-string-url@3.0.2: resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} @@ -2535,6 +2904,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -2745,9 +3117,23 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3160,6 +3546,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -3169,6 +3559,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -3186,8 +3588,35 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@alchemy.run/cloudflare-runtime@2.0.0-beta.74(@distilled.cloud/cloudflare@1.0.0-rc.6(effect@4.0.0-rc.111))(@types/node@22.19.19)(effect@4.0.0-rc.111)(rolldown@1.1.5)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@alchemy.run/node-utils': 2.0.0-beta.74 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1) + '@distilled.cloud/cloudflare': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@puppeteer/browsers': 3.2.1(yauzl@3.4.0) + capnp-es: 0.0.14(typescript@6.0.3) + effect: 4.0.0-rc.111 + magic-string: 0.30.21 + sharp: 0.35.3(@types/node@22.19.19) + unenv: 2.0.0-rc.24 + workerd: 1.20260704.1 + yauzl: 3.4.0 + optionalDependencies: + rolldown: 1.1.5 + vite: 7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - proxy-agent + - typescript + + '@alchemy.run/floci@2.0.0-beta.74(effect@4.0.0-rc.111)': + dependencies: + effect: 4.0.0-rc.111 + '@alchemy.run/node-utils@0.0.5': {} + '@alchemy.run/node-utils@2.0.0-beta.74': {} + '@ark/schema@0.56.2': dependencies: '@ark/util': 0.56.2 @@ -3499,6 +3928,8 @@ snapshots: '@cloudflare/workers-types@5.20260810.1': {} + '@cloudflare/workers-types@5.20260821.1': {} + '@distilled.cloud/aws@0.30.3(effect@4.0.0-beta.103)': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -3513,11 +3944,30 @@ snapshots: effect: 4.0.0-beta.103 fast-xml-parser: 5.10.1 + '@distilled.cloud/aws@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/credential-providers': 3.1107.0 + '@aws-sdk/types': 3.974.2 + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@smithy/shared-ini-file-loader': 4.6.16 + '@smithy/types': 4.16.1 + '@smithy/util-base64': 4.5.16 + aws4fetch: 1.0.20 + effect: 4.0.0-rc.111 + fast-xml-parser: 5.10.1 + '@distilled.cloud/axiom@0.30.3(effect@4.0.0-beta.103)': dependencies: '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.103) effect: 4.0.0-beta.103 + '@distilled.cloud/axiom@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 + '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0(rolldown@1.1.5)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260704.1)': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1) @@ -3552,30 +4002,73 @@ snapshots: '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.103) effect: 4.0.0-beta.103 + '@distilled.cloud/cloudflare@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 + '@distilled.cloud/core@0.30.3(effect@4.0.0-beta.103)': dependencies: effect: 4.0.0-beta.103 + '@distilled.cloud/core@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + effect: 4.0.0-rc.111 + + '@distilled.cloud/fly-io@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 + + '@distilled.cloud/hetzner@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 + '@distilled.cloud/neon@0.30.3(effect@4.0.0-beta.103)': dependencies: '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.103) effect: 4.0.0-beta.103 + '@distilled.cloud/neon@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 + '@distilled.cloud/planetscale@0.30.3(effect@4.0.0-beta.103)': dependencies: '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.103) effect: 4.0.0-beta.103 + '@distilled.cloud/planetscale@1.0.0-rc.6(effect@4.0.0-rc.111)': + dependencies: + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 + '@effect/sql-d1@4.0.0-beta.107(effect@4.0.0-beta.103)': dependencies: '@cloudflare/workers-types': 5.20260810.1 effect: 4.0.0-beta.103 + '@effect/sql-d1@4.0.0-rc.111(effect@4.0.0-rc.111)': + dependencies: + '@cloudflare/workers-types': 5.20260821.1 + effect: 4.0.0-rc.111 + + '@effect/sql-sqlite-do@4.0.0-rc.111(effect@4.0.0-rc.111)': + dependencies: + effect: 4.0.0-rc.111 + '@effect/vitest@4.0.0-beta.103(effect@4.0.0-beta.103)(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': dependencies: effect: 4.0.0-beta.103 vitest: 4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@effect/vitest@4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': + dependencies: + effect: 4.0.0-rc.111 + vitest: 4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: '@electric-sql/pglite': 0.3.15 @@ -3778,6 +4271,112 @@ snapshots: dependencies: hono: 4.11.4 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -4046,6 +4645,39 @@ snapshots: - workerd - ws + '@prisma/composer-cli@0.12.0(@prisma/cli-engine@packages+cli-engine)(@types/node@22.19.19)(@vercel/nft@1.10.2(rollup@4.62.2))(magicast@0.5.3)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1107.0))(mysql2@3.23.3(@types/node@22.19.19))(pg@8.23.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(ws@8.21.0)': + dependencies: + '@prisma/cli-engine': link:packages/cli-engine + '@prisma/composer': 0.12.0(@types/node@22.19.19)(@vercel/nft@1.10.2(rollup@4.62.2))(magicast@0.5.3)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1107.0))(mysql2@3.23.3(@types/node@22.19.19))(pg@8.23.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(ws@8.21.0) + alchemy: 2.0.0-beta.74(@types/node@22.19.19)(@vercel/nft@1.10.2(rollup@4.62.2))(effect@4.0.0-rc.111)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1107.0))(mysql2@3.23.3(@types/node@22.19.19))(pg@8.23.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(ws@8.21.0) + c12: 3.3.4(magicast@0.5.3) + effect: 4.0.0-rc.111 + esbuild: 0.28.2 + transitivePeerDependencies: + - '@alchemy.run/frontend-frameworks' + - '@aws/durable-execution-sdk-js' + - '@effect/platform-bun' + - '@effect/platform-node' + - '@effect/sql-mysql2' + - '@effect/sql-pg' + - '@types/node' + - '@types/react' + - '@vercel/nft' + - bufferutil + - drizzle-kit + - drizzle-orm + - magicast + - mongodb + - mysql2 + - pg + - proxy-agent + - react-devtools-core + - typescript + - utf-8-validate + - vite + - vitest + - ws + '@prisma/composer@0.11.0(@types/node@22.19.19)(magicast@0.5.3)(rollup@4.62.2)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.0)': dependencies: '@prisma/management-api-sdk': 1.62.0 @@ -4084,6 +4716,40 @@ snapshots: - workerd - ws + '@prisma/composer@0.12.0(@types/node@22.19.19)(@vercel/nft@1.10.2(rollup@4.62.2))(magicast@0.5.3)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1107.0))(mysql2@3.23.3(@types/node@22.19.19))(pg@8.23.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(ws@8.21.0)': + dependencies: + '@prisma/management-api-sdk': 1.62.0 + '@standard-schema/spec': 1.1.0 + alchemy: 2.0.0-beta.74(@types/node@22.19.19)(@vercel/nft@1.10.2(rollup@4.62.2))(effect@4.0.0-rc.111)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1107.0))(mysql2@3.23.3(@types/node@22.19.19))(pg@8.23.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(ws@8.21.0) + arktype: 2.2.3 + c12: 3.3.4(magicast@0.5.3) + effect: 4.0.0-rc.111 + esbuild: 0.28.2 + transitivePeerDependencies: + - '@alchemy.run/frontend-frameworks' + - '@aws/durable-execution-sdk-js' + - '@effect/platform-bun' + - '@effect/platform-node' + - '@effect/sql-mysql2' + - '@effect/sql-pg' + - '@types/node' + - '@types/react' + - '@vercel/nft' + - bufferutil + - drizzle-kit + - drizzle-orm + - magicast + - mongodb + - mysql2 + - pg + - proxy-agent + - react-devtools-core + - typescript + - utf-8-validate + - vite + - vitest + - ws + '@prisma/compute-sdk@0.39.0(@prisma/management-api-sdk@1.55.0)(rollup@4.62.2)': dependencies: '@prisma/management-api-sdk': 1.55.0 @@ -4155,6 +4821,15 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@prisma/orm-framework@8.0.0-rc.5(typescript@6.0.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + arktype: 2.2.3 + pathe: 2.0.3 + uniku: 0.5.0 + optionalDependencies: + typescript: 6.0.3 + '@prisma/orm-toolchain@8.0.0-rc.4(@prisma/cli-engine@packages+cli-engine)(magicast@0.5.3)(typanion@3.14.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@prisma/cli-engine': link:packages/cli-engine @@ -4183,8 +4858,43 @@ snapshots: - magicast - typanion + '@prisma/orm-toolchain@8.0.0-rc.5(@prisma/cli-engine@packages+cli-engine)(magicast@0.5.3)(typanion@3.14.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@prisma/cli-engine': link:packages/cli-engine + '@prisma/orm-framework': 8.0.0-rc.5(typescript@6.0.3) + '@vercel/detect-agent': 1.2.4 + arktype: 2.2.3 + c12: 3.3.4(magicast@0.5.3) + ci-info: 4.4.0 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + closest-match: 1.3.3 + colorette: 2.0.20 + esbuild: 0.28.2 + jsonc-parser: 3.3.1 + package-manager-detector: 1.8.0 + pathe: 2.0.3 + prettier: 3.9.6 + string-width: 8.2.2 + strip-ansi: 7.2.0 + vscode-languageserver: 10.1.0 + vscode-languageserver-textdocument: 1.0.12 + wrap-ansi: 10.0.0 + optionalDependencies: + typescript: 6.0.3 + vite: 7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - magicast + - typanion + '@prisma/query-plan-executor@7.2.0': {} + '@puppeteer/browsers@3.2.1(yauzl@3.4.0)': + dependencies: + modern-tar: 0.8.4 + yargs: 18.1.0 + optionalDependencies: + yauzl: 3.4.0 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -4631,6 +5341,63 @@ snapshots: - vitest - workerd + alchemy@2.0.0-beta.74(@types/node@22.19.19)(@vercel/nft@1.10.2(rollup@4.62.2))(effect@4.0.0-rc.111)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1107.0))(mysql2@3.23.3(@types/node@22.19.19))(pg@8.23.0)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(ws@8.21.0): + dependencies: + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.74(@distilled.cloud/cloudflare@1.0.0-rc.6(effect@4.0.0-rc.111))(@types/node@22.19.19)(effect@4.0.0-rc.111)(rolldown@1.1.5)(typescript@6.0.3)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@alchemy.run/floci': 2.0.0-beta.74(effect@4.0.0-rc.111) + '@alchemy.run/node-utils': 2.0.0-beta.74 + '@aws-sdk/credential-providers': 3.1107.0 + '@clack/prompts': 1.7.0 + '@distilled.cloud/aws': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@distilled.cloud/axiom': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@distilled.cloud/cloudflare': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@distilled.cloud/core': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@distilled.cloud/fly-io': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@distilled.cloud/hetzner': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@distilled.cloud/neon': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@distilled.cloud/planetscale': 1.0.0-rc.6(effect@4.0.0-rc.111) + '@effect/sql-d1': 4.0.0-rc.111(effect@4.0.0-rc.111) + '@effect/sql-sqlite-do': 4.0.0-rc.111(effect@4.0.0-rc.111) + '@effect/vitest': 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + '@libsql/client': 0.17.4 + '@octokit/rest': 22.0.1 + '@octokit/webhooks': 14.2.0 + '@prisma/dev': 0.20.0(typescript@6.0.3) + '@smithy/node-config-provider': 4.5.16 + '@smithy/shared-ini-file-loader': 4.6.16 + '@smithy/types': 4.16.1 + '@types/aws-lambda': 8.10.162 + aws4fetch: 1.0.20 + capnweb: 0.6.1 + effect: 4.0.0-rc.111 + fast-glob: 3.3.3 + fast-xml-parser: 5.10.1 + ink: 6.8.0(react@19.2.8) + jszip: 3.10.1 + libsodium-wrappers: 0.8.4 + pathe: 2.0.3 + picomatch: 4.0.4 + react: 19.2.8 + rolldown: 1.1.5 + undici: 7.29.0 + yaml: 2.9.0 + optionalDependencies: + '@vercel/nft': 1.10.2(rollup@4.62.2) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1107.0) + mysql2: 3.23.3(@types/node@22.19.19) + pg: 8.23.0 + vite: 7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + ws: 8.21.0 + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - bufferutil + - proxy-agent + - react-devtools-core + - typescript + - utf-8-validate + - vitest + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -4751,6 +5518,10 @@ snapshots: cac@7.0.0: {} + capnp-es@0.0.14(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + capnweb@0.6.1: {} chai@6.2.2: {} @@ -4789,6 +5560,12 @@ snapshots: dependencies: typanion: 3.14.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + closest-match@1.3.3: {} code-excerpt@4.0.0: @@ -4853,6 +5630,12 @@ snapshots: uuid: 14.0.1 yaml: 2.9.0 + effect@4.0.0-rc.111: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + msgpackr: 2.0.5 + emoji-regex@10.6.0: {} empathic@2.0.1: {} @@ -4921,6 +5704,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} + escape-string-regexp@2.0.0: {} estree-walker@2.0.2: {} @@ -5024,6 +5809,8 @@ snapshots: dependencies: is-property: 1.0.2 + get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} get-port-please@3.2.0: {} @@ -5241,6 +6028,8 @@ snapshots: dependencies: minipass: 7.1.3 + modern-tar@0.8.4: {} + mongodb-connection-string-url@3.0.2: dependencies: '@types/whatwg-url': 11.0.5 @@ -5359,6 +6148,8 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + perfect-debounce@2.1.0: {} pg-cloudflare@1.4.0: @@ -5598,8 +6389,43 @@ snapshots: semver@7.8.1: {} + semver@7.8.5: {} + setimmediate@1.0.5: {} + sharp@0.35.3(@types/node@22.19.19): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.19.19 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5953,10 +6779,27 @@ snapshots: xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@5.0.0: {} yaml@2.9.0: {} + yargs-parser@22.0.0: {} + + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yoctocolors@2.1.2: {} yoga-layout@3.2.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7d3fc123..e47d4a73 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,3 +27,15 @@ minimumReleaseAgeExclude: # The ORM family and the framework it reaches through. - '@prisma/orm-toolchain' - '@prisma/orm-framework' + - '@alchemy.run/cloudflare-runtime@2.0.0-beta.74' + - '@alchemy.run/floci@2.0.0-beta.74' + - '@alchemy.run/node-utils@2.0.0-beta.74' + - '@distilled.cloud/aws@1.0.0-rc.6' + - '@distilled.cloud/axiom@1.0.0-rc.6' + - '@distilled.cloud/cloudflare@1.0.0-rc.6' + - '@distilled.cloud/core@1.0.0-rc.6' + - '@distilled.cloud/fly-io@1.0.0-rc.6' + - '@distilled.cloud/hetzner@1.0.0-rc.6' + - '@distilled.cloud/neon@1.0.0-rc.6' + - '@distilled.cloud/planetscale@1.0.0-rc.6' + - alchemy@2.0.0-beta.74