diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4150a18..979f444 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,49 @@ 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 --install --no-install` + +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 +- 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 + - link failures and cancellations after the config is written downgrade to warnings and `nextSteps`; the config write stands and init exits 0 +- `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: + +```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 +1381,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` 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 @@ -1727,6 +1777,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/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/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..5e5a342 --- /dev/null +++ b/packages/cli/src/commands/init/index.ts @@ -0,0 +1,85 @@ +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"), + ) + .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) => { + const flags = options as { + framework?: string; + entry?: string; + httpPort?: string; + region?: string; + name?: string; + link?: boolean; + project?: string; + install?: boolean; + }; + + 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, + install: flags.install, + }), + { + 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..b492bac --- /dev/null +++ b/packages/cli/src/controllers/init.ts @@ -0,0 +1,715 @@ +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 { execa } from "execa"; + +import { + type PrismaCliPackageCommandFormatter, + resolvePrismaCliPackageCommandFormatterSync, +} from "../lib/agent/cli-command"; +import { + type AgentPackageManager, + detectPackageManagerSync, +} from "../lib/agent/package-manager"; +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, + InitTypesState, +} 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; + install?: boolean; +} + +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; + // 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, 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", + }; + 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 }, + { + 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 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: { + configPath: COMPUTE_CONFIG_FILENAME, + directory: formatInitDirectory(cwd), + app: { + name: name.value, + framework: framework.key, + httpPort: httpPort.value, + ...(entry ? { entry: entry.value } : {}), + ...(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; + // 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", + 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: { +// 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, + formatCommand: PrismaCliPackageCommandFormatter, +): 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(", ")}.`, + [formatCommand(["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) => + formatCommand(["init", "--framework", framework.key]), + ), + meta: { frameworks: FRAMEWORKS.map((framework) => framework.key) }, + }); +} + +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) { + throw usageError( + "App name required", + "--name needs a non-empty value.", + "Pass a non-empty app name.", + [formatCommand(["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, + formatCommand: PrismaCliPackageCommandFormatter, +): { 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.", + [formatCommand(["init", "--http-port", "3000"])], + "app", + ); + } + + return { value: port, source: "flag" }; +} + +function parseInitRegion( + value: string | undefined, + formatCommand: PrismaCliPackageCommandFormatter, +): 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(", ")}.`, + [formatCommand(["init", "--region", "us-east-1"])], + "app", + ); +} + +async function resolveInitEntry( + cwd: string, + resolvedFramework: ResolvedInitFramework, + explicitEntry: string | undefined, + signal: AbortSignal, +): Promise<{ value: string; source: string } | undefined> { + 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; + } + + 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; + formatCommand: PrismaCliPackageCommandFormatter; + }, +): 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 ${hooks.formatCommand(["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..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"; @@ -96,6 +97,16 @@ export function renderAppDeploy( }, ]), { label: "Logs", value: logsCommand }, + ...(deployUsedComputeConfig(result) + ? [] + : [ + { + label: "Config", + value: resolvePrismaCliPackageCommandSync(context.runtime.cwd, [ + "init", + ]), + }, + ]), ]), ...renderDeployResolvedContextBlock(context, result), ...renderDeploySettingsBlock(context, result), @@ -103,6 +114,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..492f34c --- /dev/null +++ b/packages/cli/src/presenters/init.ts @@ -0,0 +1,76 @@ +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"; +import type { InitResult } from "../types/init"; + +export function renderInit( + context: CommandContext, + _descriptor: CommandDescriptor, + result: InitResult, +): string[] { + const ui = context.ui; + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); + const lines = [ + 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( + ui, + "success", + `Installed ${result.types.package} (config types)`, + ), + ); + } else if ( + (result.types.status === "skipped" || result.types.status === "declined") && + result.types.installCommand + ) { + lines.push( + ` ${ui.dim(`For editor types: ${result.types.installCommand}`)}`, + ); + } + + 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 ${formatCommand(["project", "link"])}.`)}`, + ); + break; + case "failed": + // The failure detail renders via the runner's success-warning lines. + break; + } + + const linked = + result.link.status === "linked" || result.link.status === "already-linked"; + lines.push( + ...renderNextSteps([ + formatCommand(["app", "deploy"]), + ...(linked ? [] : [formatCommand(["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..1c0f50a 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -44,6 +44,17 @@ 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: (runtime) => + agentCommandExamples(runtime, [ + ["init"], + ["init", "--framework", "hono", "--entry", "src/index.ts"], + ["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..8ffe741 --- /dev/null +++ b/packages/cli/src/types/init.ts @@ -0,0 +1,49 @@ +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; + directory: string; + app: { + name: string; + framework: string; + httpPort: number; + entry?: string; + region?: string; + }; + settings: InitSettingRow[]; + types: InitTypesState; + link: InitLinkState; +} diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts new file mode 100644 index 0000000..d53a709 --- /dev/null +++ b/packages/cli/tests/init.test.ts @@ -0,0 +1,475 @@ +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", + }, + 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", + ], + }); + + 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([ + "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"); + }); + + 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( + "npx -y @prisma/cli@latest 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([ + "npm install -D @prisma/compute-sdk", + "npx -y @prisma/cli@latest 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: {"); + }); +}); + +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"); + }); +}); + +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"'); + }); +});