From 7890c2b8ed00821e14f9cde344e264a52c46f1a8 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 2 Jul 2026 16:51:34 +0530 Subject: [PATCH 1/4] feat(cli): add init, the compute config formalizer init writes a committed prisma.compute.ts for the app in the invocation directory: it detects the framework with the same registry deploy uses, previews every value with its source, and pins the app's identity (name, framework, httpPort, entry for entrypoint frameworks, region only when passed). It never writes a build block, never overwrites an existing config anywhere up to the repo root (INIT_CONFIG_EXISTS), and never scaffolds code; create-prisma owns scaffolding. Writing the config needs no auth. The optional link step reuses the project link flow (interactive question, --link/--no-link/--project), and link failures after the write downgrade to warnings so the config stands. Detection failure prompts interactively and fails with INIT_DETECTION_FAILED plus the framework list in JSON mode. app deploy success output now hints "Config prisma-cli init" when the deploy ran on inferred settings without a config file. --- docs/product/command-spec.md | 105 ++++- docs/product/resource-model.md | 1 + packages/cli/src/cli.ts | 2 + packages/cli/src/commands/init/index.ts | 79 ++++ packages/cli/src/controllers/app.ts | 4 +- packages/cli/src/controllers/init.ts | 509 ++++++++++++++++++++++++ packages/cli/src/lib/app/bun-project.ts | 1 + packages/cli/src/presenters/app.ts | 14 + packages/cli/src/presenters/init.ts | 53 +++ packages/cli/src/shell/command-meta.ts | 10 + packages/cli/src/types/init.ts | 34 ++ packages/cli/tests/init.test.ts | 289 ++++++++++++++ 12 files changed, 1096 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/init/index.ts create mode 100644 packages/cli/src/controllers/init.ts create mode 100644 packages/cli/src/presenters/init.ts create mode 100644 packages/cli/src/types/init.ts create mode 100644 packages/cli/tests/init.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4150a18..44e4fb4 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -19,18 +19,24 @@ The beta package includes these command groups: - `app` - `build` (includes `build logs`) -The beta package also includes one top-level utility command: +The beta package also includes two top-level commands: - `version` +- `init` `version` is intentionally outside the workflow groups: it reports CLI build and environment state, requires no auth, no project context, and no network, and is the canonical answer to "is this CLI installed and on the build I expect?" +`init` is a top-level workflow verb: it acts on the local project directory +(writing the committed compute config) rather than managing a remote resource, +so it sits beside the ORM's verb register (`generate`, `migrate`, `validate`) +that the unified CLI will absorb. `init` never scaffolds application code; +creating new apps is `create-prisma`'s job. + The Git repository connection slice uses the `git` group. It does not add a provider-specific `GitHub` group. Out of scope for the current beta: -- `init` - `schema` - `migrate` - product-specific namespaces such as `compute` @@ -38,7 +44,7 @@ Out of scope for the current beta: ## Global Rules - Canonical shape is `prisma `. -- `version` is the one top-level command outside that shape (see Scope above). +- `version` and `init` are the top-level commands outside that shape (see Scope above). - Every command supports `--json`. - Shared global flags are: - `--json` @@ -378,6 +384,42 @@ prisma-cli --version --json `prisma-cli version` is the richer environment report; `prisma-cli --version` is the terse one-liner. Both report the same `cli.version`. Use the flag for quick checks, the subcommand for support tickets and bug reports. +## `prisma-cli init --framework --entry --http-port --region --name --link --no-link --project ` + +Purpose: + +- write a committed `prisma.compute.ts` for the app in this directory + +Behavior: + +- init is the config formalizer: `app deploy` works with zero config, and init writes down what deploy would infer so the setup is committed, reviewable, and stable for teammates and CI +- writing the config requires no auth and no network; linking is the only remote step +- fails with `INIT_CONFIG_EXISTS` when a compute config already exists in the invocation directory or any ancestor up to the repository or workspace root; the error names the existing file, and init never overwrites or merges — editing a committed config is the user's editor's job +- detects the framework from the same registry and signals `app deploy` uses; explicit `--framework` wins over detection +- `--entry` sets the source entrypoint for entrypoint frameworks (Bun, Hono); `--http-port` overrides the framework default port; `--name` overrides the app name inferred from `package.json#name` or the directory name +- previews the resolved values with per-value source annotations (`detected`, `framework default`, `package.json`, `flag`) before writing; in interactive mode the preview is followed by an optional adjust step for framework and HTTP port, and `--yes` accepts the preview as shown +- the generated config pins the app's identity: `name`, `framework`, and `httpPort` always; `entry` when the framework consumes a source entrypoint; `region` only when `--region` is passed, because pinning a region the user did not choose would silently place new apps +- the generated config does not include a `build` block: build settings stay inferred (and shown with their sources by deploy) until the user adds one, which keeps package-manager and build-script inference live +- init never scaffolds application code, never creates schema or database resources, and never deploys +- with `--framework custom`, the config includes a commented `build` stub, since custom artifacts require `build.outputDirectory` and `build.entrypoint` before deploy can use them +- when detection fails and no `--framework` is passed: interactive mode prompts for the framework from the supported list; non-interactive and `--json` mode fail with `INIT_DETECTION_FAILED`, with `nextActions` enumerating the `--framework` choices +- link step, after the config is written: + - interactive mode asks `Link this directory to a Prisma Project now? (Y/n)` when the directory has no project binding; accepting enters the same picker `project link` uses + - `--no-link` suppresses the question; `--link` requires the step; `--project ` links to that project without prompting + - link failures and cancellations after the config is written downgrade to warnings and `nextSteps`; the config write stands and init exits 0 +- `nextSteps` includes `prisma-cli app deploy`, plus `prisma-cli project link` when the directory is still unlinked +- in `--json`, `result` includes `configPath`, the written `app` values, per-value `settings` sources, and `link` state; `--json` never prompts + +Examples: + +```bash +prisma-cli init +prisma-cli init --framework hono --entry src/index.ts +prisma-cli init --name api --http-port 8080 --no-link +prisma-cli init --project proj_123 +prisma-cli init --json +``` + ## ` @prisma/cli@latest agent install --agent --all-agents --skill --global --copy` Purpose: @@ -1332,6 +1374,7 @@ Behavior: - after setup, deploy prints `Deploying to / / `; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1` - deploy progress uses short stage copy (`Building locally...`, `Built `, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...` - success human output prints `Live in `, the URL on its own line, and `Logs prisma-cli app logs` +- when the deploy resolved its settings without a compute config, success human output adds a `Config prisma-cli init` hint line, pointing at the command that pins the inferred settings; the hint is omitted once a config file is discovered - with `--no-promote`, success human output instead prints `Built in (not promoted)`, the candidate URL on its own line, a note that the live deployment is unchanged, and a `Promote prisma-cli app promote ` next step - accepts repeated `--env NAME=VALUE` flags and dotenv file paths such as `--env .env` - supports `--db` to create a new empty Prisma Postgres database and write `DATABASE_URL` and `DIRECT_URL` through the existing `project env` storage; the CLI never runs schema or migration commands — applying the schema stays with the user's own tooling @@ -1727,6 +1770,62 @@ prisma-cli app logs prisma-cli app logs --deployment dep_123 ``` +## `prisma-cli build list [app] --app --project --branch --limit ` + +Status: blocked on Management API rollout. The `GET /v1/apps/{appId}/builds` +endpoint exists in the control plane but is not yet deployed or published in +`@prisma/management-api-sdk`. This section is normative for the implementation +that lands once the SDK exposes the endpoint. + +Purpose: + +- list the git build jobs for an app, so build ids are discoverable without the Console + +Behavior: + +- requires auth and project context +- resolves the selected app exactly like the other app management commands: `[app]` target argument, `--app`, compute config target, locally selected app, inferred name; never creates apps or branches +- resolves the branch it reads like management commands: explicit `--branch`, active Git branch when it exists in the project, then the project's default branch +- lists builds newest first: build id, state (`pending`, `running`, `succeeded`, `failed`, `cancelled`), source (`webhook`, `setup`, `manual`), Git branch, short commit sha, created and finished timestamps, and the produced deployment id when the build reached that stage +- build ids are the ids `build logs ` accepts +- `--limit ` caps the number of returned builds; JSON output includes `pagination.nextCursor` and `pagination.hasMore` so agents can page +- read-only; never prints secret values +- `nextSteps` includes `prisma-cli build logs ` for the newest build +- fails with the standard app selection errors (`APP_AMBIGUOUS`, app not found) when the target cannot be resolved safely + +Examples: + +```bash +prisma-cli build list +prisma-cli build list --app my-app --limit 50 +prisma-cli build list --json +``` + +## `prisma-cli build show ` + +Status: blocked on Management API rollout, same as `build list`; the backing +endpoint is `GET /v1/builds/{buildId}`. + +Purpose: + +- show one build in detail + +Behavior: + +- requires auth +- takes a build id, as shown by `build list`, the Console build view, and git-push output +- authorization matches `build logs`: access stays with the workspace that owned the build when it ran, and an unknown or foreign build id fails with an indistinguishable `BUILD_NOT_FOUND` +- shows state, source, Git branch, commit sha, created/started/finished timestamps, the error message when the build failed, and the produced deployment id and deployed URL when present +- read-only; never prints secret values +- `nextSteps` includes `prisma-cli build logs ` + +Examples: + +```bash +prisma-cli build show cmcz3v6ft0a1b2c3d +prisma-cli build show cmcz3v6ft0a1b2c3d --json +``` + ## `prisma-cli build logs --follow --cursor ` Purpose: diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index db73619..c247429 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -39,6 +39,7 @@ Rules: - Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for project resolution - `.prisma/local.json` is a gitignored local pin/cache for Workspace and Project IDs; it is not a declarative repo config file. When a `prisma.compute.ts` is discovered (nearest config from the invocation directory up to the repository or workspace root), the pin and the CLI state cache (`.prisma/cli/state.json`) are read and written in the config file's directory; without a config they stay in the invocation directory - `prisma.compute.ts` is a committed deploy-defaults file; it must not contain Workspace, Project, Branch, env-secret, or credential resolution state +- `init` is the only command that writes `prisma.compute.ts`, and it never overwrites an existing one; deploy reads the config but never writes it - Project setup is explicit: users choose an existing Project or explicitly create a new one before remote work starts - `app deploy` may orchestrate Project setup, but it must not silently choose or create Project scope - everything under a project happens in a branch diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d0c0b29..04dec58 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { createBranchCommand } from "./commands/branch"; import { createBuildCommand } from "./commands/build"; import { createDatabaseCommand } from "./commands/database"; import { createGitCommand } from "./commands/git"; +import { createInitCommand } from "./commands/init"; import { createProjectCommand } from "./commands/project"; import { createVersionCommand } from "./commands/version"; import { runVersion } from "./controllers/version"; @@ -83,6 +84,7 @@ export function createProgram(runtime: CliRuntime): Command { program.name("prisma").showSuggestionAfterError(); program.addCommand(createVersionCommand(runtime)); + program.addCommand(createInitCommand(runtime)); program.addCommand(createAgentCommand(runtime)); program.addCommand(createAuthCommand(runtime)); program.addCommand(createProjectCommand(runtime)); diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts new file mode 100644 index 0000000..110ba76 --- /dev/null +++ b/packages/cli/src/commands/init/index.ts @@ -0,0 +1,79 @@ +import { Command, Option } from "commander"; + +import { runInit } from "../../controllers/init"; +import { renderInit, serializeInit } from "../../presenters/init"; +import { attachCommandDescriptor } from "../../shell/command-meta"; +import { runCommand } from "../../shell/command-runner"; +import { addGlobalFlags } from "../../shell/global-flags"; +import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; +import type { InitResult } from "../../types/init"; + +export function createInitCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("init"), runtime), + "init", + ); + + command + .addOption( + new Option( + "--framework ", + "Framework override; detected when omitted", + ), + ) + .addOption( + new Option( + "--entry ", + "Source entrypoint for entrypoint frameworks (Bun, Hono)", + ), + ) + .addOption(new Option("--http-port ", "HTTP port the app listens on")) + .addOption( + new Option( + "--region ", + "Region used when deploy creates the app", + ), + ) + .addOption(new Option("--name ", "App name")) + .addOption(new Option("--link", "Link this directory to a Project")) + .addOption(new Option("--no-link", "Skip the Project link step")) + .addOption( + new Option("--project ", "Project to link this directory to"), + ); + addGlobalFlags(command); + + command.action(async (options) => { + const flags = options as { + framework?: string; + entry?: string; + httpPort?: string; + region?: string; + name?: string; + link?: boolean; + project?: string; + }; + + await runCommand( + runtime, + "init", + options as Record, + (context) => + runInit(context, { + framework: flags.framework, + entry: flags.entry, + httpPort: flags.httpPort, + region: flags.region, + name: flags.name, + link: flags.link, + project: flags.project, + }), + { + renderHuman: (context, descriptor, result) => + renderInit(context, descriptor, result), + renderJson: (result) => serializeInit(result), + }, + ); + }); + + return command; +} diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 678ed81..73040ef 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -3777,7 +3777,7 @@ async function resolveDeployBranch( }; } -interface ResolvedDeployFramework { +export interface ResolvedDeployFramework { key: string; buildType: FrameworkBuildType; displayName: string; @@ -4074,7 +4074,7 @@ async function resolveDeployEntrypoint( } } -async function detectDeployFramework( +export async function detectDeployFramework( cwd: string, signal: AbortSignal, ): Promise { diff --git a/packages/cli/src/controllers/init.ts b/packages/cli/src/controllers/init.ts new file mode 100644 index 0000000..611c2b8 --- /dev/null +++ b/packages/cli/src/controllers/init.ts @@ -0,0 +1,509 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + COMPUTE_CONFIG_FILENAME, + COMPUTE_REGIONS, + type ComputeConfig, + type ComputeFramework, + type ComputeRegion, + defaultHttpPortForBuildType, + FRAMEWORKS, + findComputeConfigCandidates, + findComputeConfigDir, + frameworkByKey, + frameworkFromAlias, + serializeComputeConfig, +} from "@prisma/compute-sdk/config"; + +import { + readBunPackageEntrypoint, + readBunPackageJson, +} from "../lib/app/bun-project"; +import { readLocalResolutionPin } from "../lib/project/local-pin"; +import { CliError, usageError } from "../shell/errors"; +import type { CommandSuccess } from "../shell/output"; +import { + confirmPrompt, + isPromptCancelError, + selectPrompt, + textPrompt, +} from "../shell/prompt"; +import { type CommandContext, canPrompt } from "../shell/runtime"; +import type { InitLinkState, InitResult, InitSettingRow } from "../types/init"; +import { detectDeployFramework } from "./app"; +import { runProjectLink } from "./project"; + +export interface InitFlags { + framework?: string; + entry?: string; + httpPort?: string; + region?: string; + name?: string; + link?: boolean; + project?: string; +} + +interface ResolvedInitFramework { + key: ComputeFramework; + displayName: string; + source: string; +} + +export async function runInit( + context: CommandContext, + flags: InitFlags, +): Promise> { + const cwd = context.runtime.cwd; + const signal = context.runtime.signal; + + await requireNoExistingComputeConfig(cwd, signal); + + const region = parseInitRegion(flags.region); + let framework = await resolveInitFramework(context, flags); + const name = await resolveInitAppName(cwd, flags.name, signal); + let httpPort = parseInitHttpPort(flags.httpPort) ?? { + value: defaultHttpPortForBuildType(frameworkByKey(framework.key).buildType), + source: "framework default", + }; + const entry = await resolveInitEntry(cwd, framework.key, flags.entry, signal); + + const adjusted = await maybeAdjustSettings(context, framework, httpPort, { + portExplicit: flags.httpPort !== undefined, + }); + framework = adjusted.framework; + httpPort = adjusted.httpPort; + + 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" }] : []), + ]; + + renderInitSettingsPreview(context, settings); + + const config: ComputeConfig = { + app: { + name: name.value, + framework: framework.key, + httpPort: httpPort.value, + ...(entry ? { entry: entry.value } : {}), + ...(region ? { region } : {}), + }, + }; + + const configPath = path.join(cwd, COMPUTE_CONFIG_FILENAME); + let source = serializeComputeConfig(config); + if (framework.key === "custom") { + source += CUSTOM_BUILD_STUB; + } + + signal.throwIfAborted(); + 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 initConfigExistsError(configPath); + } + throw error; + } + + const warnings: string[] = []; + const link = await resolveInitLink(context, flags, { + onWarning: (message) => warnings.push(message), + }); + + const unlinked = link.status !== "linked" && link.status !== "already-linked"; + return { + command: "init", + result: { + configPath: COMPUTE_CONFIG_FILENAME, + directory: formatInitDirectory(cwd), + app: { + name: name.value, + framework: framework.key, + httpPort: httpPort.value, + ...(entry ? { entry: entry.value } : {}), + ...(region ? { region } : {}), + }, + settings, + link, + }, + warnings, + nextSteps: [ + "prisma-cli app deploy", + ...(unlinked ? ["prisma-cli project link"] : []), + ], + }; +} + +const CUSTOM_BUILD_STUB = ` +// framework "custom" deploys a prebuilt artifact. Add its build settings: +// build: { +// command: "npm run build", +// outputDirectory: "dist", +// entrypoint: "server.js", +// }, +`; + +async function requireNoExistingComputeConfig( + cwd: string, + signal: AbortSignal, +): Promise { + const configDir = await findComputeConfigDir(cwd, signal); + if (!configDir) { + return; + } + + const candidates = await findComputeConfigCandidates(configDir, signal); + throw initConfigExistsError(candidates[0] ?? configDir); +} + +function initConfigExistsError(existingPath: string): CliError { + return new CliError({ + code: "INIT_CONFIG_EXISTS", + domain: "app", + 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.", + exitCode: 1, + nextSteps: [], + meta: { existingConfigPath: existingPath }, + }); +} + +async function resolveInitFramework( + context: CommandContext, + flags: InitFlags, +): Promise { + if (flags.framework) { + const framework = frameworkFromAlias(flags.framework.trim()); + if (!framework) { + throw usageError( + "Unknown framework", + `"${flags.framework}" is not a supported framework.`, + `Pass one of: ${FRAMEWORKS.map((candidate) => candidate.key).join(", ")}.`, + ["prisma-cli init --framework hono"], + "app", + ); + } + return { + key: framework.key, + displayName: framework.displayName, + source: "flag", + }; + } + + const detected = await detectDeployFramework( + context.runtime.cwd, + context.runtime.signal, + ); + if (detected) { + return { + key: detected.key as ComputeFramework, + displayName: detected.displayName, + source: detected.annotation, + }; + } + + if (canPrompt(context) && !context.flags.yes) { + const key = await selectPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + signal: context.runtime.signal, + message: "Which framework does this app use?", + choices: FRAMEWORKS.map((framework) => ({ + label: framework.displayName, + value: framework.key, + })), + }); + return { + key, + displayName: frameworkByKey(key).displayName, + source: "selected", + }; + } + + throw new CliError({ + code: "INIT_DETECTION_FAILED", + domain: "app", + 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(", ")}.`, + exitCode: 1, + nextSteps: FRAMEWORKS.slice(0, 3).map( + (framework) => `prisma-cli init --framework ${framework.key}`, + ), + meta: { frameworks: FRAMEWORKS.map((framework) => framework.key) }, + }); +} + +async function resolveInitAppName( + cwd: string, + explicitName: string | undefined, + signal: AbortSignal, +): Promise<{ value: string; source: string }> { + const trimmed = explicitName?.trim(); + if (explicitName !== undefined && !trimmed) { + throw usageError( + "App name required", + "--name needs a non-empty value.", + "Pass a non-empty app name.", + ["prisma-cli init --name api"], + "app", + ); + } + if (trimmed) { + return { value: trimmed, source: "flag" }; + } + + const packageJson = await readBunPackageJson(cwd, signal); + const packageName = + typeof packageJson?.name === "string" ? packageJson.name.trim() : ""; + if (packageName) { + return { value: packageName, source: "package.json" }; + } + + return { value: path.basename(cwd), source: "directory name" }; +} + +function parseInitHttpPort( + value: string | undefined, +): { value: number; source: string } | undefined { + if (value === undefined) { + return undefined; + } + + const port = Number(value.trim()); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw usageError( + "Invalid HTTP port", + "--http-port must be an integer between 1 and 65535.", + "Pass a valid port.", + ["prisma-cli init --http-port 3000"], + "app", + ); + } + + return { value: port, source: "flag" }; +} + +function parseInitRegion(value: string | undefined): ComputeRegion | undefined { + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + if ((COMPUTE_REGIONS as readonly string[]).includes(trimmed)) { + return trimmed as ComputeRegion; + } + + throw usageError( + "Unknown region", + `"${value}" is not a supported Compute region.`, + `Pass one of: ${COMPUTE_REGIONS.join(", ")}.`, + ["prisma-cli init --region us-east-1"], + "app", + ); +} + +async function resolveInitEntry( + cwd: string, + frameworkKey: ComputeFramework, + explicitEntry: string | undefined, + signal: AbortSignal, +): Promise<{ value: string; source: string } | undefined> { + const framework = frameworkByKey(frameworkKey); + if (!framework.usesEntrypoint) { + return undefined; + } + + const trimmed = explicitEntry?.trim(); + if (trimmed) { + return { value: trimmed, source: "flag" }; + } + + const packageJson = await readBunPackageJson(cwd, signal); + const packageEntrypoint = readBunPackageEntrypoint(packageJson); + if (packageEntrypoint) { + return { value: packageEntrypoint, source: "package.json" }; + } + + return undefined; +} + +async function maybeAdjustSettings( + context: CommandContext, + framework: ResolvedInitFramework, + httpPort: { value: number; source: string }, + options: { portExplicit: boolean }, +): Promise<{ + framework: ResolvedInitFramework; + httpPort: { value: number; source: string }; +}> { + if (!canPrompt(context) || context.flags.yes) { + return { framework, httpPort }; + } + + const adjust = await confirmPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + signal: context.runtime.signal, + message: `Adjust these settings? (${framework.displayName}, HTTP ${httpPort.value})`, + initialValue: false, + }); + if (!adjust) { + return { framework, httpPort }; + } + + const key = await selectPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + signal: context.runtime.signal, + message: "Framework", + choices: FRAMEWORKS.map((candidate) => ({ + label: + candidate.key === framework.key + ? `${candidate.displayName} (current)` + : candidate.displayName, + value: candidate.key, + })), + }); + const nextFramework: ResolvedInitFramework = + key === framework.key + ? framework + : { + key, + displayName: frameworkByKey(key).displayName, + source: "selected", + }; + + const defaultPort = options.portExplicit + ? httpPort.value + : defaultHttpPortForBuildType(frameworkByKey(key).buildType); + const portText = await textPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + signal: context.runtime.signal, + message: "HTTP port", + placeholder: String(defaultPort), + validate: (value) => { + if (!value?.trim()) { + return undefined; + } + const port = Number(value.trim()); + return Number.isInteger(port) && port >= 1 && port <= 65535 + ? undefined + : "Enter a port between 1 and 65535."; + }, + }); + const nextPort = portText.trim() + ? { value: Number(portText.trim()), source: "selected" } + : { + value: defaultPort, + source: options.portExplicit ? httpPort.source : "framework default", + }; + + return { framework: nextFramework, httpPort: nextPort }; +} + +function renderInitSettingsPreview( + context: CommandContext, + settings: InitSettingRow[], +): void { + if (context.flags.quiet || context.flags.json) { + return; + } + + const keyWidth = Math.max(...settings.map((row) => row.key.length)); + const valueWidth = Math.max(...settings.map((row) => row.value.length)); + const lines = settings.map( + (row) => + ` ${row.key.padEnd(keyWidth)} ${row.value.padEnd(valueWidth)} ${context.ui.dim(row.source)}`, + ); + context.output.stderr.write(`${lines.join("\n")}\n\n`); +} + +async function resolveInitLink( + context: CommandContext, + flags: InitFlags, + hooks: { onWarning: (message: string) => void }, +): Promise { + const pin = await readLocalResolutionPin( + context.runtime.cwd, + context.runtime.signal, + ); + if (pin.isOk() && pin.value.kind === "present") { + return { status: "already-linked", project: null }; + } + + if (flags.link === false) { + return { status: "skipped", project: null }; + } + + const explicitProject = flags.project?.trim(); + let shouldLink = Boolean(explicitProject) || flags.link === true; + if (!shouldLink) { + if (!canPrompt(context) || context.flags.yes) { + return { status: "skipped", project: null }; + } + try { + shouldLink = await confirmPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + signal: context.runtime.signal, + message: "Link this directory to a Prisma Project now?", + initialValue: true, + }); + } catch (error) { + if (isPromptCancelError(error)) { + return { status: "declined", project: null }; + } + throw error; + } + if (!shouldLink) { + return { status: "declined", project: null }; + } + } + + try { + const linked = await runProjectLink(context, explicitProject || undefined); + return { + status: "linked", + project: { + id: linked.result.project.id, + name: linked.result.project.name, + }, + }; + } catch (error) { + if (error instanceof CliError) { + if (isPromptCancelError(error)) { + return { status: "declined", project: null }; + } + // The config write already succeeded; a failed link must not undo it. + hooks.onWarning( + `Project link failed: ${error.summary}. Link later with prisma-cli project link.`, + ); + return { status: "failed", project: null }; + } + throw error; + } +} + +function formatInitDirectory(cwd: string): string { + const basename = path.basename(cwd); + return basename ? `./${basename}` : "."; +} diff --git a/packages/cli/src/lib/app/bun-project.ts b/packages/cli/src/lib/app/bun-project.ts index 1933a3e..c51a25a 100644 --- a/packages/cli/src/lib/app/bun-project.ts +++ b/packages/cli/src/lib/app/bun-project.ts @@ -2,6 +2,7 @@ import { access, readFile } from "node:fs/promises"; import path from "node:path"; export interface BunPackageJsonLike { + name?: unknown; main?: unknown; packageManager?: unknown; scripts?: unknown; diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index c6f9476..b6da37a 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -96,6 +96,9 @@ export function renderAppDeploy( }, ]), { label: "Logs", value: logsCommand }, + ...(deployUsedComputeConfig(result) + ? [] + : [{ label: "Config", value: "prisma-cli init" }]), ]), ...renderDeployResolvedContextBlock(context, result), ...renderDeploySettingsBlock(context, result), @@ -103,6 +106,17 @@ export function renderAppDeploy( return lines; } +function deployUsedComputeConfig(result: AppDeployResult): boolean { + return ( + result.deploySettings.config.path !== null || + [ + result.deploySettings.framework.source, + result.deploySettings.buildCommand.source, + result.deploySettings.outputDirectory.source, + ].some((source) => source?.includes("prisma.compute")) + ); +} + export function isAppDeployAllResult( result: AppDeployResult | AppDeployAllResult, ): result is AppDeployAllResult { diff --git a/packages/cli/src/presenters/init.ts b/packages/cli/src/presenters/init.ts new file mode 100644 index 0000000..bc1c10b --- /dev/null +++ b/packages/cli/src/presenters/init.ts @@ -0,0 +1,53 @@ +import type { CommandDescriptor } from "../shell/command-meta"; +import type { CommandContext } from "../shell/runtime"; +import { renderNextSteps, renderSummaryLine } from "../shell/ui"; +import type { InitResult } from "../types/init"; + +export function renderInit( + context: CommandContext, + _descriptor: CommandDescriptor, + result: InitResult, +): string[] { + const ui = context.ui; + const lines = [ + renderSummaryLine(ui, "success", `Wrote ${result.configPath}`), + ]; + + switch (result.link.status) { + case "linked": + lines.push( + renderSummaryLine( + ui, + "success", + `Linked "${result.directory}" to Project "${result.link.project?.name ?? ""}"`, + ), + ); + break; + case "already-linked": + break; + case "skipped": + case "declined": + lines.push( + ` ${ui.dim("Not linked to a Project yet; link with prisma-cli project link.")}`, + ); + break; + case "failed": + // The failure detail is in warnings; nothing extra here. + break; + } + + const linked = + result.link.status === "linked" || result.link.status === "already-linked"; + lines.push( + ...renderNextSteps([ + "prisma-cli app deploy", + ...(linked ? [] : ["prisma-cli project link"]), + ]), + ); + + return lines; +} + +export function serializeInit(result: InitResult) { + return result; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 3091b28..1e22957 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -44,6 +44,16 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Show CLI build and environment", examples: ["prisma-cli version", "prisma-cli version --json"], }, + { + id: "init", + path: ["prisma", "init"], + description: "Write a committed prisma.compute.ts for this app", + examples: [ + "prisma-cli init", + "prisma-cli init --framework hono --entry src/index.ts", + "prisma-cli init --no-link", + ], + }, { id: "agent", path: ["prisma", "agent"], diff --git a/packages/cli/src/types/init.ts b/packages/cli/src/types/init.ts new file mode 100644 index 0000000..f70da76 --- /dev/null +++ b/packages/cli/src/types/init.ts @@ -0,0 +1,34 @@ +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 interface InitResult { + configPath: string; + directory: string; + app: { + name: string; + framework: string; + httpPort: number; + entry?: string; + region?: string; + }; + settings: InitSettingRow[]; + link: InitLinkState; +} diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts new file mode 100644 index 0000000..5e2ab61 --- /dev/null +++ b/packages/cli/tests/init.test.ts @@ -0,0 +1,289 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; + +import { createTempCwd, executeCli } from "./helpers"; + +const fixturePath = path.resolve("fixtures/mock-api.json"); + +async function login(cwd: string, stateDir: string) { + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); +} + +async function writePackageJson( + cwd: string, + contents: Record, +) { + await writeFile( + path.join(cwd, "package.json"), + `${JSON.stringify(contents, null, 2)}\n`, + "utf8", + ); +} + +async function readConfig(cwd: string): Promise { + return readFile(path.join(cwd, "prisma.compute.ts"), "utf8"); +} + +describe("init", () => { + it("writes a config for an explicit framework without auth or prompts", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "billing-api" }); + + const result = await executeCli({ + argv: [ + "init", + "--framework", + "hono", + "--entry", + "src/index.ts", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "init", + result: { + configPath: "prisma.compute.ts", + app: { + name: "billing-api", + framework: "hono", + entry: "src/index.ts", + }, + link: { status: "skipped", project: null }, + }, + nextSteps: ["prisma-cli app deploy", "prisma-cli project link"], + }); + + const config = await readConfig(cwd); + expect(config).toContain('framework: "hono"'); + expect(config).toContain('name: "billing-api"'); + expect(config).toContain('entry: "src/index.ts"'); + expect(config).toContain("defineComputeConfig"); + }); + + it("detects Next.js from the config file and uses the framework default port", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "web" }); + await writeFile(path.join(cwd, "next.config.ts"), "export default {};\n"); + + const result = await executeCli({ + argv: ["init", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.app).toMatchObject({ + name: "web", + framework: "nextjs", + httpPort: 3000, + }); + expect(payload.result.settings).toContainEqual( + expect.objectContaining({ + key: "framework", + source: expect.stringContaining("next.config.ts"), + }), + ); + }); + + it("fails with INIT_DETECTION_FAILED when nothing is detectable non-interactively", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: ["init", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload).toMatchObject({ + ok: false, + command: "init", + error: { code: "INIT_DETECTION_FAILED" }, + }); + expect(payload.error.meta.frameworks).toContain("hono"); + }); + + it("fails with INIT_CONFIG_EXISTS when a config exists here or in an ancestor", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + 'export default { app: { framework: "hono" } };\n', + ); + + const direct = await executeCli({ + argv: ["init", "--framework", "hono", "--json"], + cwd, + stateDir, + fixturePath, + }); + const directPayload = JSON.parse(direct.stdout); + + expect(direct.exitCode).toBe(1); + expect(directPayload.error.code).toBe("INIT_CONFIG_EXISTS"); + expect(directPayload.error.meta.existingConfigPath).toContain( + "prisma.compute.ts", + ); + + // Ancestor config: init from a nested app dir must refuse a nested config. + await mkdir(path.join(cwd, ".git"), { recursive: true }); + const nested = path.join(cwd, "apps", "api"); + await mkdir(nested, { recursive: true }); + const fromNested = await executeCli({ + argv: ["init", "--framework", "hono", "--json"], + cwd: nested, + stateDir, + fixturePath, + }); + const nestedPayload = JSON.parse(fromNested.stdout); + + expect(fromNested.exitCode).toBe(1); + expect(nestedPayload.error.code).toBe("INIT_CONFIG_EXISTS"); + }); + + it("rejects invalid ports, regions, and frameworks before writing", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + for (const argv of [ + ["init", "--framework", "hono", "--http-port", "70000", "--json"], + ["init", "--framework", "hono", "--region", "mars-1", "--json"], + ["init", "--framework", "rails", "--json"], + ]) { + const result = await executeCli({ argv, cwd, stateDir, fixturePath }); + const payload = JSON.parse(result.stdout); + expect(result.exitCode).toBe(2); + expect(payload.error.code).toBe("USAGE_ERROR"); + } + + await expect(readConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("links to an explicit --project and reports it", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "api" }); + await login(cwd, stateDir); + + const result = await executeCli({ + argv: ["init", "--framework", "hono", "--project", "proj_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.link).toMatchObject({ + status: "linked", + project: { id: "proj_123", name: "Acme Dashboard" }, + }); + expect(payload.nextSteps).toEqual(["prisma-cli app deploy"]); + await expect( + readFile(path.join(cwd, ".prisma/local.json"), "utf8"), + ).resolves.toContain("proj_123"); + }); + + it("downgrades a failed link to a warning and keeps the config", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "api" }); + await login(cwd, stateDir); + + const result = await executeCli({ + argv: ["init", "--framework", "hono", "--project", "nope", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.link.status).toBe("failed"); + expect(payload.warnings[0]).toContain("Project link failed"); + expect(payload.nextSteps).toContain("prisma-cli project link"); + await expect(readConfig(cwd)).resolves.toContain('framework: "hono"'); + }); + + it("reports already-linked directories without prompting", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "api" }); + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile( + path.join(cwd, ".prisma/local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId: "proj_123" })}\n`, + ); + + const result = await executeCli({ + argv: ["init", "--framework", "hono", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.link.status).toBe("already-linked"); + expect(payload.nextSteps).toEqual(["prisma-cli app deploy"]); + }); + + it("prints the human summary with the wrote line", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "api" }); + + const result = await executeCli({ + argv: ["init", "--framework", "hono", "--no-link"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(stderr).toContain("framework"); + expect(stderr).toContain("Hono"); + expect(stderr).toContain("✔ Wrote prisma.compute.ts"); + expect(stderr).toContain("Not linked to a Project yet"); + expect(stderr).toContain("Next steps"); + }); + + it("appends the build stub for custom framework configs", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: ["init", "--framework", "custom", "--no-link", "--json"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(0); + const config = await readConfig(cwd); + expect(config).toContain('framework: "custom"'); + expect(config).toContain("// build: {"); + }); +}); From 8ace944eb0f0954e9c663107bd58fccd364a4e6d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 2 Jul 2026 17:16:37 +0530 Subject: [PATCH 2/4] fix(cli): format init command hints with the detected package runner init's next steps, link hints, error recovery commands, and the deploy Config hint now render through the project's detected package runner (pnpm dlx / bunx / yarn dlx / npx -y with @prisma/cli@latest), matching the agent group's convention, instead of hardcoding the prisma-cli bin name. Descriptor examples switch to the runner-aware form. --- docs/product/command-spec.md | 5 +-- packages/cli/src/controllers/init.ts | 47 ++++++++++++++++++-------- packages/cli/src/presenters/app.ts | 10 +++++- packages/cli/src/presenters/init.ts | 10 ++++-- packages/cli/src/shell/command-meta.ts | 11 +++--- packages/cli/tests/init.test.ts | 13 ++++--- 6 files changed, 66 insertions(+), 30 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 44e4fb4..40c7563 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -407,7 +407,8 @@ Behavior: - interactive mode asks `Link this directory to a Prisma Project now? (Y/n)` when the directory has no project binding; accepting enters the same picker `project link` uses - `--no-link` suppresses the question; `--link` requires the step; `--project ` links to that project without prompting - link failures and cancellations after the config is written downgrade to warnings and `nextSteps`; the config write stands and init exits 0 -- `nextSteps` includes `prisma-cli app deploy`, plus `prisma-cli project link` when the directory is still unlinked +- `nextSteps` includes the deploy command, plus the project link command when the directory is still unlinked +- user-facing command hints in init output (next steps, link hints, error recovery commands) use the package runner detected from the project, such as `pnpm dlx @prisma/cli@latest project link` or `npx -y @prisma/cli@latest app deploy`, matching the `agent` group's convention - in `--json`, `result` includes `configPath`, the written `app` values, per-value `settings` sources, and `link` state; `--json` never prompts Examples: @@ -1374,7 +1375,7 @@ Behavior: - after setup, deploy prints `Deploying to / / `; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1` - deploy progress uses short stage copy (`Building locally...`, `Built `, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...` - success human output prints `Live in `, the URL on its own line, and `Logs prisma-cli app logs` -- when the deploy resolved its settings without a compute config, success human output adds a `Config prisma-cli init` hint line, pointing at the command that pins the inferred settings; the hint is omitted once a config file is discovered +- when the deploy resolved its settings without a compute config, success human output adds a `Config` hint line with the runner-formatted init command (such as `pnpm dlx @prisma/cli@latest init`), pointing at the command that pins the inferred settings; the hint is omitted once a config file is discovered - with `--no-promote`, success human output instead prints `Built in (not promoted)`, the candidate URL on its own line, a note that the live deployment is unchanged, and a `Promote prisma-cli app promote ` next step - accepts repeated `--env NAME=VALUE` flags and dotenv file paths such as `--env .env` - supports `--db` to create a new empty Prisma Postgres database and write `DATABASE_URL` and `DIRECT_URL` through the existing `project env` storage; the CLI never runs schema or migration commands — applying the schema stays with the user's own tooling diff --git a/packages/cli/src/controllers/init.ts b/packages/cli/src/controllers/init.ts index 611c2b8..97658cc 100644 --- a/packages/cli/src/controllers/init.ts +++ b/packages/cli/src/controllers/init.ts @@ -16,6 +16,10 @@ import { serializeComputeConfig, } from "@prisma/compute-sdk/config"; +import { + type PrismaCliPackageCommandFormatter, + resolvePrismaCliPackageCommandFormatterSync, +} from "../lib/agent/cli-command"; import { readBunPackageEntrypoint, readBunPackageJson, @@ -56,13 +60,16 @@ export async function runInit( ): Promise> { const cwd = context.runtime.cwd; const signal = context.runtime.signal; + // User-facing command hints use the project's package runner (pnpm dlx, + // bunx, npx -y), matching the agent group's convention. + const formatCommand = resolvePrismaCliPackageCommandFormatterSync(cwd); await requireNoExistingComputeConfig(cwd, signal); - const region = parseInitRegion(flags.region); - let framework = await resolveInitFramework(context, flags); - const name = await resolveInitAppName(cwd, flags.name, signal); - let httpPort = parseInitHttpPort(flags.httpPort) ?? { + const region = parseInitRegion(flags.region, formatCommand); + let framework = await resolveInitFramework(context, flags, formatCommand); + const name = await resolveInitAppName(cwd, flags.name, signal, formatCommand); + let httpPort = parseInitHttpPort(flags.httpPort, formatCommand) ?? { value: defaultHttpPortForBuildType(frameworkByKey(framework.key).buildType), source: "framework default", }; @@ -124,6 +131,7 @@ export async function runInit( const warnings: string[] = []; const link = await resolveInitLink(context, flags, { onWarning: (message) => warnings.push(message), + formatCommand, }); const unlinked = link.status !== "linked" && link.status !== "already-linked"; @@ -144,8 +152,8 @@ export async function runInit( }, warnings, nextSteps: [ - "prisma-cli app deploy", - ...(unlinked ? ["prisma-cli project link"] : []), + formatCommand(["app", "deploy"]), + ...(unlinked ? [formatCommand(["project", "link"])] : []), ], }; } @@ -188,6 +196,7 @@ function initConfigExistsError(existingPath: string): CliError { async function resolveInitFramework( context: CommandContext, flags: InitFlags, + formatCommand: PrismaCliPackageCommandFormatter, ): Promise { if (flags.framework) { const framework = frameworkFromAlias(flags.framework.trim()); @@ -196,7 +205,7 @@ async function resolveInitFramework( "Unknown framework", `"${flags.framework}" is not a supported framework.`, `Pass one of: ${FRAMEWORKS.map((candidate) => candidate.key).join(", ")}.`, - ["prisma-cli init --framework hono"], + [formatCommand(["init", "--framework", "hono"])], "app", ); } @@ -244,8 +253,8 @@ async function resolveInitFramework( 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(", ")}.`, exitCode: 1, - nextSteps: FRAMEWORKS.slice(0, 3).map( - (framework) => `prisma-cli init --framework ${framework.key}`, + nextSteps: FRAMEWORKS.slice(0, 3).map((framework) => + formatCommand(["init", "--framework", framework.key]), ), meta: { frameworks: FRAMEWORKS.map((framework) => framework.key) }, }); @@ -255,6 +264,7 @@ async function resolveInitAppName( cwd: string, explicitName: string | undefined, signal: AbortSignal, + formatCommand: PrismaCliPackageCommandFormatter, ): Promise<{ value: string; source: string }> { const trimmed = explicitName?.trim(); if (explicitName !== undefined && !trimmed) { @@ -262,7 +272,7 @@ async function resolveInitAppName( "App name required", "--name needs a non-empty value.", "Pass a non-empty app name.", - ["prisma-cli init --name api"], + [formatCommand(["init", "--name", "api"])], "app", ); } @@ -282,6 +292,7 @@ async function resolveInitAppName( function parseInitHttpPort( value: string | undefined, + formatCommand: PrismaCliPackageCommandFormatter, ): { value: number; source: string } | undefined { if (value === undefined) { return undefined; @@ -293,7 +304,7 @@ function parseInitHttpPort( "Invalid HTTP port", "--http-port must be an integer between 1 and 65535.", "Pass a valid port.", - ["prisma-cli init --http-port 3000"], + [formatCommand(["init", "--http-port", "3000"])], "app", ); } @@ -301,7 +312,10 @@ function parseInitHttpPort( return { value: port, source: "flag" }; } -function parseInitRegion(value: string | undefined): ComputeRegion | undefined { +function parseInitRegion( + value: string | undefined, + formatCommand: PrismaCliPackageCommandFormatter, +): ComputeRegion | undefined { if (value === undefined) { return undefined; } @@ -315,7 +329,7 @@ function parseInitRegion(value: string | undefined): ComputeRegion | undefined { "Unknown region", `"${value}" is not a supported Compute region.`, `Pass one of: ${COMPUTE_REGIONS.join(", ")}.`, - ["prisma-cli init --region us-east-1"], + [formatCommand(["init", "--region", "us-east-1"])], "app", ); } @@ -440,7 +454,10 @@ function renderInitSettingsPreview( async function resolveInitLink( context: CommandContext, flags: InitFlags, - hooks: { onWarning: (message: string) => void }, + hooks: { + onWarning: (message: string) => void; + formatCommand: PrismaCliPackageCommandFormatter; + }, ): Promise { const pin = await readLocalResolutionPin( context.runtime.cwd, @@ -495,7 +512,7 @@ async function resolveInitLink( } // The config write already succeeded; a failed link must not undo it. hooks.onWarning( - `Project link failed: ${error.summary}. Link later with prisma-cli project link.`, + `Project link failed: ${error.summary}. Link later with ${hooks.formatCommand(["project", "link"])}.`, ); return { status: "failed", project: null }; } diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index b6da37a..fac67ec 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -1,3 +1,4 @@ +import { resolvePrismaCliPackageCommandSync } from "../lib/agent/cli-command"; import { renderDeployOutputRows } from "../lib/app/deploy-output"; import { formatDomainFailureFix } from "../lib/app/domain-guidance"; import { renderList, renderShow, serializeList } from "../output/patterns"; @@ -98,7 +99,14 @@ export function renderAppDeploy( { label: "Logs", value: logsCommand }, ...(deployUsedComputeConfig(result) ? [] - : [{ label: "Config", value: "prisma-cli init" }]), + : [ + { + label: "Config", + value: resolvePrismaCliPackageCommandSync(context.runtime.cwd, [ + "init", + ]), + }, + ]), ]), ...renderDeployResolvedContextBlock(context, result), ...renderDeploySettingsBlock(context, result), diff --git a/packages/cli/src/presenters/init.ts b/packages/cli/src/presenters/init.ts index bc1c10b..439e605 100644 --- a/packages/cli/src/presenters/init.ts +++ b/packages/cli/src/presenters/init.ts @@ -1,3 +1,4 @@ +import { resolvePrismaCliPackageCommandFormatterSync } from "../lib/agent/cli-command"; import type { CommandDescriptor } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import { renderNextSteps, renderSummaryLine } from "../shell/ui"; @@ -9,6 +10,9 @@ export function renderInit( result: InitResult, ): string[] { const ui = context.ui; + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); const lines = [ renderSummaryLine(ui, "success", `Wrote ${result.configPath}`), ]; @@ -28,7 +32,7 @@ export function renderInit( case "skipped": case "declined": lines.push( - ` ${ui.dim("Not linked to a Project yet; link with prisma-cli project link.")}`, + ` ${ui.dim(`Not linked to a Project yet; link with ${formatCommand(["project", "link"])}.`)}`, ); break; case "failed": @@ -40,8 +44,8 @@ export function renderInit( result.link.status === "linked" || result.link.status === "already-linked"; lines.push( ...renderNextSteps([ - "prisma-cli app deploy", - ...(linked ? [] : ["prisma-cli project link"]), + formatCommand(["app", "deploy"]), + ...(linked ? [] : [formatCommand(["project", "link"])]), ]), ); diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 1e22957..1c0f50a 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -48,11 +48,12 @@ const DESCRIPTORS: CommandDescriptor[] = [ id: "init", path: ["prisma", "init"], description: "Write a committed prisma.compute.ts for this app", - examples: [ - "prisma-cli init", - "prisma-cli init --framework hono --entry src/index.ts", - "prisma-cli init --no-link", - ], + examples: (runtime) => + agentCommandExamples(runtime, [ + ["init"], + ["init", "--framework", "hono", "--entry", "src/index.ts"], + ["init", "--no-link"], + ]), }, { id: "agent", diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index 5e2ab61..2cfe4b4 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -65,7 +65,10 @@ describe("init", () => { }, link: { status: "skipped", project: null }, }, - nextSteps: ["prisma-cli app deploy", "prisma-cli project link"], + nextSteps: [ + "npx -y @prisma/cli@latest app deploy", + "npx -y @prisma/cli@latest project link", + ], }); const config = await readConfig(cwd); @@ -199,7 +202,7 @@ describe("init", () => { status: "linked", project: { id: "proj_123", name: "Acme Dashboard" }, }); - expect(payload.nextSteps).toEqual(["prisma-cli app deploy"]); + expect(payload.nextSteps).toEqual(["npx -y @prisma/cli@latest app deploy"]); await expect( readFile(path.join(cwd, ".prisma/local.json"), "utf8"), ).resolves.toContain("proj_123"); @@ -222,7 +225,9 @@ describe("init", () => { expect(result.exitCode).toBe(0); expect(payload.result.link.status).toBe("failed"); expect(payload.warnings[0]).toContain("Project link failed"); - expect(payload.nextSteps).toContain("prisma-cli project link"); + expect(payload.nextSteps).toContain( + "npx -y @prisma/cli@latest project link", + ); await expect(readConfig(cwd)).resolves.toContain('framework: "hono"'); }); @@ -246,7 +251,7 @@ describe("init", () => { expect(result.exitCode).toBe(0); expect(payload.result.link.status).toBe("already-linked"); - expect(payload.nextSteps).toEqual(["prisma-cli app deploy"]); + expect(payload.nextSteps).toEqual(["npx -y @prisma/cli@latest app deploy"]); }); it("prints the human summary with the wrote line", async () => { From 6a0f2b3ff78986c3766741694bf78929ff37e907 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 3 Jul 2026 13:42:32 +0530 Subject: [PATCH 3/4] feat(cli): init offers to install @prisma/compute-sdk for config types The generated config's typed import resolves at deploy time without a local install, but editors need @prisma/compute-sdk locally for types. After writing the config, init now detects an existing dependency, otherwise offers the detected package manager's add command (pnpm add -D / bun add -d / yarn add -D / npm install -D). --install forces it, --no-install skips, non-interactive skips with the add command in nextSteps, and a failed install downgrades to a visible warning with the exact command; the config write always stands. Also surfaces types/link failures in human output directly, since the runner does not render success warnings in human mode. --- docs/product/command-spec.md | 8 +- packages/cli/src/commands/init/index.ts | 8 +- packages/cli/src/controllers/init.ts | 164 +++++++++++++++++++++++- packages/cli/src/presenters/init.ts | 34 ++++- packages/cli/src/types/init.ts | 15 +++ packages/cli/tests/init.test.ts | 123 +++++++++++++++++- 6 files changed, 345 insertions(+), 7 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 40c7563..979f444 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -384,7 +384,7 @@ prisma-cli --version --json `prisma-cli version` is the richer environment report; `prisma-cli --version` is the terse one-liner. Both report the same `cli.version`. Use the flag for quick checks, the subcommand for support tickets and bug reports. -## `prisma-cli init --framework --entry --http-port --region --name --link --no-link --project ` +## `prisma-cli init --framework --entry --http-port --region --name --link --no-link --project --install --no-install` Purpose: @@ -403,6 +403,12 @@ Behavior: - init never scaffolds application code, never creates schema or database resources, and never deploys - with `--framework custom`, the config includes a commented `build` stub, since custom artifacts require `build.outputDirectory` and `build.entrypoint` before deploy can use them - when detection fails and no `--framework` is passed: interactive mode prompts for the framework from the supported list; non-interactive and `--json` mode fail with `INIT_DETECTION_FAILED`, with `nextActions` enumerating the `--framework` choices +- types step, after the config is written: the generated config's typed import (`@prisma/compute-sdk/config`) is resolved by the CLI at deploy time without a local install, so a local `@prisma/compute-sdk` devDependency exists purely for editor types + - when the package is already a dependency or devDependency, the step is a no-op + - interactive mode asks `Install @prisma/compute-sdk for config types?` (default yes) and runs the detected package manager's add command (`pnpm add -D`, `bun add -d`, `yarn add -D`, `npm install -D`) + - `--install` runs the install without prompting; `--no-install` skips the step; non-interactive and `--json` mode skip by default + - a skipped, declined, or failed install downgrades to a hint or warning with the exact add command in `nextSteps`; the config write stands and init exits 0 + - a directory without a `package.json` skips the step with the hint - link step, after the config is written: - interactive mode asks `Link this directory to a Prisma Project now? (Y/n)` when the directory has no project binding; accepting enters the same picker `project link` uses - `--no-link` suppresses the question; `--link` requires the step; `--project ` links to that project without prompting diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 110ba76..5e5a342 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -39,7 +39,11 @@ export function createInitCommand(runtime: CliRuntime): Command { .addOption(new Option("--no-link", "Skip the Project link step")) .addOption( new Option("--project ", "Project to link this directory to"), - ); + ) + .addOption( + new Option("--install", "Install @prisma/compute-sdk for config types"), + ) + .addOption(new Option("--no-install", "Skip the types install step")); addGlobalFlags(command); command.action(async (options) => { @@ -51,6 +55,7 @@ export function createInitCommand(runtime: CliRuntime): Command { name?: string; link?: boolean; project?: string; + install?: boolean; }; await runCommand( @@ -66,6 +71,7 @@ export function createInitCommand(runtime: CliRuntime): Command { name: flags.name, link: flags.link, project: flags.project, + install: flags.install, }), { renderHuman: (context, descriptor, result) => diff --git a/packages/cli/src/controllers/init.ts b/packages/cli/src/controllers/init.ts index 97658cc..ac1496b 100644 --- a/packages/cli/src/controllers/init.ts +++ b/packages/cli/src/controllers/init.ts @@ -1,6 +1,5 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; - import { COMPUTE_CONFIG_FILENAME, COMPUTE_REGIONS, @@ -15,11 +14,16 @@ import { frameworkFromAlias, serializeComputeConfig, } from "@prisma/compute-sdk/config"; +import { execa } from "execa"; import { type PrismaCliPackageCommandFormatter, resolvePrismaCliPackageCommandFormatterSync, } from "../lib/agent/cli-command"; +import { + type AgentPackageManager, + detectPackageManagerSync, +} from "../lib/agent/package-manager"; import { readBunPackageEntrypoint, readBunPackageJson, @@ -34,7 +38,12 @@ import { textPrompt, } from "../shell/prompt"; import { type CommandContext, canPrompt } from "../shell/runtime"; -import type { InitLinkState, InitResult, InitSettingRow } from "../types/init"; +import type { + InitLinkState, + InitResult, + InitSettingRow, + InitTypesState, +} from "../types/init"; import { detectDeployFramework } from "./app"; import { runProjectLink } from "./project"; @@ -46,6 +55,7 @@ export interface InitFlags { name?: string; link?: boolean; project?: string; + install?: boolean; } interface ResolvedInitFramework { @@ -129,12 +139,17 @@ export async function runInit( } const warnings: string[] = []; + const types = await resolveInitTypes(context, flags, { + onWarning: (message) => warnings.push(message), + }); const link = await resolveInitLink(context, flags, { onWarning: (message) => warnings.push(message), formatCommand, }); const unlinked = link.status !== "linked" && link.status !== "already-linked"; + const typesMissing = + types.status !== "installed" && types.status !== "already-installed"; return { command: "init", result: { @@ -148,16 +163,161 @@ export async function runInit( ...(region ? { region } : {}), }, settings, + types, link, }, warnings, nextSteps: [ + ...(typesMissing && types.installCommand ? [types.installCommand] : []), formatCommand(["app", "deploy"]), ...(unlinked ? [formatCommand(["project", "link"])] : []), ], }; } +/** Dev dependency that provides editor types for the generated config. */ +const COMPUTE_SDK_PACKAGE = "@prisma/compute-sdk"; + +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]; + } +} + +/** + * 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. + */ +async function resolveInitTypes( + context: CommandContext, + flags: InitFlags, + hooks: { onWarning: (message: string) => void }, +): Promise { + const cwd = context.runtime.cwd; + const packageJson = await readBunPackageJson(cwd, context.runtime.signal); + if (hasComputeSdkDependency(packageJson)) { + return { + status: "already-installed", + package: COMPUTE_SDK_PACKAGE, + installCommand: null, + }; + } + + const packageManager = detectPackageManagerSync(cwd) ?? "npm"; + const installCommand = packageAddCommand(packageManager); + 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) { + return state("skipped"); + } + + if (flags.install === false) { + return state("skipped"); + } + + let shouldInstall = flags.install === true; + if (!shouldInstall) { + if (!canPrompt(context) || context.flags.yes) { + return state("skipped"); + } + try { + shouldInstall = await confirmPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + signal: context.runtime.signal, + message: `Install ${COMPUTE_SDK_PACKAGE} for config types? (${installCommandText})`, + initialValue: true, + }); + } catch (error) { + if (isPromptCancelError(error)) { + return state("declined"); + } + throw error; + } + if (!shouldInstall) { + return state("declined"); + } + } + + const command = resolveInitInstallCommandOverride(context) ?? installCommand; + if (!context.flags.quiet && !context.flags.json) { + context.output.stderr.write(`Installing ${COMPUTE_SDK_PACKAGE}...\n`); + } + try { + const [executable, ...args] = command; + await execa(executable as string, args, { + cwd, + env: context.runtime.env, + cancelSignal: context.runtime.signal, + stdin: "ignore", + }); + return state("installed"); + } catch (error) { + if (context.runtime.signal.aborted) { + throw error; + } + // execa's first message line is the short "Command failed" summary; the + // full package-manager output stays out of the warning. + const detail = + error instanceof Error ? error.message.split("\n")[0] : String(error); + hooks.onWarning( + `Installing ${COMPUTE_SDK_PACKAGE} failed: ${detail}. Install it later with ${installCommandText}.`, + ); + return state("failed"); + } +} + +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 resolveInitInstallCommandOverride( + context: CommandContext, +): string[] | null { + const raw = context.runtime.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; + } +} + const CUSTOM_BUILD_STUB = ` // framework "custom" deploys a prebuilt artifact. Add its build settings: // build: { diff --git a/packages/cli/src/presenters/init.ts b/packages/cli/src/presenters/init.ts index 439e605..f1cf2d6 100644 --- a/packages/cli/src/presenters/init.ts +++ b/packages/cli/src/presenters/init.ts @@ -17,6 +17,31 @@ export function renderInit( renderSummaryLine(ui, "success", `Wrote ${result.configPath}`), ]; + if (result.types.status === "installed") { + lines.push( + renderSummaryLine( + ui, + "success", + `Installed ${result.types.package} (config types)`, + ), + ); + } else if (result.types.status === "failed" && result.types.installCommand) { + lines.push( + renderSummaryLine( + ui, + "warning", + `Could not install ${result.types.package}; install later with ${result.types.installCommand}`, + ), + ); + } else if ( + result.types.status !== "already-installed" && + result.types.installCommand + ) { + lines.push( + ` ${ui.dim(`For editor types: ${result.types.installCommand}`)}`, + ); + } + switch (result.link.status) { case "linked": lines.push( @@ -36,7 +61,14 @@ export function renderInit( ); break; case "failed": - // The failure detail is in warnings; nothing extra here. + // Human mode does not render success.warnings, so surface it here. + lines.push( + renderSummaryLine( + ui, + "warning", + `Project link failed; link later with ${formatCommand(["project", "link"])}`, + ), + ); break; } diff --git a/packages/cli/src/types/init.ts b/packages/cli/src/types/init.ts index f70da76..8ffe741 100644 --- a/packages/cli/src/types/init.ts +++ b/packages/cli/src/types/init.ts @@ -19,6 +19,20 @@ export interface InitLinkState { } | 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; directory: string; @@ -30,5 +44,6 @@ export interface InitResult { region?: string; }; settings: InitSettingRow[]; + types: InitTypesState; link: InitLinkState; } diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index 2cfe4b4..d56252f 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -63,9 +63,15 @@ describe("init", () => { framework: "hono", entry: "src/index.ts", }, + types: { + status: "skipped", + package: "@prisma/compute-sdk", + installCommand: "npm install -D @prisma/compute-sdk", + }, link: { status: "skipped", project: null }, }, nextSteps: [ + "npm install -D @prisma/compute-sdk", "npx -y @prisma/cli@latest app deploy", "npx -y @prisma/cli@latest project link", ], @@ -202,7 +208,10 @@ describe("init", () => { status: "linked", project: { id: "proj_123", name: "Acme Dashboard" }, }); - expect(payload.nextSteps).toEqual(["npx -y @prisma/cli@latest app deploy"]); + expect(payload.nextSteps).toEqual([ + "npm install -D @prisma/compute-sdk", + "npx -y @prisma/cli@latest app deploy", + ]); await expect( readFile(path.join(cwd, ".prisma/local.json"), "utf8"), ).resolves.toContain("proj_123"); @@ -251,7 +260,10 @@ describe("init", () => { expect(result.exitCode).toBe(0); expect(payload.result.link.status).toBe("already-linked"); - expect(payload.nextSteps).toEqual(["npx -y @prisma/cli@latest app deploy"]); + expect(payload.nextSteps).toEqual([ + "npm install -D @prisma/compute-sdk", + "npx -y @prisma/cli@latest app deploy", + ]); }); it("prints the human summary with the wrote line", async () => { @@ -292,3 +304,110 @@ describe("init", () => { expect(config).toContain("// build: {"); }); }); + +describe("init types install", () => { + it("reports already-installed when the sdk is a devDependency", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { + name: "api", + devDependencies: { "@prisma/compute-sdk": "^0.1.0" }, + }); + + const result = await executeCli({ + argv: ["init", "--framework", "hono", "--no-link", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.types).toEqual({ + status: "already-installed", + package: "@prisma/compute-sdk", + installCommand: null, + }); + expect(payload.nextSteps).not.toContain( + "npm install -D @prisma/compute-sdk", + ); + }); + + it("skips with --no-install and keeps the hint in nextSteps", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "api" }); + + const result = await executeCli({ + argv: [ + "init", + "--framework", + "hono", + "--no-install", + "--no-link", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.types.status).toBe("skipped"); + expect(payload.nextSteps).toContain("npm install -D @prisma/compute-sdk"); + }); + + it("installs with --install and reports success", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "api" }); + + const result = await executeCli({ + argv: ["init", "--framework", "hono", "--install", "--no-link", "--json"], + cwd, + stateDir, + fixturePath, + env: { + PRISMA_CLI_INIT_INSTALL_COMMAND: JSON.stringify([ + "node", + "-e", + "process.exit(0)", + ]), + }, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.types.status).toBe("installed"); + expect(payload.warnings).toEqual([]); + }); + + it("downgrades a failed install to a warning", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "api" }); + + const result = await executeCli({ + argv: ["init", "--framework", "hono", "--install", "--no-link", "--json"], + cwd, + stateDir, + fixturePath, + env: { + PRISMA_CLI_INIT_INSTALL_COMMAND: JSON.stringify([ + "node", + "-e", + "process.exit(1)", + ]), + }, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.types.status).toBe("failed"); + expect(payload.warnings[0]).toContain( + "Installing @prisma/compute-sdk failed", + ); + expect(payload.warnings[0]).toContain("npm install -D @prisma/compute-sdk"); + }); +}); From 28399f19a53685ec383fce690290f209bb275f57 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 3 Jul 2026 14:00:58 +0530 Subject: [PATCH 4/4] fix(cli): address init review feedback - --entry now fails with a usage error for frameworks that derive their entrypoint from build output, instead of being silently dropped - entry resolution moved after the interactive adjust step and validates against the final framework, so a framework switch cannot write a stale or missing entry - the post-write types step degrades to a skip with a warning when package.json is unreadable, so a malformed file cannot fail the command after the config was written (retry no longer gets stuck on INIT_CONFIG_EXISTS) - init presenter drops its bespoke warning lines now that the runner renders success warnings (cherry-picked from the project PR) - INIT_CONFIG_EXISTS and INIT_DETECTION_FAILED registered in error-conventions.md --- docs/product/error-conventions.md | 4 ++ packages/cli/src/controllers/init.ts | 41 +++++++++++++++--- packages/cli/src/presenters/init.ts | 21 ++-------- packages/cli/tests/init.test.ts | 62 ++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 23 deletions(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 76f610e..ef9fa9f 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -187,6 +187,8 @@ These codes are the minimum stable set for the MVP: - `BUILD_SETTINGS_MIGRATION_REQUIRED` - `BUILD_SETTINGS_UNSUPPORTED` - `FRAMEWORK_NOT_DETECTED` +- `INIT_CONFIG_EXISTS` +- `INIT_DETECTION_FAILED` - `DEPLOYMENT_NOT_FOUND` - `NO_DEPLOYMENTS` - `NO_PREVIOUS_DEPLOYMENT` @@ -258,6 +260,8 @@ Recommended meanings: - `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_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 diff --git a/packages/cli/src/controllers/init.ts b/packages/cli/src/controllers/init.ts index ac1496b..b492bac 100644 --- a/packages/cli/src/controllers/init.ts +++ b/packages/cli/src/controllers/init.ts @@ -83,14 +83,16 @@ export async function runInit( value: defaultHttpPortForBuildType(frameworkByKey(framework.key).buildType), source: "framework default", }; - const entry = await resolveInitEntry(cwd, framework.key, flags.entry, signal); - const adjusted = await maybeAdjustSettings(context, framework, httpPort, { portExplicit: flags.httpPort !== undefined, }); framework = adjusted.framework; httpPort = adjusted.httpPort; + // Entry resolves against the FINAL framework so an interactive framework + // switch cannot leave a stale (or missing) entry in the written config. + const entry = await resolveInitEntry(cwd, framework, flags.entry, signal); + const settings: InitSettingRow[] = [ { key: "app", value: name.value, source: name.source }, { @@ -203,7 +205,25 @@ async function resolveInitTypes( hooks: { onWarning: (message: string) => void }, ): Promise { const cwd = context.runtime.cwd; - const packageJson = await readBunPackageJson(cwd, context.runtime.signal); + // This step runs after prisma.compute.ts 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(cwd, context.runtime.signal); + } catch (error) { + if (context.runtime.signal.aborted) { + throw error; + } + hooks.onWarning( + `Skipped the ${COMPUTE_SDK_PACKAGE} types install: package.json could not be read (${error instanceof Error ? error.message.split("\n")[0] : String(error)}).`, + ); + return { + status: "skipped", + package: COMPUTE_SDK_PACKAGE, + installCommand: null, + }; + } if (hasComputeSdkDependency(packageJson)) { return { status: "already-installed", @@ -496,16 +516,25 @@ function parseInitRegion( async function resolveInitEntry( cwd: string, - frameworkKey: ComputeFramework, + resolvedFramework: ResolvedInitFramework, explicitEntry: string | undefined, signal: AbortSignal, ): Promise<{ value: string; source: string } | undefined> { - const framework = frameworkByKey(frameworkKey); + const framework = frameworkByKey(resolvedFramework.key); + const trimmed = explicitEntry?.trim(); if (!framework.usesEntrypoint) { + if (trimmed) { + throw usageError( + "--entry is not supported for this framework", + `${resolvedFramework.displayName} derives its entrypoint from build output; --entry applies only to frameworks that run a source entrypoint (Bun, Hono).`, + "Drop --entry, or pass an entrypoint framework with --framework.", + [], + "app", + ); + } return undefined; } - const trimmed = explicitEntry?.trim(); if (trimmed) { return { value: trimmed, source: "flag" }; } diff --git a/packages/cli/src/presenters/init.ts b/packages/cli/src/presenters/init.ts index f1cf2d6..492f34c 100644 --- a/packages/cli/src/presenters/init.ts +++ b/packages/cli/src/presenters/init.ts @@ -17,6 +17,8 @@ export function renderInit( renderSummaryLine(ui, "success", `Wrote ${result.configPath}`), ]; + // Failed steps surface through the runner's success-warning rendering, so + // this block only covers the success and quiet-hint cases. if (result.types.status === "installed") { lines.push( renderSummaryLine( @@ -25,16 +27,8 @@ export function renderInit( `Installed ${result.types.package} (config types)`, ), ); - } else if (result.types.status === "failed" && result.types.installCommand) { - lines.push( - renderSummaryLine( - ui, - "warning", - `Could not install ${result.types.package}; install later with ${result.types.installCommand}`, - ), - ); } else if ( - result.types.status !== "already-installed" && + (result.types.status === "skipped" || result.types.status === "declined") && result.types.installCommand ) { lines.push( @@ -61,14 +55,7 @@ export function renderInit( ); break; case "failed": - // Human mode does not render success.warnings, so surface it here. - lines.push( - renderSummaryLine( - ui, - "warning", - `Project link failed; link later with ${formatCommand(["project", "link"])}`, - ), - ); + // The failure detail renders via the runner's success-warning lines. break; } diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index d56252f..d53a709 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -411,3 +411,65 @@ describe("init types install", () => { expect(payload.warnings[0]).toContain("npm install -D @prisma/compute-sdk"); }); }); + +describe("init edge cases", () => { + it("rejects --entry for frameworks that derive their entrypoint", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writePackageJson(cwd, { name: "web" }); + + const result = await executeCli({ + argv: [ + "init", + "--framework", + "nextjs", + "--entry", + "src/index.ts", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + command: "init", + error: { code: "USAGE_ERROR" }, + }); + await expect(readConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("keeps the written config when package.json is unreadable in the types step", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile(path.join(cwd, "package.json"), "{ not json", "utf8"); + + const result = await executeCli({ + argv: [ + "init", + "--framework", + "hono", + "--entry", + "src/index.ts", + "--name", + "api", + "--no-link", + "--install", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.ok).toBe(true); + expect(payload.result.types.status).toBe("skipped"); + expect(payload.warnings[0]).toContain("package.json could not be read"); + await expect(readConfig(cwd)).resolves.toContain('framework: "hono"'); + }); +});