diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ff856dd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + runs-on: ${{ matrix.os }} + permissions: + contents: read + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + + - name: Enable pnpm + shell: bash + run: | + corepack enable + PNPM_VERSION="$(node -p 'const pm = require("./package.json").packageManager; const match = pm && pm.match(/^pnpm@(.+)$/); if (!match) throw new Error("packageManager must be pnpm@"); match[1]')" + corepack prepare "pnpm@${PNPM_VERSION}" --activate + echo "PNPM_STORE_PATH=$(pnpm store path)" >> "$GITHUB_ENV" + + - name: Cache pnpm store + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.PNPM_STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run CLI tests + run: pnpm --filter @prisma/cli test + + - name: Build CLI + run: pnpm --filter @prisma/cli build diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4374bd2..ca4a174 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -58,7 +58,7 @@ Out of scope for the current beta: non-TTY stderr, and when `NO_UPDATE_NOTIFIER` is set. When shown, update notifications are stderr-only human output and do not change the original command result. -- Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for Project -> Branch -> App resolution. `.prisma/local.json` is a gitignored local pin/cache, not a declarative repo config file. `prisma.app.json` is only for app build settings. +- Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for Project -> Branch resolution. `.prisma/local.json` is a gitignored local pin/cache, not a declarative repo config file. `prisma.app.json` is legacy and no longer read or written. `prisma.compute.ts` supplies typed `app deploy` defaults (app name, app root, framework, entrypoint, HTTP port, env inputs) and never selects Project or Branch scope. - Remote commands do not silently change local context. ## Authentication @@ -109,12 +109,22 @@ Preview app commands that need an app resolve it in this order: 1. `--app ` 2. `PRISMA_APP_ID` when set for headless deploy/domain commands -3. locally selected app for non-deploy commands when it still exists in the resolved branch -4. inferred app name from `package.json#name` -5. current directory name -6. create the inferred app in the resolved branch when no existing app matches -7. interactive picker only when multiple matching apps make the target ambiguous -8. `APP_AMBIGUOUS` in non-interactive or `--json` mode when unresolved +3. compute config target from the `[app]` argument, or inferred from the invocation directory being inside a target's `root`; the target's `name` (or `apps` key) selects the app +4. locally selected app for non-deploy commands when it still exists in the resolved branch +5. inferred app name from `package.json#name` +6. current directory name +7. `app deploy` only: create the inferred app in the resolved branch when no existing app matches +8. interactive picker only when multiple matching apps make the target ambiguous +9. `APP_AMBIGUOUS` in non-interactive or `--json` mode when unresolved + +App management commands (`show`, `open`, `logs`, `list-deploys`, `promote`, +`rollback`, `remove`, `domain`) accept the same `[app]` target argument and +upward config discovery as `app deploy`: the project binding is read from the +config file's directory, and the config target is an additional app-name +source. Unlike deploy, management commands never require a target: with +multiple targets, no argument, and nothing inferred from the invocation +directory, they fall back to the selection order above — except step 7; +management commands never create apps or mutate remote state to resolve one. `.prisma/local.json` pins the directory to a Workspace and Project only. It does not pin an App ID. App services are branch-scoped; a service ID from `main` @@ -729,7 +739,7 @@ Examples: prisma-cli database connection remove conn_123 --confirm conn_123 ``` -## `prisma-cli app build --entry --build-type ` +## `prisma-cli app build [app] --entry --build-type ` Purpose: @@ -737,7 +747,8 @@ Purpose: Behavior: -- detects supported project shapes when `--build-type auto` is used +- resolves the optional `[app]` target, app root, framework, and entrypoint from `prisma.compute.ts` exactly like `app deploy`; explicit `--entry` and a non-`auto` `--build-type` override the config +- detects supported project shapes when `--build-type auto` is used and no config framework applies - supports Bun, Next.js, Nuxt, Astro, and TanStack Start app builds in the beta package - fails with `USAGE_ERROR` when framework detection is ambiguous @@ -749,9 +760,10 @@ prisma-cli app build --build-type nuxt prisma-cli app build --build-type astro prisma-cli app build --build-type tanstack-start prisma-cli app build --build-type bun --entry server.ts +prisma-cli app build api ``` -## `prisma-cli app run --entry --build-type --port ` +## `prisma-cli app run [app] --entry --build-type --port ` Purpose: @@ -759,7 +771,9 @@ Purpose: Behavior: -- detects supported project shapes when `--build-type auto` is used +- resolves the optional `[app]` target, app root, framework, entrypoint, and port from `prisma.compute.ts` exactly like `app deploy`; explicit `--entry`, `--port`, and a non-`auto` `--build-type` override the config +- fails with `USAGE_ERROR` when the configured framework has no local dev server in the current preview +- detects supported project shapes when `--build-type auto` is used and no config framework applies - starts the local framework command - reports `RUN_FAILED` when the local process cannot start or exits unsuccessfully @@ -768,14 +782,79 @@ Examples: ```bash prisma-cli app run --build-type nextjs prisma-cli app run --build-type bun --entry server.ts --port 3000 +prisma-cli app run api ``` -## `prisma-cli app deploy --project --create-project --app --branch --framework --entry --http-port --env --db --no-db --prod` +## `prisma-cli app deploy [app] --project --create-project --app --branch --framework --entry --http-port --env --db --no-db --prod` Purpose: - creates a new deployment for the app +Compute config file (`prisma.compute.ts`): + +- deploy reads an optional typed config file, using the nearest one from the invocation directory up to the repository or workspace root (the closest ancestor with `.git`, `pnpm-workspace.yaml`, `bun.lock`, or a `workspaces` field); without such a boundary only the invocation directory is checked, so discovery never escapes the repository +- per directory, exactly one of `prisma.compute.ts`, `prisma.compute.mts`, `prisma.compute.js`, `prisma.compute.mjs`, or `prisma.compute.cjs` may exist +- config-relative paths (`root`, `env.file`) resolve from the config file's directory, so the config means the same thing from any working directory; `--env` flag paths still resolve from the invocation directory +- when a config is discovered, its directory is the project directory: `.prisma/local.json` is read and written there, the local CLI state cache (`.prisma/cli/state.json`) lives there, and `--db` scans for Prisma schema sources there (for prompting and suggestions only); locating the config for these purposes never evaluates it +- the config default-exports `defineComputeConfig({ ... })` from `@prisma/compute-sdk/config` (the shared compute config contract; the CLI loader resolves the import without a local install); the helper is an identity function, so plain object exports also work for JavaScript configs +- the config defines exactly one of: + - `app` — a single-app repository + - `apps` — a multi-app or monorepo repository, keyed by deploy target name +- each app accepts `name`, `root`, `framework`, `entry`, `httpPort`, `env`, and `build`: + - `env` is a dotenv file path, or `{ file, vars }` with file path(s) and inline assignments + - `build` is `{ command, outputDirectory }`; both fields are optional and `command: null` skips the build step + - `build` applies to frameworks whose preview build consumes committed settings (`nextjs`, `hono`, `tanstack-start`, `bun`); `nuxt` and `astro` builds run their framework CLI automatically, so a `build` block with those frameworks is a validation error (`BUILD_SETTINGS_UNSUPPORTED` when only detected at deploy time) +- when `build` is present, the compute config owns build settings for that app: fields it sets override framework defaults, fields it omits are inferred; without a `build` block, settings are inferred entirely, with their sources shown +- the compute config does not declare databases in the current beta; database setup stays on the `--db`/`--no-db` flags (a future project-level `database` field is the reserved growth path, since databases are branch resources shared by every app on the branch) + +Unification note (forward-looking, normative for design decisions): Prisma ORM +ships `prisma.config.ts` (`defineConfig` from `prisma/config`). The compute +config is designed to become a `compute` key inside that unified file: its +shape must remain self-contained, must not add top-level keys that collide +with ORM config keys, and `database.schema` will become unnecessary once the +unified file's own `schema` field is in scope. Project, branch, and +production targeting stay out of committed config in the unified file for the +same reasons they are excluded today. +- config values are deploy defaults; explicit flags always win: `--framework`, `--entry`, `--http-port` override per value, and any `--env` flag replaces the config env inputs entirely +- the config `name` (or the `apps` key when `name` is absent) selects the app like `--app`, but ranks below both `--app` and `PRISMA_APP_ID` +- `root` is a relative path inside the repository; framework detection, entrypoint resolution, build settings, and the build/upload run in that directory while Project binding and the local pin stay in the config file's directory +- `prisma.compute.ts` never selects Project or Branch scope; project resolution is unchanged +- the `[app]` argument selects an `apps` target by key: + - without an `[app]` argument, a command run from inside a target's `root` selects that target, so `cd apps/api && prisma-cli app deploy` deploys `api`; the deepest matching root wins and an ambiguous tie selects nothing + - with multiple `apps` entries, no `[app]` argument, and no target inferred from the invocation directory, deploy deploys every target sequentially in declaration order — the config declares the system, and a bare `prisma-cli app deploy` ships it + - deploying all targets rejects per-app inputs (`--app`, `--framework`, `--entry`, `--http-port`, `--env`, `PRISMA_APP_ID`) with a usage error; project- and branch-level flags (`--project`, `--create-project`, `--branch`, `--db`, `--no-db`, `--prod`, `--yes`) apply to the whole run, with `--create-project` creating and binding the Project once before the first target + - a deploy-all run stops at the first failure and reports the targets already live; `--json` output aggregates one full deploy result per target + - `app build` and `app run` still require a target in multi-app configs and fail with `COMPUTE_CONFIG_TARGET_REQUIRED` (a dev server cannot run N apps at once; build keeps the same shape) + - an `[app]` argument that matches no target fails with `COMPUTE_CONFIG_TARGET_UNKNOWN` + - a single-entry `apps` map deploys its only target without an argument + - with a single `app` config, `[app]` is accepted only when it equals the configured `name` + - `[app]` without any compute config file is a usage error +- a config that fails to load or validate fails with `COMPUTE_CONFIG_INVALID` before any remote work +- settings sourced from the config are annotated `set by prisma.compute.ts` in human output and deploy settings metadata + +```ts +import { defineComputeConfig } from "@prisma/compute-sdk/config"; + +// Single-app repository: prisma-cli app deploy +export default defineComputeConfig({ + app: { + name: "api", + framework: "hono", + httpPort: 8080, + env: ".env", + }, +}); + +// Multi-app repository: prisma-cli app deploy web +export default defineComputeConfig({ + apps: { + web: { root: "apps/web", framework: "nextjs" }, + worker: { root: "apps/worker", framework: "bun", entry: "src/index.ts" }, + }, +}); +``` + Behavior: - requires auth @@ -805,16 +884,18 @@ Behavior: - writes `.prisma/local.json` after Project binding succeeds and before build/deploy starts, so retries after a failed deploy do not repeat setup - before asking `Customize build settings? (y/N)`, previews the detected framework and runtime so the user can see the defaults they are accepting or changing - asks `Customize build settings? (y/N)` only while binding the directory for the first time, and only asks for Framework and HTTP port when the user opts in -- for Next.js, TanStack Start, and Bun/Hono deploys, reads or creates `prisma.app.json` before build and uses it for app build settings: +- resolves build settings from the compute config `build` block over framework inference; nothing is read from or written to disk for them: - `Build Command` prefers ` run build` when `package.json` has `scripts.build` + - the package manager is detected from the app directory first, then from each ancestor up to the repository or workspace root, so workspace apps build with the workspace package manager + - build commands run with every `node_modules/.bin` between the app and the repository or workspace root on `PATH`, so hoisted workspace binaries resolve - otherwise `Build Command` falls back to the framework default, such as `next build` - `Output Directory` is a literal framework output path, such as `.next/standalone`, `.output`, or `.` -- does not overwrite an existing `prisma.app.json`; edit the file or delete it and rerun deploy to regenerate defaults +- `prisma.app.json` is legacy and never read or written; a leftover file that matches the resolved settings produces a deletion warning, an unparsable one is ignored with a warning, and one with custom values fails with `BUILD_SETTINGS_MIGRATION_REQUIRED` including the exact `build` block to move into `prisma.compute.ts` - 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` - accepts repeated `--env NAME=VALUE` flags and dotenv file paths such as `--env .env` -- supports `--db` to create a new empty Prisma Postgres database, apply a supported local Prisma schema source when one exists, and write `DATABASE_URL` and `DIRECT_URL` through the existing `project env` storage +- 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 - supports `--no-db` to suppress automatic database prompting for the deploy - `--db` and `--no-db` are mutually exclusive; passing both is rejected - `--yes` alone never creates a database; CI must pass `--db --yes` to create and wire one @@ -824,21 +905,17 @@ Behavior: - database setup never overwrites an existing branch-scoped `DATABASE_URL`; when the branch already has `DATABASE_URL`, `--db` leaves branch database env vars unchanged and continues - production setup treats existing production `DATABASE_URL` or `DIRECT_URL` as BYO DB intent; it does not prompt, and explicit `--db` leaves production env vars unchanged and continues with a warning - when only `DIRECT_URL` exists on a preview branch, explicit `--db` treats it as partial setup and repairs the pair by writing fresh branch database env values -- if schema setup or env-var wiring fails after database creation, the CLI deletes the newly created database before returning the error -- database setup does not clone or infer schema from another database; it only creates an empty database and optionally applies schema from local code -- Prisma Next config (`prisma-next.config.*`) is preferred over `schema.prisma`; setup runs `prisma-next contract emit` and then `prisma-next db init` -- for Prisma ORM `schema.prisma`, setup runs `prisma migrate deploy` when `prisma/migrations` exists next to the schema, otherwise it runs `prisma db push` -- when no supported Prisma schema source is found, `--db` still creates the database and env overrides but skips schema setup +- if env-var wiring fails after database creation, the CLI deletes the newly created database before returning the error +- after creating a database, the CLI emits a warning that the database is empty and suggests a schema command based on the detected local schema source (`prisma migrate deploy` when `prisma/migrations` exists, `prisma db push` for a bare `schema.prisma`, `prisma-next db init` for a Prisma Next config); the suggestion is never executed - known non-Postgres Prisma sources do not trigger automatic database prompting; explicit `--db` is rejected because the created database is Prisma Postgres -- if schema setup fails, deploy stops before the app build/deploy starts - `--env DATABASE_URL=...`, `--env DIRECT_URL=...`, or the same keys loaded from an env file suppress automatic database prompting; combining those database env vars with `--db` is rejected - maps user-facing framework names to deploy build strategies -- does not accept `--build-command` or `--output-directory`; custom build settings are edited in `prisma.app.json`, which is initially generated from `package.json` `scripts.build` and framework defaults for config-backed deploy types +- does not accept `--build-command` or `--output-directory`; custom build settings live in the `build` block of `prisma.compute.ts` - uses `src/index.ts` as the Hono deploy entrypoint when the app has no `package.json#main` or `package.json#module` and that file exists - supports vanilla Bun apps with `--framework bun` using `package.json#main` or `package.json#module`, or with `--entry ` - treats `--entry ` without `--framework` as a Bun app deploy - does not print secret values -- returns app, deployment id, URL, deploy settings including `prisma.app.json` status/build/output metadata, and next steps in `--json` output +- returns app, deployment id, URL, deploy settings including build settings origin metadata, and next steps in `--json` output Examples: @@ -855,6 +932,8 @@ prisma-cli app deploy --branch feat-login --framework hono --http-port 3000 prisma-cli app deploy --prod --yes prisma-cli app deploy --framework bun --entry src/server.ts --http-port 3000 prisma-cli app deploy --entry src/server.ts --http-port 3000 +prisma-cli app deploy web +prisma-cli app deploy worker --branch feat-queue ``` ## `prisma-cli project env` @@ -999,7 +1078,7 @@ prisma-cli project env remove STRIPE_KEY --role preview prisma-cli project env remove DATABASE_URL --branch feature/foo ``` -## `prisma-cli app show --app ` +## `prisma-cli app show [app] --app ` Purpose: @@ -1018,7 +1097,7 @@ prisma-cli app show prisma-cli app show --app hello-world ``` -## `prisma-cli app open --app ` +## `prisma-cli app open [app] --app ` Purpose: @@ -1068,7 +1147,7 @@ prisma-cli app domain wait shop.acme.com --timeout 15m prisma-cli app domain retry shop.acme.com ``` -## `prisma-cli app domain add ` +## `prisma-cli app domain add [app]` Purpose: @@ -1094,7 +1173,7 @@ prisma-cli app domain add shop.acme.com prisma-cli app domain add shop.acme.com --app shop --branch production ``` -## `prisma-cli app domain show ` +## `prisma-cli app domain show [app]` Purpose: @@ -1114,7 +1193,7 @@ Examples: prisma-cli app domain show checkout.acme.com ``` -## `prisma-cli app domain remove ` +## `prisma-cli app domain remove [app]` Purpose: @@ -1134,7 +1213,7 @@ prisma-cli app domain remove old.acme.com prisma-cli app domain remove old.acme.com --yes ``` -## `prisma-cli app domain retry ` +## `prisma-cli app domain retry [app]` Purpose: @@ -1156,7 +1235,7 @@ Examples: prisma-cli app domain retry checkout.acme.com ``` -## `prisma-cli app domain wait ` +## `prisma-cli app domain wait [app]` Purpose: @@ -1180,7 +1259,7 @@ prisma-cli app domain wait shop.acme.com prisma-cli app domain wait shop.acme.com --timeout 0 --json ``` -## `prisma-cli app logs --app --deployment ` +## `prisma-cli app logs [app] --app --deployment ` Purpose: @@ -1203,7 +1282,7 @@ prisma-cli app logs prisma-cli app logs --deployment dep_123 ``` -## `prisma-cli app list-deploys --app ` +## `prisma-cli app list-deploys [app] --app ` Purpose: @@ -1240,7 +1319,7 @@ Examples: prisma-cli app show-deploy dep_123 ``` -## `prisma-cli app promote --app ` +## `prisma-cli app promote [app] --app ` Purpose: @@ -1260,7 +1339,7 @@ prisma-cli app promote dep_123 prisma-cli app promote dep_123 --app hello-world ``` -## `prisma-cli app rollback --app --to ` +## `prisma-cli app rollback [app] --app --to ` Purpose: @@ -1280,7 +1359,7 @@ prisma-cli app rollback prisma-cli app rollback --app hello-world --to dep_123 ``` -## `prisma-cli app remove --app -y --yes` +## `prisma-cli app remove [app] --app -y --yes` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 69adb66..8f85e8c 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -170,7 +170,11 @@ These codes are the minimum stable set for the MVP: - `LOCAL_STATE_WRITE_FAILED` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` -- `APP_CONFIG_INVALID` +- `COMPUTE_CONFIG_INVALID` +- `COMPUTE_CONFIG_TARGET_REQUIRED` +- `COMPUTE_CONFIG_TARGET_UNKNOWN` +- `BUILD_SETTINGS_MIGRATION_REQUIRED` +- `BUILD_SETTINGS_UNSUPPORTED` - `FRAMEWORK_NOT_DETECTED` - `DEPLOYMENT_NOT_FOUND` - `NO_DEPLOYMENTS` @@ -223,7 +227,11 @@ Recommended meanings: - `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context -- `APP_CONFIG_INVALID`: `prisma.app.json` is missing required build settings, has invalid JSON, or points outside the app root +- `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate +- `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred +- `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app +- `BUILD_SETTINGS_MIGRATION_REQUIRED`: a legacy `prisma.app.json` contains custom build settings that must move into the `build` block of `prisma.compute.ts` +- `BUILD_SETTINGS_UNSUPPORTED`: a compute config `build` block targets a framework whose strategy owns its build (nuxt, astro) - `FRAMEWORK_NOT_DETECTED`: app deploy could not detect a supported Beta framework and no explicit framework/build type was provided - `DEPLOYMENT_NOT_FOUND`: requested deployment id does not exist - `NO_DEPLOYMENTS`: command resolved a branch or app but found no deployments diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index ae4f7a3..625bb58 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -338,7 +338,7 @@ Examples: - `app deploy` should state the resolved target that matters in the current slice - first local `app deploy` binding should make the Project choice explicit before work begins - subsequent `app deploy` calls should use a compact target header such as `Deploying ./j1 to j1 / main / j1` -- config-backed `app deploy` builds should show whether they created or used `prisma.app.json` before build starts: `Build Command` with its source when inferred, and `Output Directory` as a literal path such as `.next/standalone` rather than an opaque framework default label +- config-backed `app deploy` builds should show the resolved build settings before build starts: `Build Command` and `Output Directory` with their sources (`prisma.compute.ts` or inference), `Output Directory` as a literal path such as `.next/standalone` rather than an opaque framework default label - `app logs` should state the deployment it resolved - `app list-deploys` should state which app or branch is being listed diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 5debe8f..1c88c5d 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -37,8 +37,8 @@ Rules: - `project` is not the same thing as `app` - 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 -- `prisma.app.json` is a committed app build-settings file only; it must not contain Workspace, Project, Branch, App, env, or secret resolution state +- `.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 - 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 @@ -104,7 +104,7 @@ Rules: - the runtime app service is scoped by branch in the platform model - the app may be selected or created as part of app deployment workflows - app selection is local CLI state when needed for the beta package -- app build settings may live in `prisma.app.json` beside `package.json`; v1 fields are `buildCommand` and `outputDirectory` +- app build settings live in the `build` block of `prisma.compute.ts` (`command`, `outputDirectory`); `prisma.app.json` is legacy and no longer read ### Deployment diff --git a/package.json b/package.json index e1a2a58..908cc53 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "biome check .", "lint:fix": "biome check . --write", "lint:skills": "node scripts/validate-skills.mjs", - "prepare": "skills add ./skills --skill '*' --agent universal claude-code -y", + "prepare": "node scripts/prepare-skills.mjs", "prepare:cli-publish": "node scripts/prepare-cli-publish.mjs", "smoke:cli-nextjs": "node scripts/smoke-cli-nextjs-artifact.mjs", "test:skills": "node --test scripts/validate-skills.test.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index bacbb5a..4347bd2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,6 +6,9 @@ "bin": { "prisma-cli": "./dist/cli.js" }, + "exports": { + "./package.json": "./package.json" + }, "files": [ "dist", "README.md", @@ -41,11 +44,10 @@ }, "dependencies": { "@clack/prompts": "^1.5.0", - "@prisma/compute-sdk": "^0.22.0", + "@prisma/compute-sdk": "^0.23.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "^1.37.0", "better-result": "^2.9.2", - "c12": "4.0.0-beta.5", "colorette": "^2.0.20", "commander": "^14.0.3", "dotenv": "^17.4.2", diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index df0d6cf..e63cb6e 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -1,5 +1,8 @@ +import { + FRAMEWORK_KEYS, + LOCAL_DEV_BUILD_TYPES, +} from "@prisma/compute-sdk/config"; import { Command, Option } from "commander"; - import { runAppBuild, runAppDeploy, @@ -20,8 +23,10 @@ import { } from "../../controllers/app"; import { PREVIEW_BUILD_TYPES } from "../../lib/app/preview-build"; import { + isAppDeployAllResult, renderAppBuild, renderAppDeploy, + renderAppDeployAll, renderAppDomainAdd, renderAppDomainRemove, renderAppDomainRetry, @@ -36,6 +41,7 @@ import { renderAppShowDeploy, serializeAppBuild, serializeAppDeploy, + serializeAppDeployAll, serializeAppDomainAdd, serializeAppDomainRemove, serializeAppDomainRetry, @@ -59,6 +65,7 @@ import { import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; import type { AppBuildResult, + AppDeployAllResult, AppDeployResult, AppDomainAddResult, AppDomainRemoveResult, @@ -105,6 +112,10 @@ function createBuildCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption( new Option("--entry ", "Entrypoint path for Bun or auto builds"), ) @@ -115,7 +126,7 @@ function createBuildCommand(runtime: CliRuntime): Command { ); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const entry = (options as { entry?: string }).entry; const buildType = (options as { buildType?: string }).buildType; @@ -123,7 +134,8 @@ function createBuildCommand(runtime: CliRuntime): Command { runtime, "app.build", options as Record, - (context) => runAppBuild(context, entry, buildType), + (context) => + runAppBuild(context, { entrypoint: entry, buildType, configTarget }), { renderHuman: (context, descriptor, result) => renderAppBuild(context, descriptor, result), @@ -142,18 +154,22 @@ function createRunCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption( new Option("--entry ", "Entrypoint path for Bun or auto runs"), ) .addOption( new Option("--build-type ", "Local framework type") - .choices(["auto", "bun", "nextjs"]) + .choices(["auto", ...LOCAL_DEV_BUILD_TYPES]) .default("auto"), ) .addOption(new Option("--port ", "Local port")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const entry = (options as { entry?: string }).entry; const buildType = (options as { buildType?: string }).buildType; const port = (options as { port?: string }).port; @@ -162,7 +178,13 @@ function createRunCommand(runtime: CliRuntime): Command { runtime, "app.run", options as Record, - (context) => runAppRun(context, entry, buildType, port), + (context) => + runAppRun(context, { + entrypoint: entry, + buildType, + port, + configTarget, + }), { renderHuman: (context, descriptor, result) => renderAppRun(context, descriptor, result), @@ -181,6 +203,10 @@ function createDeployCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")) .addOption( @@ -192,10 +218,7 @@ function createDeployCommand(runtime: CliRuntime): Command { .addOption(new Option("--branch ", "Branch name")) .addOption( new Option("--framework ", "Framework to deploy").choices([ - "nextjs", - "hono", - "tanstack-start", - "bun", + ...FRAMEWORK_KEYS, ]), ) .addOption(new Option("--entry ", "Entrypoint path for Bun deploys")) @@ -221,7 +244,7 @@ function createDeployCommand(runtime: CliRuntime): Command { .addOption(new Option("--prod", "Confirm intent to deploy to production")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const entry = (options as { entry?: string }).entry; const branchName = (options as { branch?: string }).branch; @@ -236,7 +259,7 @@ function createDeployCommand(runtime: CliRuntime): Command { const hasDbConflict = hasFlag(runtime.argv, "--db") && hasFlag(runtime.argv, "--no-db"); - await runCommand( + await runCommand( runtime, "app.deploy", options as Record, @@ -261,12 +284,18 @@ function createDeployCommand(runtime: CliRuntime): Command { envAssignments, prod: prod === true, db, + configTarget, }); }, { renderHuman: (context, descriptor, result) => - renderAppDeploy(context, descriptor, result), - renderJson: (result) => serializeAppDeploy(result), + isAppDeployAllResult(result) + ? renderAppDeployAll(context, descriptor, result) + : renderAppDeploy(context, descriptor, result), + renderJson: (result) => + isAppDeployAllResult(result) + ? serializeAppDeployAll(result) + : serializeAppDeploy(result), }, ); }); @@ -285,11 +314,15 @@ function createShowCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const projectRef = (options as { project?: string }).project; @@ -297,7 +330,7 @@ function createShowCommand(runtime: CliRuntime): Command { runtime, "app.show", options as Record, - (context) => runAppShow(context, appName, projectRef), + (context) => runAppShow(context, appName, projectRef, configTarget), { renderHuman: (context, descriptor, result) => renderAppShow(context, descriptor, result), @@ -316,11 +349,15 @@ function createOpenCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const projectRef = (options as { project?: string }).project; @@ -328,7 +365,7 @@ function createOpenCommand(runtime: CliRuntime): Command { runtime, "app.open", options as Record, - (context) => runAppOpen(context, appName, projectRef), + (context) => runAppOpen(context, appName, projectRef, configTarget), { renderHuman: (context, descriptor, result) => renderAppOpen(context, descriptor, result), @@ -371,27 +408,38 @@ function createDomainAddCommand(runtime: CliRuntime): Command { ); command.argument("", "Custom domain hostname"); + command.argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ); addDomainTargetOptions(command); addGlobalFlags(command); - command.action(async (hostname: string, options) => { - const appName = (options as { app?: string }).app; - const projectRef = (options as { project?: string }).project; - const branchName = (options as { branch?: string }).branch; - - await runCommand( - runtime, - "app.domain.add", - options as Record, - (context) => - runAppDomainAdd(context, hostname, { appName, projectRef, branchName }), - { - renderHuman: (context, descriptor, result) => - renderAppDomainAdd(context, descriptor, result), - renderJson: (result) => serializeAppDomainAdd(result), - }, - ); - }); + command.action( + async (hostname: string, configTarget: string | undefined, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.add", + options as Record, + (context) => + runAppDomainAdd(context, hostname, { + appName, + projectRef, + branchName, + configTarget, + }), + { + renderHuman: (context, descriptor, result) => + renderAppDomainAdd(context, descriptor, result), + renderJson: (result) => serializeAppDomainAdd(result), + }, + ); + }, + ); return command; } @@ -403,31 +451,38 @@ function createDomainShowCommand(runtime: CliRuntime): Command { ); command.argument("", "Custom domain hostname"); + command.argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ); addDomainTargetOptions(command); addGlobalFlags(command); - command.action(async (hostname: string, options) => { - const appName = (options as { app?: string }).app; - const projectRef = (options as { project?: string }).project; - const branchName = (options as { branch?: string }).branch; - - await runCommand( - runtime, - "app.domain.show", - options as Record, - (context) => - runAppDomainShow(context, hostname, { - appName, - projectRef, - branchName, - }), - { - renderHuman: (context, descriptor, result) => - renderAppDomainShow(context, descriptor, result), - renderJson: (result) => serializeAppDomainShow(result), - }, - ); - }); + command.action( + async (hostname: string, configTarget: string | undefined, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.show", + options as Record, + (context) => + runAppDomainShow(context, hostname, { + appName, + projectRef, + branchName, + configTarget, + }), + { + renderHuman: (context, descriptor, result) => + renderAppDomainShow(context, descriptor, result), + renderJson: (result) => serializeAppDomainShow(result), + }, + ); + }, + ); return command; } @@ -439,31 +494,38 @@ function createDomainRemoveCommand(runtime: CliRuntime): Command { ); command.argument("", "Custom domain hostname"); + command.argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ); addDomainTargetOptions(command); addGlobalFlags(command); - command.action(async (hostname: string, options) => { - const appName = (options as { app?: string }).app; - const projectRef = (options as { project?: string }).project; - const branchName = (options as { branch?: string }).branch; - - await runCommand( - runtime, - "app.domain.remove", - options as Record, - (context) => - runAppDomainRemove(context, hostname, { - appName, - projectRef, - branchName, - }), - { - renderHuman: (context, descriptor, result) => - renderAppDomainRemove(context, descriptor, result), - renderJson: (result) => serializeAppDomainRemove(result), - }, - ); - }); + command.action( + async (hostname: string, configTarget: string | undefined, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.remove", + options as Record, + (context) => + runAppDomainRemove(context, hostname, { + appName, + projectRef, + branchName, + configTarget, + }), + { + renderHuman: (context, descriptor, result) => + renderAppDomainRemove(context, descriptor, result), + renderJson: (result) => serializeAppDomainRemove(result), + }, + ); + }, + ); return command; } @@ -475,31 +537,38 @@ function createDomainRetryCommand(runtime: CliRuntime): Command { ); command.argument("", "Custom domain hostname"); + command.argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ); addDomainTargetOptions(command); addGlobalFlags(command); - command.action(async (hostname: string, options) => { - const appName = (options as { app?: string }).app; - const projectRef = (options as { project?: string }).project; - const branchName = (options as { branch?: string }).branch; - - await runCommand( - runtime, - "app.domain.retry", - options as Record, - (context) => - runAppDomainRetry(context, hostname, { - appName, - projectRef, - branchName, - }), - { - renderHuman: (context, descriptor, result) => - renderAppDomainRetry(context, descriptor, result), - renderJson: (result) => serializeAppDomainRetry(result), - }, - ); - }); + command.action( + async (hostname: string, configTarget: string | undefined, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.retry", + options as Record, + (context) => + runAppDomainRetry(context, hostname, { + appName, + projectRef, + branchName, + configTarget, + }), + { + renderHuman: (context, descriptor, result) => + renderAppDomainRetry(context, descriptor, result), + renderJson: (result) => serializeAppDomainRetry(result), + }, + ); + }, + ); return command; } @@ -511,31 +580,38 @@ function createDomainWaitCommand(runtime: CliRuntime): Command { ); command.argument("", "Custom domain hostname"); + command.argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ); addDomainTargetOptions(command); command.addOption( new Option("--timeout ", "Maximum time to wait").default("15m"), ); addGlobalFlags(command); - command.action(async (hostname: string, options) => { - const appName = (options as { app?: string }).app; - const projectRef = (options as { project?: string }).project; - const branchName = (options as { branch?: string }).branch; - const timeout = (options as { timeout?: string }).timeout; - - await runStreamingCommand( - runtime, - "app.domain.wait", - options as Record, - (context) => - runAppDomainWait(context, hostname, { - appName, - projectRef, - branchName, - timeout, - }), - ); - }); + command.action( + async (hostname: string, configTarget: string | undefined, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const timeout = (options as { timeout?: string }).timeout; + + await runStreamingCommand( + runtime, + "app.domain.wait", + options as Record, + (context) => + runAppDomainWait(context, hostname, { + appName, + projectRef, + branchName, + timeout, + configTarget, + }), + ); + }, + ); return command; } @@ -547,12 +623,16 @@ function createLogsCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")) .addOption(new Option("--deployment ", "Deployment id")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const deploymentId = (options as { deployment?: string }).deployment; const projectRef = (options as { project?: string }).project; @@ -561,7 +641,8 @@ function createLogsCommand(runtime: CliRuntime): Command { runtime, "app.logs", options as Record, - (context) => runAppLogs(context, appName, deploymentId, projectRef), + (context) => + runAppLogs(context, appName, deploymentId, projectRef, configTarget), ); }); @@ -582,11 +663,15 @@ function createListDeploysCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const projectRef = (options as { project?: string }).project; @@ -594,7 +679,8 @@ function createListDeploysCommand(runtime: CliRuntime): Command { runtime, "app.list-deploys", options as Record, - (context) => runAppListDeploys(context, appName, projectRef), + (context) => + runAppListDeploys(context, appName, projectRef, configTarget), { renderHuman: (context, descriptor, result) => renderAppListDeploys(context, descriptor, result), @@ -639,27 +725,40 @@ function createPromoteCommand(runtime: CliRuntime): Command { ); command.argument("", "Deployment id"); + command.argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ); command .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); - command.action(async (deploymentId: string, options) => { - const appName = (options as { app?: string }).app; - const projectRef = (options as { project?: string }).project; - - await runCommand( - runtime, - "app.promote", - options as Record, - (context) => runAppPromote(context, deploymentId, appName, projectRef), - { - renderHuman: (context, descriptor, result) => - renderAppPromote(context, descriptor, result), - renderJson: (result) => serializeAppPromote(result), - }, - ); - }); + command.action( + async (deploymentId: string, configTarget: string | undefined, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + + await runCommand( + runtime, + "app.promote", + options as Record, + (context) => + runAppPromote( + context, + deploymentId, + appName, + projectRef, + configTarget, + ), + { + renderHuman: (context, descriptor, result) => + renderAppPromote(context, descriptor, result), + renderJson: (result) => serializeAppPromote(result), + }, + ); + }, + ); return command; } @@ -671,12 +770,16 @@ function createRollbackCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")) .addOption(new Option("--to ", "Deployment id")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const deploymentId = (options as { to?: string }).to; const projectRef = (options as { project?: string }).project; @@ -685,7 +788,14 @@ function createRollbackCommand(runtime: CliRuntime): Command { runtime, "app.rollback", options as Record, - (context) => runAppRollback(context, appName, deploymentId, projectRef), + (context) => + runAppRollback( + context, + appName, + deploymentId, + projectRef, + configTarget, + ), { renderHuman: (context, descriptor, result) => renderAppRollback(context, descriptor, result), @@ -704,11 +814,15 @@ function createRemoveCommand(runtime: CliRuntime): Command { ); command + .argument( + "[app]", + "App target from prisma.compute.ts when the config defines multiple apps", + ) .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); - command.action(async (options) => { + command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const projectRef = (options as { project?: string }).project; @@ -716,7 +830,7 @@ function createRemoveCommand(runtime: CliRuntime): Command { runtime, "app.remove", options as Record, - (context) => runAppRemove(context, appName, projectRef), + (context) => runAppRemove(context, appName, projectRef, configTarget), { renderHuman: (context, descriptor, result) => renderAppRemove(context, descriptor, result), diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 9b6f3e4..b82d89c 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -1,13 +1,21 @@ -// biome-ignore-all lint/performance/noAwaitInLoops: Polling and ordered filesystem probes are intentionally sequential. -// biome-ignore-all lint/performance/useTopLevelRegex: Existing domain and parsing regexes are kept inline for readability. -// biome-ignore-all lint/style/noNestedTernary: Existing app presentation expressions are intentionally compact. import { access, readFile } from "node:fs/promises"; import path from "node:path"; import type { PortMapping, StreamRecord } from "@prisma/compute-sdk"; +import { + type ComputeFramework, + type ConfigBackedBuildType, + ENTRYPOINT_BUILD_TYPES, + FRAMEWORKS, + type FrameworkBuildType, + type FrameworkDescriptor, + frameworkByKey, + frameworkFromAlias, + isConfigBackedBuildType, + LOCAL_DEV_BUILD_TYPES, +} from "@prisma/compute-sdk/config"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { matchError, Result } from "better-result"; import open from "open"; - import { FileTokenStorage } from "../adapters/token-storage"; import { type BranchDatabaseDeployBranch, @@ -18,6 +26,22 @@ import { readBunPackageEntrypoint, readBunPackageJson, } from "../lib/app/bun-project"; +import { + COMPUTE_CONFIG_FILENAME, + type ComputeConfigCommandName, + ComputeConfigTargetRequiredError, + type ComputeDeployTarget, + computeConfigErrorToCliError, + computeFrameworkToBuildType, + computeTargetAppDir, + inferComputeTargetFromCwd, + type LoadedComputeConfig, + loadComputeConfig, + type MergedDeployInput, + mergeComputeDeployInputs, + mergeComputeLocalInputs, + selectComputeDeployTarget, +} from "../lib/app/compute-config"; import { renderDeployOutputRows, renderDeploySettingsPreview, @@ -26,17 +50,21 @@ import { formatDomainFailureFix } from "../lib/app/domain-guidance"; import { envVarNames, parseEnvInputs } from "../lib/app/env-vars"; import { DEFAULT_LOCAL_DEV_PORT, - resolveLocalBuildType, + type LocalBuildType, runLocalApp, } from "../lib/app/local-dev"; import { + detectLegacyBuildSettings, executePreviewBuild, PREVIEW_BUILD_TYPES, - type PreviewBuildSettingsBuildType, + PRISMA_APP_CONFIG_FILENAME, + type PreviewBuildSettings, type PreviewBuildSettingsResolution, type PreviewBuildType, RESOLVED_PREVIEW_BUILD_TYPES, - resolveOrCreatePreviewBuildSettings, + type ResolvedPreviewBuildType, + resolveConfiguredPreviewBuildSettings, + resolveInferredPreviewBuildSettings, } from "../lib/app/preview-build"; import { PREVIEW_DEFAULT_REGION } from "../lib/app/preview-interaction"; import { @@ -96,6 +124,7 @@ import { type CommandContext, canPrompt } from "../shell/runtime"; import { renderCommandHeader } from "../shell/ui"; import type { AppBuildResult, + AppDeployAllResult, AppDeploymentSummary, AppDeployResult, AppDomainAddResult, @@ -124,18 +153,6 @@ import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; type AppDomainCommand = "add" | "show" | "remove" | "retry" | "wait"; -type DeployFramework = "nextjs" | "hono" | "tanstack-start" | "bun"; - -const DEPLOY_FRAMEWORKS = [ - "nextjs", - "hono", - "tanstack-start", - "bun", -] as const satisfies readonly DeployFramework[]; -const TANSTACK_START_PACKAGES = [ - "@tanstack/react-start", - "@tanstack/solid-start", -] as const; const FRAMEWORK_DEFAULT_HTTP_PORT = 3000; const PRISMA_PROJECT_ID_ENV_VAR = "PRISMA_PROJECT_ID"; const PRISMA_APP_ID_ENV_VAR = "PRISMA_APP_ID"; @@ -149,17 +166,64 @@ function isRealMode(context: CommandContext): boolean { export async function runAppBuild( context: CommandContext, - entrypoint: string | undefined, - requestedBuildType: string | undefined, + options?: { + entrypoint?: string; + buildType?: string; + configTarget?: string; + }, ): Promise> { - const buildType = normalizeBuildType(requestedBuildType); - assertSupportedEntrypoint(buildType, entrypoint, "build"); + const compute = await resolveComputeTargetOrThrow( + context, + options?.configTarget, + "build", + ); + const merged = mergeComputeLocalInputs({ + cli: { entrypoint: options?.entrypoint, buildType: options?.buildType }, + target: compute.target, + }); + const appDir = await resolveComputeAppDir(context, compute); + let buildType = normalizeBuildType(merged.buildType); + if (compute.target?.build && buildType === "auto") { + // A committed build block must never be silently ignored, so resolve the + // framework the same way deploy does instead of deferring to the + // strategy's auto detection. + const detected = await detectDeployFramework( + appDir, + context.runtime.signal, + ); + if (!detected) { + throw frameworkNotDetectedError(appDir); + } + buildType = detected.buildType; + } + assertSupportedEntrypoint(buildType, merged.entrypoint, "build"); + + if (compute.target?.build && buildType !== "auto") { + assertConfigBackedBuildSettings(buildType); + } + // Config-owned build settings apply when the build type is determinate; + // auto detection resolves inside the strategy and keeps its own fallback. + const buildSettings = + compute.config && + compute.target?.build && + isConfigBackedBuildType(buildType) + ? ( + await resolveConfiguredPreviewBuildSettings({ + appPath: appDir, + buildType, + configured: compute.target.build, + configPath: compute.config.configPath, + signal: context.runtime.signal, + }) + ).settings + : undefined; try { const { artifact, buildType: actualBuildType } = await executePreviewBuild({ - appPath: context.runtime.cwd, - entrypoint, + appPath: appDir, + entrypoint: merged.entrypoint, buildType, + buildSettings, signal: context.runtime.signal, }); @@ -190,9 +254,12 @@ export async function runAppBuild( export async function runAppRun( context: CommandContext, - entrypoint: string | undefined, - requestedBuildType: string | undefined, - requestedPort: string | undefined, + options?: { + entrypoint?: string; + buildType?: string; + port?: string; + configTarget?: string; + }, ): Promise> { if (context.flags.json) { throw usageError( @@ -204,20 +271,60 @@ export async function runAppRun( ); } - const buildType = normalizeBuildType(requestedBuildType); - assertSupportedEntrypoint(buildType, entrypoint, "run"); - const port = parseLocalPort(requestedPort); - const resolvedBuildType = await requireLocalBuildType( + const compute = await resolveComputeTargetOrThrow( context, - buildType, + options?.configTarget, "run", ); + const merged = mergeComputeLocalInputs({ + cli: { + entrypoint: options?.entrypoint, + buildType: options?.buildType, + port: options?.port, + }, + target: compute.target, + }); + if ( + merged.buildTypeFromConfig && + compute.target?.framework && + !frameworkByKey(compute.target.framework).hasLocalDevServer + ) { + throw usageError( + `App run does not support the ${compute.target?.framework} framework yet`, + `${compute.config?.relativeConfigPath ?? COMPUTE_CONFIG_FILENAME} sets a framework that has no local dev server in the current preview.`, + "Run the framework dev server directly, or pass --build-type nextjs or --build-type bun to override.", + [ + "prisma-cli app run --build-type nextjs", + "prisma-cli app run --build-type bun --entry server.ts", + ], + "app", + ); + } + const appDir = await resolveComputeAppDir(context, compute); + const buildType = normalizeBuildType(merged.buildType); + assertSupportedEntrypoint(buildType, merged.entrypoint, "run"); + const port = parseLocalPort(merged.port); + const framework = await resolveLocalRunFramework(context, { + requestedBuildType: buildType, + configFramework: compute.target?.framework ?? null, + appDir, + }); + // Hono apps get the same src/index.ts entrypoint default as deploy. + const entrypoint = + framework.buildType === "bun" + ? await resolveDeployEntrypoint( + appDir, + framework, + merged.entrypoint, + context.runtime.signal, + ) + : merged.entrypoint; let runResult: Awaited>; try { runResult = await runLocalApp({ - appPath: context.runtime.cwd, - buildType: resolvedBuildType, + appPath: appDir, + buildType: framework.buildType as LocalBuildType, entrypoint, port, env: context.runtime.env, @@ -250,23 +357,220 @@ export async function runAppRun( }; } +interface AppDeployOptions { + projectRef?: string; + createProjectName?: string; + branchName?: string; + entrypoint?: string; + framework?: string; + httpPort?: string; + envAssignments?: string[]; + prod?: boolean; + db?: boolean; + configTarget?: string; +} + export async function runAppDeploy( context: CommandContext, appName: string | undefined, - options?: { - projectRef?: string; - createProjectName?: string; - branchName?: string; - entrypoint?: string; - framework?: string; - httpPort?: string; - envAssignments?: string[]; - prod?: boolean; - db?: boolean; - }, -): Promise> { + options?: AppDeployOptions, +): Promise> { ensurePreviewAppMode(context); + const loaded = await loadComputeConfig( + context.runtime.cwd, + context.runtime.signal, + ); + if (loaded.isErr()) { + throw computeConfigErrorToCliError(loaded.error, "deploy"); + } + const config = loaded.value; + + // A multi-app config with no target named or inferred means the whole + // system: deploy every target, exactly as if each were deployed by hand. + const requestedTarget = + options?.configTarget ?? + (config + ? inferComputeTargetFromCwd(config, context.runtime.cwd) + : undefined); + if ( + config && + config.kind === "multi" && + !requestedTarget && + config.targets.length > 1 + ) { + return runAppDeployAll(context, config, appName, options); + } + + return runSingleAppDeploy(context, appName, options, config); +} + +async function runAppDeployAll( + context: CommandContext, + config: LoadedComputeConfig, + appName: string | undefined, + options?: AppDeployOptions, +): Promise> { + assertNoPerAppInputsForDeployAll(context, config, appName, options); + + const deployments: AppDeployAllResult["deployments"] = []; + const warnings: string[] = []; + for (const [index, target] of config.targets.entries()) { + const targetKey = target.key!; + maybeRenderDeployAllTargetHeader( + context, + targetKey, + index, + config.targets.length, + ); + // --create-project applies once: after the first target binds the + // Project, the remaining targets resolve it through the local pin. + const targetOptions: AppDeployOptions = { + ...options, + configTarget: targetKey, + createProjectName: index === 0 ? options?.createProjectName : undefined, + }; + try { + const single = await runSingleAppDeploy( + context, + undefined, + targetOptions, + config, + ); + deployments.push({ target: targetKey, result: single.result }); + warnings.push(...single.warnings); + } catch (error) { + throw deployAllFailedError(error, config, index, deployments); + } + } + + return { + command: "app.deploy", + result: { deployments }, + warnings, + // Bare list-deploys follows the remembered selection (the last target + // deployed), so the multi-app suggestion must name a target. + nextSteps: ["prisma-cli app list-deploys "], + }; +} + +function assertNoPerAppInputsForDeployAll( + context: CommandContext, + config: LoadedComputeConfig, + appName: string | undefined, + options?: AppDeployOptions, +): void { + const targets = config.targets.map((target) => target.key!).join(", "); + const perAppInputs: Array<[string, unknown]> = [ + ["--app", appName], + ["--framework", options?.framework], + ["--entry", options?.entrypoint], + ["--http-port", options?.httpPort], + [ + "--env", + options?.envAssignments?.length ? options.envAssignments : undefined, + ], + [ + PRISMA_APP_ID_ENV_VAR, + readDeployEnvOverride(context, PRISMA_APP_ID_ENV_VAR), + ], + ]; + const used = perAppInputs + .filter(([, value]) => value !== undefined) + .map(([flag]) => flag); + if (used.length === 0) { + return; + } + + throw usageError( + `Deploying all apps does not accept ${used.join(", ")}`, + `Without a target, app deploy deploys every configured app (${targets}), so per-app inputs are ambiguous.`, + "Pass the app target to apply per-app inputs to one app, or remove them to deploy all apps.", + config.targets.map((target) => `prisma-cli app deploy ${target.key}`), + "app", + ); +} + +function maybeRenderDeployAllTargetHeader( + context: CommandContext, + targetKey: string, + index: number, + total: number, +): void { + if (context.flags.json || context.flags.quiet) { + return; + } + + context.output.stderr.write( + `${index > 0 ? "\n" : ""}── ${targetKey} (${index + 1}/${total}) ──\n\n`, + ); +} + +function deployAllFailedError( + error: unknown, + config: LoadedComputeConfig, + failedIndex: number, + deployments: AppDeployAllResult["deployments"], +): unknown { + if (!(error instanceof CliError)) { + return error; + } + + const failedTarget = config.targets[failedIndex]!.key!; + const completed = deployments.map(({ target, result }) => ({ + target, + deploymentId: result.deployment.id, + url: result.deployment.url, + })); + const notAttempted = config.targets + .slice(failedIndex + 1) + .map((target) => target.key!); + const contextLines = [ + `Deploying all apps stopped at "${failedTarget}" (${failedIndex + 1}/${config.targets.length}).`, + ...(completed.length > 0 + ? [ + `Already live: ${completed.map((deployment) => deployment.target).join(", ")}.`, + ] + : []), + ...(notAttempted.length > 0 + ? [`Not attempted: ${notAttempted.join(", ")}.`] + : []), + ]; + const contextSentence = contextLines.join(" "); + + return new CliError({ + code: error.code, + domain: error.domain, + summary: error.summary, + // The deploy-all context renders through whichever path the original + // error uses: appended to humanLines when they replace the structured + // rendering, folded into `why` otherwise. + why: error.humanLines + ? error.why + : [error.why, contextSentence].filter(Boolean).join(" "), + fix: error.fix, + debug: error.debug, + where: error.where, + meta: { + ...error.meta, + deployAll: { failedTarget, completed, notAttempted }, + }, + docsUrl: error.docsUrl, + exitCode: error.exitCode, + nextSteps: error.nextSteps, + nextActions: error.nextActions, + humanLines: error.humanLines + ? [...error.humanLines, "", ...contextLines] + : undefined, + }); +} + +async function runSingleAppDeploy( + context: CommandContext, + appName: string | undefined, + options: AppDeployOptions | undefined, + preloadedConfig: LoadedComputeConfig | null, +): Promise> { const envProjectId = readDeployEnvOverride( context, PRISMA_PROJECT_ID_ENV_VAR, @@ -278,24 +582,48 @@ export async function runAppDeploy( envProjectId, }); + const computeConfig = await resolveComputeTargetOrThrow( + context, + options?.configTarget, + "deploy", + { + preloaded: preloadedConfig, + }, + ); + const merged = mergeComputeDeployInputs({ + cli: { + framework: options?.framework, + entrypoint: options?.entrypoint, + httpPort: options?.httpPort, + envInputs: options?.envAssignments, + }, + target: computeConfig.target, + configFilename: + computeConfig.config?.relativeConfigPath ?? COMPUTE_CONFIG_FILENAME, + }); + const appDir = await resolveComputeAppDir(context, computeConfig); + // The compute config marks the project root: the Project binding and other + // repo-level concerns live next to the config, not wherever deploy ran. + const projectDir = computeConfig.config?.configDir ?? context.runtime.cwd; + const skipLocalPin = Boolean( envProjectId || options?.projectRef || options?.createProjectName, ); const localPinReadResult = skipLocalPin ? Result.ok({ kind: "missing" } satisfies LocalResolutionPinReadResult) - : await readLocalResolutionPin(context.runtime.cwd, context.runtime.signal); + : await readLocalResolutionPin(projectDir, context.runtime.signal); if (localPinReadResult.isErr()) { throw localPinReadErrorToDeployError(localPinReadResult.error); } const localPin = localPinReadResult.value; const branch = await resolveDeployBranch(context, options?.branchName); - if (options?.httpPort) { - parseDeployHttpPort(options.httpPort); + if (merged.httpPort) { + parseDeployHttpPort(merged.httpPort.value); } assertSupportedEntrypointForRequestedDeployShape({ - requestedFramework: options?.framework, - entrypoint: options?.entrypoint, + requestedFramework: merged.framework?.value, + entrypoint: merged.entrypoint?.value, }); const { provider, target, projectId } = await requireProviderAndDeployProjectContext(context, options?.projectRef, { @@ -311,6 +639,7 @@ export async function runAppDeploy( target.workspace, target.project, target.localPinAction, + projectDir, ); if (setupResult.isErr()) { throw projectDirectoryBindingErrorToCliError(setupResult.error); @@ -326,15 +655,32 @@ export async function runAppDeploy( } let framework = await resolveDeployFramework(context, { - requestedFramework: options?.framework, - entrypoint: options?.entrypoint, + requestedFramework: merged.framework?.value, + requestedFrameworkAnnotation: merged.framework?.annotation, + entrypoint: merged.entrypoint?.value, + entrypointAnnotation: merged.entrypoint?.annotation, + appDir, }); - let runtime = resolveDeployRuntime(options?.httpPort, framework); - assertSupportedEntrypoint(framework.buildType, options?.entrypoint, "deploy"); + let runtime = resolveDeployRuntime( + merged.httpPort?.value, + merged.httpPort?.annotation, + framework, + ); + assertSupportedEntrypoint( + framework.buildType, + merged.entrypoint?.value, + "deploy", + ); const envVars = toOptionalEnvVars( - await parseEnvInputs(context.runtime.cwd, options?.envAssignments, { - commandName: "deploy", - }), + // Config env file paths resolve from the config directory; --env flag + // paths resolve from where the command ran. + await parseEnvInputs( + merged.envInputsFromConfig ? projectDir : context.runtime.cwd, + merged.envInputs, + { + commandName: "deploy", + }, + ), ); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await resolveDeployAppSelection( @@ -344,14 +690,15 @@ export async function runAppDeploy( { explicitAppName: appName, explicitAppId: envAppId, + configAppName: merged.configAppName, firstDeploy: Boolean(target.localPinAction), - inferName: () => - inferTargetName(context.runtime.cwd, context.runtime.signal), + inferName: () => inferTargetName(appDir, context.runtime.signal), }, ); await maybeRenderDeploySetupBlock(context, { includeDirectory: !target.localPinAction, + appDir, projectName: target.project.name, branchName: target.branch.name, appName: selectedApp.displayName, @@ -361,9 +708,9 @@ export async function runAppDeploy( framework, runtime, firstDeploy: selectedApp.firstDeploy, - explicitFramework: Boolean(options?.framework), - explicitEntrypoint: Boolean(options?.entrypoint), - explicitHttpPort: Boolean(options?.httpPort), + explicitFramework: Boolean(merged.framework), + explicitEntrypoint: Boolean(merged.entrypoint), + explicitHttpPort: Boolean(merged.httpPort), }); framework = customized.framework; runtime = customized.runtime; @@ -382,18 +729,39 @@ export async function runAppDeploy( // Customization can switch from a Bun-compatible framework to one that // derives its entrypoint from build output, so validate --entry again after it. const buildType = framework.buildType; - assertSupportedEntrypoint(buildType, options?.entrypoint, "deploy"); + assertSupportedEntrypoint(buildType, merged.entrypoint?.value, "deploy"); const entrypoint = await resolveDeployEntrypoint( - context.runtime.cwd, + appDir, framework, - options?.entrypoint, + merged.entrypoint?.value, context.runtime.signal, ); - const buildSettingsResolution = await resolveOrCreatePreviewBuildSettings({ - appPath: context.runtime.cwd, - buildType, - signal: context.runtime.signal, - }); + if (computeConfig.target?.build) { + assertConfigBackedBuildSettings(buildType); + } + // Build settings come from the compute config's build block over framework + // defaults; nothing is read from or written to disk for them. + const buildSettingsResolution = + computeConfig.config && + computeConfig.target?.build && + isConfigBackedBuildType(buildType) + ? await resolveConfiguredPreviewBuildSettings({ + appPath: appDir, + buildType, + configured: computeConfig.target.build, + configPath: computeConfig.config.configPath, + signal: context.runtime.signal, + }) + : await resolveInferredPreviewBuildSettings({ + appPath: appDir, + buildType, + signal: context.runtime.signal, + }); + const legacyWarnings = await handleLegacyBuildSettings( + context, + appDir, + buildSettingsResolution.settings, + ); maybeRenderDeployBuildSettings(context, buildSettingsResolution); const portMapping = parseDeployPortMapping(String(runtime.port)); const branchDatabaseSetup = await maybeSetupBranchDatabase( @@ -405,6 +773,7 @@ export async function runAppDeploy( db: options?.db, providedEnvVars: envVars, firstProductionDeploy: productionDeployGate.firstProductionDeploy, + projectDir, }, ); @@ -412,7 +781,7 @@ export async function runAppDeploy( const deployStartedAt = Date.now(); const deployResult = await provider .deployApp({ - cwd: context.runtime.cwd, + cwd: appDir, projectId, branchName: target.branch.name, appId: selectedApp.appId, @@ -487,7 +856,7 @@ export async function runAppDeploy( durationMs: deployDurationMs, localPin: localPinResult, }, - warnings: branchDatabaseSetup.warnings, + warnings: [...legacyWarnings, ...branchDatabaseSetup.warnings], nextSteps: [ "prisma-cli app list-deploys", `prisma-cli app show-deploy ${deployResult.deployment.id}`, @@ -499,19 +868,26 @@ export async function runAppListDeploys( context: CommandContext, appName: string | undefined, projectRef?: string, + configTarget?: string, ): Promise> { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + configTarget, + "list-deploys", + ); const { provider, target, projectId } = await requireProviderAndProjectContext(context, projectRef, { commandName: "app list-deploys", + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await resolveExistingAppSelection( context, projectId, apps, - appName, + appName ?? compute.configAppName, ); if (!selectedApp) { @@ -580,19 +956,26 @@ export async function runAppShow( context: CommandContext, appName: string | undefined, projectRef?: string, + configTarget?: string, ): Promise> { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + configTarget, + "show", + ); const { provider, target, projectId } = await requireProviderAndProjectContext(context, projectRef, { commandName: "app show", + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await resolveExistingAppSelection( context, projectId, apps, - appName, + appName ?? compute.configAppName, ); if (!selectedApp) { @@ -736,12 +1119,20 @@ export async function runAppOpen( context: CommandContext, appName: string | undefined, projectRef?: string, + configTarget?: string, ): Promise> { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + configTarget, + "open", + ); + appName = appName ?? compute.configAppName; const { provider, target, projectId } = await requireProviderAndProjectContext(context, projectRef, { commandName: "app open", + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await resolveExistingAppSelection( @@ -844,6 +1235,7 @@ export async function runAppDomainAdd( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -885,6 +1277,7 @@ export async function runAppDomainShow( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -924,6 +1317,7 @@ export async function runAppDomainRemove( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -967,6 +1361,7 @@ export async function runAppDomainRetry( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -1007,6 +1402,7 @@ export async function runAppDomainWait( projectRef?: string; branchName?: string; timeout?: string; + configTarget?: string; }, ): Promise { const normalizedHostname = normalizeDomainHostname(hostname); @@ -1107,15 +1503,23 @@ export async function runAppLogs( appName: string | undefined, deploymentId: string | undefined, projectRef?: string, + configTarget?: string, ): Promise { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + configTarget, + "logs", + ); + appName = appName ?? compute.configAppName; const { provider, target: resolvedTarget, projectId, } = await requireProviderAndProjectContext(context, projectRef, { commandName: "app logs", + projectDir: compute.projectDir, }); const target = deploymentId ? await resolveExplicitLogDeployment( @@ -1138,8 +1542,6 @@ export async function runAppLogs( const lines = renderCommandHeader(context.ui, { commandLabel: "app logs", description: "Streaming logs for the selected deployment.", - docsPath: - "docs/product/command-spec.md#prisma-cli-app-logs---app-name---deployment-id", rows: [ { key: "project", value: projectId }, { key: "app", value: target.app.name }, @@ -1357,12 +1759,20 @@ export async function runAppPromote( deploymentId: string, appName: string | undefined, projectRef?: string, + configTarget?: string, ): Promise> { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + configTarget, + "promote", + ); + appName = appName ?? compute.configAppName; const { provider, target, projectId } = await requireProviderAndProjectContext(context, projectRef, { commandName: "app promote", + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await requireReleaseAppSelection( @@ -1451,12 +1861,20 @@ export async function runAppRollback( appName: string | undefined, deploymentId: string | undefined, projectRef?: string, + configTarget?: string, ): Promise> { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + configTarget, + "rollback", + ); + appName = appName ?? compute.configAppName; const { provider, target, projectId } = await requireProviderAndProjectContext(context, projectRef, { commandName: "app rollback", + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await requireReleaseAppSelection( @@ -1555,12 +1973,20 @@ export async function runAppRemove( context: CommandContext, appName: string | undefined, projectRef?: string, + configTarget?: string, ): Promise> { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + configTarget, + "remove", + ); + appName = appName ?? compute.configAppName; const { provider, target, projectId } = await requireProviderAndProjectContext(context, projectRef, { commandName: "app remove", + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await requireReleaseAppSelection( @@ -1616,11 +2042,17 @@ async function resolveAppDomainTarget( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, commandName = "app domain", ): Promise { ensurePreviewAppMode(context); + const compute = await resolveComputeManagementContext( + context, + options?.configTarget, + commandName.replace(/^app /, ""), + ); const branch = resolveDomainBranch(options?.branchName); if (toBranchKind(branch.name) !== "production") { throw new CliError({ @@ -1645,6 +2077,7 @@ async function resolveAppDomainTarget( branch, commandName, envProjectId, + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await resolveDomainAppSelection( @@ -1652,7 +2085,7 @@ async function resolveAppDomainTarget( projectId, apps, { - explicitAppName: options?.appName, + explicitAppName: options?.appName ?? compute.configAppName, explicitAppId: envAppId, }, ); @@ -1886,122 +2319,87 @@ function domainCommandError( hostname: string, ): CliError { if (error instanceof PreviewDomainApiError) { - return domainApiCommandError(command, error, hostname); - } - - return new CliError({ - code: "DEPLOY_FAILED", - domain: "app", - summary: `Custom domain ${command} failed`, - why: error instanceof Error ? error.message : String(error), - fix: "Retry the command, or rerun with --trace for more detailed diagnostics.", - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps: [`prisma-cli app domain show ${hostname}`], - }); -} - -function domainApiCommandError( - command: AppDomainCommand, - error: PreviewDomainApiError, - hostname: string, -): CliError { - if (command === "add") { - return domainAddCommandError(error, hostname); - } - - if (error.status === 404) { - return domainNotFoundError(hostname); - } - - if (command === "retry" && error.status === 409) { - return domainRetryNotEligibleError(error, hostname); - } + if ( + command === "add" && + (error.status === 400 || error.status === 422) && + isDomainDnsError(error) + ) { + return domainDnsNotConfiguredError(hostname, error); + } - return domainGenericCommandError(command, error, hostname); -} + if (command === "add" && error.status === 400) { + return new CliError({ + code: "DOMAIN_HOSTNAME_INVALID", + domain: "app", + summary: `Invalid custom domain "${hostname}"`, + why: error.message, + fix: "Pass a valid hostname like shop.acme.com and make sure DNS can be verified.", + debug: formatDebugDetails(error), + exitCode: 2, + nextSteps: ["prisma-cli app domain add shop.acme.com"], + }); + } -function domainAddCommandError( - error: PreviewDomainApiError, - hostname: string, -): CliError { - if ( - (error.status === 400 || error.status === 422) && - isDomainDnsError(error) - ) { - return domainDnsNotConfiguredError(hostname, error); - } + if ( + command === "add" && + (error.status === 429 || isDomainQuotaError(error)) + ) { + return new CliError({ + code: "DOMAIN_QUOTA_EXCEEDED", + domain: "app", + summary: "Custom domain quota exceeded", + why: error.message, + fix: "Remove an existing custom domain before adding another one.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: ["prisma-cli app domain remove "], + }); + } - if (error.status === 400) { - return new CliError({ - code: "DOMAIN_HOSTNAME_INVALID", - domain: "app", - summary: `Invalid custom domain "${hostname}"`, - why: error.message, - fix: "Pass a valid hostname like shop.acme.com and make sure DNS can be verified.", - debug: formatDebugDetails(error), - exitCode: 2, - nextSteps: ["prisma-cli app domain add shop.acme.com"], - }); - } + if (command === "add" && error.status === 409) { + return domainAlreadyRegisteredError(hostname, error); + } - if (error.status === 429 || isDomainQuotaError(error)) { - return new CliError({ - code: "DOMAIN_QUOTA_EXCEEDED", - domain: "app", - summary: "Custom domain quota exceeded", - why: error.message, - fix: "Remove an existing custom domain before adding another one.", - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps: ["prisma-cli app domain remove "], - }); - } + if (command === "add" && error.status === 422) { + return new CliError({ + code: "NO_DEPLOYMENTS", + domain: "app", + summary: "Custom domain requires a live production deployment", + why: "The selected production app does not have a promoted version that can receive a custom domain.", + fix: "Deploy the app to the production branch, then rerun the domain command.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: [ + "prisma-cli app deploy --branch production", + `prisma-cli app domain add ${hostname}`, + ], + }); + } - if (error.status === 409) { - return domainAlreadyRegisteredError(hostname, error); - } + if ( + (command === "show" || + command === "remove" || + command === "retry" || + command === "wait") && + error.status === 404 + ) { + return domainNotFoundError(hostname); + } - if (error.status === 422) { - return new CliError({ - code: "NO_DEPLOYMENTS", - domain: "app", - summary: "Custom domain requires a live production deployment", - why: "The selected production app does not have a promoted version that can receive a custom domain.", - fix: "Deploy the app to the production branch, then rerun the domain command.", - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps: [ - "prisma-cli app deploy --branch production", - `prisma-cli app domain add ${hostname}`, - ], - }); + if (command === "retry" && error.status === 409) { + return new CliError({ + code: "DOMAIN_RETRY_NOT_ELIGIBLE", + domain: "app", + summary: `Custom domain "${hostname}" is not eligible for retry`, + why: error.message, + fix: "Wait for the current verification or TLS step to finish, then rerun retry if the domain fails.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: [`prisma-cli app domain show ${hostname}`], + }); + } } - return domainGenericCommandError("add", error, hostname); -} - -function domainRetryNotEligibleError( - error: PreviewDomainApiError, - hostname: string, -): CliError { - return new CliError({ - code: "DOMAIN_RETRY_NOT_ELIGIBLE", - domain: "app", - summary: `Custom domain "${hostname}" is not eligible for retry`, - why: error.message, - fix: "Wait for the current verification or TLS step to finish, then rerun retry if the domain fails.", - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps: [`prisma-cli app domain show ${hostname}`], - }); -} - -function domainGenericCommandError( - command: AppDomainCommand, - error: unknown, - hostname: string, -): CliError { return new CliError({ code: "DEPLOY_FAILED", domain: "app", @@ -2225,6 +2623,7 @@ async function resolveDeployAppSelection( options: { explicitAppName: string | undefined; explicitAppId: string | undefined; + configAppName: MergedDeployInput | undefined; firstDeploy: boolean; inferName: () => Promise; }, @@ -2285,19 +2684,50 @@ async function resolveDeployAppSelection( }; } - const inferredName = await options.inferName(); - const matches = findAppsByName(apps, inferredName.name); - if (matches.length > 1) { - return resolveAmbiguousDeployApp( - context, - matches, - inferredName.name, - options.firstDeploy, - ); - } - - const matched = matches[0]; - if (matched) { + if (options.configAppName) { + const configName = options.configAppName; + const matches = findAppsByName(apps, configName.value); + if (matches.length > 1) { + return resolveAmbiguousDeployApp( + context, + matches, + configName.value, + options.firstDeploy, + ); + } + + const matched = matches[0]; + if (matched) { + return { + appId: matched.id, + displayName: matched.name, + annotation: configName.annotation, + firstDeploy: options.firstDeploy, + }; + } + + return { + appName: configName.value, + region: PREVIEW_DEFAULT_REGION, + displayName: configName.value, + annotation: configName.annotation, + firstDeploy: options.firstDeploy, + }; + } + + const inferredName = await options.inferName(); + const matches = findAppsByName(apps, inferredName.name); + if (matches.length > 1) { + return resolveAmbiguousDeployApp( + context, + matches, + inferredName.name, + options.firstDeploy, + ); + } + + const matched = matches[0]; + if (matched) { return { appId: matched.id, displayName: matched.name, @@ -2764,6 +3194,7 @@ async function requireProviderAndProjectContext( branch?: ResolvedDeployBranch; commandName?: string; envProjectId?: string; + projectDir?: string; }, ): Promise<{ client: ManagementApiClient; @@ -2827,21 +3258,26 @@ async function resolveProjectContext( branch?: ResolvedDeployBranch; commandName?: string; envProjectId?: string; + projectDir?: string; }, ): Promise { const authState = await requireAuthenticatedAuthState(context); - const workspace = authState.workspace; - if (!workspace) { + if (!authState.workspace) { throw workspaceRequiredError(); } const resolvedResult = await resolveProjectTarget({ context, - workspace, + workspace: authState.workspace, explicitProject, envProjectId: options?.envProjectId, + projectDir: options?.projectDir, listProjects: () => - listRealWorkspaceProjects(client, workspace, context.runtime.signal), + listRealWorkspaceProjects( + client, + authState.workspace!, + context.runtime.signal, + ), commandName: options?.commandName, }); if (resolvedResult.isErr()) { @@ -2887,90 +3323,27 @@ async function resolveDeployProjectContext( context.runtime.signal, ); - const resolved = await resolveDeployProjectSetup( - context, - provider, - workspace, - projects, - explicitProject, - options, - ); - return withRemoteDeployBranch( - provider, - resolved, - branch, - context.runtime.signal, - ); -} - -async function resolveDeployProjectSetup( - context: CommandContext, - provider: ReturnType, - workspace: AuthWorkspace, - projects: ProjectCandidate[], - explicitProject: string | undefined, - options: { - createProjectName?: string; - envProjectId?: string; - localPin: LocalResolutionPinReadResult; - }, -): Promise> { - const selected = await resolveNonInteractiveDeployProjectSetup( - context, - provider, - workspace, - projects, - explicitProject, - options, - ); - if (selected) { - return selected; - } - - if (canPrompt(context) && !context.flags.yes) { - return resolveInteractiveDeployProjectSetup( - context, - provider, - workspace, - projects, - ); - } - - const suggestedName = await inferTargetName( - context.runtime.cwd, - context.runtime.signal, - ); - throw projectSetupRequiredError(projects, suggestedName); -} - -async function resolveNonInteractiveDeployProjectSetup( - context: CommandContext, - provider: ReturnType, - workspace: AuthWorkspace, - projects: ProjectCandidate[], - explicitProject: string | undefined, - options: { - createProjectName?: string; - envProjectId?: string; - localPin: LocalResolutionPinReadResult; - }, -): Promise | null> { if (explicitProject) { const project = resolveProjectForSetup( explicitProject, projects, workspace, ); - return { - workspace, - project: toProjectSummary(project), - resolution: { - projectSource: "explicit", - targetName: explicitProject, - targetNameSource: "explicit", + return withRemoteDeployBranch( + provider, + { + workspace, + project: toProjectSummary(project), + resolution: { + projectSource: "explicit", + targetName: explicitProject, + targetNameSource: "explicit", + }, + localPinAction: "linked", }, - localPinAction: "linked", - }; + branch, + context.runtime.signal, + ); } if (options.createProjectName) { @@ -2985,16 +3358,21 @@ async function resolveNonInteractiveDeployProjectSetup( workspace, context.runtime.signal, ); - return { - workspace, - project: toProjectSummary(created), - resolution: { - projectSource: "created", - targetName: projectName, - targetNameSource: "explicit", + return withRemoteDeployBranch( + provider, + { + workspace, + project: toProjectSummary(created), + resolution: { + projectSource: "created", + targetName: projectName, + targetNameSource: "explicit", + }, + localPinAction: "created", }, - localPinAction: "created", - }; + branch, + context.runtime.signal, + ); } if (options.envProjectId) { @@ -3004,15 +3382,20 @@ async function resolveNonInteractiveDeployProjectSetup( if (!project) { throw projectNotFoundError(options.envProjectId, workspace); } - return { - workspace, - project: toProjectSummary(project), - resolution: { - projectSource: "env", - targetName: options.envProjectId, - targetNameSource: "env", + return withRemoteDeployBranch( + provider, + { + workspace, + project: toProjectSummary(project), + resolution: { + projectSource: "env", + targetName: options.envProjectId, + targetNameSource: "env", + }, }, - }; + branch, + context.runtime.signal, + ); } const localPin = options.localPin; @@ -3028,31 +3411,60 @@ async function resolveNonInteractiveDeployProjectSetup( throw localResolutionPinStaleError(); } - return { - workspace, - project: toProjectSummary(project), - resolution: { - projectSource: "local-pin", - targetName: project.name, - targetNameSource: "local-pin", + return withRemoteDeployBranch( + provider, + { + workspace, + project: toProjectSummary(project), + resolution: { + projectSource: "local-pin", + targetName: project.name, + targetNameSource: "local-pin", + }, }, - }; + branch, + context.runtime.signal, + ); } const platformMapping = await resolveDurablePlatformMapping(); if (platformMapping && platformMapping.workspace.id === workspace.id) { - return { - workspace, - project: toProjectSummary(platformMapping), - resolution: { - projectSource: "platform-mapping", - targetName: platformMapping.name, - targetNameSource: "platform-mapping", + return withRemoteDeployBranch( + provider, + { + workspace, + project: toProjectSummary(platformMapping), + resolution: { + projectSource: "platform-mapping", + targetName: platformMapping.name, + targetNameSource: "platform-mapping", + }, }, - }; + branch, + context.runtime.signal, + ); } - return null; + if (canPrompt(context) && !context.flags.yes) { + const resolved = await resolveInteractiveDeployProjectSetup( + context, + provider, + workspace, + projects, + ); + return withRemoteDeployBranch( + provider, + resolved, + branch, + context.runtime.signal, + ); + } + + const suggestedName = await inferTargetName( + context.runtime.cwd, + context.runtime.signal, + ); + throw projectSetupRequiredError(projects, suggestedName); } async function resolveInteractiveDeployProjectSetup( @@ -3247,7 +3659,7 @@ async function resolveDeployBranch( interface ResolvedDeployFramework { key: string; - buildType: PreviewBuildSettingsBuildType; + buildType: FrameworkBuildType; displayName: string; annotation: string; } @@ -3257,17 +3669,197 @@ interface ResolvedDeployRuntime { annotation: string; } +async function resolveComputeTargetOrThrow( + context: CommandContext, + configTarget: string | undefined, + commandName: ComputeConfigCommandName, + options?: { + /** + * Management commands treat the config target as an extra app-name + * source, not a requirement: with multiple targets and nothing inferred + * they fall back to their existing app selection instead of failing. + */ + targetOptional?: boolean; + /** Already-loaded config (or null for none); skips loading when provided. */ + preloaded?: LoadedComputeConfig | null; + }, +): Promise<{ + config: LoadedComputeConfig | null; + target: ComputeDeployTarget | null; +}> { + let config: LoadedComputeConfig | null; + if (options?.preloaded !== undefined) { + config = options.preloaded; + } else { + const loaded = await loadComputeConfig( + context.runtime.cwd, + context.runtime.signal, + ); + if (loaded.isErr()) { + throw computeConfigErrorToCliError(loaded.error, commandName); + } + config = loaded.value; + } + if (!config) { + if (configTarget) { + throw usageError( + `App target "${configTarget}" requires a compute config file`, + `No ${COMPUTE_CONFIG_FILENAME} exists in the current directory, so there are no named app targets.`, + `Create ${COMPUTE_CONFIG_FILENAME} with an apps entry named "${configTarget}", or rerun without the target argument.`, + [`prisma-cli app ${commandName}`], + "app", + ); + } + return { config: null, target: null }; + } + + // With no explicit target, a command run from inside a target's root + // selects that target, so `cd apps/api && prisma-cli app deploy` works. + const requestedTarget = + configTarget ?? inferComputeTargetFromCwd(config, context.runtime.cwd); + const selected = selectComputeDeployTarget(config, requestedTarget); + if (selected.isErr()) { + if ( + options?.targetOptional && + selected.error instanceof ComputeConfigTargetRequiredError + ) { + return { config, target: null }; + } + throw computeConfigErrorToCliError(selected.error, commandName); + } + + return { config, target: selected.value }; +} + +/** + * Compute-config context for app management commands: the project directory + * (where `.prisma/local.json` lives) and the config-selected app name, which + * ranks below `--app` but above the remembered app selection. + */ +async function resolveComputeManagementContext( + context: CommandContext, + configTarget: string | undefined, + commandName: ComputeConfigCommandName, +): Promise<{ projectDir: string; configAppName: string | undefined }> { + const compute = await resolveComputeTargetOrThrow( + context, + configTarget, + commandName, + { targetOptional: true }, + ); + return { + projectDir: compute.config?.configDir ?? context.runtime.cwd, + configAppName: compute.target?.name ?? compute.target?.key ?? undefined, + }; +} + +async function resolveComputeAppDir( + context: CommandContext, + compute: { + config: LoadedComputeConfig | null; + target: ComputeDeployTarget | null; + }, +): Promise { + if (!compute.config || !compute.target) { + return context.runtime.cwd; + } + + const appDir = computeTargetAppDir(compute.config, compute.target); + if (!compute.target.root) { + // The config directory itself; it exists because the config loaded from it. + return appDir; + } + + context.runtime.signal.throwIfAborted(); + try { + // access does not accept AbortSignal; check before and after the filesystem boundary. + await access(appDir); + context.runtime.signal.throwIfAborted(); + } catch (error) { + if (context.runtime.signal.aborted) throw error; + throw new CliError({ + code: "COMPUTE_CONFIG_INVALID", + domain: "app", + summary: `App root "${compute.target.root}" does not exist`, + why: `${compute.config.relativeConfigPath} points the selected app at "${compute.target.root}", but that directory does not exist.`, + fix: `Fix the root path in ${compute.config.relativeConfigPath} or create the directory.`, + where: appDir, + meta: { appRoot: compute.target.root, appDir }, + exitCode: 2, + nextSteps: ["prisma-cli app deploy"], + }); + } + + return appDir; +} + +/** + * `prisma.app.json` is no longer read or written. A leftover file that + * matches the effective settings only warns; one with custom values fails + * with migration guidance so builds never silently change. + */ +async function handleLegacyBuildSettings( + context: CommandContext, + appDir: string, + effective: PreviewBuildSettings, +): Promise { + const legacy = await detectLegacyBuildSettings({ + appPath: appDir, + effective, + signal: context.runtime.signal, + }); + + switch (legacy.kind) { + case "absent": + return []; + case "matching": + return [ + `${PRISMA_APP_CONFIG_FILENAME} is no longer used and matches the resolved build settings. Delete it.`, + ]; + case "invalid": + return [ + `${PRISMA_APP_CONFIG_FILENAME} is no longer used and could not be parsed. Delete it.`, + ]; + case "custom": { + const buildBlock = [ + "build: {", + ` command: ${legacy.buildCommand === null ? "null" : JSON.stringify(legacy.buildCommand)},`, + ` outputDirectory: ${JSON.stringify(legacy.outputDirectory)},`, + "}", + ].join(" "); + throw new CliError({ + code: "BUILD_SETTINGS_MIGRATION_REQUIRED", + domain: "app", + summary: `${PRISMA_APP_CONFIG_FILENAME} is no longer supported`, + why: `${PRISMA_APP_CONFIG_FILENAME} contains custom build settings that differ from the resolved defaults, and the file is no longer read.`, + fix: `Move the settings into prisma.compute.ts as \`${buildBlock}\` on this app, then delete ${PRISMA_APP_CONFIG_FILENAME}.`, + where: legacy.configPath, + meta: { + configPath: legacy.configPath, + buildCommand: legacy.buildCommand, + outputDirectory: legacy.outputDirectory, + }, + exitCode: 2, + nextSteps: ["prisma-cli app deploy"], + }); + } + } +} + async function resolveDeployFramework( context: CommandContext, options: { requestedFramework: string | undefined; + requestedFrameworkAnnotation: string | undefined; entrypoint: string | undefined; + entrypointAnnotation: string | undefined; + appDir: string; }, ): Promise { if (options.requestedFramework) { return frameworkFromUserFacingValue( options.requestedFramework, - "set by --framework", + options.requestedFrameworkAnnotation ?? "set by --framework", ); } @@ -3276,29 +3868,30 @@ async function resolveDeployFramework( key: "bun", buildType: "bun", displayName: "Bun", - annotation: "set by --entry", + annotation: options.entrypointAnnotation ?? "set by --entry", }; } const detected = await detectDeployFramework( - context.runtime.cwd, + options.appDir, context.runtime.signal, ); if (detected) { return detected; } - throw frameworkNotDetectedError(context.runtime.cwd); + throw frameworkNotDetectedError(options.appDir); } function resolveDeployRuntime( requestedHttpPort: string | undefined, + requestedHttpPortAnnotation: string | undefined, framework: ResolvedDeployFramework, ): ResolvedDeployRuntime { if (requestedHttpPort) { return { port: parseDeployHttpPort(requestedHttpPort), - annotation: "set by --http-port", + annotation: requestedHttpPortAnnotation ?? "set by --http-port", }; } @@ -3339,11 +3932,13 @@ async function resolveDeployEntrypoint( return packageEntrypoint; } - if (framework.key !== "hono") { + const defaultEntrypoint = frameworkFromAlias( + framework.key, + )?.defaultEntrypoint; + if (!defaultEntrypoint) { return undefined; } - const defaultEntrypoint = "src/index.ts"; signal.throwIfAborted(); try { // access does not accept AbortSignal; check before and after the filesystem boundary. @@ -3364,54 +3959,49 @@ async function detectDeployFramework( signal: AbortSignal, ): Promise { const packageJson = await readBunPackageJson(cwd, signal); - const nextConfig = await detectNextConfig(cwd, signal); - if (nextConfig.exists || hasPackageDependency(packageJson, "next")) { - return { - key: "nextjs", - buildType: "nextjs", - displayName: "Next.js", - annotation: nextConfig.standalone - ? "standalone output detected" - : nextConfig.exists - ? "detected from next.config" - : "detected from package.json", - }; - } + for (const framework of FRAMEWORKS) { + if ( + framework.detectConfigFiles.length === 0 && + framework.detectPackages.length === 0 + ) { + continue; + } - if (hasPackageDependency(packageJson, "hono")) { - return { - key: "hono", - buildType: "bun", - displayName: "Hono", - annotation: "detected from package.json", - }; - } + const configFile = await detectFrameworkConfigFile(cwd, framework, signal); + if ( + !configFile.exists && + !hasAnyPackageDependency(packageJson, framework.detectPackages) + ) { + continue; + } + + // Next.js standalone output gets a richer annotation; everything else is + // attributed to the signal that matched. + const annotation = + framework.key === "nextjs" && configFile.standalone + ? "standalone output detected" + : configFile.exists + ? `detected from ${path.basename(configFile.path!)}` + : "detected from package.json"; - if (hasAnyPackageDependency(packageJson, TANSTACK_START_PACKAGES)) { return { - key: "tanstack-start", - buildType: "tanstack-start", - displayName: "TanStack Start", - annotation: "detected from package.json", + key: framework.key, + buildType: framework.buildType, + displayName: framework.displayName, + annotation, }; } return null; } -async function detectNextConfig( +async function detectFrameworkConfigFile( cwd: string, + framework: FrameworkDescriptor, signal: AbortSignal, -): Promise<{ exists: boolean; standalone: boolean }> { - const candidates = [ - "next.config.js", - "next.config.mjs", - "next.config.ts", - "next.config.mts", - ]; - - for (const candidate of candidates) { +): Promise<{ exists: boolean; standalone: boolean; path: string | null }> { + for (const candidate of framework.detectConfigFiles) { const filePath = path.join(cwd, candidate); signal.throwIfAborted(); try { @@ -3419,6 +4009,7 @@ async function detectNextConfig( return { exists: true, standalone: /\boutput\s*:\s*["'`]standalone["'`]/.test(content), + path: filePath, }; } catch (error) { if (signal.aborted) throw error; @@ -3428,10 +4019,7 @@ async function detectNextConfig( } } - return { - exists: false, - standalone: false, - }; + return { exists: false, standalone: false, path: null }; } function hasPackageDependency( @@ -3465,50 +4053,51 @@ function frameworkFromUserFacingValue( value: string, annotation: string, ): ResolvedDeployFramework { - switch (value.trim().toLowerCase()) { - case "next": - case "next.js": - case "nextjs": - return { - key: "nextjs", - buildType: "nextjs", - displayName: "Next.js", - annotation, - }; - case "hono": - return { - key: "hono", - buildType: "bun", - displayName: "Hono", - annotation, - }; - case "bun": - return { - key: "bun", - buildType: "bun", - displayName: "Bun", - annotation, - }; - case "tanstack": - case "tanstack-start": - case "@tanstack/react-start": - case "@tanstack/solid-start": - return { - key: "tanstack-start", - buildType: "tanstack-start", - displayName: "TanStack Start", - annotation, - }; - default: - throw frameworkNotDetectedError(undefined, value); + const framework = frameworkFromAlias(value); + if (!framework) { + throw frameworkNotDetectedError(undefined, value); } + + return { + key: framework.key, + buildType: framework.buildType, + displayName: framework.displayName, + annotation, + }; +} + +/** + * The nuxt and astro strategies build with their framework CLI and stage + * fixed output, so a compute config `build` block has nothing to apply to. + * Erroring beats silently ignoring committed settings. + */ +function assertConfigBackedBuildSettings( + buildType: FrameworkBuildType, +): asserts buildType is ConfigBackedBuildType { + if (isConfigBackedBuildType(buildType)) { + return; + } + const displayName = + FRAMEWORKS.find((framework) => framework.buildType === buildType) + ?.displayName ?? buildType; + + throw new CliError({ + code: "BUILD_SETTINGS_UNSUPPORTED", + domain: "app", + summary: `build settings are not supported for ${displayName} apps`, + why: `${displayName} deploys run \`${buildType} build\` and package its output automatically.`, + fix: "Remove the `build` block from prisma.compute.ts for this app.", + exitCode: 2, + }); } function frameworkNotDetectedError( cwd: string | undefined, requestedFramework?: string, ): CliError { - const supported = "Next.js, Hono, TanStack Start, Bun"; + const supported = FRAMEWORKS.map((framework) => framework.displayName).join( + ", ", + ); const directory = cwd ? ` in ${formatDeployDirectory(cwd)}` : ""; return new CliError({ @@ -3518,7 +4107,7 @@ function frameworkNotDetectedError( ? `Unsupported framework "${requestedFramework}"` : `Cannot detect a supported framework${directory}`, why: `Supported Beta frameworks: ${supported}.`, - fix: "Add one of these frameworks as a dependency, pass --framework , or pass --entry for a Bun app.", + fix: `Add one of these frameworks as a dependency, pass --framework <${FRAMEWORKS.map((framework) => framework.key).join("|")}>, or pass --entry for a Bun app.`, exitCode: 2, nextSteps: [ "prisma-cli app deploy --framework nextjs", @@ -3534,6 +4123,7 @@ async function maybeRenderDeploySetupBlock( context: CommandContext, details: { includeDirectory: boolean; + appDir: string; projectName: string; branchName: string; appName: string; @@ -3543,7 +4133,10 @@ async function maybeRenderDeploySetupBlock( return; } - const directory = formatDeployDirectory(context.runtime.cwd); + const directory = formatAppDirectoryLabel( + context.runtime.cwd, + details.appDir, + ); const prefix = details.includeDirectory ? `Deploying ${directory} to` : "Deploying to"; @@ -3562,9 +4155,9 @@ function maybeRenderDeployBuildSettings( const settings = resolution.settings; const title = - resolution.status === "created" - ? `Created ${resolution.relativeConfigPath}` - : `Using ${resolution.relativeConfigPath}`; + resolution.status === "config" + ? `Using ${resolution.relativeConfigPath}` + : "Build settings"; context.output.stderr.write( `${title}\n` + @@ -3646,13 +4239,13 @@ async function maybeCustomizeDeploySettings( }; } - const frameworkKey = await selectPrompt({ + const frameworkKey = await selectPrompt({ input: context.runtime.stdin, output: context.runtime.stderr, message: `Framework (${options.framework.displayName})`, - choices: DEPLOY_FRAMEWORKS.map((framework) => ({ - label: frameworkDisplayName(framework), - value: framework, + choices: FRAMEWORKS.map((framework) => ({ + label: framework.displayName, + value: framework.key, })), }); const framework = frameworkFromUserFacingValue(frameworkKey, "set by you"); @@ -3727,17 +4320,8 @@ function maybeRenderDeploySettingsPreview( ); } -function frameworkDisplayName(framework: DeployFramework): string { - switch (framework) { - case "nextjs": - return "Next.js"; - case "hono": - return "Hono"; - case "tanstack-start": - return "TanStack Start"; - case "bun": - return "Bun"; - } +function frameworkDisplayName(framework: ComputeFramework): string { + return frameworkByKey(framework).displayName; } function validateDeployHttpPortText( @@ -3760,6 +4344,15 @@ function formatDeployDirectory(cwd: string): string { return basename ? `./${basename}` : "."; } +function formatAppDirectoryLabel(cwd: string, appDir: string): string { + if (appDir === cwd) { + return formatDeployDirectory(cwd); + } + + const relative = path.relative(cwd, appDir).split(path.sep).join("/"); + return relative.startsWith("..") ? relative : `./${relative}`; +} + async function readCurrentWorkspaceId( context: CommandContext, ): Promise { @@ -3813,7 +4406,11 @@ function assertSupportedEntrypoint( ) { // Framework strategies derive their runtime entrypoints from build output. // Only Bun consumes a user-provided source entrypoint; auto may fall back to Bun. - if (buildType !== "auto" && buildType !== "bun" && entrypoint) { + if ( + buildType !== "auto" && + !(ENTRYPOINT_BUILD_TYPES as readonly string[]).includes(buildType) && + entrypoint + ) { if (commandName === "deploy") { throw usageError( `App deploy does not accept --entry with ${formatBuildTypeName(buildType)}`, @@ -3840,30 +4437,61 @@ function assertSupportedEntrypoint( } } -async function requireLocalBuildType( +/** + * Resolves the framework for `app run` with the same detection as deploy, so + * a repo that deploys without flags also runs without flags. Local dev server + * support is intentionally narrower than deploy build support: only Next.js + * and Bun/Hono have dev servers in the current preview. + */ +async function resolveLocalRunFramework( context: CommandContext, - buildType: PreviewBuildType, - commandName: "build" | "run", -) { - // Local dev server support is intentionally narrower than deploy build support. - // Nuxt, Astro, and TanStack Start can deploy via SDK strategies, but app run - // only starts the local dev servers currently documented for the preview. - const resolvedBuildType = await resolveLocalBuildType( - context.runtime.cwd, - buildType, + options: { + requestedBuildType: PreviewBuildType; + configFramework: ComputeFramework | null; + appDir: string; + }, +): Promise { + if ( + (LOCAL_DEV_BUILD_TYPES as readonly string[]).includes( + options.requestedBuildType, + ) + ) { + // Preserve the configured framework identity (e.g. hono) so entrypoint + // defaults match deploy; an explicit --build-type stays literal. + if ( + options.configFramework && + computeFrameworkToBuildType(options.configFramework) === + options.requestedBuildType + ) { + return frameworkFromUserFacingValue( + options.configFramework, + `set by ${COMPUTE_CONFIG_FILENAME}`, + ); + } + return frameworkFromUserFacingValue( + options.requestedBuildType, + "set by --build-type", + ); + } + + const detected = await detectDeployFramework( + options.appDir, context.runtime.signal, ); - if (resolvedBuildType) { - return resolvedBuildType; + if ( + detected && + (LOCAL_DEV_BUILD_TYPES as readonly string[]).includes(detected.buildType) + ) { + return detected; } throw usageError( - `App ${commandName} requires an explicit framework when detection is ambiguous`, + "App run requires an explicit framework when detection is ambiguous", "This preview only starts local dev servers for clear Next.js or Bun project shapes.", "Pass --build-type nextjs for a Next.js app, or pass --build-type bun with --entry for a Bun app.", [ - `prisma-cli app ${commandName} --build-type nextjs`, - `prisma-cli app ${commandName} --build-type bun --entry server.ts`, + "prisma-cli app run --build-type nextjs", + "prisma-cli app run --build-type bun --entry server.ts", ], "app", ); @@ -3952,7 +4580,52 @@ function appDeployFailedError( const debug = formatDebugDetails(error); if (progress.buildStarted && !progress.buildCompleted) { - return appBuildFailedError(why, debug); + const standaloneOutputFailure = isNextStandaloneOutputFailure(why); + const fix = standaloneOutputFailure + ? 'Add output: "standalone" to next.config.*, then rerun deploy.' + : "Inspect the build output above, fix the error, and redeploy."; + const nextSteps = standaloneOutputFailure + ? [ + 'Add output: "standalone" to next.config.*, then rerun prisma-cli app deploy', + ] + : []; + const nextActions = standaloneOutputFailure + ? [ + { + kind: "edit-file" as const, + journey: "deploy-app" as const, + label: "Add Next.js standalone output", + reason: + "Prisma Compute needs Next.js standalone output to build a deployable server artifact.", + }, + { + kind: "run-command" as const, + journey: "deploy-app" as const, + label: "Rerun deploy", + command: "prisma-cli app deploy", + }, + ] + : []; + + return new CliError({ + code: "BUILD_FAILED", + domain: "app", + summary: "Build failed locally.", + why, + fix, + debug, + meta: { phase: "build" }, + humanLines: [ + "Build failed locally.", + "", + `✗ Built ${why}`, + "", + `Fix: ${fix}`, + ], + exitCode: 1, + nextSteps, + nextActions, + }); } if (!progress.buildStarted) { @@ -4019,58 +4692,6 @@ function appDeployFailedError( }); } -function appBuildFailedError( - why: string, - debug: string | null | undefined, -): CliError { - const standaloneOutputFailure = isNextStandaloneOutputFailure(why); - const fix = standaloneOutputFailure - ? 'Add output: "standalone" to next.config.*, then rerun deploy.' - : "Inspect the build output above, fix the error, and redeploy."; - const nextSteps = standaloneOutputFailure - ? [ - 'Add output: "standalone" to next.config.*, then rerun prisma-cli app deploy', - ] - : []; - const nextActions = standaloneOutputFailure - ? [ - { - kind: "edit-file" as const, - journey: "deploy-app" as const, - label: "Add Next.js standalone output", - reason: - "Prisma Compute needs Next.js standalone output to build a deployable server artifact.", - }, - { - kind: "run-command" as const, - journey: "deploy-app" as const, - label: "Rerun deploy", - command: "prisma-cli app deploy", - }, - ] - : []; - - return new CliError({ - code: "BUILD_FAILED", - domain: "app", - summary: "Build failed locally.", - why, - fix, - debug, - meta: { phase: "build" }, - humanLines: [ - "Build failed locally.", - "", - `✗ Built ${why}`, - "", - `Fix: ${fix}`, - ], - exitCode: 1, - nextSteps, - nextActions, - }); -} - function localResolutionPinStaleError(): CliError { return new CliError({ code: "LOCAL_STATE_STALE", diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index a1f4394..92b6216 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -8,11 +8,9 @@ import type { AppDeployResult } from "../../types/app"; import { formatCommandArgument } from "../project/setup"; import { type BranchDatabaseSchema, - type BranchDatabaseSchemaSetupResult, type BranchDatabaseSignal, hasBranchDatabaseSignal, inspectBranchDatabaseSignal, - runBranchDatabaseSchemaSetup, type UnsupportedBranchDatabaseSchema, } from "./branch-database"; import type { @@ -47,15 +45,37 @@ export async function maybeSetupBranchDatabase( db: boolean | undefined; providedEnvVars: Record | undefined; firstProductionDeploy: boolean; + /** Directory to scan for Prisma schema sources (suggestions only). */ + projectDir: string; }, ): Promise { if (options.db === false) { return emptyBranchDatabaseSetupOutcome(); } - const preflight = branchDatabasePreflight(branch, options); - if (preflight) { - return preflight; + if (hasProvidedDatabaseEnvVars(options.providedEnvVars)) { + if (options.db === true) { + throw usageError( + "Database setup cannot be combined with provided database env vars", + "The deploy command received --db and a DATABASE_URL or DIRECT_URL value from --env.", + "Remove the --env database value to let --db create and wire a database, or remove --db to deploy with the provided value.", + [ + "prisma-cli app deploy --db", + "prisma-cli app deploy --env DATABASE_URL=postgresql://example", + ], + "app", + ); + } + + return emptyBranchDatabaseSetupOutcome(); + } + + if (branch.kind === "production" && !options.firstProductionDeploy) { + if (options.db === true) { + throw productionDatabaseSetupAfterFirstDeployError(); + } + + return emptyBranchDatabaseSetupOutcome(); } const envState = await inspectBranchDatabaseEnv( @@ -66,19 +86,30 @@ export async function maybeSetupBranchDatabase( ); const targetEnvVars = getTargetDatabaseEnvVarKeys(envState); - const existingEnvOutcome = existingBranchDatabaseEnvOutcome( - context, - branch, - targetEnvVars, - envState, - options.db, - ); - if (existingEnvOutcome) { - return existingEnvOutcome; + if (hasExistingDatabaseEnvForTarget(branch, envState)) { + const warning = + options.db === true + ? existingDatabaseEnvWarning(branch, targetEnvVars) + : null; + if (warning) { + emitBranchDatabaseWarning(context, warning); + } + + return { + result: + options.db === true + ? { + status: "skipped", + reason: existingDatabaseEnvReason(branch), + envVars: targetEnvVars, + } + : undefined, + warnings: warning ? [warning] : [], + }; } const localSignal = await inspectBranchDatabaseSignal( - context.runtime.cwd, + options.projectDir, context.runtime.signal, ); if (localSignal.unsupportedSchema) { @@ -93,18 +124,35 @@ export async function maybeSetupBranchDatabase( return emptyBranchDatabaseSetupOutcome(); } - const promptOutcome = await branchDatabasePromptOutcome( - context, - branch, - localSignal, - envState, - options.db, - ); - if (promptOutcome) { - return promptOutcome; - } + const hasSignal = + hasBranchDatabaseSignal(localSignal) || + Boolean(envState.inheritedPreviewDatabaseUrl); + if (options.db !== true) { + if (!hasSignal) { + return emptyBranchDatabaseSetupOutcome(); + } - if (options.db === true && !canPrompt(context) && !context.flags.yes) { + if (!canPrompt(context) || context.flags.yes) { + const warning = databasePromptSuppressedWarning(branch); + emitBranchDatabaseWarning(context, warning); + return { + result: undefined, + warnings: [warning], + }; + } + + maybeRenderBranchDatabaseSignal(context, branch, localSignal, envState); + const shouldCreate = await confirmPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + message: databasePromptMessage(branch), + initialValue: false, + }); + + if (!shouldCreate) { + return emptyBranchDatabaseSetupOutcome(); + } + } else if (!canPrompt(context) && !context.flags.yes) { throw nonInteractiveDatabaseSetupRequiresYesError(branch); } @@ -115,116 +163,10 @@ export async function maybeSetupBranchDatabase( branch, localSignal, envState, + options.projectDir, ); } -function branchDatabasePreflight( - branch: BranchDatabaseDeployBranch, - options: { - db: boolean | undefined; - providedEnvVars: Record | undefined; - firstProductionDeploy: boolean; - }, -): BranchDatabaseSetupOutcome | null { - if (hasProvidedDatabaseEnvVars(options.providedEnvVars)) { - if (options.db === true) { - throw usageError( - "Database setup cannot be combined with provided database env vars", - "The deploy command received --db and a DATABASE_URL or DIRECT_URL value from --env.", - "Remove the --env database value to let --db create and wire a database, or remove --db to deploy with the provided value.", - [ - "prisma-cli app deploy --db", - "prisma-cli app deploy --env DATABASE_URL=postgresql://example", - ], - "app", - ); - } - - return emptyBranchDatabaseSetupOutcome(); - } - - if (branch.kind === "production" && !options.firstProductionDeploy) { - if (options.db === true) { - throw productionDatabaseSetupAfterFirstDeployError(); - } - - return emptyBranchDatabaseSetupOutcome(); - } - - return null; -} - -function existingBranchDatabaseEnvOutcome( - context: CommandContext, - branch: BranchDatabaseDeployBranch, - targetEnvVars: string[], - envState: BranchDatabaseEnvState, - requested: boolean | undefined, -): BranchDatabaseSetupOutcome | null { - if (!hasExistingDatabaseEnvForTarget(branch, envState)) { - return null; - } - - const warning = - requested === true - ? existingDatabaseEnvWarning(branch, targetEnvVars) - : null; - if (warning) { - emitBranchDatabaseWarning(context, warning); - } - - return { - result: - requested === true - ? { - status: "skipped", - reason: existingDatabaseEnvReason(branch), - envVars: targetEnvVars, - schema: null, - } - : undefined, - warnings: warning ? [warning] : [], - }; -} - -async function branchDatabasePromptOutcome( - context: CommandContext, - branch: BranchDatabaseDeployBranch, - localSignal: BranchDatabaseSignal, - envState: BranchDatabaseEnvState, - requested: boolean | undefined, -): Promise { - if (requested === true) { - return null; - } - - const hasSignal = - hasBranchDatabaseSignal(localSignal) || - Boolean(envState.inheritedPreviewDatabaseUrl); - if (!hasSignal) { - return emptyBranchDatabaseSetupOutcome(); - } - - if (!canPrompt(context) || context.flags.yes) { - const warning = databasePromptSuppressedWarning(branch); - emitBranchDatabaseWarning(context, warning); - return { - result: undefined, - warnings: [warning], - }; - } - - maybeRenderBranchDatabaseSignal(context, branch, localSignal, envState); - const shouldCreate = await confirmPrompt({ - input: context.runtime.stdin, - output: context.output.stderr, - message: databasePromptMessage(branch), - initialValue: false, - }); - - return shouldCreate ? null : emptyBranchDatabaseSetupOutcome(); -} - async function setupBranchDatabase( context: CommandContext, provider: PreviewAppProvider, @@ -232,6 +174,7 @@ async function setupBranchDatabase( branch: BranchDatabaseDeployBranch, signal: BranchDatabaseSignal, envState: BranchDatabaseEnvState, + projectDir: string, ): Promise { emitBranchDatabaseProgress(context, "pending", "Creating database"); const database = await provider @@ -251,35 +194,6 @@ async function setupBranchDatabase( emitBranchDatabaseProgress(context, "success", "Created database"); try { - let schemaSetup: BranchDatabaseSchemaSetupResult | null = null; - const warnings: string[] = []; - let skippedSchemaWarning: string | null = null; - const schema = signal.schema; - if (schema) { - emitBranchDatabaseProgress( - context, - "pending", - `Applying database schema with ${formatSchemaSetupCommand(schema.command)}`, - ); - schemaSetup = await runBranchDatabaseSchemaSetup({ - context, - schema, - databaseUrl: database.databaseUrl, - directUrl: database.directUrl, - }).catch((error) => { - throw schemaSetupFailedError( - error, - schema, - branch, - context.runtime.cwd, - ); - }); - emitBranchDatabaseProgress(context, "success", "Applied database schema"); - } else { - skippedSchemaWarning = - "No supported Prisma schema source was found. Database env vars were created, but schema setup was skipped."; - } - const envVars = await upsertBranchDatabaseEnvVars( context, provider, @@ -293,10 +207,15 @@ async function setupBranchDatabase( "success", `Added ${envScopeLabel(branch)} env var${envVars.length === 1 ? "" : "s"} ${envVars.join(", ")}`, ); - if (skippedSchemaWarning) { - emitBranchDatabaseWarning(context, skippedSchemaWarning); - warnings.push(skippedSchemaWarning); - } + + // The CLI provisions and wires credentials; it never runs schema or + // migration commands. Env values are write-only on the platform, so the + // suggestion routes through a one-time connection URL the user mints. + const schemaCommand = signal.schema + ? `DATABASE_URL= npx ${formatSchemaSetupCommand(signal.schema.command)} (detected ${path.relative(projectDir, signal.schema.path) || signal.schema.path})` + : "your own migration tooling"; + const schemaSuggestion = `The new database is empty. Get a connection URL with \`prisma-cli database connection create ${database.id}\`, then apply your schema with ${schemaCommand}.`; + emitBranchDatabaseWarning(context, schemaSuggestion); return { result: { @@ -306,15 +225,8 @@ async function setupBranchDatabase( name: database.name, }, envVars, - schema: schemaSetup - ? { - command: schemaSetup.command, - source: schemaSetup.source, - path: schemaSetup.schemaPath, - } - : null, }, - warnings, + warnings: [schemaSuggestion], }; } catch (error) { throw await cleanupCreatedBranchDatabaseAfterFailure( @@ -644,7 +556,7 @@ function nonInteractiveDatabaseSetupRequiresYesError( } function formatSchemaSetupCommand( - command: BranchDatabaseSchemaSetupResult["command"], + command: BranchDatabaseSchema["command"], ): string { switch (command) { case "migrate-deploy": @@ -747,33 +659,6 @@ function branchDatabaseCleanupFailedError( }); } -function schemaSetupFailedError( - error: unknown, - schema: BranchDatabaseSchema, - branch: BranchDatabaseDeployBranch, - cwd: string, -): CliError { - return new CliError({ - code: "SCHEMA_SETUP_FAILED", - domain: "app", - summary: "Database schema setup failed", - why: error instanceof Error ? error.message : String(error), - fix: "Fix the Prisma schema or migrations, then rerun deploy with --db.", - debug: formatDebugDetails(error), - meta: { - branch: branch.name, - schemaPath: schema.path, - source: schema.kind, - command: schema.command, - }, - exitCode: 1, - nextSteps: [ - ...formatSchemaSetupNextSteps(schema, cwd), - formatAppDeployWithDbNextStep(branch), - ], - }); -} - function unsupportedBranchDatabaseSchemaError( schema: UnsupportedBranchDatabaseSchema, branch: BranchDatabaseDeployBranch, @@ -816,29 +701,6 @@ function formatProjectEnvAddNextStep( : `prisma-cli project env add DATABASE_URL= --branch ${formatCommandArgument(branch.name)}`; } -function formatSchemaSetupNextSteps( - schema: BranchDatabaseSchema, - cwd: string, -): string[] { - const sourcePath = - path.relative(cwd, schema.path) || defaultSchemaSourcePath(schema); - switch (schema.command) { - case "migrate-deploy": - return [ - `npx --no-install prisma migrate deploy --schema ${formatCommandArgument(sourcePath)}`, - ]; - case "db-push": - return [ - `npx --no-install prisma db push --schema ${formatCommandArgument(sourcePath)}`, - ]; - case "prisma-next-db-init": - return [ - `npx --no-install prisma-next contract emit --config ${formatCommandArgument(sourcePath)}`, - `npx --no-install prisma-next db init --config ${formatCommandArgument(sourcePath)} --db `, - ]; - } -} - function defaultSchemaSourcePath(schema: BranchDatabaseSchema): string { return schema.kind === "prisma-next" ? "prisma-next.config.ts" diff --git a/packages/cli/src/lib/app/branch-database.ts b/packages/cli/src/lib/app/branch-database.ts index 779433b..aae6a71 100644 --- a/packages/cli/src/lib/app/branch-database.ts +++ b/packages/cli/src/lib/app/branch-database.ts @@ -1,13 +1,7 @@ -// biome-ignore-all lint/performance/noAwaitInLoops: Schema setup and filesystem scans are intentionally sequential. -// biome-ignore-all lint/performance/useTopLevelRegex: Existing schema inspection regexes are kept inline for readability. -// biome-ignore-all lint/style/noNestedTernary: Existing schema selection expression is intentionally compact. -import { spawn } from "node:child_process"; import type { Dirent } from "node:fs"; import { access, readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; -import type { CommandContext } from "../../shell/runtime"; - export type BranchDatabaseSchemaCommand = | "migrate-deploy" | "db-push" @@ -41,12 +35,6 @@ export interface BranchDatabaseSignal { databaseUrlReferences: string[]; } -export interface BranchDatabaseSchemaSetupResult { - command: BranchDatabaseSchemaCommand; - source: BranchDatabaseSchemaSourceKind; - schemaPath: string; -} - const SKIPPED_DIRECTORIES = new Set([ ".git", ".next", @@ -147,41 +135,6 @@ export function hasBranchDatabaseSignal(signal: BranchDatabaseSignal): boolean { return Boolean(signal.schema || signal.databaseUrlReferences.length > 0); } -export async function runBranchDatabaseSchemaSetup(options: { - context: CommandContext; - schema: BranchDatabaseSchema; - databaseUrl: string; - directUrl: string | null; -}): Promise { - const schemaPath = - path.relative(options.context.runtime.cwd, options.schema.path) || - defaultSchemaSourcePath(options.schema); - const prisma = await resolvePrismaInvocation(options.context.runtime.cwd); - const commands = buildSchemaSetupCommands( - options.schema, - schemaPath, - options.databaseUrl, - prisma, - ); - - for (const command of commands) { - await runPrismaCommand({ - context: options.context, - ...command, - env: { - DATABASE_URL: options.databaseUrl, - ...(options.directUrl ? { DIRECT_URL: options.directUrl } : {}), - }, - }); - } - - return { - command: options.schema.command, - source: options.schema.kind, - schemaPath, - }; -} - interface ScanState { filesVisited: number; schemaCandidates: string[]; @@ -194,14 +147,6 @@ interface ClassifiedPrismaNextConfig { target: "postgresql" | "unknown" | UnsupportedBranchDatabaseSchemaTarget; } -interface SupportedPrismaNextConfig extends ClassifiedPrismaNextConfig { - target: "postgresql" | "unknown"; -} - -interface UnsupportedPrismaNextConfig extends ClassifiedPrismaNextConfig { - target: UnsupportedBranchDatabaseSchemaTarget; -} - interface PrismaOrmSchemaSelection { schema: BranchDatabaseSchema | null; unsupportedSchema: UnsupportedBranchDatabaseSchema | null; @@ -220,9 +165,15 @@ async function scanDirectory( return; } - const entries = await readDirectoryEntries(directory); - if (!entries) return; - + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { @@ -231,82 +182,40 @@ async function scanDirectory( return; } - await scanDirectoryEntry(cwd, directory, entry, depth, state, signal); - } -} - -async function readDirectoryEntries( - directory: string, -): Promise { - try { - return await readdir(directory, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + await scanDirectory(cwd, entryPath, depth + 1, state, signal); + } + continue; } - throw error; - } -} -async function scanDirectoryEntry( - cwd: string, - directory: string, - entry: Dirent, - depth: number, - state: ScanState, - signal: AbortSignal, -): Promise { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - if (!SKIPPED_DIRECTORIES.has(entry.name)) { - await scanDirectory(cwd, entryPath, depth + 1, state, signal); + if (!entry.isFile()) { + continue; } - return; - } - - if (!entry.isFile()) { - return; - } - state.filesVisited += 1; - collectBranchDatabaseCandidate(entryPath, entry.name, state); + state.filesVisited += 1; - if ( - await shouldRecordDatabaseUrlReference(entryPath, entry.name, state, signal) - ) { - state.databaseUrlReferences.push( - path.relative(cwd, entryPath) || entry.name, - ); - } -} + if (entry.name === "schema.prisma") { + state.schemaCandidates.push(entryPath); + } -function collectBranchDatabaseCandidate( - entryPath: string, - entryName: string, - state: ScanState, -): void { - if (entryName === "schema.prisma") { - state.schemaCandidates.push(entryPath); - } + if (isPrismaNextConfigFile(entry.name)) { + state.prismaNextConfigCandidates.push(entryPath); + } - if (isPrismaNextConfigFile(entryName)) { - state.prismaNextConfigCandidates.push(entryPath); + if ( + state.databaseUrlReferences.length < MAX_DATABASE_URL_REFERENCE_FILES && + shouldScanForDatabaseUrl(entry.name) && + (await fileContainsDatabaseUrl(entryPath, signal)) + ) { + state.databaseUrlReferences.push( + path.relative(cwd, entryPath) || entry.name, + ); + } } } -async function shouldRecordDatabaseUrlReference( - entryPath: string, - entryName: string, - state: ScanState, - signal: AbortSignal, -): Promise { - return ( - state.databaseUrlReferences.length < MAX_DATABASE_URL_REFERENCE_FILES && - shouldScanForDatabaseUrl(entryName) && - (await fileContainsDatabaseUrl(entryPath, signal)) - ); -} - async function selectPrismaOrmSchema( cwd: string, candidates: string[], @@ -353,22 +262,26 @@ function selectPrismaNextConfig( cwd: string, candidates: ClassifiedPrismaNextConfig[], mode: "supported", -): SupportedPrismaNextConfig | null; +): (ClassifiedPrismaNextConfig & { target: "postgresql" | "unknown" }) | null; function selectPrismaNextConfig( cwd: string, candidates: ClassifiedPrismaNextConfig[], mode: "unsupported", -): UnsupportedPrismaNextConfig | null; +): + | (ClassifiedPrismaNextConfig & { + target: UnsupportedBranchDatabaseSchemaTarget; + }) + | null; function selectPrismaNextConfig( cwd: string, candidates: ClassifiedPrismaNextConfig[], mode: "supported" | "unsupported", ): ClassifiedPrismaNextConfig | null { - const matches = candidates.filter( - mode === "supported" - ? isSupportedPrismaNextConfig - : isUnsupportedPrismaNextConfig, - ); + const matches = candidates.filter((candidate) => { + const isSupported = + candidate.target === "postgresql" || candidate.target === "unknown"; + return mode === "supported" ? isSupported : !isSupported; + }); return ( sortByPreferredRelativePath( @@ -385,18 +298,6 @@ function selectPrismaNextConfig( ); } -function isSupportedPrismaNextConfig( - candidate: ClassifiedPrismaNextConfig, -): candidate is SupportedPrismaNextConfig { - return candidate.target === "postgresql" || candidate.target === "unknown"; -} - -function isUnsupportedPrismaNextConfig( - candidate: ClassifiedPrismaNextConfig, -): candidate is UnsupportedPrismaNextConfig { - return !isSupportedPrismaNextConfig(candidate); -} - function sortByPreferredRelativePath( cwd: string, candidates: string[], @@ -538,188 +439,8 @@ async function readTextFileIfSmall( return readFile(filePath, { encoding: "utf8", signal }); } -// Last resort for repos that ship a schema with no Prisma packages -// installed at all. Pinned to the 6.x line: Prisma 7 rejects the classic -// `url = env(...)` datasource form (P1012), which is exactly the schema -// shape such repos have. Bump deliberately, never to `latest`. -const FALLBACK_PRISMA_CLI_VERSION = "6.19.3"; - -interface PrismaInvocation { - argsPrefix: string[]; - displayPrefix: string; -} - -/** - * Picks how `prisma` CLI commands are invoked for schema setup. Projects - * with the CLI installed run their own binary (version-exact). Projects - * without it fall back to a versioned `npx prisma@` pinned to the - * installed `@prisma/client` — never bare `npx prisma`, which resolves to - * latest and can be a major version ahead of the project's schema. - */ -async function resolvePrismaInvocation(cwd: string): Promise { - if (await localPrismaBinExists(cwd)) { - return { - argsPrefix: ["--no-install", "prisma"], - displayPrefix: "npx --no-install prisma", - }; - } - - const clientVersion = await readInstalledPrismaClientVersion(cwd); - const pinned = clientVersion ?? FALLBACK_PRISMA_CLI_VERSION; - return { - argsPrefix: ["--yes", `prisma@${pinned}`], - displayPrefix: `npx prisma@${pinned}`, - }; -} - -/** npm/pnpm name the local CLI shim `prisma` on POSIX and `prisma.cmd`/`prisma.ps1` on Windows. */ -async function localPrismaBinExists(cwd: string): Promise { - const binDir = path.join(cwd, "node_modules", ".bin"); - const checks = await Promise.all( - ["prisma", "prisma.cmd", "prisma.ps1"].map((name) => - fileExists(path.join(binDir, name)), - ), - ); - return checks.some(Boolean); -} - -async function fileExists(filePath: string): Promise { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -async function readInstalledPrismaClientVersion( - cwd: string, -): Promise { - try { - const raw = await readFile( - path.join(cwd, "node_modules", "@prisma", "client", "package.json"), - { encoding: "utf8" }, - ); - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) { - return null; - } - const version = (parsed as { version?: unknown }).version; - return typeof version === "string" && version.length > 0 ? version : null; - } catch { - return null; - } -} - -function buildSchemaSetupCommands( - schema: BranchDatabaseSchema, - schemaPath: string, - databaseUrl: string, - prisma: PrismaInvocation, -): Array<{ - args: string[]; - displayCommand: string; -}> { - if (schema.command === "migrate-deploy") { - return [ - { - args: [ - ...prisma.argsPrefix, - "migrate", - "deploy", - "--schema", - schemaPath, - ], - displayCommand: `${prisma.displayPrefix} migrate deploy`, - }, - ]; - } - - if (schema.command === "db-push") { - return [ - { - args: [...prisma.argsPrefix, "db", "push", "--schema", schemaPath], - displayCommand: `${prisma.displayPrefix} db push`, - }, - ]; - } - - return [ - { - args: [ - "--no-install", - "prisma-next", - "contract", - "emit", - "--config", - schemaPath, - ], - displayCommand: "npx --no-install prisma-next contract emit", - }, - { - args: [ - "--no-install", - "prisma-next", - "db", - "init", - "--config", - schemaPath, - "--db", - databaseUrl, - ], - displayCommand: "npx --no-install prisma-next db init", - }, - ]; -} - function defaultSchemaSourcePath(schema: BranchDatabaseSchema): string { return schema.kind === "prisma-next" ? "prisma-next.config.ts" : "schema.prisma"; } - -async function runPrismaCommand(options: { - context: CommandContext; - args: string[]; - displayCommand: string; - env: Record; -}): Promise { - const shouldPipeOutput = - !options.context.flags.json && !options.context.flags.quiet; - const child = spawn("npx", options.args, { - cwd: options.context.runtime.cwd, - env: { - ...options.context.runtime.env, - ...options.env, - }, - signal: options.context.runtime.signal, - stdio: shouldPipeOutput - ? ["ignore", "pipe", "pipe"] - : ["ignore", "ignore", "ignore"], - }); - - if (shouldPipeOutput) { - child.stdout?.pipe(options.context.output.stderr, { end: false }); - child.stderr?.pipe(options.context.output.stderr, { end: false }); - } - - const exit = await new Promise<{ - code: number | null; - signal: NodeJS.Signals | null; - }>((resolve, reject) => { - child.once("error", reject); - child.once("close", (code, signal) => resolve({ code, signal })); - }); - - if (exit.signal) { - throw new Error( - `${options.displayCommand} was terminated by ${exit.signal}.`, - ); - } - - if (exit.code !== 0) { - throw new Error( - `${options.displayCommand} exited with code ${exit.code ?? 1}.`, - ); - } -} diff --git a/packages/cli/src/lib/app/compute-config.ts b/packages/cli/src/lib/app/compute-config.ts new file mode 100644 index 0000000..cb073b2 --- /dev/null +++ b/packages/cli/src/lib/app/compute-config.ts @@ -0,0 +1,256 @@ +import path from "node:path"; + +import { + COMPUTE_CONFIG_FILENAME, + type ComputeConfigError, + type ComputeConfigTargetError, + type ComputeDeployTarget, + type ComputeFramework, + type FrameworkBuildType, + frameworkByKey, + type LoadedComputeConfig, + loadComputeConfig as loadComputeConfigFromSdk, +} from "@prisma/compute-sdk/config"; +import { matchError, type Result } from "better-result"; + +import { CliError } from "../../shell/errors"; + +// The compute config contract (types, validation, discovery, loading) lives +// in @prisma/compute-sdk/config so the CLI, build-runner, and scaffolding +// share one implementation. This module re-exports what app commands consume +// and keeps the CLI-specific glue: flag/config precedence and CliError +// presentation. +export { + COMPUTE_CONFIG_FILENAME, + COMPUTE_CONFIG_FILENAMES, + ComputeConfigAmbiguousError, + type ComputeConfigError, + ComputeConfigInvalidError, + ComputeConfigLoadError, + type ComputeConfigTargetError, + ComputeConfigTargetRequiredError, + ComputeConfigTargetUnknownError, + type ComputeDeployTarget, + type ComputeDeployTargetBuild, + computeTargetAppDir, + inferComputeTargetFromCwd, + type LoadedComputeConfig, + normalizeComputeConfig, + selectComputeDeployTarget, +} from "@prisma/compute-sdk/config"; + +/** + * Loads the nearest compute config, searching from `cwd` up to the source + * root (repository or workspace boundary). Thin adapter over the SDK loader + * keeping the CLI's positional-signal call shape. + */ +export async function loadComputeConfig( + cwd: string, + signal?: AbortSignal, +): Promise> { + return loadComputeConfigFromSdk(cwd, { signal }); +} + +/** Local build/run strategy implied by a configured framework. */ +export function computeFrameworkToBuildType( + framework: ComputeFramework, +): FrameworkBuildType { + return frameworkByKey(framework).buildType; +} + +export interface MergedDeployInput { + value: string; + annotation: string; +} + +export interface MergedComputeDeployInputs { + framework: MergedDeployInput | undefined; + entrypoint: MergedDeployInput | undefined; + httpPort: MergedDeployInput | undefined; + /** `--env` flags replace config env inputs entirely; they never merge. */ + envInputs: string[] | undefined; + /** True when env inputs came from the config; their file paths then resolve from the config directory. */ + envInputsFromConfig: boolean; + /** Config-provided app name; ranks below --app and PRISMA_APP_ID. */ + configAppName: MergedDeployInput | undefined; + /** App directory relative to the config directory, or undefined for the config directory. */ + appRoot: string | undefined; +} + +export function mergeComputeDeployInputs(options: { + cli: { + framework?: string; + entrypoint?: string; + httpPort?: string; + envInputs?: string[]; + }; + target: ComputeDeployTarget | null; + configFilename: string; +}): MergedComputeDeployInputs { + const { cli, target, configFilename } = options; + const configAnnotation = `set by ${configFilename}`; + + const framework = cli.framework + ? { value: cli.framework, annotation: "set by --framework" } + : target?.framework + ? { value: target.framework, annotation: configAnnotation } + : undefined; + + const entrypoint = cli.entrypoint + ? { value: cli.entrypoint, annotation: "set by --entry" } + : target?.entry + ? { value: target.entry, annotation: configAnnotation } + : undefined; + + const httpPort = cli.httpPort + ? { value: cli.httpPort, annotation: "set by --http-port" } + : target?.httpPort + ? { value: String(target.httpPort), annotation: configAnnotation } + : undefined; + + const cliEnvInputs = + cli.envInputs && cli.envInputs.length > 0 ? cli.envInputs : undefined; + const configEnvInputs = + target && target.envInputs.length > 0 ? target.envInputs : undefined; + const envInputs = cliEnvInputs ?? configEnvInputs; + + const configAppName = target?.name + ? { value: target.name, annotation: configAnnotation } + : target?.key + ? { value: target.key, annotation: configAnnotation } + : undefined; + + return { + framework, + entrypoint, + httpPort, + envInputs, + envInputsFromConfig: !cliEnvInputs && configEnvInputs !== undefined, + configAppName, + appRoot: target?.root ?? undefined, + }; +} + +export interface MergedComputeLocalInputs { + entrypoint: string | undefined; + /** Resolved build type, or undefined for auto detection. */ + buildType: string | undefined; + /** True when the build type came from the config framework, not a flag. */ + buildTypeFromConfig: boolean; + port: string | undefined; + /** App directory relative to the invocation directory, or undefined for the invocation directory. */ + appRoot: string | undefined; +} + +/** + * Merges CLI inputs for the local `app build` and `app run` commands with a + * selected config target. Explicit flags win; `--build-type auto` is the + * flag default and defers to the configured framework. + */ +export function mergeComputeLocalInputs(options: { + cli: { + entrypoint?: string; + buildType?: string; + port?: string; + }; + target: ComputeDeployTarget | null; +}): MergedComputeLocalInputs { + const { cli, target } = options; + const cliBuildType = + cli.buildType && cli.buildType !== "auto" ? cli.buildType : undefined; + const configBuildType = target?.framework + ? computeFrameworkToBuildType(target.framework) + : undefined; + + return { + entrypoint: cli.entrypoint ?? target?.entry ?? undefined, + buildType: cliBuildType ?? configBuildType, + buildTypeFromConfig: !cliBuildType && configBuildType !== undefined, + port: cli.port ?? (target?.httpPort ? String(target.httpPort) : undefined), + appRoot: target?.root ?? undefined, + }; +} + +/** The `app` subcommand used in error guidance text, e.g. "deploy" or "domain add". */ +export type ComputeConfigCommandName = string; + +export function computeConfigErrorToCliError( + error: ComputeConfigError | ComputeConfigTargetError, + commandName: ComputeConfigCommandName = "deploy", +): CliError { + const command = `prisma-cli app ${commandName}`; + return matchError(error, { + ComputeConfigAmbiguousError: (ambiguous) => + new CliError({ + code: "COMPUTE_CONFIG_INVALID", + domain: "app", + summary: "Multiple compute config files found", + why: ambiguous.message, + fix: `Keep exactly one compute config file, preferably ${COMPUTE_CONFIG_FILENAME}.`, + meta: { configPaths: ambiguous.configPaths }, + exitCode: 2, + nextSteps: [command], + }), + ComputeConfigLoadError: (load) => + new CliError({ + code: "COMPUTE_CONFIG_INVALID", + domain: "app", + summary: `Could not load ${path.basename(load.configPath)}`, + why: load.message, + fix: `Fix the error in ${path.basename(load.configPath)} and rerun the command.`, + where: load.configPath, + meta: { configPath: load.configPath }, + exitCode: 2, + nextSteps: [command], + }), + ComputeConfigInvalidError: (invalid) => + new CliError({ + code: "COMPUTE_CONFIG_INVALID", + domain: "app", + summary: `Invalid ${path.basename(invalid.configPath)}`, + why: invalid.issues.join(" "), + fix: `Edit ${path.basename(invalid.configPath)} so it default-exports defineComputeConfig({ app }) or defineComputeConfig({ apps }).`, + where: invalid.configPath, + meta: { configPath: invalid.configPath, issues: invalid.issues }, + exitCode: 2, + nextSteps: [command], + }), + ComputeConfigTargetRequiredError: (required) => + new CliError({ + code: "COMPUTE_CONFIG_TARGET_REQUIRED", + domain: "app", + summary: "App target required", + why: required.message, + fix: `Pass the app target, for example ${command} .`, + meta: { + configPath: required.configPath, + availableTargets: required.availableTargets, + }, + exitCode: 2, + nextSteps: required.availableTargets.map( + (target) => `${command} ${target}`, + ), + }), + ComputeConfigTargetUnknownError: (unknown) => + new CliError({ + code: "COMPUTE_CONFIG_TARGET_UNKNOWN", + domain: "app", + summary: `Unknown app target "${unknown.requestedTarget}"`, + why: unknown.message, + fix: + unknown.availableTargets.length > 0 + ? `Pass one of the configured targets: ${unknown.availableTargets.join(", ")}.` + : "Remove the target argument; this config defines a single app.", + meta: { + configPath: unknown.configPath, + requestedTarget: unknown.requestedTarget, + availableTargets: unknown.availableTargets, + }, + exitCode: 2, + nextSteps: + unknown.availableTargets.length > 0 + ? unknown.availableTargets.map((target) => `${command} ${target}`) + : [command], + }), + }); +} diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts index 60e7dc1..45743b5 100644 --- a/packages/cli/src/lib/app/preview-build-settings.ts +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -1,23 +1,23 @@ -// biome-ignore-all lint/performance/noAwaitInLoops: Config discovery probes ordered candidates sequentially. import { exec } from "node:child_process"; -import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; - +import { + type ConfigBackedBuildType, + sourceRootLineage, +} from "@prisma/compute-sdk/config"; import { type ASTNode, parseModule } from "magicast"; -import { CliError } from "../../shell/errors"; import { type BunPackageJsonLike, readBunPackageJson } from "./bun-project"; import type { ResolvedPreviewBuildType } from "./preview-build"; type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; export type PreviewBuildSettingsBuildType = Extract< ResolvedPreviewBuildType, - "nextjs" | "tanstack-start" | "bun" + ConfigBackedBuildType >; +/** Legacy build-settings file: no longer read or written, only detected for migration. */ export const PRISMA_APP_CONFIG_FILENAME = "prisma.app.json"; -export const PRISMA_APP_CONFIG_SCHEMA_URL = - "https://pris.ly/schemas/prisma-app-config.v1.json"; interface ResolvedBuildCommand { command: string | null; @@ -37,87 +37,161 @@ export interface PreviewBuildSettings { } export interface PreviewBuildSettingsResolution { - status: "created" | "used"; - configPath: string; - relativeConfigPath: typeof PRISMA_APP_CONFIG_FILENAME; + /** "config" when the compute config owns the settings, "inferred" otherwise. */ + status: "config" | "inferred"; + /** The compute config path when status is "config". */ + configPath: string | null; + relativeConfigPath: string | null; settings: PreviewBuildSettings; } -export async function resolveOrCreatePreviewBuildSettings(options: { +export type LegacyBuildSettingsDetection = + | { kind: "absent" } + | { kind: "matching"; configPath: string } + | { kind: "invalid"; configPath: string } + | { + kind: "custom"; + configPath: string; + buildCommand: string | null; + outputDirectory: string; + }; + +/** + * Detects a leftover `prisma.app.json`. The file is no longer used: one that + * matches the effective settings is reported for deletion, one with custom + * values must be migrated to the compute config so builds never silently + * change. + */ +export async function detectLegacyBuildSettings(options: { appPath: string; - buildType: PreviewBuildSettingsBuildType; + effective: PreviewBuildSettings; signal?: AbortSignal; -}): Promise { +}): Promise { const configPath = path.join(options.appPath, PRISMA_APP_CONFIG_FILENAME); - const existing = await readPreviewBuildSettingsConfig( - configPath, - options.signal, - ); - if (existing) { - return { - status: "used", - configPath, - relativeConfigPath: PRISMA_APP_CONFIG_FILENAME, - settings: { - buildCommand: existing.buildCommand, - buildCommandSource: null, - outputDirectory: existing.outputDirectory, - outputDirectorySource: null, - }, - }; - } - - const settings = await resolvePreviewBuildSettings(options); - const config = { - $schema: PRISMA_APP_CONFIG_SCHEMA_URL, - buildCommand: settings.buildCommand, - outputDirectory: settings.outputDirectory, - }; - + let content: string; try { - await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { + options.signal?.throwIfAborted(); + content = await readFile(configPath, { encoding: "utf8", - flag: "wx", signal: options.signal, }); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - const raced = await readPreviewBuildSettingsConfig( - configPath, - options.signal, - ); - if (raced) { - return { - status: "used", - configPath, - relativeConfigPath: PRISMA_APP_CONFIG_FILENAME, - settings: { - buildCommand: raced.buildCommand, - buildCommandSource: null, - outputDirectory: raced.outputDirectory, - outputDirectorySource: null, - }, - }; - } - } + if (options.signal?.aborted) throw error; + return { kind: "absent" }; + } - throw error; + let legacy: { buildCommand: string | null; outputDirectory: string }; + try { + const parsed = JSON.parse(content) as Record; + const buildCommand = + parsed.buildCommand === null || typeof parsed.buildCommand === "string" + ? typeof parsed.buildCommand === "string" + ? parsed.buildCommand.trim() || null + : null + : undefined; + const outputDirectory = + typeof parsed.outputDirectory === "string" + ? normalizeRelativePath(parsed.outputDirectory) + : undefined; + if (buildCommand === undefined || !outputDirectory) { + return { kind: "invalid", configPath }; + } + legacy = { buildCommand, outputDirectory }; + } catch { + return { kind: "invalid", configPath }; } + const matches = + legacy.buildCommand === options.effective.buildCommand && + legacy.outputDirectory === options.effective.outputDirectory; + return matches + ? { kind: "matching", configPath } + : { kind: "custom", configPath, ...legacy }; +} + +/** Resolves build settings purely from framework inference; nothing is read or written. */ +export async function resolveInferredPreviewBuildSettings(options: { + appPath: string; + buildType: ResolvedPreviewBuildType; + signal?: AbortSignal; +}): Promise { return { - status: "created", - configPath, - relativeConfigPath: PRISMA_APP_CONFIG_FILENAME, - settings, + status: "inferred", + configPath: null, + relativeConfigPath: null, + settings: await resolvePreviewBuildSettings(options), }; } -export async function resolvePreviewBuildSettings(options: { +/** + * Resolves build settings when the compute config owns them: configured + * fields win, omitted fields fall back to framework defaults. + */ +export async function resolveConfiguredPreviewBuildSettings(options: { appPath: string; buildType: PreviewBuildSettingsBuildType; + configured: { + command: string | null | undefined; + outputDirectory: string | undefined; + }; + /** Absolute path of the compute config file owning these settings. */ + configPath: string; + signal?: AbortSignal; +}): Promise { + const configFilename = path.basename(options.configPath); + const source = `set by ${configFilename}`; + const needsFallback = + options.configured.command === undefined || + options.configured.outputDirectory === undefined; + const fallback = needsFallback + ? await resolvePreviewBuildSettings(options) + : null; + + return { + status: "config", + configPath: options.configPath, + relativeConfigPath: configFilename, + settings: { + buildCommand: + options.configured.command !== undefined + ? options.configured.command + : fallback!.buildCommand, + buildCommandSource: + options.configured.command !== undefined + ? source + : fallback!.buildCommandSource, + outputDirectory: + options.configured.outputDirectory ?? fallback!.outputDirectory, + outputDirectorySource: + options.configured.outputDirectory !== undefined + ? source + : fallback!.outputDirectorySource, + }, + }; +} + +export async function resolvePreviewBuildSettings(options: { + appPath: string; + buildType: ResolvedPreviewBuildType; signal?: AbortSignal; }): Promise { switch (options.buildType) { + // The nuxt and astro strategies invoke the framework CLI and stage fixed + // output themselves; these settings only describe that for display. + case "nuxt": + return { + buildCommand: "nuxt build", + buildCommandSource: "Nuxt default", + outputDirectory: ".output", + outputDirectorySource: "Nuxt output", + }; + case "astro": + return { + buildCommand: "astro build", + buildCommandSource: "Astro default", + outputDirectory: "dist", + outputDirectorySource: "Astro output", + }; case "nextjs": { const packageJson = await readBunPackageJson( options.appPath, @@ -189,128 +263,6 @@ export async function resolvePreviewBuildSettings(options: { } } -interface PreviewBuildSettingsConfig { - buildCommand: string | null; - outputDirectory: string; -} - -async function readPreviewBuildSettingsConfig( - configPath: string, - signal?: AbortSignal, -): Promise { - let content: string; - try { - content = await readFile(configPath, { encoding: "utf8", signal }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - - throw error; - } - - let parsed: unknown; - try { - parsed = JSON.parse(content) as unknown; - } catch (error) { - throw invalidPrismaAppConfigError( - configPath, - `The file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw invalidPrismaAppConfigError( - configPath, - "The file must contain a JSON object.", - ); - } - - const raw = parsed as Record; - if ( - "$schema" in raw && - raw.$schema !== undefined && - typeof raw.$schema !== "string" - ) { - throw invalidPrismaAppConfigError( - configPath, - "The $schema field must be a string when present.", - ); - } - - if (raw.buildCommand !== null && typeof raw.buildCommand !== "string") { - throw invalidPrismaAppConfigError( - configPath, - "The buildCommand field must be a string or null.", - ); - } - - let buildCommand: string | null = null; - if (typeof raw.buildCommand === "string") { - buildCommand = raw.buildCommand.trim(); - if (buildCommand.length === 0) { - throw invalidPrismaAppConfigError( - configPath, - "The buildCommand field must not be an empty string. Use null to skip the build step.", - ); - } - } - - const outputDirectory = normalizeConfigOutputDirectory( - configPath, - raw.outputDirectory, - ); - - return { - buildCommand, - outputDirectory, - }; -} - -function normalizeConfigOutputDirectory( - configPath: string, - value: unknown, -): string { - if (typeof value !== "string" || value.trim().length === 0) { - throw invalidPrismaAppConfigError( - configPath, - "The outputDirectory field must be a non-empty string.", - ); - } - - const normalized = normalizeRelativePath(value); - if (!normalized) { - throw invalidPrismaAppConfigError( - configPath, - "The outputDirectory field must be a relative path inside the app directory.", - ); - } - - return normalized; -} - -function invalidPrismaAppConfigError( - configPath: string, - why: string, -): CliError { - return new CliError({ - code: "APP_CONFIG_INVALID", - domain: "app", - summary: `Invalid ${PRISMA_APP_CONFIG_FILENAME}`, - why, - fix: - `Edit ${PRISMA_APP_CONFIG_FILENAME} so buildCommand is a string or null ` + - "and outputDirectory is a relative path inside the app root. " + - "Delete the file and rerun prisma-cli app deploy to regenerate defaults.", - where: configPath, - meta: { - configPath, - }, - exitCode: 2, - nextSteps: ["prisma-cli app deploy"], - }); -} - export async function hasRootFile( appPath: string, filenames: readonly string[], @@ -413,30 +365,40 @@ async function resolvePackageManager( packageJson: BunPackageJsonLike | null, signal?: AbortSignal, ): Promise { - const fromPackageManager = packageManagerFromPackageJson( - packageJson?.packageManager, - ); - if (fromPackageManager) { - return fromPackageManager; - } + // Workspace repos keep the lockfile and packageManager field at the + // workspace root, so check every level from the app up to the source root. + // The nearest level wins. + for (const directory of await sourceRootLineage(appPath, signal)) { + const levelPackageJson = + directory === path.resolve(appPath) + ? packageJson + : await readBunPackageJson(directory, signal); + + const fromPackageManager = packageManagerFromPackageJson( + levelPackageJson?.packageManager, + ); + if (fromPackageManager) { + return fromPackageManager; + } - if ( - (await pathExists(path.join(appPath, "bun.lock"), signal)) || - (await pathExists(path.join(appPath, "bun.lockb"), signal)) - ) { - return "bun"; - } + if ( + (await pathExists(path.join(directory, "bun.lock"), signal)) || + (await pathExists(path.join(directory, "bun.lockb"), signal)) + ) { + return "bun"; + } - if (await pathExists(path.join(appPath, "pnpm-lock.yaml"), signal)) { - return "pnpm"; - } + if (await pathExists(path.join(directory, "pnpm-lock.yaml"), signal)) { + return "pnpm"; + } - if (await pathExists(path.join(appPath, "yarn.lock"), signal)) { - return "yarn"; - } + if (await pathExists(path.join(directory, "yarn.lock"), signal)) { + return "yarn"; + } - if (await pathExists(path.join(appPath, "package-lock.json"), signal)) { - return "npm"; + if (await pathExists(path.join(directory, "package-lock.json"), signal)) { + return "npm"; + } } } @@ -461,12 +423,24 @@ export async function runResolvedBuildCommand( return; } - await execBuildCommand(settings.buildCommand, appPath, failurePrefix, signal); + // Workspace repos may hoist binaries like `next` to the workspace root, so + // expose every node_modules/.bin between the app and its source root. + const binDirs = (await sourceRootLineage(appPath, signal)).map((directory) => + path.join(directory, "node_modules", ".bin"), + ); + await execBuildCommand( + settings.buildCommand, + appPath, + binDirs, + failurePrefix, + signal, + ); } function execBuildCommand( command: string, cwd: string, + binDirs: string[], failurePrefix: string, signal?: AbortSignal, ): Promise { @@ -477,7 +451,7 @@ function execBuildCommand( cwd, env: { ...process.env, - PATH: [path.join(cwd, "node_modules", ".bin"), process.env.PATH] + PATH: [...binDirs, process.env.PATH] .filter(Boolean) .join(path.delimiter), }, @@ -757,11 +731,16 @@ function propertyKeyName(value: unknown): string | undefined { return undefined; } -function normalizeRelativePath(value: string): string | undefined { +export function normalizeRelativePath(value: string): string | undefined { const raw = value.trim().replace(/\\/g, "/"); if (raw.length === 0 || raw.split("/").includes("..")) { return undefined; } + // Windows drive-relative paths ("C:dir") escape the base directory but + // are not absolute under either path.win32 or path.posix. + if (/^[A-Za-z]:/.test(raw)) { + return undefined; + } const normalized = path.posix.normalize(raw); const segments = normalized.split("/"); diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index 646355a..60e3d1a 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -1,4 +1,3 @@ -// biome-ignore-all lint/performance/noAwaitInLoops: Build strategy probing and filesystem traversal are intentionally sequential. import { chmod, copyFile, @@ -23,6 +22,7 @@ import { BunBuild, NuxtBuild, } from "@prisma/compute-sdk"; +import { resolveSourceRoot } from "@prisma/compute-sdk/config"; import { readBunPackageJson, resolveBunEntrypoint } from "./bun-project"; import { hasAnyPackageDependency, @@ -36,14 +36,15 @@ import { runResolvedBuildCommand, } from "./preview-build-settings"; -// biome-ignore lint/performance/noBarrelFile: Preview build settings are re-exported from this public app build module. export { + detectLegacyBuildSettings, + type LegacyBuildSettingsDetection, PRISMA_APP_CONFIG_FILENAME, - PRISMA_APP_CONFIG_SCHEMA_URL, type PreviewBuildSettings, type PreviewBuildSettingsBuildType, type PreviewBuildSettingsResolution, - resolveOrCreatePreviewBuildSettings, + resolveConfiguredPreviewBuildSettings, + resolveInferredPreviewBuildSettings, resolvePreviewBuildSettings, } from "./preview-build-settings"; @@ -422,7 +423,7 @@ class PreviewTanstackStartBuild implements BuildStrategy { if (!entryStat?.isFile()) { throw new Error( `TanStack Start build did not produce a Nitro node server entrypoint at ${joinPosix(settings.outputDirectory, entrypoint)}. ` + - `Ensure your vite.config includes the tanstackStart() and nitro() plugins with the default node preset, or update ${PRISMA_APP_CONFIG_FILENAME}.`, + `Ensure your vite.config includes the tanstackStart() and nitro() plugins with the default node preset, or set build.outputDirectory in prisma.compute.ts.`, ); } @@ -515,7 +516,7 @@ export async function stageNextjsStandaloneArtifact(options: { sourceRoot, signal: options.signal, }); - await hoistPnpmDependencies( + await hoistIsolatedStoreDependencies( path.join(artifactRoot, "node_modules"), options.signal, ); @@ -661,20 +662,42 @@ function nextjsServerSubpath(entrypoint: string): string { return dir === "." ? "" : dir; } -async function hoistPnpmDependencies( +/** + * pnpm and bun (isolated linker) both keep packages in a virtual store with a + * shared symlink farm (`.pnpm/node_modules`, `.bun/node_modules`). Hoist the + * farm entries to the artifact's node_modules root so Node-style resolution + * works after symlinks are materialized. + */ +async function hoistIsolatedStoreDependencies( nodeModulesDir: string, signal?: AbortSignal, ): Promise { - const pnpmNodeModulesDir = path.join(nodeModulesDir, ".pnpm", "node_modules"); - if (!(await directoryExists(pnpmNodeModulesDir, signal))) { + await hoistStoreDependencies( + nodeModulesDir, + path.join(nodeModulesDir, ".pnpm", "node_modules"), + signal, + ); + await hoistStoreDependencies( + nodeModulesDir, + path.join(nodeModulesDir, ".bun", "node_modules"), + signal, + ); +} + +async function hoistStoreDependencies( + nodeModulesDir: string, + storeNodeModulesDir: string, + signal?: AbortSignal, +): Promise { + if (!(await directoryExists(storeNodeModulesDir, signal))) { return; } const entries = await unsupportedFilesystemBoundary(signal, () => - readdir(pnpmNodeModulesDir, { withFileTypes: true }), + readdir(storeNodeModulesDir, { withFileTypes: true }), ); for (const entry of entries) { - const sourcePath = path.join(pnpmNodeModulesDir, entry.name); + const sourcePath = path.join(storeNodeModulesDir, entry.name); if (entry.name.startsWith("@") && entry.isDirectory()) { const scopedEntries = await unsupportedFilesystemBoundary(signal, () => @@ -697,7 +720,7 @@ async function hoistPnpmDependencies( path.join(sourcePath, scopedEntry.name), scopedDestination, { - standaloneRoot: pnpmNodeModulesDir, + standaloneRoot: storeNodeModulesDir, appRoot: nodeModulesDir, sourceRoot: nodeModulesDir, signal, @@ -713,7 +736,7 @@ async function hoistPnpmDependencies( } await copyPathMaterializingSymlinks(sourcePath, destinationPath, { - standaloneRoot: pnpmNodeModulesDir, + standaloneRoot: storeNodeModulesDir, appRoot: nodeModulesDir, sourceRoot: nodeModulesDir, signal, @@ -748,54 +771,39 @@ export async function normalizeArtifactSymlinks( continue; } - const materialized = await materializeArtifactSymlink({ - fullPath, - normalizedArtifactDir, - normalizedAppPath, - signal, - }); - if (materialized === "directory") { - await walkDirectory(fullPath); + const target = await unsupportedFilesystemBoundary(signal, () => + readlink(fullPath), + ); + const resolvedTarget = path.resolve(path.dirname(fullPath), target); + + if (isPathWithin(normalizedArtifactDir, resolvedTarget)) { + continue; } - } - } -} -async function materializeArtifactSymlink(options: { - fullPath: string; - normalizedArtifactDir: string; - normalizedAppPath: string; - signal?: AbortSignal; -}): Promise<"directory" | "file" | "internal"> { - const target = await unsupportedFilesystemBoundary(options.signal, () => - readlink(options.fullPath), - ); - const resolvedTarget = path.resolve(path.dirname(options.fullPath), target); + if (!isPathWithin(normalizedAppPath, resolvedTarget)) { + throw new Error( + `Build artifact symlink escapes the app directory: ${resolvedTarget}`, + ); + } - if (isPathWithin(options.normalizedArtifactDir, resolvedTarget)) { - return "internal"; - } + const targetStat = await unsupportedFilesystemBoundary(signal, () => + stat(resolvedTarget), + ); + await unsupportedFilesystemBoundary(signal, () => + rm(fullPath, { force: true, recursive: true }), + ); + await unsupportedFilesystemBoundary(signal, () => + cp(resolvedTarget, fullPath, { + recursive: targetStat.isDirectory(), + dereference: true, + }), + ); - if (!isPathWithin(options.normalizedAppPath, resolvedTarget)) { - throw new Error( - `Build artifact symlink escapes the app directory: ${resolvedTarget}`, - ); + if (targetStat.isDirectory()) { + await walkDirectory(fullPath); + } + } } - - const targetStat = await unsupportedFilesystemBoundary(options.signal, () => - stat(resolvedTarget), - ); - await unsupportedFilesystemBoundary(options.signal, () => - rm(options.fullPath, { force: true, recursive: true }), - ); - await unsupportedFilesystemBoundary(options.signal, () => - cp(resolvedTarget, options.fullPath, { - recursive: targetStat.isDirectory(), - dereference: true, - }), - ); - - return targetStat.isDirectory() ? "directory" : "file"; } function isPathWithin(rootPath: string, candidatePath: string): boolean { @@ -964,50 +972,6 @@ async function directoryExists( } } -async function resolveSourceRoot( - appRoot: string, - signal?: AbortSignal, -): Promise { - let current = path.resolve(appRoot); - - while (true) { - if ( - (await pathExists(path.join(current, ".git"), signal)) || - (await pathExists(path.join(current, "pnpm-workspace.yaml"), signal)) || - (await pathExists(path.join(current, "bun.lock"), signal)) || - (await pathExists(path.join(current, "bun.lockb"), signal)) || - (await packageJsonDeclaresWorkspaces(current, signal)) - ) { - return current; - } - - const parent = path.dirname(current); - if (parent === current) { - return path.resolve(appRoot); - } - - current = parent; - } -} - -async function packageJsonDeclaresWorkspaces( - directory: string, - signal?: AbortSignal, -): Promise { - signal?.throwIfAborted(); - try { - const content = await readFile(path.join(directory, "package.json"), { - encoding: "utf8", - signal, - }); - const parsed = JSON.parse(content) as { workspaces?: unknown }; - return Boolean(parsed.workspaces); - } catch (error) { - if (signal?.aborted) throw error; - return false; - } -} - async function unsupportedFilesystemBoundary( signal: AbortSignal | undefined, operation: () => Promise, diff --git a/packages/cli/src/lib/app/production-deploy-gate.ts b/packages/cli/src/lib/app/production-deploy-gate.ts index 4144d73..223dd20 100644 --- a/packages/cli/src/lib/app/production-deploy-gate.ts +++ b/packages/cli/src/lib/app/production-deploy-gate.ts @@ -178,12 +178,12 @@ function productionDeployRequiresFlagError(): CliError { "", "Production deploys require explicit intent. Re-run with:", "", - " prisma app deploy --prod", + " prisma-cli app deploy --prod", "", "Or deploy a preview from a feature branch:", "", " git checkout -b ", - " prisma app deploy", + " prisma-cli app deploy", ], }); } diff --git a/packages/cli/src/lib/diagnostics.ts b/packages/cli/src/lib/diagnostics.ts index 9a68372..c3ec632 100644 --- a/packages/cli/src/lib/diagnostics.ts +++ b/packages/cli/src/lib/diagnostics.ts @@ -7,7 +7,7 @@ export async function collectCommandDiagnostics( context: CommandContext, options: { durationMs?: number } = {}, ): Promise { - const stateDir = resolveStateDir(context.runtime); + const stateDir = await resolveStateDir(context.runtime); return { cwd: context.runtime.cwd, diff --git a/packages/cli/src/lib/fs/home-path.ts b/packages/cli/src/lib/fs/home-path.ts new file mode 100644 index 0000000..e5c7a74 --- /dev/null +++ b/packages/cli/src/lib/fs/home-path.ts @@ -0,0 +1,37 @@ +import path from "node:path"; + +/** + * Shortens a path under the user's home directory to `~/...` for display, + * posix-style on every platform. Falls back to the Windows home variables + * when `HOME` is unset (native cmd/PowerShell sessions). + */ +export function shortenHomePath(value: string, env: NodeJS.ProcessEnv): string { + const resolved = path.resolve(value); + const home = resolveHomeDirectory(env); + + if ( + home && + (resolved === home || resolved.startsWith(`${home}${path.sep}`)) + ) { + const relative = path.relative(home, resolved).split(path.sep).join("/"); + return relative ? `~/${relative}` : "~"; + } + + return resolved; +} + +function resolveHomeDirectory(env: NodeJS.ProcessEnv): string | null { + if (env.HOME) { + return path.resolve(env.HOME); + } + + if (env.USERPROFILE) { + return path.resolve(env.USERPROFILE); + } + + if (env.HOMEDRIVE && env.HOMEPATH) { + return path.resolve(`${env.HOMEDRIVE}${env.HOMEPATH}`); + } + + return null; +} diff --git a/packages/cli/src/lib/git/local-branch.ts b/packages/cli/src/lib/git/local-branch.ts index 947c2d3..24188d1 100644 --- a/packages/cli/src/lib/git/local-branch.ts +++ b/packages/cli/src/lib/git/local-branch.ts @@ -1,16 +1,38 @@ import { access, readFile } from "node:fs/promises"; import path from "node:path"; +/** + * Resolves the checked-out branch the way git does: the nearest `.git` + * (directory or worktree file) from `cwd` upward owns the answer, so + * monorepo commands run from inside a package see the repository branch. + * Returns null for detached HEAD or when no repository contains `cwd`. + */ export async function readLocalGitBranch( cwd: string, signal: AbortSignal, ): Promise { - const gitPath = path.join(cwd, ".git"); - const headPath = await resolveGitHeadPath(gitPath, signal); - if (!headPath) { - return null; + for (let directory = path.resolve(cwd); ; ) { + const headPath = await resolveGitHeadPath( + path.join(directory, ".git"), + signal, + ); + if (headPath) { + // This repository owns cwd; never walk past it to an outer repository. + return readBranchFromHead(headPath, signal); + } + + const parent = path.dirname(directory); + if (parent === directory) { + return null; + } + directory = parent; } +} +async function readBranchFromHead( + headPath: string, + signal: AbortSignal, +): Promise { try { const head = ( await readFile(headPath, { encoding: "utf8", signal }) @@ -21,7 +43,6 @@ export async function readLocalGitBranch( } } catch (error) { if (signal.aborted) throw error; - return null; } return null; diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index e2a30e0..4e1ce88 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -1,4 +1,3 @@ -// biome-ignore-all lint/performance/useTopLevelRegex: Existing project-name validation regexes are kept inline for readability. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -148,6 +147,8 @@ export interface ResolveProjectOptions { explicitProject?: string; envProjectId?: string; commandName?: string; + /** Directory holding `.prisma/local.json`. Defaults to the invocation directory. */ + projectDir?: string; listProjects(): Promise; } @@ -690,7 +691,7 @@ async function readImplicitLocalPin( } const localPinResult = await readLocalResolutionPin( - options.context.runtime.cwd, + options.projectDir ?? options.context.runtime.cwd, options.context.runtime.signal, ); if (localPinResult.isErr()) { diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index 29c36a5..8c7b6c9 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -1,9 +1,10 @@ -// biome-ignore-all lint/performance/useTopLevelRegex: Existing setup formatting regexes are kept inline for readability. +import path from "node:path"; import { matchError, Result } from "better-result"; import { CliError, usageError } from "../../shell/errors"; import type { CommandContext } from "../../shell/runtime"; import type { AuthWorkspace } from "../../types/auth"; import type { ProjectSetupResult, ProjectSummary } from "../../types/project"; +import { shortenHomePath } from "../fs/home-path"; import { ensureLocalResolutionPinGitignore, LOCAL_RESOLUTION_PIN_RELATIVE_PATH, @@ -17,7 +18,6 @@ import { projectNotFoundError, } from "./resolution"; -// biome-ignore lint/performance/noBarrelFile: Project setup exposes command formatting for related project flows. export { formatCommandArgument } from "../../shell/command-arguments"; export type ProjectDirectoryBindingError = @@ -47,9 +47,8 @@ export function resolveProjectForSetup( const matches = projects.filter( (project) => project.id === projectRef || project.name === projectRef, ); - const match = matches[0]; - if (matches.length === 1 && match) { - return match; + if (matches.length === 1) { + return matches[0]!; } if (matches.length > 1) { throw projectAmbiguousError(projectRef, matches); @@ -62,11 +61,12 @@ export async function bindProjectToDirectory( workspace: AuthWorkspace, project: ProjectSummary, action: ProjectSetupResult["action"], + directory: string = context.runtime.cwd, ): Promise> { return Result.gen(async function* () { yield* Result.await( writeLocalResolutionPin( - context.runtime.cwd, + directory, { workspaceId: workspace.id, projectId: project.id, @@ -75,16 +75,13 @@ export async function bindProjectToDirectory( ), ); yield* Result.await( - ensureLocalResolutionPinGitignore( - context.runtime.cwd, - context.runtime.signal, - ), + ensureLocalResolutionPinGitignore(directory, context.runtime.signal), ); return Result.ok({ workspace, project, - directory: formatSetupDirectory(context.runtime.cwd), + directory: formatSetupDirectory(directory, context), localPin: { path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, written: true, @@ -204,8 +201,17 @@ export function projectCreateFailedError( }); } -function formatSetupDirectory(cwd: string): string { - const basename = cwd.split(/[\\/]/).filter(Boolean).pop(); +function formatSetupDirectory( + directory: string, + context: CommandContext, +): string { + // Binding can target an ancestor compute-config directory; a bare + // basename would misread as a subdirectory of the invocation directory. + if (path.resolve(directory) !== path.resolve(context.runtime.cwd)) { + return shortenHomePath(directory, context.runtime.env); + } + + const basename = directory.split(/[\\/]/).filter(Boolean).pop(); return basename ? `./${basename}` : "."; } diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index da59ef5..feeeb70 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -6,6 +6,7 @@ import type { CommandContext } from "../shell/runtime"; import { renderVerboseBlock, type VerboseRow } from "../shell/ui"; import type { AppBuildResult, + AppDeployAllResult, AppDeployResult, AppDeploySettings, AppDomainAddResult, @@ -58,16 +59,22 @@ export function renderAppDeploy( context: CommandContext, descriptor: CommandDescriptor, result: AppDeployResult, + options?: { logsTarget?: string }, ): string[] { void descriptor; + // After a deploy-all, bare `app logs` follows the remembered selection, so + // each app's hint must name its target to actually show that app's logs. + const logsCommand = options?.logsTarget + ? `prisma-cli app logs ${options.logsTarget}` + : "prisma-cli app logs"; const lines = [ `Live in ${formatDuration(result.durationMs)}`, ...(result.deployment.url ? [context.ui.link(result.deployment.url)] : []), ...renderBranchDatabaseDeploySummary(context, result), "", ...renderDeployOutputRows(context.ui, [ - { label: "Logs", value: "prisma-cli app logs" }, + { label: "Logs", value: logsCommand }, ]), ...renderDeployResolvedContextBlock(context, result), ...renderDeploySettingsBlock(context, result), @@ -75,6 +82,51 @@ export function renderAppDeploy( return lines; } +export function isAppDeployAllResult( + result: AppDeployResult | AppDeployAllResult, +): result is AppDeployAllResult { + return "deployments" in result; +} + +export function renderAppDeployAll( + context: CommandContext, + descriptor: CommandDescriptor, + result: AppDeployAllResult, +): string[] { + const lines: string[] = []; + for (const deployment of result.deployments) { + lines.push(deployment.target); + lines.push( + ...renderAppDeploy(context, descriptor, deployment.result, { + logsTarget: deployment.target, + }).map((line) => (line ? ` ${line}` : line)), + ); + lines.push(""); + } + + lines.push( + ...renderDeployOutputRows( + context.ui, + result.deployments.map((deployment) => ({ + label: deployment.target, + value: + deployment.result.deployment.url ?? deployment.result.deployment.id, + })), + ), + ); + return lines; +} + +export function serializeAppDeployAll(result: AppDeployAllResult) { + return { + count: result.deployments.length, + deployments: result.deployments.map((deployment) => ({ + target: deployment.target, + ...serializeAppDeploy(deployment.result), + })), + }; +} + export function serializeAppDeploy(result: AppDeployResult) { const { deploySettings, localPin: _localPin, ...serialized } = result; const { id: _branchId, ...branch } = serialized.branch; @@ -94,7 +146,7 @@ function renderBranchDatabaseDeploySummary( context: CommandContext, result: AppDeployResult, ): string[] { - if (result.branchDatabase?.status !== "created") { + if (!result.branchDatabase || result.branchDatabase.status !== "created") { return []; } @@ -109,33 +161,10 @@ function renderBranchDatabaseDeploySummary( label: "Env", value: result.branchDatabase.envVars.join(", "), }, - ...(result.branchDatabase.schema - ? [ - { - label: "Schema", - value: formatBranchDatabaseSchemaCommand( - result.branchDatabase.schema.command, - ), - }, - ] - : []), ]), ]; } -function formatBranchDatabaseSchemaCommand( - command: "migrate-deploy" | "db-push" | "prisma-next-db-init", -): string { - switch (command) { - case "migrate-deploy": - return "prisma migrate deploy"; - case "db-push": - return "prisma db push"; - case "prisma-next-db-init": - return "prisma-next db init"; - } -} - function formatDuration(durationMs: number): string { if (durationMs < 1000) { return `${durationMs}ms`; @@ -234,14 +263,6 @@ function branchDatabaseRows( ...(branchDatabase.envVars.length > 0 ? [{ key: "branch db env", value: branchDatabase.envVars.join(", ") }] : []), - ...(branchDatabase.schema - ? [ - { - key: "branch db schema", - value: `${formatBranchDatabaseSchemaCommand(branchDatabase.schema.command)} (${branchDatabase.schema.source}, ${branchDatabase.schema.path})`, - }, - ] - : []), ]; } diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index e0776c6..fa928fc 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -1,6 +1,6 @@ import path from "node:path"; - import stringWidth from "string-width"; +import { shortenHomePath } from "../lib/fs/home-path"; import { renderMutate, renderShow, serializeList } from "../output/patterns"; import { formatCommandArgument } from "../shell/command-arguments"; import type { CommandDescriptor } from "../shell/command-meta"; @@ -263,18 +263,7 @@ function renderBoundProjectShow( } function formatLocalRepoPath(cwd: string, env: NodeJS.ProcessEnv): string { - const resolved = path.resolve(cwd); - const home = env.HOME ? path.resolve(env.HOME) : null; - - if ( - home && - (resolved === home || resolved.startsWith(`${home}${path.sep}`)) - ) { - const relative = path.relative(home, resolved); - return relative ? `~/${relative}` : "~"; - } - - return resolved; + return shortenHomePath(cwd, env); } function formatGitConnectionDetail( diff --git a/packages/cli/src/shell/diagnostics-output.ts b/packages/cli/src/shell/diagnostics-output.ts index cd7c2f4..81e4029 100644 --- a/packages/cli/src/shell/diagnostics-output.ts +++ b/packages/cli/src/shell/diagnostics-output.ts @@ -1,5 +1,7 @@ import path from "node:path"; +import { shortenHomePath } from "../lib/fs/home-path"; + import type { CommandDiagnostics } from "../types/diagnostics"; import type { CommandContext } from "./runtime"; import { renderVerboseBlock, type VerboseRow } from "./ui"; @@ -54,18 +56,7 @@ export function renderCommandDiagnostics( } export function formatLocalPath(value: string, env: NodeJS.ProcessEnv): string { - const resolved = path.resolve(value); - const home = env.HOME ? path.resolve(env.HOME) : null; - - if ( - home && - (resolved === home || resolved.startsWith(`${home}${path.sep}`)) - ) { - const relative = path.relative(home, resolved); - return relative ? `~/${relative}` : "~"; - } - - return resolved; + return shortenHomePath(value, env); } function formatDirtyState(dirty: boolean | null): string { diff --git a/packages/cli/src/shell/runtime.ts b/packages/cli/src/shell/runtime.ts index 8b26d1e..89d223c 100644 --- a/packages/cli/src/shell/runtime.ts +++ b/packages/cli/src/shell/runtime.ts @@ -1,7 +1,6 @@ import path from "node:path"; - +import { findComputeConfigDir } from "@prisma/compute-sdk/config"; import type { Command } from "commander"; - import { LocalStateStore } from "../adapters/local-state"; import { MockApi } from "../adapters/mock-api"; import type { GlobalFlags } from "./global-flags"; @@ -61,7 +60,7 @@ export async function createCommandContext( ): Promise { const fixturePath = runtime.fixturePath ?? runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH; - const stateDir = resolveStateDir(runtime); + const stateDir = await resolveStateDir(runtime); // Load the mock API only when fixture mode is explicitly enabled. let loadedApi: MockApi | undefined; @@ -90,12 +89,17 @@ export async function createCommandContext( }; } -export function resolveStateDir(runtime: CliRuntime): string { - return ( - runtime.stateDir ?? - runtime.env.PRISMA_CLI_STATE_DIR ?? - path.join(runtime.cwd, DEFAULT_STATE_DIR_NAME) - ); +export async function resolveStateDir(runtime: CliRuntime): Promise { + const explicitStateDir = runtime.stateDir ?? runtime.env.PRISMA_CLI_STATE_DIR; + if (explicitStateDir) { + return explicitStateDir; + } + + // The compute config marks the project root, so the local state cache lives + // next to it instead of fragmenting across invocation directories. This is + // location-only discovery; the config itself is not loaded here. + const projectDir = await findComputeConfigDir(runtime.cwd, runtime.signal); + return path.join(projectDir ?? runtime.cwd, DEFAULT_STATE_DIR_NAME); } export function canPrompt(context: CommandContext): boolean { diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts index 5b7d6e9..babdaa8 100644 --- a/packages/cli/src/types/app.ts +++ b/packages/cli/src/types/app.ts @@ -28,8 +28,9 @@ export interface AppResolvedContext { export interface AppDeploySettings { config: { - path: string; - status: "created" | "used"; + /** The compute config path when it owns the build settings. */ + path: string | null; + status: "config" | "inferred"; }; buildCommand: { value: string | null; @@ -68,11 +69,6 @@ export interface AppDeployResult { name: string; }; envVars: string[]; - schema: { - command: "migrate-deploy" | "db-push" | "prisma-next-db-init"; - source: "prisma-orm" | "prisma-next"; - path: string; - } | null; }; app: AppSummary; deployment: { @@ -88,6 +84,14 @@ export interface AppDeployResult { }; } +export interface AppDeployAllResult { + /** Aggregate of one full deploy per config target, in declaration order. */ + deployments: Array<{ + target: string; + result: AppDeployResult; + }>; +} + export interface AppListDeploysResult { projectId: string; verboseContext?: AppResolvedContext; diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 3d94fbe..7f0199b 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { asSingleDeployResult } from "./helpers/deploy-result"; import { createProjectClient, createResolveBranch, @@ -48,286 +49,6 @@ afterEach(() => { }); describe("app deploy branch database setup", () => { - it("runs the expected schema setup commands for Prisma Next and Prisma ORM", async () => { - const spawn = vi.fn().mockImplementation(() => { - const child = new EventEmitter(); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - spawn, - }; - }); - - const { createTempCwd, createTestCommandContext } = await import( - "./helpers" - ); - const { runBranchDatabaseSchemaSetup } = await import( - "../src/lib/app/branch-database" - ); - const cwd = await createTempCwd(); - await mkdir(path.join(cwd, "prisma"), { recursive: true }); - await writeFile( - path.join(cwd, "prisma/schema.prisma"), - 'datasource db { provider = "postgresql" url = env("DATABASE_URL") }\n', - ); - await writeFile( - path.join(cwd, "prisma-next.config.ts"), - [ - 'import { defineConfig } from "@prisma-next/postgres/config";', - "", - "export default defineConfig({ contract: './src/prisma/contract.prisma' });", - "", - ].join("\n"), - ); - const { context } = await createTestCommandContext({ - cwd, - stateDir: path.join(cwd, ".state"), - flags: { - quiet: true, - }, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - await runBranchDatabaseSchemaSetup({ - context, - schema: { - kind: "prisma-next", - path: path.join(cwd, "prisma-next.config.ts"), - command: "prisma-next-db-init", - hasMigrations: false, - target: "postgresql", - }, - databaseUrl: "postgres://pooled", - directUrl: "postgres://direct", - }); - - expect(spawn).toHaveBeenNthCalledWith( - 1, - "npx", - [ - "--no-install", - "prisma-next", - "contract", - "emit", - "--config", - "prisma-next.config.ts", - ], - expect.objectContaining({ - cwd, - env: expect.objectContaining({ - DATABASE_URL: "postgres://pooled", - DIRECT_URL: "postgres://direct", - }), - stdio: ["ignore", "ignore", "ignore"], - }), - ); - expect(spawn).toHaveBeenNthCalledWith( - 2, - "npx", - [ - "--no-install", - "prisma-next", - "db", - "init", - "--config", - "prisma-next.config.ts", - "--db", - "postgres://pooled", - ], - expect.objectContaining({ - cwd, - env: expect.objectContaining({ - DATABASE_URL: "postgres://pooled", - DIRECT_URL: "postgres://direct", - }), - stdio: ["ignore", "ignore", "ignore"], - }), - ); - - spawn.mockClear(); - await mkdir(path.join(cwd, "node_modules/.bin"), { recursive: true }); - await writeFile(path.join(cwd, "node_modules/.bin/prisma"), ""); - await runBranchDatabaseSchemaSetup({ - context, - schema: { - kind: "prisma-orm", - path: path.join(cwd, "prisma/schema.prisma"), - command: "db-push", - hasMigrations: false, - target: "postgresql", - }, - databaseUrl: "postgres://pooled", - directUrl: null, - }); - - expect(spawn).toHaveBeenCalledWith( - "npx", - [ - "--no-install", - "prisma", - "db", - "push", - "--schema", - "prisma/schema.prisma", - ], - expect.objectContaining({ - cwd, - env: expect.objectContaining({ - DATABASE_URL: "postgres://pooled", - }), - stdio: ["ignore", "ignore", "ignore"], - }), - ); - expect(spawn.mock.calls[0]?.[1]).not.toContain("--skip-generate"); - }); - - it("falls back to a versioned npx prisma matched to @prisma/client when the prisma CLI is not installed", async () => { - const spawn = vi.fn().mockImplementation(() => { - const child = new EventEmitter(); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - spawn, - }; - }); - - const { createTempCwd, createTestCommandContext } = await import( - "./helpers" - ); - const { runBranchDatabaseSchemaSetup } = await import( - "../src/lib/app/branch-database" - ); - const cwd = await createTempCwd(); - await mkdir(path.join(cwd, "prisma"), { recursive: true }); - await writeFile( - path.join(cwd, "prisma/schema.prisma"), - 'datasource db { provider = "postgresql" url = env("DATABASE_URL") }\n', - ); - await mkdir(path.join(cwd, "node_modules/@prisma/client"), { - recursive: true, - }); - await writeFile( - path.join(cwd, "node_modules/@prisma/client/package.json"), - JSON.stringify({ name: "@prisma/client", version: "5.22.0" }), - ); - const { context } = await createTestCommandContext({ - cwd, - stateDir: path.join(cwd, ".state"), - flags: { - quiet: true, - }, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - await runBranchDatabaseSchemaSetup({ - context, - schema: { - kind: "prisma-orm", - path: path.join(cwd, "prisma/schema.prisma"), - command: "db-push", - hasMigrations: false, - target: "postgresql", - }, - databaseUrl: "postgres://pooled", - directUrl: null, - }); - - expect(spawn).toHaveBeenCalledWith( - "npx", - [ - "--yes", - "prisma@5.22.0", - "db", - "push", - "--schema", - "prisma/schema.prisma", - ], - expect.objectContaining({ cwd }), - ); - }); - - it("falls back to the pinned prisma version when neither the CLI nor @prisma/client is installed", async () => { - const spawn = vi.fn().mockImplementation(() => { - const child = new EventEmitter(); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - vi.doMock("node:child_process", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - spawn, - }; - }); - - const { createTempCwd, createTestCommandContext } = await import( - "./helpers" - ); - const { runBranchDatabaseSchemaSetup } = await import( - "../src/lib/app/branch-database" - ); - const cwd = await createTempCwd(); - await mkdir(path.join(cwd, "prisma"), { recursive: true }); - await writeFile( - path.join(cwd, "prisma/schema.prisma"), - 'datasource db { provider = "postgresql" url = env("DATABASE_URL") }\n', - ); - const { context } = await createTestCommandContext({ - cwd, - stateDir: path.join(cwd, ".state"), - flags: { - quiet: true, - }, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - await runBranchDatabaseSchemaSetup({ - context, - schema: { - kind: "prisma-orm", - path: path.join(cwd, "prisma/schema.prisma"), - command: "migrate-deploy", - hasMigrations: true, - target: "postgresql", - }, - databaseUrl: "postgres://pooled", - directUrl: null, - }); - - expect(spawn).toHaveBeenCalledWith( - "npx", - [ - "--yes", - "prisma@6.19.3", - "migrate", - "deploy", - "--schema", - "prisma/schema.prisma", - ], - expect.objectContaining({ cwd }), - ); - }); - it("deploy --db creates a branch database, applies schema, and writes branch env overrides before deploying", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; @@ -379,11 +100,6 @@ describe("app deploy branch database setup", () => { url: "https://hello-world.prisma.app", }, }); - const runBranchDatabaseSchemaSetup = vi.fn().mockResolvedValue({ - command: "db-push", - source: "prisma-orm", - schemaPath: "prisma/schema.prisma", - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, @@ -393,7 +109,6 @@ describe("app deploy branch database setup", () => { await importOriginal(); return { ...actual, - runBranchDatabaseSchemaSetup, }; }); vi.doMock("../src/lib/app/preview-provider", () => ({ @@ -449,15 +164,6 @@ describe("app deploy branch database setup", () => { branchName: "feature/db", signal: context.runtime.signal, }); - expect(runBranchDatabaseSchemaSetup.mock.calls[0]?.[0].context).toBe( - context, - ); - expect(runBranchDatabaseSchemaSetup.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - databaseUrl: "postgres://pooled", - directUrl: "postgres://direct", - }), - ); expect(createEnvironmentVariable).toHaveBeenCalledWith( expect.objectContaining({ projectId: "proj_123", @@ -479,9 +185,6 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase.mock.invocationCallOrder[0]).toBeLessThan( deployApp.mock.invocationCallOrder[0], ); - expect( - runBranchDatabaseSchemaSetup.mock.invocationCallOrder[0], - ).toBeLessThan(deployApp.mock.invocationCallOrder[0]); expect(deployApp).toHaveBeenCalledWith( expect.objectContaining({ projectId: "proj_123", @@ -489,18 +192,13 @@ describe("app deploy branch database setup", () => { envVars: undefined, }), ); - expect(result.result.branchDatabase).toEqual({ + expect(asSingleDeployResult(result).result.branchDatabase).toEqual({ status: "created", database: { id: "db_1", name: "feature/db", }, envVars: ["DATABASE_URL", "DIRECT_URL"], - schema: { - command: "db-push", - source: "prisma-orm", - path: "prisma/schema.prisma", - }, }); }); @@ -563,11 +261,6 @@ describe("app deploy branch database setup", () => { url: "https://hello-world.prisma.app", }, }); - const runBranchDatabaseSchemaSetup = vi.fn().mockResolvedValue({ - command: "db-push", - source: "prisma-orm", - schemaPath: "prisma/schema.prisma", - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, @@ -577,7 +270,6 @@ describe("app deploy branch database setup", () => { await importOriginal(); return { ...actual, - runBranchDatabaseSchemaSetup, }; }); vi.doMock("../src/lib/app/preview-provider", () => ({ @@ -634,12 +326,6 @@ describe("app deploy branch database setup", () => { branchName: "main", signal: context.runtime.signal, }); - expect(runBranchDatabaseSchemaSetup).toHaveBeenCalledWith( - expect.objectContaining({ - databaseUrl: "postgres://pooled", - directUrl: "postgres://direct", - }), - ); expect(createEnvironmentVariable.mock.calls[0]?.[0]).toEqual({ projectId: "proj_123", className: "production", @@ -657,21 +343,13 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase.mock.invocationCallOrder[0]).toBeLessThan( deployApp.mock.invocationCallOrder[0], ); - expect( - runBranchDatabaseSchemaSetup.mock.invocationCallOrder[0], - ).toBeLessThan(deployApp.mock.invocationCallOrder[0]); - expect(result.result.branchDatabase).toEqual({ + expect(asSingleDeployResult(result).result.branchDatabase).toEqual({ status: "created", database: { id: "db_1", name: "main", }, envVars: ["DATABASE_URL", "DIRECT_URL"], - schema: { - command: "db-push", - source: "prisma-orm", - path: "prisma/schema.prisma", - }, }); }); @@ -709,11 +387,6 @@ describe("app deploy branch database setup", () => { isManagedBySystem: false, }), ); - const runBranchDatabaseSchemaSetup = vi.fn().mockResolvedValue({ - command: "prisma-next-db-init", - source: "prisma-next", - schemaPath: "prisma-next.config.ts", - }); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", app: { @@ -738,7 +411,6 @@ describe("app deploy branch database setup", () => { await importOriginal(); return { ...actual, - runBranchDatabaseSchemaSetup, }; }); vi.doMock("../src/lib/app/preview-provider", () => ({ @@ -794,40 +466,19 @@ describe("app deploy branch database setup", () => { db: true, }); - expect(runBranchDatabaseSchemaSetup).toHaveBeenCalledWith( - expect.objectContaining({ - databaseUrl: "postgres://pooled", - directUrl: "postgres://direct", - schema: expect.objectContaining({ - kind: "prisma-next", - path: path.join(cwd, "prisma-next.config.ts"), - command: "prisma-next-db-init", - hasMigrations: false, - target: "postgresql", - }), - }), - ); expect(createEnvironmentVariable).toHaveBeenCalledWith( expect.objectContaining({ key: "DATABASE_URL", value: "postgres://pooled", }), ); - expect( - runBranchDatabaseSchemaSetup.mock.invocationCallOrder[0], - ).toBeLessThan(deployApp.mock.invocationCallOrder[0]); - expect(result.result.branchDatabase).toEqual({ + expect(asSingleDeployResult(result).result.branchDatabase).toEqual({ status: "created", database: { id: "db_1", name: "feature/next", }, envVars: ["DATABASE_URL", "DIRECT_URL"], - schema: { - command: "prisma-next-db-init", - source: "prisma-next", - path: "prisma-next.config.ts", - }, }); }); @@ -934,11 +585,10 @@ describe("app deploy branch database setup", () => { expect(updateEnvironmentVariable).not.toHaveBeenCalled(); expect(deleteBranchDatabase).not.toHaveBeenCalled(); expect(deployApp).toHaveBeenCalled(); - expect(result.result.branchDatabase).toEqual({ + expect(asSingleDeployResult(result).result.branchDatabase).toEqual({ status: "skipped", reason: "branch-env-exists", envVars: ["DATABASE_URL"], - schema: null, }); }); @@ -1062,11 +712,10 @@ describe("app deploy branch database setup", () => { expect(createEnvironmentVariable).not.toHaveBeenCalled(); expect(updateEnvironmentVariable).not.toHaveBeenCalled(); expect(deployApp).toHaveBeenCalled(); - expect(result.result.branchDatabase).toEqual({ + expect(asSingleDeployResult(result).result.branchDatabase).toEqual({ status: "skipped", reason: "production-env-exists", envVars: ["DATABASE_URL", "DIRECT_URL"], - schema: null, }); }); @@ -1192,11 +841,10 @@ describe("app deploy branch database setup", () => { expect(createEnvironmentVariable).not.toHaveBeenCalled(); expect(updateEnvironmentVariable).not.toHaveBeenCalled(); expect(deployApp).toHaveBeenCalled(); - expect(result.result.branchDatabase).toEqual({ + expect(asSingleDeployResult(result).result.branchDatabase).toEqual({ status: "skipped", reason: "production-env-exists", envVars: [existingKey], - schema: null, }); }); @@ -1338,7 +986,7 @@ describe("app deploy branch database setup", () => { value: "postgres://direct", signal: context.runtime.signal, }); - expect(result.result.branchDatabase).toMatchObject({ + expect(asSingleDeployResult(result).result.branchDatabase).toMatchObject({ status: "created", envVars: ["DATABASE_URL", "DIRECT_URL"], }); @@ -1477,7 +1125,7 @@ describe("app deploy branch database setup", () => { envVarId: "env_direct_url", signal: context.runtime.signal, }); - expect(result.result.branchDatabase).toMatchObject({ + expect(asSingleDeployResult(result).result.branchDatabase).toMatchObject({ status: "created", envVars: ["DATABASE_URL"], }); @@ -1690,7 +1338,7 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase).not.toHaveBeenCalled(); expect(deployApp).toHaveBeenCalled(); - expect(result.result.branchDatabase).toBeUndefined(); + expect(asSingleDeployResult(result).result.branchDatabase).toBeUndefined(); }); it("rejects --db for production apps that already have a live deployment", async () => { @@ -1849,106 +1497,6 @@ describe("app deploy branch database setup", () => { expect(deployApp).not.toHaveBeenCalled(); }); - it("stops deploy when branch database schema setup fails", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); - const branchId = "branch_feature_db"; - const listApps = vi.fn().mockResolvedValue([ - { - id: "app_1", - name: "hello-world", - region: "eu-central-1", - liveDeploymentId: null, - liveUrl: null, - }, - ]); - const createBranchDatabase = vi.fn().mockResolvedValue({ - id: "db_1", - name: "feature/db", - branchId, - databaseUrl: "postgres://pooled", - directUrl: "postgres://direct", - }); - const deleteBranchDatabase = vi.fn().mockResolvedValue(undefined); - const createEnvironmentVariable = vi.fn(); - const updateEnvironmentVariable = vi.fn(); - const deployApp = vi.fn(); - const runBranchDatabaseSchemaSetup = vi - .fn() - .mockRejectedValue(new Error("Migration failed")); - - vi.doMock("../src/lib/auth/guard", () => ({ - requireComputeAuth, - })); - vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - runBranchDatabaseSchemaSetup, - }; - }); - vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ - resolveBranch: vi.fn().mockResolvedValue({ - id: branchId, - name: "feature/db", - role: "preview", - }), - listApps, - createBranchDatabase, - deleteBranchDatabase, - listEnvironmentVariables: vi.fn().mockResolvedValue([]), - createEnvironmentVariable, - updateEnvironmentVariable, - deployApp, - listDeployments: vi.fn(), - showDeployment: vi.fn(), - })), - })); - - const { createTempCwd, createTestCommandContext } = await import( - "./helpers" - ); - const { runAppDeploy } = await import("../src/controllers/app"); - const cwd = await createTempCwd(); - await mkdir(path.join(cwd, "prisma"), { recursive: true }); - await writeFile( - path.join(cwd, "prisma/schema.prisma"), - 'datasource db { provider = "postgresql" url = env("DATABASE_URL") }\n', - ); - const { context } = await createTestCommandContext({ - cwd, - stateDir: path.join(cwd, ".state"), - flags: { - yes: true, - }, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - await expect( - runAppDeploy(context, "hello-world", { - projectRef: "proj_123", - branchName: "feature/db", - framework: "hono", - db: true, - }), - ).rejects.toMatchObject({ - code: "SCHEMA_SETUP_FAILED", - domain: "app", - }); - expect(createBranchDatabase).toHaveBeenCalled(); - expect(deleteBranchDatabase).toHaveBeenCalledWith({ - databaseId: "db_1", - signal: context.runtime.signal, - }); - expect(createEnvironmentVariable).not.toHaveBeenCalled(); - expect(updateEnvironmentVariable).not.toHaveBeenCalled(); - expect(deployApp).not.toHaveBeenCalled(); - }); - it("cleans up the created branch database when env wiring fails", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; diff --git a/packages/cli/tests/app-build.test.ts b/packages/cli/tests/app-build.test.ts index 6d46ff2..a454a06 100644 --- a/packages/cli/tests/app-build.test.ts +++ b/packages/cli/tests/app-build.test.ts @@ -1,4 +1,3 @@ -// biome-ignore-all lint/performance/noAwaitInLoops: Table-driven test cases create isolated temp directories sequentially. import { access, lstat, @@ -21,11 +20,10 @@ afterEach(() => { }); describe("preview build strategy", () => { - it("creates prisma.app.json with inferred Next.js settings", async () => { - const { - PRISMA_APP_CONFIG_SCHEMA_URL, - resolveOrCreatePreviewBuildSettings, - } = await import("../src/lib/app/preview-build"); + it("resolves inferred Next.js settings without writing any file", async () => { + const { resolveInferredPreviewBuildSettings } = await import( + "../src/lib/app/preview-build" + ); const cwd = await createTempCwd(); const appPath = path.join(cwd, "app"); @@ -48,13 +46,13 @@ describe("preview build strategy", () => { "utf8", ); - const resolution = await resolveOrCreatePreviewBuildSettings({ + const resolution = await resolveInferredPreviewBuildSettings({ appPath, buildType: "nextjs", }); - expect(resolution.status).toBe("created"); - expect(resolution.relativeConfigPath).toBe("prisma.app.json"); + expect(resolution.status).toBe("inferred"); + expect(resolution.configPath).toBeNull(); expect(resolution.settings).toEqual({ buildCommand: "bun run build", buildCommandSource: "package.json scripts.build", @@ -63,17 +61,36 @@ describe("preview build strategy", () => { }); await expect( readFile(path.join(appPath, "prisma.app.json"), "utf8"), - ).resolves.toBe( - `${JSON.stringify( - { - $schema: PRISMA_APP_CONFIG_SCHEMA_URL, - buildCommand: "bun run build", - outputDirectory: ".next/standalone", - }, - null, - 2, - )}\n`, + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("describes the strategy-owned builds for nuxt and astro", async () => { + const { resolveInferredPreviewBuildSettings } = await import( + "../src/lib/app/preview-build" ); + const cwd = await createTempCwd(); + + const nuxt = await resolveInferredPreviewBuildSettings({ + appPath: cwd, + buildType: "nuxt", + }); + expect(nuxt.settings).toEqual({ + buildCommand: "nuxt build", + buildCommandSource: "Nuxt default", + outputDirectory: ".output", + outputDirectorySource: "Nuxt output", + }); + + const astro = await resolveInferredPreviewBuildSettings({ + appPath: cwd, + buildType: "astro", + }); + expect(astro.settings).toEqual({ + buildCommand: "astro build", + buildCommandSource: "Astro default", + outputDirectory: "dist", + outputDirectorySource: "Astro output", + }); }); it("packages the full tree with a next start launcher when the build produces no standalone output", async () => { @@ -155,7 +172,9 @@ describe("preview build strategy", () => { "node_modules/.bin/next-link", ); expect((await lstat(linkPath)).isSymbolicLink()).toBe(true); - await expect(readlink(linkPath)).resolves.toBe("../next/package.json"); + expect((await readlink(linkPath)).split(path.sep).join("/")).toBe( + "../next/package.json", + ); await expect( access(path.join(artifact.directory, ".env")), @@ -170,8 +189,8 @@ describe("preview build strategy", () => { } }); - it("creates TanStack and Hono build config defaults", async () => { - const { resolveOrCreatePreviewBuildSettings } = await import( + it("infers TanStack and Hono build defaults", async () => { + const { resolveInferredPreviewBuildSettings } = await import( "../src/lib/app/preview-build" ); const cwd = await createTempCwd(); @@ -208,24 +227,24 @@ describe("preview build strategy", () => { ); await expect( - resolveOrCreatePreviewBuildSettings({ + resolveInferredPreviewBuildSettings({ appPath: tanstackPath, buildType: "tanstack-start", }), ).resolves.toMatchObject({ - status: "created", + status: "inferred", settings: { buildCommand: "vite build", outputDirectory: ".output", }, }); await expect( - resolveOrCreatePreviewBuildSettings({ + resolveInferredPreviewBuildSettings({ appPath: honoPath, buildType: "bun", }), ).resolves.toMatchObject({ - status: "created", + status: "inferred", settings: { buildCommand: null, outputDirectory: ".", @@ -233,103 +252,54 @@ describe("preview build strategy", () => { }); }); - it("uses an existing prisma.app.json without overwriting it", async () => { - const { resolveOrCreatePreviewBuildSettings } = await import( + it("classifies leftover prisma.app.json files for migration", async () => { + const { detectLegacyBuildSettings } = await import( "../src/lib/app/preview-build" ); const cwd = await createTempCwd(); - const appPath = path.join(cwd, "app"); - const configPath = path.join(appPath, "prisma.app.json"); - const config = { - $schema: "custom-schema", - buildCommand: null, - outputDirectory: "custom-output", + const effective = { + buildCommand: "bun run build", + buildCommandSource: null, + outputDirectory: ".next/standalone", + outputDirectorySource: null, }; - await mkdir(appPath, { recursive: true }); - await writeFile( - path.join(appPath, "package.json"), - JSON.stringify( - { - scripts: { - build: "next build", - }, - dependencies: { - next: "15.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - await expect( - resolveOrCreatePreviewBuildSettings({ - appPath, - buildType: "nextjs", - }), - ).resolves.toMatchObject({ - status: "used", - settings: { - buildCommand: null, - buildCommandSource: null, - outputDirectory: "custom-output", - outputDirectorySource: null, - }, - }); - await expect(readFile(configPath, "utf8")).resolves.toBe( - `${JSON.stringify(config, null, 2)}\n`, - ); - }); - - it("rejects invalid prisma.app.json files", async () => { - const { resolveOrCreatePreviewBuildSettings } = await import( - "../src/lib/app/preview-build" - ); - const cwd = await createTempCwd(); - const invalidJsonPath = path.join(cwd, "invalid-json"); - const escapingPath = path.join(cwd, "escaping-output"); + detectLegacyBuildSettings({ appPath: cwd, effective }), + ).resolves.toEqual({ kind: "absent" }); - await mkdir(invalidJsonPath, { recursive: true }); await writeFile( - path.join(invalidJsonPath, "prisma.app.json"), - "{ nope\n", + path.join(cwd, "prisma.app.json"), + JSON.stringify({ + buildCommand: "bun run build", + outputDirectory: ".next/standalone", + }), "utf8", ); - await mkdir(escapingPath, { recursive: true }); + await expect( + detectLegacyBuildSettings({ appPath: cwd, effective }), + ).resolves.toMatchObject({ kind: "matching" }); + await writeFile( - path.join(escapingPath, "prisma.app.json"), - JSON.stringify( - { - buildCommand: "bun run build", - outputDirectory: "../dist", - }, - null, - 2, - ), + path.join(cwd, "prisma.app.json"), + JSON.stringify({ + buildCommand: "custom-build", + outputDirectory: "dist", + }), "utf8", ); - await expect( - resolveOrCreatePreviewBuildSettings({ - appPath: invalidJsonPath, - buildType: "nextjs", - }), - ).rejects.toMatchObject({ - code: "APP_CONFIG_INVALID", - domain: "app", + detectLegacyBuildSettings({ appPath: cwd, effective }), + ).resolves.toMatchObject({ + kind: "custom", + buildCommand: "custom-build", + outputDirectory: "dist", }); + + await writeFile(path.join(cwd, "prisma.app.json"), "{ nope\n", "utf8"); await expect( - resolveOrCreatePreviewBuildSettings({ - appPath: escapingPath, - buildType: "nextjs", - }), - ).rejects.toMatchObject({ - code: "APP_CONFIG_INVALID", - domain: "app", - }); + detectLegacyBuildSettings({ appPath: cwd, effective }), + ).resolves.toMatchObject({ kind: "invalid" }); }); it("resolves package.json build scripts and literal framework output directories", async () => { @@ -514,6 +484,147 @@ describe("preview build strategy", () => { } }); + it("detects the package manager from the workspace root for app build scripts", async () => { + const { resolvePreviewBuildSettings } = await import( + "../src/lib/app/preview-build" + ); + const cases = [ + { + rootFiles: ["pnpm-workspace.yaml", "pnpm-lock.yaml"], + command: "pnpm run build", + }, + { + rootFiles: ["package-lock.json"], + rootPackageJson: { workspaces: ["apps/*"] }, + command: "npm run build", + }, + { + rootFiles: ["yarn.lock"], + rootPackageJson: { workspaces: ["apps/*"] }, + command: "yarn run build", + }, + ]; + + for (const testCase of cases) { + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "apps", "web"); + + await mkdir(appPath, { recursive: true }); + if (testCase.rootPackageJson) { + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify(testCase.rootPackageJson, null, 2), + "utf8", + ); + } + for (const rootFile of testCase.rootFiles) { + await writeFile(path.join(cwd, rootFile), "", "utf8"); + } + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify( + { + scripts: { + build: "next build", + }, + dependencies: { + next: "15.0.0", + }, + }, + null, + 2, + ), + "utf8", + ); + + await expect( + resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + }), + ).resolves.toMatchObject({ + buildCommand: testCase.command, + buildCommandSource: "package.json scripts.build", + }); + } + }); + + it("prefers the app-level lockfile over the workspace root lockfile", async () => { + const { resolvePreviewBuildSettings } = await import( + "../src/lib/app/preview-build" + ); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "apps", "web"); + + await mkdir(appPath, { recursive: true }); + await writeFile(path.join(cwd, "pnpm-workspace.yaml"), "", "utf8"); + await writeFile(path.join(cwd, "pnpm-lock.yaml"), "", "utf8"); + await writeFile(path.join(appPath, "bun.lock"), "", "utf8"); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify( + { + scripts: { + build: "next build", + }, + dependencies: { + next: "15.0.0", + }, + }, + null, + 2, + ), + "utf8", + ); + + await expect( + resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + }), + ).resolves.toMatchObject({ + buildCommand: "bun run build", + }); + }); + + it("does not use lockfiles above the repository root", async () => { + const { resolvePreviewBuildSettings } = await import( + "../src/lib/app/preview-build" + ); + const cwd = await createTempCwd(); + const repoPath = path.join(cwd, "repo"); + const appPath = path.join(repoPath, "app"); + + await mkdir(path.join(repoPath, ".git"), { recursive: true }); + await mkdir(appPath, { recursive: true }); + await writeFile(path.join(cwd, "pnpm-lock.yaml"), "", "utf8"); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify( + { + scripts: { + build: "custom-build", + }, + dependencies: { + next: "15.0.0", + }, + }, + null, + 2, + ), + "utf8", + ); + + await expect( + resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + }), + ).resolves.toMatchObject({ + buildCommand: "custom-build", + }); + }); + it("uses the literal package.json build script when no package manager is detected", async () => { const { resolvePreviewBuildSettings } = await import( "../src/lib/app/preview-build" @@ -684,7 +795,6 @@ describe("preview build strategy", () => { const cwd = await createTempCwd(); const appPath = path.join(cwd, "app"); const standaloneDir = path.join(appPath, ".next", "standalone"); - const nextBin = path.join(appPath, "node_modules", ".bin", "next"); await mkdir(path.join(standaloneDir, ".next", "static"), { recursive: true, @@ -701,7 +811,14 @@ describe("preview build strategy", () => { "hello\n", "utf8", ); - await mkdir(path.dirname(nextBin), { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + scripts: { build: "node -e 0" }, + dependencies: { next: "15.0.0" }, + }), + "utf8", + ); await writeFile( path.join(appPath, "next.config.ts"), "export default { output: 'standalone' };\n", @@ -712,7 +829,6 @@ describe("preview build strategy", () => { "console.log('next');\n", "utf8", ); - await symlink("/usr/bin/true", nextBin); const { executePreviewBuild } = await import( "../src/lib/app/preview-build" diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 85173db..0c9ab4c 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -1,9 +1,9 @@ -// biome-ignore-all lint/performance/noAwaitInLoops: Test fixture file writes are ordered for deterministic setup. import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { asSingleDeployResult } from "./helpers/deploy-result"; import { createProjectClient, createResolveBranch, @@ -176,6 +176,292 @@ async function writeLocalPin( } describe("app controller", () => { + it("deploy with a multi-app config and no target deploys every target in order", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([]); + const deployApp = vi + .fn() + .mockImplementation((options: { appName?: string }) => + Promise.resolve({ + projectId: "proj_123", + app: { + id: `app_${options.appName}`, + name: options.appName, + region: "eu-west-3", + liveDeploymentId: `dep_${options.appName}`, + }, + deployment: { + id: `dep_${options.appName}`, + status: "running", + url: `https://${options.appName}.prisma.app`, + }, + }), + ); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + }), + ), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".git"), { recursive: true }); + await mkdir(path.join(cwd, "apps", "api"), { recursive: true }); + await mkdir(path.join(cwd, "apps", "web"), { recursive: true }); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' api: { root: "apps/api", framework: "hono", entry: "src/index.ts" },', + ' web: { root: "apps/web", framework: "bun", entry: "server.ts" },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppDeploy(context, undefined, { + projectRef: "proj_123", + }); + + expect(deployApp).toHaveBeenCalledTimes(2); + expect(deployApp.mock.calls[0]?.[0]).toMatchObject({ appName: "api" }); + expect(deployApp.mock.calls[1]?.[0]).toMatchObject({ appName: "web" }); + expect(result.result).toMatchObject({ + deployments: [ + { + target: "api", + result: { deployment: { url: "https://api.prisma.app" } }, + }, + { + target: "web", + result: { deployment: { url: "https://web.prisma.app" } }, + }, + ], + }); + }); + + it("deploy-all stops at the first failing target and reports the rest", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([]); + const deployApp = vi.fn().mockRejectedValue(new Error("upload exploded")); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + }), + ), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".git"), { recursive: true }); + await mkdir(path.join(cwd, "apps", "api"), { recursive: true }); + await mkdir(path.join(cwd, "apps", "web"), { recursive: true }); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' api: { root: "apps/api", framework: "hono", entry: "src/index.ts" },', + ' web: { root: "apps/web", framework: "bun", entry: "server.ts" },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect( + runAppDeploy(context, undefined, { projectRef: "proj_123" }), + ).rejects.toMatchObject({ + meta: expect.objectContaining({ + deployAll: { + failedTarget: "api", + completed: [], + notAttempted: ["web"], + }, + }), + }); + expect(deployApp).toHaveBeenCalledTimes(1); + }); + + it("deploy-all rejects per-app flags", async () => { + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".git"), { recursive: true }); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' api: { root: "apps/api" },', + ' web: { root: "apps/web" },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect( + runAppDeploy(context, undefined, { framework: "hono" }), + ).rejects.toMatchObject({ + code: "USAGE_ERROR", + summary: expect.stringContaining("--framework"), + }); + }); + + it("show run from inside a target root uses the root project pin and the config app name", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_api", + name: "api", + region: "eu-west-3", + liveDeploymentId: "dep_api", + liveUrl: null, + }, + { + id: "app_web", + name: "web", + region: "eu-west-3", + liveDeploymentId: "dep_web", + liveUrl: null, + }, + ]); + const listDeployments = vi.fn().mockResolvedValue({ + app: { + id: "app_api", + name: "api", + region: "eu-west-3", + liveDeploymentId: "dep_api", + liveUrl: "https://api.prisma.app", + }, + deployments: [ + { + id: "dep_api", + status: "running", + url: "https://api.prisma.app", + createdAt: "2026-06-10T00:00:00.000Z", + live: true, + }, + ], + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + listApps, + listDeployments, + deployApp: vi.fn(), + showDeployment: vi.fn(), + }), + ), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppShow } = await import("../src/controllers/app"); + const repoDir = await createTempCwd(); + const appCwd = path.join(repoDir, "apps", "api"); + await mkdir(path.join(repoDir, ".git"), { recursive: true }); + await mkdir(path.join(repoDir, ".prisma"), { recursive: true }); + await mkdir(appCwd, { recursive: true }); + await writeFile( + path.join(repoDir, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' api: { root: "apps/api", framework: "hono" },', + ' web: { root: "apps/web", framework: "nextjs" },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + await writeFile( + path.join(repoDir, ".prisma", "local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId: "proj_123" })}\n`, + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd: appCwd, + stateDir: path.join(repoDir, ".state"), + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppShow(context, undefined, undefined, undefined); + + expect(listDeployments).toHaveBeenCalledWith("app_api", expect.anything()); + expect(result.result.app).toEqual({ + id: "app_api", + name: "api", + }); + }); + it("deploy selects the correct existing app when --app is provided", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ @@ -339,7 +625,7 @@ describe("app controller", () => { framework: "hono", }); - expect(result.result.branch).toEqual({ + expect(asSingleDeployResult(result).result.branch).toEqual({ id: "branch_production", name: "production", kind: "preview", @@ -1379,8 +1665,8 @@ describe("app controller", () => { }, deploySettings: { config: { - path: "prisma.app.json", - status: "created", + path: null, + status: "inferred", }, buildCommand: { value: "next build", @@ -1401,15 +1687,13 @@ describe("app controller", () => { ); expect(stderr.buffer).toContain("Saved .prisma/local.json"); expect(stderr.buffer).toContain("Deploying to my-app / feat-j1 / my-app"); - expect(stderr.buffer).toContain("Created prisma.app.json"); + expect(stderr.buffer).toContain("Build settings"); expect(stderr.buffer).toContain("Build Command"); expect(stderr.buffer).toContain("next build"); expect(stderr.buffer).toContain("Output Directory"); expect(stderr.buffer).toContain(".next/standalone"); - await expect(readPrismaAppConfig(cwd)).resolves.toEqual({ - $schema: "https://pris.ly/schemas/prisma-app-config.v1.json", - buildCommand: "next build", - outputDirectory: ".next/standalone", + await expect(readPrismaAppConfig(cwd)).rejects.toMatchObject({ + code: "ENOENT", }); await expect(readLocalPin(cwd)).resolves.toEqual({ workspaceId: "ws_123", @@ -1475,7 +1759,82 @@ describe("app controller", () => { }); }); - it("uses existing prisma.app.json deploy settings", async () => { + it("fails with migration guidance for a customized prisma.app.json", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: null, + liveUrl: null, + }, + ]); + const deployApp = vi.fn(); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + }), + ), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await writePackageJson(cwd, { + name: "hello-world", + dependencies: { + next: "15.0.0", + }, + }); + await writeFile( + path.join(cwd, "prisma.app.json"), + `${JSON.stringify( + { + buildCommand: "bun run custom-build", + outputDirectory: "dist", + }, + null, + 2, + )}\n`, + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + isTTY: false, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect( + runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + framework: "nextjs", + }), + ).rejects.toMatchObject({ + code: "BUILD_SETTINGS_MIGRATION_REQUIRED", + fix: expect.stringContaining( + 'command: "bun run custom-build", outputDirectory: "dist",', + ), + }); + expect(deployApp).not.toHaveBeenCalled(); + }); + + it("warns about and ignores a matching prisma.app.json", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { @@ -1506,14 +1865,16 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ - resolveBranch: createResolveBranch(), - listEnvironmentVariables: vi.fn().mockResolvedValue([]), - listApps, - deployApp, - listDeployments: vi.fn(), - showDeployment: vi.fn(), - })), + createPreviewAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + }), + ), })); const { createTempCwd, createTestCommandContext } = await import( @@ -1531,8 +1892,7 @@ describe("app controller", () => { path.join(cwd, "prisma.app.json"), `${JSON.stringify( { - $schema: "https://pris.ly/schemas/prisma-app-config.v1.json", - buildCommand: "bun run build", + buildCommand: "next build", outputDirectory: ".next/standalone", }, null, @@ -1540,10 +1900,9 @@ describe("app controller", () => { )}\n`, "utf8", ); - const stateDir = path.join(cwd, ".state"); - const { context, stderr } = await createTestCommandContext({ + const { context } = await createTestCommandContext({ cwd, - stateDir, + stateDir: path.join(cwd, ".state"), isTTY: false, env: { ...process.env, @@ -1556,36 +1915,13 @@ describe("app controller", () => { framework: "nextjs", }); - expect(deployApp).toHaveBeenCalledWith( - expect.objectContaining({ - buildType: "nextjs", - buildSettings: { - buildCommand: "bun run build", - buildCommandSource: null, - outputDirectory: ".next/standalone", - outputDirectorySource: null, - }, - }), + expect(result.warnings.join(" ")).toContain( + "prisma.app.json is no longer used", ); - expect(result.result.deploySettings).toMatchObject({ - config: { - path: "prisma.app.json", - status: "used", - }, - buildCommand: { - value: "bun run build", - source: null, - }, - outputDirectory: { - value: ".next/standalone", - source: null, - }, + expect(asSingleDeployResult(result).result.deploySettings.config).toEqual({ + path: null, + status: "inferred", }); - expect(stderr.buffer).toContain("Using prisma.app.json"); - expect(stderr.buffer).toContain("Build Command"); - expect(stderr.buffer).toContain("bun run build"); - expect(stderr.buffer).toContain("Output Directory"); - expect(stderr.buffer).toContain(".next/standalone"); }); it("writes the local binding before build failures and renders build-failure copy", async () => { @@ -2124,9 +2460,11 @@ describe("app controller", () => { appName: path.basename(cwd), }), ); - expect(result.result.project.id).toBe("proj_123"); - expect(result.result.resolution.projectSource).toBe("env"); - expect(result.result.localPin).toBeUndefined(); + expect(asSingleDeployResult(result).result.project.id).toBe("proj_123"); + expect(asSingleDeployResult(result).result.resolution.projectSource).toBe( + "env", + ); + expect(asSingleDeployResult(result).result.localPin).toBeUndefined(); await expect(readLocalPin(cwd)).resolves.toEqual({ workspaceId: "ws_123", projectId: "proj_stale", @@ -2338,8 +2676,10 @@ describe("app controller", () => { }); expect(createProject).not.toHaveBeenCalled(); - expect(result.result.resolution.projectSource).toBe("prompt"); - expect(result.result.localPin).toEqual({ + expect(asSingleDeployResult(result).result.resolution.projectSource).toBe( + "prompt", + ); + expect(asSingleDeployResult(result).result.localPin).toEqual({ path: ".prisma/local.json", written: true, }); @@ -2902,7 +3242,7 @@ describe("app controller", () => { interaction: undefined, }), ); - expect(result.result.app).toEqual({ + expect(asSingleDeployResult(result).result.app).toEqual({ id: "app_new", name: path.basename(cwd), }); @@ -3225,7 +3565,7 @@ describe("app controller", () => { createProjectName: "next-smoke", framework: "hono", }); - expect(firstResult.result.localPin).toEqual({ + expect(asSingleDeployResult(firstResult).result.localPin).toEqual({ path: ".prisma/local.json", written: true, }); @@ -3281,7 +3621,7 @@ describe("app controller", () => { }); expect(createProject).toHaveBeenCalledTimes(1); - expect(secondResult.result.localPin).toBeUndefined(); + expect(asSingleDeployResult(secondResult).result.localPin).toBeUndefined(); expect(stderr.buffer).toContain(`Deploying ./${path.basename(cwd)}`); expect(stderr.buffer).not.toContain("Set up"); await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).resolves.toBe( diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index 6b031a8..f29535d 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -586,8 +586,8 @@ describe("app env vars", () => { }, deploySettings: { config: { - path: "prisma.app.json", - status: "used", + path: null, + status: "inferred", }, buildCommand: { value: "bun run build", @@ -678,8 +678,8 @@ describe("app env vars", () => { }, deploySettings: { config: { - path: "prisma.app.json", - status: "used", + path: null, + status: "inferred", }, buildCommand: { value: "bun run build", diff --git a/packages/cli/tests/app-local-dev.test.ts b/packages/cli/tests/app-local-dev.test.ts index 8fc9238..144b3c7 100644 --- a/packages/cli/tests/app-local-dev.test.ts +++ b/packages/cli/tests/app-local-dev.test.ts @@ -1,3 +1,4 @@ +import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -40,7 +41,10 @@ describe("app local dev commands", () => { stateDir, }); - const result = await runAppBuild(context, "server.ts", "bun"); + const result = await runAppBuild(context, { + entrypoint: "server.ts", + buildType: "bun", + }); expect(executePreviewBuild).toHaveBeenCalledWith({ appPath: cwd, @@ -55,6 +59,211 @@ describe("app local dev commands", () => { }); }); + it("build resolves the app target from prisma.compute.ts", async () => { + const executePreviewBuild = vi.fn().mockResolvedValue({ + artifact: { + directory: "/tmp/compute-build/app", + entrypoint: "index.js", + }, + buildType: "bun", + }); + + vi.doMock("../src/lib/app/preview-build", async () => { + const actual = await vi.importActual< + typeof import("../src/lib/app/preview-build") + >("../src/lib/app/preview-build"); + return { + ...actual, + executePreviewBuild, + }; + }); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppBuild } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await mkdir(path.join(cwd, "apps", "api"), { recursive: true }); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' api: { root: "apps/api", framework: "hono", entry: "src/index.ts" },', + ' web: { root: "apps/web", framework: "nextjs" },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + }); + + await runAppBuild(context, { configTarget: "api" }); + + expect(executePreviewBuild).toHaveBeenCalledWith({ + appPath: path.join(cwd, "apps", "api"), + entrypoint: "src/index.ts", + buildType: "bun", + signal: context.runtime.signal, + }); + }); + + it("build run from inside a target root discovers the config and infers the target", async () => { + const executePreviewBuild = vi.fn().mockResolvedValue({ + artifact: { + directory: "/tmp/compute-build/app", + entrypoint: "index.js", + }, + buildType: "bun", + }); + + vi.doMock("../src/lib/app/preview-build", async () => { + const actual = await vi.importActual< + typeof import("../src/lib/app/preview-build") + >("../src/lib/app/preview-build"); + return { + ...actual, + executePreviewBuild, + }; + }); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppBuild } = await import("../src/controllers/app"); + const repoDir = await createTempCwd(); + const appCwd = path.join(repoDir, "apps", "api", "src"); + await mkdir(path.join(repoDir, ".git"), { recursive: true }); + await mkdir(appCwd, { recursive: true }); + await writeFile( + path.join(repoDir, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' api: { root: "apps/api", framework: "hono", entry: "src/index.ts" },', + ' web: { root: "apps/web", framework: "nextjs" },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd: appCwd, + stateDir: path.join(repoDir, ".state"), + }); + + await runAppBuild(context, {}); + + expect(executePreviewBuild).toHaveBeenCalledWith({ + appPath: path.join(repoDir, "apps", "api"), + entrypoint: "src/index.ts", + buildType: "bun", + signal: context.runtime.signal, + }); + }); + + it("build applies a committed build block by detecting the framework instead of ignoring it", async () => { + const executePreviewBuild = vi.fn().mockResolvedValue({ + artifact: { + directory: "/tmp/compute-build/app", + entrypoint: "server.js", + }, + buildType: "nextjs", + }); + + vi.doMock("../src/lib/app/preview-build", async () => { + const actual = await vi.importActual< + typeof import("../src/lib/app/preview-build") + >("../src/lib/app/preview-build"); + return { + ...actual, + executePreviewBuild, + }; + }); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppBuild } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, "apps", "web"), { recursive: true }); + await writeFile( + path.join(cwd, "apps", "web", "package.json"), + JSON.stringify({ + name: "web", + dependencies: { next: "15.0.0" }, + }), + "utf8", + ); + // No framework declared: the build block still applies via detection. + await writeFile( + path.join(cwd, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' web: { root: "apps/web", build: { command: "echo custom-build", outputDirectory: "out" } },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + }); + + await runAppBuild(context, { configTarget: "web" }); + + expect(executePreviewBuild).toHaveBeenCalledWith( + expect.objectContaining({ + appPath: path.join(cwd, "apps", "web"), + buildType: "nextjs", + buildSettings: expect.objectContaining({ + buildCommand: "echo custom-build", + outputDirectory: "out", + }), + }), + ); + }); + + it("build fails clearly when a build block exists but no framework is detectable", async () => { + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppBuild } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, "apps", "mystery"), { recursive: true }); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + [ + "export default {", + " apps: {", + ' mystery: { root: "apps/mystery", build: { command: "make build", outputDirectory: "out" } },', + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + }); + + await expect( + runAppBuild(context, { configTarget: "mystery" }), + ).rejects.toMatchObject({ + code: "FRAMEWORK_NOT_DETECTED", + }); + }); + it("build accepts explicit SDK framework strategies", async () => { const executePreviewBuild = vi.fn().mockResolvedValue({ artifact: { @@ -85,7 +294,7 @@ describe("app local dev commands", () => { stateDir, }); - const result = await runAppBuild(context, undefined, "astro"); + const result = await runAppBuild(context, { buildType: "astro" }); expect(executePreviewBuild).toHaveBeenCalledWith({ appPath: cwd, @@ -130,14 +339,14 @@ describe("app local dev commands", () => { stateDir, }); - await expect(runAppBuild(context, undefined, "auto")).rejects.toMatchObject( - { - code: "USAGE_ERROR", - domain: "app", - summary: - "App build requires an explicit framework when detection is ambiguous", - }, - ); + await expect( + runAppBuild(context, { buildType: "auto" }), + ).rejects.toMatchObject({ + code: "USAGE_ERROR", + domain: "app", + summary: + "App build requires an explicit framework when detection is ambiguous", + }); }); it("run returns USAGE_ERROR for --json", async () => { @@ -156,7 +365,7 @@ describe("app local dev commands", () => { }); await expect( - runAppRun(context, undefined, "auto", undefined), + runAppRun(context, { buildType: "auto" }), ).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", @@ -189,7 +398,7 @@ describe("app local dev commands", () => { }); await expect( - runAppRun(context, undefined, "auto", undefined), + runAppRun(context, { buildType: "auto" }), ).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", @@ -211,7 +420,7 @@ describe("app local dev commands", () => { }); await expect( - runAppRun(context, "server.ts", "nextjs", undefined), + runAppRun(context, { entrypoint: "server.ts", buildType: "nextjs" }), ).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", @@ -250,7 +459,11 @@ describe("app local dev commands", () => { stateDir, }); - const result = await runAppRun(context, "server.ts", "bun", "4000"); + const result = await runAppRun(context, { + entrypoint: "server.ts", + buildType: "bun", + port: "4000", + }); expect(runLocalApp).toHaveBeenCalledWith({ appPath: cwd, diff --git a/packages/cli/tests/app-presenter.test.ts b/packages/cli/tests/app-presenter.test.ts index 503e84b..1e5a5fa 100644 --- a/packages/cli/tests/app-presenter.test.ts +++ b/packages/cli/tests/app-presenter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { renderAppDeploy, + renderAppDeployAll, renderAppDomainAdd, renderAppDomainRetry, renderAppDomainShow, @@ -71,8 +72,8 @@ function createDeployResult(): AppDeployResult { }, deploySettings: { config: { - path: "prisma.app.json", - status: "used", + path: null, + status: "inferred", }, buildCommand: { value: "bun run build", @@ -227,6 +228,24 @@ describe("app deploy presenter", () => { expect(lines).not.toContain("postgresql://"); }); + it("names each target in the deploy-all logs hints", async () => { + const { context } = await createTestCommandContext({}); + const lines = renderAppDeployAll( + context, + getCommandDescriptor("app.deploy"), + { + deployments: [ + { target: "api", result: createDeployResult() }, + { target: "web", result: createDeployResult() }, + ], + }, + ).join("\n"); + + expect(lines).toContain("prisma-cli app logs api"); + expect(lines).toContain("prisma-cli app logs web"); + expect(lines).not.toMatch(/prisma-cli app logs$/m); + }); + it("keeps verbose-only deploy details out of JSON serialization", () => { const json = JSON.parse( JSON.stringify(serializeAppDeploy(createDeployResult())), @@ -234,8 +253,8 @@ describe("app deploy presenter", () => { expect(json.deploySettings).toEqual({ config: { - path: "prisma.app.json", - status: "used", + path: null, + status: "inferred", }, buildCommand: { value: "bun run build", diff --git a/packages/cli/tests/compute-config.test.ts b/packages/cli/tests/compute-config.test.ts new file mode 100644 index 0000000..295fbf0 --- /dev/null +++ b/packages/cli/tests/compute-config.test.ts @@ -0,0 +1,881 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { defineComputeConfig } from "@prisma/compute-sdk/config"; +import { afterEach, describe, expect, it } from "vitest"; +import { + COMPUTE_CONFIG_FILENAME, + ComputeConfigAmbiguousError, + ComputeConfigInvalidError, + ComputeConfigLoadError, + ComputeConfigTargetRequiredError, + ComputeConfigTargetUnknownError, + type ComputeDeployTarget, + computeConfigErrorToCliError, + computeFrameworkToBuildType, + inferComputeTargetFromCwd, + type LoadedComputeConfig, + loadComputeConfig, + mergeComputeDeployInputs, + mergeComputeLocalInputs, + normalizeComputeConfig, + selectComputeDeployTarget, +} from "../src/lib/app/compute-config"; +import { CliError } from "../src/shell/errors"; + +const CONFIG_PATH = "/repo/prisma.compute.ts"; + +function normalizeOrThrow(exported: unknown): LoadedComputeConfig { + const result = normalizeComputeConfig(exported, CONFIG_PATH); + if (result.isErr()) { + throw result.error; + } + return result.value; +} + +function normalizeIssues(exported: unknown): string[] { + const result = normalizeComputeConfig(exported, CONFIG_PATH); + expect(result.isErr()).toBe(true); + if (!result.isErr()) { + throw new Error("expected invalid config"); + } + expect(result.error).toBeInstanceOf(ComputeConfigInvalidError); + return result.error.issues; +} + +describe("normalizeComputeConfig", () => { + it("normalizes a single-app config", () => { + const config = normalizeOrThrow( + defineComputeConfig({ + app: { + name: "api", + framework: "hono", + httpPort: 8080, + env: ".env", + }, + }), + ); + + expect(config.kind).toBe("single"); + expect(config.relativeConfigPath).toBe(COMPUTE_CONFIG_FILENAME); + expect(config.targets).toEqual([ + { + key: null, + name: "api", + root: null, + framework: "hono", + entry: null, + httpPort: 8080, + envInputs: [".env"], + build: null, + }, + ]); + }); + + it("normalizes a multi-app config with roots and env objects", () => { + const config = normalizeOrThrow( + defineComputeConfig({ + apps: { + web: { + root: "apps/web", + framework: "nextjs", + env: { file: "packages/db/.env" }, + }, + worker: { + root: "./apps/worker/", + framework: "bun", + entry: "src/index.ts", + env: { + file: [".env", ".env.production"], + vars: { LOG_LEVEL: "debug" }, + }, + }, + }, + }), + ); + + expect(config.kind).toBe("multi"); + expect(config.targets).toHaveLength(2); + expect(config.targets[0]).toMatchObject({ + key: "web", + root: "apps/web", + framework: "nextjs", + envInputs: ["packages/db/.env"], + }); + expect(config.targets[1]).toMatchObject({ + key: "worker", + root: "apps/worker", + entry: "src/index.ts", + envInputs: [".env", ".env.production", "LOG_LEVEL=debug"], + }); + }); + + it("rejects non-object default exports", () => { + expect(normalizeIssues(undefined).join(" ")).toContain( + "export default defineComputeConfig", + ); + expect(normalizeIssues([1]).join(" ")).toContain( + "export default defineComputeConfig", + ); + }); + + it("requires exactly one of app or apps", () => { + expect(normalizeIssues({})).toEqual([ + "Define `app` for a single-app repository or `apps` for a multi-app repository.", + ]); + expect(normalizeIssues({ app: {}, apps: {} })).toEqual([ + "Use either `app` (single app) or `apps` (multi-app), not both.", + ]); + expect(normalizeIssues({ apps: {} })).toEqual([ + "`apps` must define at least one app.", + ]); + }); + + it("rejects unknown keys to catch typos", () => { + expect(normalizeIssues({ app: {}, framework: "hono" }).join(" ")).toContain( + 'Unknown top-level key "framework"', + ); + expect(normalizeIssues({ app: { htpPort: 8080 } }).join(" ")).toContain( + 'Unknown key "htpPort"', + ); + expect( + normalizeIssues({ app: { env: { files: ".env" } } }).join(" "), + ).toContain('Unknown key "files"'); + }); + + it("validates field values", () => { + const issues = normalizeIssues({ + apps: { + web: { + name: "", + framework: "rails", + httpPort: 0, + root: "../outside", + env: { vars: { EMPTY: "" } }, + }, + }, + }); + + expect(issues.join(" ")).toContain( + "`apps.web.name` must be a non-empty string.", + ); + expect(issues.join(" ")).toContain( + "`apps.web.framework` must be one of: nextjs, nuxt, astro, hono, tanstack-start, bun.", + ); + expect(issues.join(" ")).toContain( + "`apps.web.httpPort` must be an integer between 1 and 65535.", + ); + expect(issues.join(" ")).toContain( + "`apps.web.root` must be a relative path inside the repository.", + ); + expect(issues.join(" ")).toContain( + "`apps.web.env.vars.EMPTY` must be a non-empty string.", + ); + }); + + it("rejects build blocks for frameworks whose strategy owns the build", () => { + expect( + normalizeIssues({ + app: { framework: "nuxt", build: { command: "nuxt build" } }, + }).join(" "), + ).toContain("`app.build` is not supported with the nuxt framework"); + expect( + normalizeIssues({ + app: { framework: "astro", build: { outputDirectory: "dist" } }, + }).join(" "), + ).toContain("`app.build` is not supported with the astro framework"); + }); + + it("rejects roots that escape the config directory, including Windows drive-relative paths", () => { + for (const root of ["C:apps", "C:/apps", "/apps", "..\\apps"]) { + expect(normalizeIssues({ app: { root } }).join(" ")).toContain( + "`app.root` must be a relative path inside the repository.", + ); + } + }); + + it("normalizes build blocks", () => { + const config = normalizeOrThrow( + defineComputeConfig({ + apps: { + web: { + root: "apps/web", + framework: "nextjs", + build: { + command: "pnpm --filter web build", + outputDirectory: ".next/standalone/", + }, + }, + api: { + root: "apps/api", + framework: "hono", + build: { command: null }, + }, + }, + }), + ); + + expect(config.targets[0]).toMatchObject({ + build: { + command: "pnpm --filter web build", + outputDirectory: ".next/standalone", + }, + }); + expect(config.targets[1]).toMatchObject({ + build: { command: null, outputDirectory: undefined }, + }); + }); + + it("validates build blocks", () => { + const issues = normalizeIssues({ + apps: { + web: { + build: { command: "", outputDir: ".next" }, + db: true, + }, + api: { + build: {}, + }, + }, + }); + + expect(issues.join(" ")).toContain( + "`apps.web.build.command` must be a non-empty string, or null to skip the build step.", + ); + expect(issues.join(" ")).toContain( + 'Unknown key "outputDir" in `apps.web.build`.', + ); + expect(issues.join(" ")).toContain('Unknown key "db" in `apps.web`.'); + expect(issues.join(" ")).toContain( + "`apps.api.build` must set `command` and/or `outputDirectory`.", + ); + }); + + it("rejects entry with frameworks that derive entrypoints from build output", () => { + const issues = normalizeIssues({ + app: { framework: "nextjs", entry: "src/index.ts" }, + }); + expect(issues.join(" ")).toContain( + "`app.entry` is not supported with the nextjs framework", + ); + + expect( + normalizeOrThrow({ app: { framework: "hono", entry: "src/index.ts" } }) + .targets[0]?.entry, + ).toBe("src/index.ts"); + }); +}); + +describe("selectComputeDeployTarget", () => { + const single = normalizeOrThrow({ app: { name: "api" } }); + const multi = normalizeOrThrow({ + apps: { + web: { root: "apps/web" }, + worker: { root: "apps/worker" }, + }, + }); + + it("returns the single app without a target argument", () => { + expect(selectComputeDeployTarget(single, undefined).unwrap().name).toBe( + "api", + ); + }); + + it("accepts a target argument matching the single app name", () => { + expect(selectComputeDeployTarget(single, "api").unwrap().name).toBe("api"); + }); + + it("rejects a target argument that does not match the single app", () => { + const result = selectComputeDeployTarget(single, "web"); + expect(result.isErr() && result.error).toBeInstanceOf( + ComputeConfigTargetUnknownError, + ); + }); + + it("requires a target when multiple apps are configured", () => { + const result = selectComputeDeployTarget(multi, undefined); + expect(result.isErr() && result.error).toBeInstanceOf( + ComputeConfigTargetRequiredError, + ); + if ( + result.isErr() && + result.error instanceof ComputeConfigTargetRequiredError + ) { + expect(result.error.availableTargets).toEqual(["web", "worker"]); + } + }); + + it("selects a multi-app target by key", () => { + expect(selectComputeDeployTarget(multi, "worker").unwrap().root).toBe( + "apps/worker", + ); + }); + + it("defaults to the only app of a single-entry apps map", () => { + const oneEntry = normalizeOrThrow({ apps: { web: { root: "apps/web" } } }); + expect(selectComputeDeployTarget(oneEntry, undefined).unwrap().key).toBe( + "web", + ); + }); + + it("rejects unknown multi-app targets", () => { + const result = selectComputeDeployTarget(multi, "docs"); + expect(result.isErr() && result.error).toBeInstanceOf( + ComputeConfigTargetUnknownError, + ); + }); +}); + +describe("mergeComputeDeployInputs", () => { + const target: ComputeDeployTarget = { + key: "web", + name: null, + root: "apps/web", + framework: "nextjs", + entry: null, + httpPort: 8080, + envInputs: [".env", "LOG_LEVEL=debug"], + build: null, + }; + + it("uses config values when flags are absent", () => { + const merged = mergeComputeDeployInputs({ + cli: {}, + target, + configFilename: COMPUTE_CONFIG_FILENAME, + }); + + expect(merged.framework).toEqual({ + value: "nextjs", + annotation: "set by prisma.compute.ts", + }); + expect(merged.httpPort).toEqual({ + value: "8080", + annotation: "set by prisma.compute.ts", + }); + expect(merged.envInputs).toEqual([".env", "LOG_LEVEL=debug"]); + expect(merged.configAppName).toEqual({ + value: "web", + annotation: "set by prisma.compute.ts", + }); + expect(merged.appRoot).toBe("apps/web"); + }); + + it("prefers explicit flags over config values", () => { + const merged = mergeComputeDeployInputs({ + cli: { + framework: "bun", + entrypoint: "server.ts", + httpPort: "3000", + envInputs: ["DATABASE_URL=postgresql://example"], + }, + target, + configFilename: COMPUTE_CONFIG_FILENAME, + }); + + expect(merged.framework).toEqual({ + value: "bun", + annotation: "set by --framework", + }); + expect(merged.entrypoint).toEqual({ + value: "server.ts", + annotation: "set by --entry", + }); + expect(merged.httpPort).toEqual({ + value: "3000", + annotation: "set by --http-port", + }); + // --env replaces config env inputs entirely; they never merge. + expect(merged.envInputs).toEqual(["DATABASE_URL=postgresql://example"]); + }); + + it("prefers the config name over the apps key", () => { + const merged = mergeComputeDeployInputs({ + cli: {}, + target: { ...target, name: "storefront" }, + configFilename: COMPUTE_CONFIG_FILENAME, + }); + + expect(merged.configAppName?.value).toBe("storefront"); + }); + + it("passes CLI values through without a config file", () => { + const merged = mergeComputeDeployInputs({ + cli: { framework: "hono" }, + target: null, + configFilename: COMPUTE_CONFIG_FILENAME, + }); + + expect(merged.framework).toEqual({ + value: "hono", + annotation: "set by --framework", + }); + expect(merged.entrypoint).toBeUndefined(); + expect(merged.envInputs).toBeUndefined(); + expect(merged.configAppName).toBeUndefined(); + expect(merged.appRoot).toBeUndefined(); + }); +}); + +describe("mergeComputeLocalInputs", () => { + const target: ComputeDeployTarget = { + key: "api", + name: null, + root: "apps/api", + framework: "hono", + entry: "src/index.ts", + httpPort: 8080, + envInputs: [], + build: null, + }; + + it("maps the configured framework to a local build type", () => { + expect(computeFrameworkToBuildType("nextjs")).toBe("nextjs"); + expect(computeFrameworkToBuildType("hono")).toBe("bun"); + expect(computeFrameworkToBuildType("bun")).toBe("bun"); + expect(computeFrameworkToBuildType("tanstack-start")).toBe( + "tanstack-start", + ); + }); + + it("uses config values when flags are absent or default", () => { + const merged = mergeComputeLocalInputs({ + cli: { buildType: "auto" }, + target, + }); + + expect(merged).toEqual({ + entrypoint: "src/index.ts", + buildType: "bun", + buildTypeFromConfig: true, + port: "8080", + appRoot: "apps/api", + }); + }); + + it("prefers explicit flags over config values", () => { + const merged = mergeComputeLocalInputs({ + cli: { entrypoint: "server.ts", buildType: "nextjs", port: "4000" }, + target, + }); + + expect(merged).toEqual({ + entrypoint: "server.ts", + buildType: "nextjs", + buildTypeFromConfig: false, + port: "4000", + appRoot: "apps/api", + }); + }); + + it("falls back to auto detection without a config target", () => { + const merged = mergeComputeLocalInputs({ + cli: { buildType: "auto" }, + target: null, + }); + + expect(merged).toEqual({ + entrypoint: undefined, + buildType: undefined, + buildTypeFromConfig: false, + port: undefined, + appRoot: undefined, + }); + }); +}); + +describe("loadComputeConfig", () => { + const tempDirs: string[] = []; + + async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "prisma-compute-config-")); + tempDirs.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it("returns null when no config file exists", async () => { + const dir = await createTempDir(); + expect((await loadComputeConfig(dir)).unwrap()).toBeNull(); + }); + + it("loads a TypeScript config that imports @prisma/compute-sdk/config", async () => { + const dir = await createTempDir(); + await writeFile( + path.join(dir, "prisma.compute.ts"), + [ + 'import { defineComputeConfig } from "@prisma/compute-sdk/config";', + "", + "export default defineComputeConfig({", + ' app: { name: "api", framework: "hono", httpPort: 8080 satisfies number },', + "});", + "", + ].join("\n"), + "utf8", + ); + + const config = (await loadComputeConfig(dir)).unwrap(); + expect(config?.kind).toBe("single"); + expect(config?.targets[0]).toMatchObject({ + name: "api", + framework: "hono", + httpPort: 8080, + }); + }); + + it("loads a plain JavaScript config", async () => { + const dir = await createTempDir(); + await mkdir(path.join(dir, "apps/web"), { recursive: true }); + await writeFile( + path.join(dir, "prisma.compute.mjs"), + [ + "export default {", + ' apps: { web: { root: "apps/web", framework: "nextjs" } },', + "};", + "", + ].join("\n"), + "utf8", + ); + + const config = (await loadComputeConfig(dir)).unwrap(); + expect(config?.kind).toBe("multi"); + expect(config?.targets[0]).toMatchObject({ + key: "web", + root: "apps/web", + framework: "nextjs", + }); + }); + + it("returns a load error for configs that fail to evaluate", async () => { + const dir = await createTempDir(); + await writeFile( + path.join(dir, "prisma.compute.ts"), + "export default {", + "utf8", + ); + + const result = await loadComputeConfig(dir); + expect(result.isErr() && result.error).toBeInstanceOf( + ComputeConfigLoadError, + ); + }); + + it("returns an invalid error for configs without a default export", async () => { + const dir = await createTempDir(); + await writeFile( + path.join(dir, "prisma.compute.ts"), + 'export const app = { name: "api" };\n', + "utf8", + ); + + const result = await loadComputeConfig(dir); + expect(result.isErr() && result.error).toBeInstanceOf( + ComputeConfigInvalidError, + ); + }); + + it("rejects multiple coexisting config files", async () => { + const dir = await createTempDir(); + await writeFile( + path.join(dir, "prisma.compute.ts"), + "export default { app: {} };\n", + "utf8", + ); + await writeFile( + path.join(dir, "prisma.compute.js"), + "export default { app: {} };\n", + "utf8", + ); + + const result = await loadComputeConfig(dir); + expect(result.isErr() && result.error).toBeInstanceOf( + ComputeConfigAmbiguousError, + ); + }); + + it("discovers the config upward from a nested directory inside a repository", async () => { + const dir = await createTempDir(); + await mkdir(path.join(dir, ".git"), { recursive: true }); + await mkdir(path.join(dir, "apps", "api", "src"), { recursive: true }); + await writeFile( + path.join(dir, "prisma.compute.ts"), + 'export default { apps: { api: { root: "apps/api" } } };\n', + "utf8", + ); + + const config = ( + await loadComputeConfig(path.join(dir, "apps", "api", "src")) + ).unwrap(); + expect(config?.configDir).toBe(dir); + expect(config?.targets[0]?.key).toBe("api"); + }); + + it("prefers the nearest config over an ancestor config", async () => { + const dir = await createTempDir(); + await mkdir(path.join(dir, ".git"), { recursive: true }); + await mkdir(path.join(dir, "apps", "api"), { recursive: true }); + await writeFile( + path.join(dir, "prisma.compute.ts"), + 'export default { app: { name: "root" } };\n', + "utf8", + ); + await writeFile( + path.join(dir, "apps", "api", "prisma.compute.ts"), + 'export default { app: { name: "nested" } };\n', + "utf8", + ); + + const config = ( + await loadComputeConfig(path.join(dir, "apps", "api")) + ).unwrap(); + expect(config?.targets[0]?.name).toBe("nested"); + expect(config?.configDir).toBe(path.join(dir, "apps", "api")); + }); + + it("does not search above the repository root", async () => { + const dir = await createTempDir(); + const repo = path.join(dir, "repo"); + await mkdir(path.join(repo, ".git"), { recursive: true }); + await mkdir(path.join(repo, "app"), { recursive: true }); + // A config above the repository boundary must never be picked up. + await writeFile( + path.join(dir, "prisma.compute.ts"), + 'export default { app: { name: "outside" } };\n', + "utf8", + ); + + expect( + (await loadComputeConfig(path.join(repo, "app"))).unwrap(), + ).toBeNull(); + }); + + it("does not walk upward without a repository boundary", async () => { + const dir = await createTempDir(); + await mkdir(path.join(dir, "nested"), { recursive: true }); + await writeFile( + path.join(dir, "prisma.compute.ts"), + 'export default { app: { name: "parent" } };\n', + "utf8", + ); + + // No .git or workspace marker anywhere: only the invocation directory is checked. + expect( + (await loadComputeConfig(path.join(dir, "nested"))).unwrap(), + ).toBeNull(); + }); + + it("observes config file edits across repeated loads", async () => { + const dir = await createTempDir(); + const configPath = path.join(dir, "prisma.compute.ts"); + await writeFile( + configPath, + 'export default { app: { name: "one" } };\n', + "utf8", + ); + expect((await loadComputeConfig(dir)).unwrap()?.targets[0]?.name).toBe( + "one", + ); + + await writeFile( + configPath, + 'export default { app: { name: "two" } };\n', + "utf8", + ); + expect((await loadComputeConfig(dir)).unwrap()?.targets[0]?.name).toBe( + "two", + ); + }); +}); + +describe("compute config discovery and state location", () => { + const tempDirs: string[] = []; + + async function createTempDir(): Promise { + const dir = await mkdtemp( + path.join(os.tmpdir(), "prisma-compute-discovery-"), + ); + tempDirs.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it("locates the nearest config directory without loading the config", async () => { + const { findComputeConfigDir } = await import("@prisma/compute-sdk/config"); + const dir = await createTempDir(); + await mkdir(path.join(dir, ".git"), { recursive: true }); + await mkdir(path.join(dir, "apps", "api"), { recursive: true }); + // Deliberately broken config: location discovery must not evaluate it. + await writeFile( + path.join(dir, "prisma.compute.ts"), + "export default {", + "utf8", + ); + + expect(await findComputeConfigDir(path.join(dir, "apps", "api"))).toBe(dir); + expect(await findComputeConfigDir(dir)).toBe(dir); + }); + + it("returns null without a config or outside the repository boundary", async () => { + const { findComputeConfigDir } = await import("@prisma/compute-sdk/config"); + const dir = await createTempDir(); + const repo = path.join(dir, "repo"); + await mkdir(path.join(repo, ".git"), { recursive: true }); + await mkdir(path.join(repo, "app"), { recursive: true }); + await writeFile( + path.join(dir, "prisma.compute.ts"), + "export default { app: {} };\n", + "utf8", + ); + + expect(await findComputeConfigDir(path.join(repo, "app"))).toBeNull(); + }); + + it("anchors the default state directory at the config directory", async () => { + const { resolveStateDir } = await import("../src/shell/runtime"); + const dir = await createTempDir(); + const appCwd = path.join(dir, "apps", "api"); + await mkdir(path.join(dir, ".git"), { recursive: true }); + await mkdir(appCwd, { recursive: true }); + await writeFile( + path.join(dir, "prisma.compute.ts"), + "export default { app: {} };\n", + "utf8", + ); + + const runtime = { cwd: appCwd, env: {} } as Parameters< + typeof resolveStateDir + >[0]; + expect(await resolveStateDir(runtime)).toBe( + path.join(dir, ".prisma", "cli"), + ); + + const standaloneRuntime = { + cwd: dir, + env: {}, + stateDir: "/explicit/state", + } as Parameters[0]; + expect(await resolveStateDir(standaloneRuntime)).toBe("/explicit/state"); + }); + + it("keeps the default state directory at the invocation directory without a config", async () => { + const { resolveStateDir } = await import("../src/shell/runtime"); + const dir = await createTempDir(); + const appCwd = path.join(dir, "apps", "api"); + await mkdir(path.join(dir, ".git"), { recursive: true }); + await mkdir(appCwd, { recursive: true }); + + const runtime = { cwd: appCwd, env: {} } as Parameters< + typeof resolveStateDir + >[0]; + expect(await resolveStateDir(runtime)).toBe( + path.join(appCwd, ".prisma", "cli"), + ); + }); +}); + +describe("inferComputeTargetFromCwd", () => { + function multiConfig( + configDir: string, + apps: Record, + ): LoadedComputeConfig { + const result = normalizeComputeConfig( + { apps }, + path.join(configDir, COMPUTE_CONFIG_FILENAME), + ); + if (result.isErr()) { + throw result.error; + } + return result.value; + } + + const config = multiConfig("/repo", { + api: { root: "apps/api" }, + web: { root: "apps/web" }, + }); + + it("infers the target whose root contains the invocation directory", () => { + expect(inferComputeTargetFromCwd(config, "/repo/apps/api")).toBe("api"); + expect(inferComputeTargetFromCwd(config, "/repo/apps/api/src/routes")).toBe( + "api", + ); + expect(inferComputeTargetFromCwd(config, "/repo/apps/web")).toBe("web"); + }); + + it("infers nothing from the config directory or outside any root", () => { + expect(inferComputeTargetFromCwd(config, "/repo")).toBeUndefined(); + expect( + inferComputeTargetFromCwd(config, "/repo/packages/db"), + ).toBeUndefined(); + }); + + it("picks the deepest root when targets nest", () => { + const nested = multiConfig("/repo", { + all: { root: "apps" }, + api: { root: "apps/api" }, + }); + + expect(inferComputeTargetFromCwd(nested, "/repo/apps/api")).toBe("api"); + expect(inferComputeTargetFromCwd(nested, "/repo/apps/web")).toBe("all"); + }); + + it("infers nothing on an ambiguous tie", () => { + const tied = multiConfig("/repo", { + one: { root: "apps/shared" }, + two: { root: "apps/shared" }, + }); + + expect( + inferComputeTargetFromCwd(tied, "/repo/apps/shared"), + ).toBeUndefined(); + }); + + it("never infers for single-app configs", () => { + const single = normalizeComputeConfig( + { app: { name: "api" } }, + "/repo/prisma.compute.ts", + ); + expect( + inferComputeTargetFromCwd(single.unwrap(), "/repo/anywhere"), + ).toBeUndefined(); + }); +}); + +describe("computeConfigErrorToCliError", () => { + it("maps config errors to structured CliErrors", () => { + const invalid = computeConfigErrorToCliError( + new ComputeConfigInvalidError(CONFIG_PATH, ["bad"]), + ); + expect(invalid).toBeInstanceOf(CliError); + expect(invalid.code).toBe("COMPUTE_CONFIG_INVALID"); + expect(invalid.exitCode).toBe(2); + + const required = computeConfigErrorToCliError( + new ComputeConfigTargetRequiredError(CONFIG_PATH, ["web", "worker"]), + ); + expect(required.code).toBe("COMPUTE_CONFIG_TARGET_REQUIRED"); + expect(required.nextSteps).toEqual([ + "prisma-cli app deploy web", + "prisma-cli app deploy worker", + ]); + + const unknown = computeConfigErrorToCliError( + new ComputeConfigTargetUnknownError(CONFIG_PATH, "docs", ["web"]), + ); + expect(unknown.code).toBe("COMPUTE_CONFIG_TARGET_UNKNOWN"); + expect(unknown.summary).toContain('"docs"'); + }); +}); diff --git a/packages/cli/tests/helpers.ts b/packages/cli/tests/helpers.ts index a86f5e0..54b46d1 100644 --- a/packages/cli/tests/helpers.ts +++ b/packages/cli/tests/helpers.ts @@ -180,7 +180,9 @@ async function seedRememberedProjectStateForTest( return; } - await new LocalStateStore(resolveStateDir(runtime)).setRememberedProject({ + await new LocalStateStore( + await resolveStateDir(runtime), + ).setRememberedProject({ id: projectId, name: runtime.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME ?? "Acme Dashboard", workspaceId: runtime.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID ?? "ws_123", diff --git a/packages/cli/tests/helpers/deploy-result.ts b/packages/cli/tests/helpers/deploy-result.ts new file mode 100644 index 0000000..4da9b6d --- /dev/null +++ b/packages/cli/tests/helpers/deploy-result.ts @@ -0,0 +1,17 @@ +import type { AppDeployAllResult, AppDeployResult } from "../../src/types/app"; + +/** + * Narrows a deploy result to the single-app shape; throws on deploy-all. + * Only type-only src imports here, so test files can import this statically + * without loading the CLI module graph before vi.doMock calls apply. + */ +export function asSingleDeployResult< + T extends { result: AppDeployResult | AppDeployAllResult }, +>(success: T): T & { result: AppDeployResult } { + if ("deployments" in success.result) { + throw new Error( + "Expected a single-app deploy result, got a deploy-all result.", + ); + } + return success as T & { result: AppDeployResult }; +} diff --git a/packages/cli/tests/local-branch.test.ts b/packages/cli/tests/local-branch.test.ts new file mode 100644 index 0000000..02f5798 --- /dev/null +++ b/packages/cli/tests/local-branch.test.ts @@ -0,0 +1,68 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { readLocalGitBranch } from "../src/lib/git/local-branch"; +import { createTempCwd } from "./helpers"; + +const signal = new AbortController().signal; + +async function writeGitHead(repoDir: string, head: string): Promise { + await mkdir(path.join(repoDir, ".git"), { recursive: true }); + await writeFile(path.join(repoDir, ".git", "HEAD"), `${head}\n`, "utf8"); +} + +describe("readLocalGitBranch", () => { + it("reads the branch from the repository at cwd", async () => { + const repo = await createTempCwd(); + await writeGitHead(repo, "ref: refs/heads/feat/api"); + + expect(await readLocalGitBranch(repo, signal)).toBe("feat/api"); + }); + + it("walks up to the repository root from inside a monorepo package", async () => { + const repo = await createTempCwd(); + await writeGitHead(repo, "ref: refs/heads/feat/compute"); + const packageDir = path.join(repo, "apps", "api", "src"); + await mkdir(packageDir, { recursive: true }); + + expect(await readLocalGitBranch(packageDir, signal)).toBe("feat/compute"); + }); + + it("treats the nearest repository as the boundary even when its HEAD is detached", async () => { + const outer = await createTempCwd(); + await writeGitHead(outer, "ref: refs/heads/outer-branch"); + const inner = path.join(outer, "vendored"); + await writeGitHead(inner, "0123456789abcdef0123456789abcdef01234567"); + + expect(await readLocalGitBranch(inner, signal)).toBeNull(); + }); + + it("supports worktree-style .git files pointing at the real git directory", async () => { + const base = await createTempCwd(); + const gitDir = path.join(base, "real-git"); + await mkdir(gitDir, { recursive: true }); + await writeFile( + path.join(gitDir, "HEAD"), + "ref: refs/heads/worktree-branch\n", + "utf8", + ); + const worktree = path.join(base, "tree"); + await mkdir(worktree, { recursive: true }); + await writeFile( + path.join(worktree, ".git"), + `gitdir: ${path.join("..", "real-git")}\n`, + "utf8", + ); + + const nested = path.join(worktree, "apps", "web"); + await mkdir(nested, { recursive: true }); + expect(await readLocalGitBranch(nested, signal)).toBe("worktree-branch"); + }); + + it("returns null when no repository contains cwd", async () => { + const dir = await createTempCwd(); + expect(await readLocalGitBranch(dir, signal)).toBeNull(); + }); +}); diff --git a/packages/cli/tests/production-deploy-gate.test.ts b/packages/cli/tests/production-deploy-gate.test.ts index b68b032..6658654 100644 --- a/packages/cli/tests/production-deploy-gate.test.ts +++ b/packages/cli/tests/production-deploy-gate.test.ts @@ -87,12 +87,12 @@ describe("production deploy gate", () => { "", "Production deploys require explicit intent. Re-run with:", "", - " prisma app deploy --prod", + " prisma-cli app deploy --prod", "", "Or deploy a preview from a feature branch:", "", " git checkout -b ", - " prisma app deploy", + " prisma-cli app deploy", ], }); }); diff --git a/packages/cli/tests/publish-prep.test.ts b/packages/cli/tests/publish-prep.test.ts index b2b9f31..5a72a87 100644 --- a/packages/cli/tests/publish-prep.test.ts +++ b/packages/cli/tests/publish-prep.test.ts @@ -1,10 +1,33 @@ +import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { stageCliPublishPackage } from "../../../scripts/prepare-cli-publish.mjs"; +// The script is exercised as a subprocess, exactly as CI invokes it. Tests +// never import it: out-of-root shebang scripts break the Windows transform. +const execFileAsync = promisify(execFile); +const scriptPath = fileURLToPath( + new URL("../../../scripts/prepare-cli-publish.mjs", import.meta.url), +); + +async function stagePackage(options: { + sourceDir: string; + outputDir: string; + publishVersion?: string; +}): Promise { + const { stdout } = await execFileAsync(process.execPath, [ + scriptPath, + options.outputDir, + "--source-dir", + options.sourceDir, + ...(options.publishVersion ? ["--version", options.publishVersion] : []), + ]); + return stdout.trim(); +} function createTempCwd(): Promise { return mkdtemp(path.join(os.tmpdir(), "prisma-cli-")); @@ -28,6 +51,9 @@ describe("prepare cli publish", () => { description: "Command-line interface for the Prisma Developer Platform.", type: "module", + exports: { + "./package.json": "./package.json", + }, engines: { node: ">=20", }, @@ -62,7 +88,7 @@ describe("prepare cli publish", () => { "utf8", ); - const stagedPath = await stageCliPublishPackage({ sourceDir, outputDir }); + const stagedPath = await stagePackage({ sourceDir, outputDir }); const manifest = JSON.parse( await readFile(path.join(stagedPath, "package.json"), "utf8"), ); @@ -76,6 +102,9 @@ describe("prepare cli publish", () => { bin: { "prisma-cli": "./dist/cli.js", }, + exports: { + "./package.json": "./package.json", + }, files: ["dist", "README.md", "LICENSE"], publishConfig: { access: "public", @@ -135,7 +164,7 @@ describe("prepare cli publish", () => { "utf8", ); - const stagedPath = await stageCliPublishPackage({ + const stagedPath = await stagePackage({ sourceDir, outputDir, publishVersion: "3.0.0-beta.0", @@ -195,7 +224,7 @@ describe("prepare cli publish", () => { "utf8", ); - const stagedPath = await stageCliPublishPackage({ sourceDir, outputDir }); + const stagedPath = await stagePackage({ sourceDir, outputDir }); const topLevelFiles = await readdir(stagedPath); const distFiles = await readdir(path.join(stagedPath, "dist")); diff --git a/packages/cli/tests/resolve-cli-version.test.ts b/packages/cli/tests/resolve-cli-version.test.ts index d7061f0..7ae7f09 100644 --- a/packages/cli/tests/resolve-cli-version.test.ts +++ b/packages/cli/tests/resolve-cli-version.test.ts @@ -5,12 +5,8 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { - resolveDevVersion, - resolveNextBetaVersion, - resolvePrVersion, -} from "../../../scripts/resolve-cli-version.mjs"; - +// The script is exercised as a subprocess, exactly as CI invokes it. Tests +// never import it: out-of-root shebang scripts break the Windows transform. const execFileAsync = promisify(execFile); const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -18,48 +14,81 @@ const repoRoot = path.resolve( ); const scriptPath = path.join(repoRoot, "scripts/resolve-cli-version.mjs"); +async function runScript( + args: string[], +): Promise<{ stdout: string; stderr: string; failed: boolean }> { + try { + const { stdout, stderr } = await execFileAsync(process.execPath, [ + scriptPath, + ...args, + ]); + return { stdout, stderr, failed: false }; + } catch (error) { + const failure = error as { stdout?: string; stderr?: string }; + return { + stdout: failure.stdout ?? "", + stderr: failure.stderr ?? "", + failed: true, + }; + } +} + describe("resolve cli version", () => { - it("computes the first beta when npm latest is missing or still legacy 2.x", () => { - expect(resolveNextBetaVersion("")).toBe("3.0.0-beta.0"); - expect(resolveNextBetaVersion("2.20.1")).toBe("3.0.0-beta.0"); + it("computes the first beta when npm latest is missing or still legacy 2.x", async () => { + await expect( + runScript(["next-beta", "--latest", ""]), + ).resolves.toMatchObject({ + stdout: "latest=\nversion=3.0.0-beta.0\n", + failed: false, + }); + await expect( + runScript(["next-beta", "--latest", "2.20.1"]), + ).resolves.toMatchObject({ + stdout: "latest=2.20.1\nversion=3.0.0-beta.0\n", + failed: false, + }); }); - it("increments the beta number from the current npm latest", () => { - expect(resolveNextBetaVersion("3.0.0-beta.0")).toBe("3.0.0-beta.1"); + it("increments the beta number from the current npm latest", async () => { + await expect( + runScript(["next-beta", "--latest", "3.0.0-beta.0"]), + ).resolves.toMatchObject({ + stdout: "latest=3.0.0-beta.0\nversion=3.0.0-beta.1\n", + failed: false, + }); }); - it("fails when npm latest is outside the supported beta line", () => { - expect(() => resolveNextBetaVersion("3.0.0")).toThrow( + it("fails when npm latest is outside the supported beta line", async () => { + const result = await runScript(["next-beta", "--latest", "3.0.0"]); + expect(result.failed).toBe(true); + expect(result.stderr).toContain( "Cannot compute the next beta from npm latest (3.0.0).", ); }); - it("computes a unique dev build version", () => { - expect( - resolveDevVersion({ - runNumber: "123", - runAttempt: "2", - }), - ).toBe("3.0.0-dev.123.2"); + it("computes a unique dev build version", async () => { + await expect( + runScript(["dev", "--run-number", "123", "--run-attempt", "2"]), + ).resolves.toMatchObject({ + stdout: "version=3.0.0-dev.123.2\n", + failed: false, + }); }); - it("computes an exact PR preview version", () => { - expect( - resolvePrVersion({ - prNumber: "43", - sha: "f1110dd704a9382c429b", - }), - ).toBe("3.0.0-pr.43.shaf1110dd704a9"); + it("computes an exact PR preview version", async () => { + await expect( + runScript(["pr", "--pr-number", "43", "--sha", "f1110dd704a9382c429b"]), + ).resolves.toMatchObject({ + stdout: "version=3.0.0-pr.43.shaf1110dd704a9\n", + failed: false, + }); }); it("prints GitHub output lines for the next beta command", async () => { - const { stdout } = await execFileAsync(process.execPath, [ - scriptPath, - "next-beta", - "--latest", - "3.0.0-beta.0", - ]); - - expect(stdout).toBe("latest=3.0.0-beta.0\nversion=3.0.0-beta.1\n"); + await expect( + runScript(["next-beta", "--latest", "3.0.0-beta.0"]), + ).resolves.toMatchObject({ + stdout: "latest=3.0.0-beta.0\nversion=3.0.0-beta.1\n", + }); }); }); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 303877e..9322554 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -10,5 +10,4 @@ export default defineConfig({ unbundle: true, fixedExtension: false, outDir: "dist", - dts: false, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1089f6f..5bd005f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.5.0 version: 1.5.0 '@prisma/compute-sdk': - specifier: ^0.22.0 - version: 0.22.0(@prisma/management-api-sdk@1.37.0) + specifier: ^0.23.0 + version: 0.23.0(@prisma/management-api-sdk@1.37.0) '@prisma/credentials-store': specifier: ^7.8.0 version: 7.8.0 @@ -41,9 +41,6 @@ importers: better-result: specifier: ^2.9.2 version: 2.9.2 - c12: - specifier: 4.0.0-beta.5 - version: 4.0.0-beta.5(dotenv@17.4.2)(jiti@2.7.0)(magicast@0.5.3) colorette: specifier: ^2.0.20 version: 2.0.20 @@ -548,8 +545,8 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@prisma/compute-sdk@0.22.0': - resolution: {integrity: sha512-kP5DjbCuL+HyEOsLCnrbodtTxwPvcBWADzGHfrL+LFa1DdpbM8Uit7rGH8mN311icD4PgCssyg42W4XWVQ6O0g==} + '@prisma/compute-sdk@0.23.0': + resolution: {integrity: sha512-DvgU1CKyiKF3i8tdKnezIlU+XArTv2q/ZFkhqPpisSRGJIaF+Vs8gcUdBAAtT/2Tx7PtZ8rL6Wr2srjTv5uyxg==} engines: {node: '>=18.0.0'} peerDependencies: '@prisma/management-api-sdk': '>=1.36.0' @@ -931,26 +928,6 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} - c12@4.0.0-beta.5: - resolution: {integrity: sha512-yWGCPCQGJeFq4R0mFg5HOhC3Rg+B0PCdM+ldXWUhughoGgeeq8/tjRmXh4/lmhKWyhf+KOFxB/JMXf0Yv1Fd5A==} - peerDependencies: - chokidar: ^5 - dotenv: '*' - giget: '*' - jiti: '*' - magicast: '*' - peerDependenciesMeta: - chokidar: - optional: true - dotenv: - optional: true - giget: - optional: true - jiti: - optional: true - magicast: - optional: true - cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -966,9 +943,6 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -987,9 +961,6 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -1035,9 +1006,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -1167,9 +1135,6 @@ packages: resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} hasBin: true - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1181,9 +1146,6 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} - rc9@3.0.1: - resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1754,10 +1716,11 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@prisma/compute-sdk@0.22.0(@prisma/management-api-sdk@1.37.0)': + '@prisma/compute-sdk@0.23.0(@prisma/management-api-sdk@1.37.0)': dependencies: '@prisma/management-api-sdk': 1.37.0 better-result: 2.9.2 + jiti: 2.7.0 tar-stream: 3.2.0 tiny-invariant: 1.3.3 ws: 8.21.0 @@ -2029,19 +1992,6 @@ snapshots: dependencies: run-applescript: 7.1.0 - c12@4.0.0-beta.5(dotenv@17.4.2)(jiti@2.7.0)(magicast@0.5.3): - dependencies: - confbox: 0.2.4 - defu: 6.1.7 - exsolve: 1.0.8 - pathe: 2.0.3 - pkg-types: 2.3.1 - rc9: 3.0.1 - optionalDependencies: - dotenv: 17.4.2 - jiti: 2.7.0 - magicast: 0.5.3 - cac@7.0.0: {} chai@6.2.2: {} @@ -2050,8 +2000,6 @@ snapshots: commander@14.0.3: {} - confbox@0.2.4: {} - convert-source-map@2.0.0: {} default-browser-id@5.0.1: {} @@ -2065,8 +2013,6 @@ snapshots: defu@6.1.7: {} - destr@2.0.5: {} - dotenv@17.4.2: {} dts-resolver@2.1.3: {} @@ -2147,8 +2093,6 @@ snapshots: expect-type@1.3.0: {} - exsolve@1.0.8: {} - extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -2203,8 +2147,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - jiti@2.7.0: - optional: true + jiti@2.7.0: {} js-yaml@3.14.2: dependencies: @@ -2256,12 +2199,6 @@ snapshots: pkg-pr-new@0.0.75: {} - pkg-types@2.3.1: - dependencies: - confbox: 0.2.4 - exsolve: 1.0.8 - pathe: 2.0.3 - postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -2272,11 +2209,6 @@ snapshots: quansync@1.0.0: {} - rc9@3.0.1: - dependencies: - defu: 6.1.7 - destr: 2.0.5 - resolve-pkg-maps@1.0.0: {} rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.17)(typescript@6.0.3): diff --git a/scripts/prepare-cli-publish.mjs b/scripts/prepare-cli-publish.mjs index 189e02a..f8779c6 100644 --- a/scripts/prepare-cli-publish.mjs +++ b/scripts/prepare-cli-publish.mjs @@ -40,6 +40,7 @@ export async function stageCliPublishPackage(options = {}) { bin: { "prisma-cli": "./dist/cli.js", }, + exports: sourceManifest.exports, files: ["dist", "README.md", "LICENSE"], publishConfig: { access: "public", @@ -85,10 +86,13 @@ function removeUndefinedFields(value) { } async function main() { - const { outputDir, publishVersion } = parseCliArgs(process.argv.slice(2)); + const { outputDir, publishVersion, sourceDir } = parseCliArgs( + process.argv.slice(2), + ); const stagedPath = await stageCliPublishPackage({ ...(outputDir ? { outputDir: path.resolve(outputDir) } : {}), ...(publishVersion ? { publishVersion } : {}), + ...(sourceDir ? { sourceDir: path.resolve(sourceDir) } : {}), }); process.stdout.write(`${stagedPath}\n`); } @@ -96,10 +100,21 @@ async function main() { function parseCliArgs(args) { let outputDir; let publishVersion = process.env.CLI_PUBLISH_VERSION; + let sourceDir; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; + if (arg === "--source-dir") { + const nextArg = args[index + 1]; + if (!nextArg || nextArg.startsWith("--")) { + throw new Error("--source-dir requires a value."); + } + sourceDir = nextArg; + index += 1; + continue; + } + if (arg === "--version") { const nextArg = args[index + 1]; if (!nextArg || nextArg.startsWith("--")) { @@ -130,7 +145,7 @@ function parseCliArgs(args) { throw new Error("Publish version cannot be empty."); } - return { outputDir, publishVersion }; + return { outputDir, publishVersion, sourceDir }; } if ( diff --git a/scripts/prepare-skills.mjs b/scripts/prepare-skills.mjs new file mode 100644 index 0000000..6fe6ae4 --- /dev/null +++ b/scripts/prepare-skills.mjs @@ -0,0 +1,24 @@ +import { spawnSync } from "node:child_process"; + +// Installing agent skills is contributor-machine DX; CI installs must not +// depend on it (the skills CLI's "*" matching is also broken on Windows). +if (process.env.CI) { + console.log("CI detected; skipping agent skills installation."); + process.exit(0); +} + +const result = spawnSync( + "skills", + [ + "add", + "./skills", + "--skill", + "*", + "--agent", + "universal", + "claude-code", + "-y", + ], + { stdio: "inherit", shell: process.platform === "win32" }, +); +process.exit(result.status ?? 1);