From 55783f65d9869f24351cb82e48ed7646dc618d9e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 14:06:27 +0530 Subject: [PATCH 01/16] feat: typed prisma.compute.ts compute config Adds a typed, committed deploy configuration for Prisma Compute and makes the whole app command surface config-aware. Config: - prisma.compute.ts (.mts/.js/.mjs/.cjs) with defineComputeConfig from the new @prisma/cli/config export; single `app` or multi-target `apps` with name, root, framework, entry, httpPort, env, and build per app - compile-time safety (literal unions, app/apps exclusivity, typo suggestions) plus runtime validation reporting every issue at once - loaded via jiti with an alias so configs resolve @prisma/cli/config even when the package is not installed locally Resolution: - upward discovery from cwd to the source root (.git/workspace markers); nearest config wins and discovery never escapes the repository - the config directory is the project directory: local pin, state cache, and the --db schema scan anchor there; config-relative paths (root, env.file) resolve from the config file - target inference: commands run inside a target's root select it - app selection precedence: --app > PRISMA_APP_ID > config target > remembered selection > inference Command surface: - app deploy/build/run and every management command (show, open, logs, list-deploys, promote, rollback, remove, domain *) accept an [app] target argument - bare app deploy with a multi-app config deploys every target in declaration order, fail-fast with already-live/not-attempted reporting; per-app flags are rejected in deploy-all - run/build share deploy's framework detection (one detection, three commands), driven by a framework capability registry that replaces scattered framework literals Build settings: - the config build block owns build settings; otherwise they are inferred with sources shown; prisma.app.json is no longer read or written - leftover matching files warn, customized files fail with BUILD_SETTINGS_MIGRATION_REQUIRED including the exact build block - workspace builds: package-manager detection walks up to the workspace root, ancestor node_modules/.bin dirs join the build PATH, and Next.js standalone staging flattens bun's isolated store alongside pnpm's Database (--db flag flow, no config surface in this PR): - provision-only: deploy creates the branch database and wires DATABASE_URL/DIRECT_URL but never runs schema, migration, or generate commands; it suggests the detected command and a one-time connection URL via database connection create instead Packaging: - exports map with ./config (+ d.ts), jiti dependency, publish-prep now carries exports into the published manifest Spec updated across command-spec, resource-model, output- and error-conventions. 462 tests. --- docs/product/command-spec.md | 148 +++- docs/product/error-conventions.md | 3 +- docs/product/output-conventions.md | 2 +- docs/product/resource-model.md | 4 +- packages/cli/package.json | 8 + packages/cli/src/commands/app/index.ts | 93 +- packages/cli/src/config.ts | 67 ++ packages/cli/src/controllers/app.ts | 832 ++++++++++++++---- .../cli/src/lib/app/branch-database-deploy.ts | 92 +- packages/cli/src/lib/app/branch-database.ts | 171 +--- .../src/lib/app/compute-config-discovery.ts | 53 ++ packages/cli/src/lib/app/compute-config.ts | 717 +++++++++++++++ packages/cli/src/lib/app/frameworks.ts | 118 +++ .../cli/src/lib/app/preview-build-settings.ts | 294 +++---- packages/cli/src/lib/app/preview-build.ts | 70 +- .../cli/src/lib/app/production-deploy-gate.ts | 4 +- packages/cli/src/lib/diagnostics.ts | 2 +- packages/cli/src/lib/fs/source-root.ts | 77 ++ packages/cli/src/lib/project/resolution.ts | 4 +- packages/cli/src/lib/project/setup.ts | 7 +- packages/cli/src/presenters/app.ts | 56 +- packages/cli/src/shell/runtime.ts | 16 +- packages/cli/src/types/app.ts | 18 +- .../cli/tests/app-branch-database.test.ts | 382 +------- packages/cli/tests/app-build.test.ts | 232 +++-- packages/cli/tests/app-controller.test.ts | 300 +++++-- packages/cli/tests/app-env-vars.test.ts | 8 +- packages/cli/tests/app-local-dev.test.ts | 112 ++- packages/cli/tests/app-presenter.test.ts | 8 +- packages/cli/tests/compute-config.test.ts | 635 +++++++++++++ packages/cli/tests/helpers.ts | 3 +- packages/cli/tests/helpers/deploy-result.ts | 13 + .../cli/tests/production-deploy-gate.test.ts | 4 +- packages/cli/tests/publish-prep.test.ts | 14 + packages/cli/tsdown.config.ts | 4 +- pnpm-lock.yaml | 6 +- scripts/prepare-cli-publish.mjs | 1 + 37 files changed, 3296 insertions(+), 1282 deletions(-) create mode 100644 packages/cli/src/config.ts create mode 100644 packages/cli/src/lib/app/compute-config-discovery.ts create mode 100644 packages/cli/src/lib/app/compute-config.ts create mode 100644 packages/cli/src/lib/app/frameworks.ts create mode 100644 packages/cli/src/lib/fs/source-root.ts create mode 100644 packages/cli/tests/compute-config.test.ts create mode 100644 packages/cli/tests/helpers/deploy-result.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4374bd2..70033d2 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,21 @@ 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. 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. `.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 +738,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 +746,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 +759,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 +770,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 +781,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/cli/config`; 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 +- 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 invocation 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/cli/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({ + database: { schema: "packages/db/prisma/schema.prisma" }, + apps: { + web: { root: "apps/web", framework: "nextjs" }, + worker: { root: "apps/worker", framework: "bun", entry: "src/index.ts" }, + }, +}); +``` + Behavior: - requires auth @@ -805,16 +883,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 +904,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 +931,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 +1077,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 +1096,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 +1146,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 +1172,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 +1192,7 @@ Examples: prisma-cli app domain show checkout.acme.com ``` -## `prisma-cli app domain remove ` +## `prisma-cli app domain remove [app]` Purpose: @@ -1134,7 +1212,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 +1234,7 @@ Examples: prisma-cli app domain retry checkout.acme.com ``` -## `prisma-cli app domain wait ` +## `prisma-cli app domain wait [app]` Purpose: @@ -1180,7 +1258,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 +1281,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 +1318,7 @@ Examples: prisma-cli app show-deploy dep_123 ``` -## `prisma-cli app promote --app ` +## `prisma-cli app promote [app] --app ` Purpose: @@ -1260,7 +1338,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 +1358,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..c804202 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -223,7 +223,8 @@ 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 +- `BUILD_SETTINGS_MIGRATION_REQUIRED`: a legacy `prisma.app.json` contains custom build settings that must move into the `build` block of `prisma.compute.ts` - `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..d8e9428 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -38,7 +38,7 @@ 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.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/packages/cli/package.json b/packages/cli/package.json index bacbb5a..b6e1943 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,6 +6,13 @@ "bin": { "prisma-cli": "./dist/cli.js" }, + "exports": { + "./config": { + "types": "./dist/config.d.ts", + "default": "./dist/config.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "README.md", @@ -49,6 +56,7 @@ "colorette": "^2.0.20", "commander": "^14.0.3", "dotenv": "^17.4.2", + "jiti": "^2.7.0", "magicast": "^0.5.3", "open": "^11.0.0", "string-width": "^8.2.1", diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index c7052fd..2d6baa7 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -19,8 +19,10 @@ import { runAppShowDeploy, } from "../../controllers/app"; import { + isAppDeployAllResult, renderAppBuild, renderAppDeploy, + renderAppDeployAll, renderAppDomainAdd, renderAppDomainRemove, renderAppDomainRetry, @@ -35,6 +37,7 @@ import { renderAppShowDeploy, serializeAppBuild, serializeAppDeploy, + serializeAppDeployAll, serializeAppDomainAdd, serializeAppDomainRemove, serializeAppDomainRetry, @@ -54,8 +57,10 @@ import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags" import { runCommand, runStreamingCommand } from "../../shell/command-runner"; import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; import { PREVIEW_BUILD_TYPES } from "../../lib/app/preview-build"; +import { FRAMEWORK_KEYS, LOCAL_DEV_BUILD_TYPES } from "../../lib/app/frameworks"; import type { AppBuildResult, + AppDeployAllResult, AppDeployResult, AppDomainAddResult, AppDomainRemoveResult, @@ -99,6 +104,7 @@ 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")) .addOption( new Option("--build-type ", "Local build type") @@ -107,7 +113,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; @@ -115,7 +121,7 @@ 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), renderJson: (result) => serializeAppBuild(result), @@ -133,16 +139,17 @@ 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; @@ -151,7 +158,7 @@ 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), renderJson: (result) => serializeAppRun(result), @@ -169,13 +176,14 @@ 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(new Option("--create-project ", "Create and link a new Project before deploying")) .addOption(new Option("--branch ", "Branch name")) .addOption( new Option("--framework ", "Framework to deploy") - .choices(["nextjs", "hono", "tanstack-start", "bun"]), + .choices([...FRAMEWORK_KEYS]), ) .addOption(new Option("--entry ", "Entrypoint path for Bun deploys")) .addOption(new Option("--http-port ", "HTTP port override for the deployed app")) @@ -188,7 +196,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; @@ -201,7 +209,7 @@ function createDeployCommand(runtime: CliRuntime): Command { const db = (options as { db?: boolean }).db; const hasDbConflict = hasFlag(runtime.argv, "--db") && hasFlag(runtime.argv, "--no-db"); - await runCommand( + await runCommand( runtime, "app.deploy", options as Record, @@ -229,11 +237,16 @@ function createDeployCommand(runtime: CliRuntime): Command { envAssignments, prod: prod === true, db, + configTarget, }); }, { - renderHuman: (context, descriptor, result) => renderAppDeploy(context, descriptor, result), - renderJson: (result) => serializeAppDeploy(result), + renderHuman: (context, descriptor, result) => isAppDeployAllResult(result) + ? renderAppDeployAll(context, descriptor, result) + : renderAppDeploy(context, descriptor, result), + renderJson: (result) => isAppDeployAllResult(result) + ? serializeAppDeployAll(result) + : serializeAppDeploy(result), }, ); }); @@ -252,11 +265,12 @@ 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; @@ -264,7 +278,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), renderJson: (result) => serializeAppShow(result), @@ -282,11 +296,12 @@ 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; @@ -294,7 +309,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), renderJson: (result) => serializeAppOpen(result), @@ -336,10 +351,11 @@ 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) => { + 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; @@ -348,7 +364,7 @@ function createDomainAddCommand(runtime: CliRuntime): Command { runtime, "app.domain.add", options as Record, - (context) => runAppDomainAdd(context, hostname, { appName, projectRef, branchName }), + (context) => runAppDomainAdd(context, hostname, { appName, projectRef, branchName, configTarget }), { renderHuman: (context, descriptor, result) => renderAppDomainAdd(context, descriptor, result), renderJson: (result) => serializeAppDomainAdd(result), @@ -366,10 +382,11 @@ 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) => { + 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; @@ -378,7 +395,7 @@ function createDomainShowCommand(runtime: CliRuntime): Command { runtime, "app.domain.show", options as Record, - (context) => runAppDomainShow(context, hostname, { appName, projectRef, branchName }), + (context) => runAppDomainShow(context, hostname, { appName, projectRef, branchName, configTarget }), { renderHuman: (context, descriptor, result) => renderAppDomainShow(context, descriptor, result), renderJson: (result) => serializeAppDomainShow(result), @@ -396,10 +413,11 @@ 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) => { + 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; @@ -408,7 +426,7 @@ function createDomainRemoveCommand(runtime: CliRuntime): Command { runtime, "app.domain.remove", options as Record, - (context) => runAppDomainRemove(context, hostname, { appName, projectRef, branchName }), + (context) => runAppDomainRemove(context, hostname, { appName, projectRef, branchName, configTarget }), { renderHuman: (context, descriptor, result) => renderAppDomainRemove(context, descriptor, result), renderJson: (result) => serializeAppDomainRemove(result), @@ -426,10 +444,11 @@ 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) => { + 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; @@ -438,7 +457,7 @@ function createDomainRetryCommand(runtime: CliRuntime): Command { runtime, "app.domain.retry", options as Record, - (context) => runAppDomainRetry(context, hostname, { appName, projectRef, branchName }), + (context) => runAppDomainRetry(context, hostname, { appName, projectRef, branchName, configTarget }), { renderHuman: (context, descriptor, result) => renderAppDomainRetry(context, descriptor, result), renderJson: (result) => serializeAppDomainRetry(result), @@ -456,11 +475,12 @@ 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) => { + 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; @@ -470,7 +490,7 @@ function createDomainWaitCommand(runtime: CliRuntime): Command { runtime, "app.domain.wait", options as Record, - (context) => runAppDomainWait(context, hostname, { appName, projectRef, branchName, timeout }), + (context) => runAppDomainWait(context, hostname, { appName, projectRef, branchName, timeout, configTarget }), ); }); @@ -484,12 +504,13 @@ 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; @@ -498,7 +519,7 @@ function createLogsCommand(runtime: CliRuntime): Command { runtime, "app.logs", options as Record, - (context) => runAppLogs(context, appName, deploymentId, projectRef), + (context) => runAppLogs(context, appName, deploymentId, projectRef, configTarget), ); }); @@ -516,11 +537,12 @@ 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; @@ -528,7 +550,7 @@ 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), renderJson: (result) => serializeAppListDeploys(result), @@ -571,12 +593,13 @@ 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) => { + command.action(async (deploymentId: string, configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const projectRef = (options as { project?: string }).project; @@ -584,7 +607,7 @@ function createPromoteCommand(runtime: CliRuntime): Command { runtime, "app.promote", options as Record, - (context) => runAppPromote(context, deploymentId, appName, projectRef), + (context) => runAppPromote(context, deploymentId, appName, projectRef, configTarget), { renderHuman: (context, descriptor, result) => renderAppPromote(context, descriptor, result), renderJson: (result) => serializeAppPromote(result), @@ -602,12 +625,13 @@ 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; @@ -616,7 +640,7 @@ 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), renderJson: (result) => serializeAppRollback(result), @@ -634,11 +658,12 @@ 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; @@ -646,7 +671,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), renderJson: (result) => serializeAppRemove(result), diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..0605be6 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,67 @@ +/** + * Public typed config surface for `prisma.compute.ts`. + * + * This module is published as `@prisma/cli/config` and must stay free of + * runtime dependencies so user config files can import it cheaply. + */ + +export const COMPUTE_FRAMEWORKS = ["nextjs", "hono", "tanstack-start", "bun"] as const; + +export type ComputeFramework = (typeof COMPUTE_FRAMEWORKS)[number]; + +export interface ComputeEnvConfig { + /** Dotenv file path(s) resolved relative to the config file directory. */ + file?: string | string[]; + /** Inline environment variable assignments. Values are deployed as-is. */ + vars?: Record; +} + +export interface ComputeBuildConfig { + /** Build command run in the app root. `null` skips the build step. */ + command?: string | null; + /** Framework output path relative to the app root, e.g. ".next/standalone". */ + outputDirectory?: string; +} + + +export interface ComputeAppConfig { + /** Deployed app name. Defaults to the `apps` key, then package/directory inference. */ + name?: string; + /** App directory relative to the config file. Defaults to the config file directory. */ + root?: string; + /** Framework to deploy. Defaults to detection from the app directory. */ + framework?: ComputeFramework; + /** Entrypoint path for Bun (and Hono) deploys, relative to the app root. */ + entry?: string; + /** HTTP port the deployed app listens on. Defaults to the framework default. */ + httpPort?: number; + /** Environment variables for the deploy. A string is shorthand for `{ file }`. */ + env?: string | ComputeEnvConfig; + /** Build settings. When present, these own the app's build configuration. */ + build?: ComputeBuildConfig; +} + +/** + * `prisma.compute.ts` accepts exactly one of: + * + * - `app` — a repository that deploys a single app + * - `apps` — a monorepo or multi-app repository, keyed by deploy target + */ +export type ComputeConfig = + | { app: ComputeAppConfig; apps?: never } + | { apps: Record; app?: never }; + +/** + * Identity helper that gives `prisma.compute.ts` full type checking: + * + * ```ts + * import { defineComputeConfig } from "@prisma/cli/config"; + * + * export default defineComputeConfig({ + * app: { framework: "hono", httpPort: 8080 }, + * }); + * ``` + */ +export function defineComputeConfig(config: ComputeConfig): ComputeConfig { + return config; +} diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 18769ce..62bac28 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -14,6 +14,7 @@ import { confirmPrompt, selectPrompt, textPrompt } from "../shell/prompt"; import { renderCommandHeader } from "../shell/ui"; import type { AppBuildResult, + AppDeployAllResult, AppDeployResult, AppDeploymentSummary, AppDomainAddResult, @@ -44,8 +45,8 @@ import { envVarNames, parseEnvInputs } from "../lib/app/env-vars"; import { renderDeployOutputRows, renderDeploySettingsPreview } from "../lib/app/deploy-output"; import { DEFAULT_LOCAL_DEV_PORT, - resolveLocalBuildType, runLocalApp, + type LocalBuildType, } from "../lib/app/local-dev"; import { readBunPackageEntrypoint, readBunPackageJson, type BunPackageJsonLike } from "../lib/app/bun-project"; import { @@ -80,7 +81,11 @@ import { executePreviewBuild, PREVIEW_BUILD_TYPES, RESOLVED_PREVIEW_BUILD_TYPES, - resolveOrCreatePreviewBuildSettings, + detectLegacyBuildSettings, + PRISMA_APP_CONFIG_FILENAME, + resolveConfiguredPreviewBuildSettings, + resolveInferredPreviewBuildSettings, + type PreviewBuildSettings, type PreviewBuildSettingsBuildType, type PreviewBuildSettingsResolution, type ResolvedPreviewBuildType, @@ -101,19 +106,38 @@ import { type PreviewDomainRecord, } from "../lib/app/preview-provider"; import { enforceProductionDeployGate } from "../lib/app/production-deploy-gate"; +import { + COMPUTE_CONFIG_FILENAME, + ComputeConfigTargetRequiredError, + computeConfigErrorToCliError, + computeFrameworkToBuildType, + computeTargetAppDir, + inferComputeTargetFromCwd, + loadComputeConfig, + mergeComputeDeployInputs, + mergeComputeLocalInputs, + selectComputeDeployTarget, + type ComputeConfigCommandName, + type ComputeDeployTarget, + type LoadedComputeConfig, + type MergedDeployInput, +} from "../lib/app/compute-config"; +import type { ComputeFramework } from "../config"; +import { + ENTRYPOINT_BUILD_TYPES, + FRAMEWORKS, + frameworkByKey, + frameworkFromAlias, + isFrameworkBuildType, + LOCAL_DEV_BUILD_TYPES, + type FrameworkDescriptor, +} from "../lib/app/frameworks"; import { formatDomainFailureFix } from "../lib/app/domain-guidance"; import { requireAuthenticatedAuthState } from "./auth"; 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"; @@ -124,17 +148,39 @@ 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); + const buildType = normalizeBuildType(merged.buildType); + assertSupportedEntrypoint(buildType, merged.entrypoint, "build"); + + // 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 && isFrameworkBuildType(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, }); @@ -165,9 +211,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( @@ -179,16 +228,39 @@ export async function runAppRun( ); } - const buildType = normalizeBuildType(requestedBuildType); - assertSupportedEntrypoint(buildType, entrypoint, "run"); - const port = parseLocalPort(requestedPort); - const resolvedBuildType = await requireLocalBuildType(context, buildType, "run"); + const compute = await resolveComputeTargetOrThrow(context, 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, @@ -221,23 +293,178 @@ 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, + 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); const envAppId = readDeployEnvOverride(context, PRISMA_APP_ID_ENV_VAR); assertExclusiveDeployProjectInputs({ @@ -246,22 +473,40 @@ 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, { branch, @@ -276,6 +521,7 @@ export async function runAppDeploy( target.workspace, target.project, target.localPinAction, + projectDir, ); if (setupResult.isErr()) { throw projectDirectoryBindingErrorToCliError(setupResult.error); @@ -286,13 +532,18 @@ export async function runAppDeploy( } let framework = await resolveDeployFramework(context, { - requestedFramework: options?.framework, - entrypoint: options?.entrypoint, - }); - let runtime = resolveDeployRuntime(options?.httpPort, framework); - assertSupportedEntrypoint(framework.buildType, options?.entrypoint, "deploy"); + requestedFramework: merged.framework?.value, + requestedFrameworkAnnotation: merged.framework?.annotation, + entrypoint: merged.entrypoint?.value, + entrypointAnnotation: merged.entrypoint?.annotation, + appDir, + }); + 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, { + // 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", }), ); @@ -300,12 +551,14 @@ export async function runAppDeploy( const selectedApp = await resolveDeployAppSelection(context, projectId, apps, { 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, @@ -315,9 +568,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; @@ -332,25 +585,37 @@ 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"); - const entrypoint = await resolveDeployEntrypoint(context.runtime.cwd, framework, options?.entrypoint, context.runtime.signal); - const buildSettingsResolution = await resolveOrCreatePreviewBuildSettings({ - appPath: context.runtime.cwd, - buildType, - signal: context.runtime.signal, - }); + assertSupportedEntrypoint(buildType, merged.entrypoint?.value, "deploy"); + const entrypoint = await resolveDeployEntrypoint(appDir, framework, merged.entrypoint?.value, context.runtime.signal); + // 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 + ? 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(context, provider, projectId, toBranchDatabaseDeployBranch(target.branch), { db: options?.db, providedEnvVars: envVars, firstProductionDeploy: productionDeployGate.firstProductionDeploy, + projectDir, }); const progressState = createPreviewDeployProgressState(); const deployStartedAt = Date.now(); const deployResult = await provider.deployApp({ - cwd: context.runtime.cwd, + cwd: appDir, projectId, branchName: target.branch.name, appId: selectedApp.appId, @@ -415,7 +680,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}`], }; } @@ -424,14 +689,17 @@ 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); + const selectedApp = await resolveExistingAppSelection(context, projectId, apps, appName ?? compute.configAppName); if (!selectedApp) { return { @@ -487,14 +755,17 @@ 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); + const selectedApp = await resolveExistingAppSelection(context, projectId, apps, appName ?? compute.configAppName); if (!selectedApp) { return { @@ -608,11 +879,15 @@ 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(context, projectId, apps, appName); @@ -694,6 +969,7 @@ export async function runAppDomainAdd( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -729,6 +1005,7 @@ export async function runAppDomainShow( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -756,6 +1033,7 @@ export async function runAppDomainRemove( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -787,6 +1065,7 @@ export async function runAppDomainRetry( appName?: string; projectRef?: string; branchName?: string; + configTarget?: string; }, ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); @@ -815,6 +1094,7 @@ export async function runAppDomainWait( projectRef?: string; branchName?: string; timeout?: string; + configTarget?: string; }, ): Promise { const normalizedHostname = normalizeDomainHostname(hostname); @@ -896,11 +1176,15 @@ 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(context, provider, projectId, resolvedTarget.branch.name, appName, deploymentId) @@ -1095,11 +1379,15 @@ 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(context, projectId, apps, appName, "promote"); @@ -1165,11 +1453,15 @@ 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(context, projectId, apps, appName, "rollback"); @@ -1236,11 +1528,15 @@ 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(context, projectId, apps, appName, "remove"); @@ -1281,11 +1577,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({ @@ -1306,10 +1608,11 @@ async function resolveAppDomainTarget( branch, commandName, envProjectId, + projectDir: compute.projectDir, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await resolveDomainAppSelection(context, projectId, apps, { - explicitAppName: options?.appName, + explicitAppName: options?.appName ?? compute.configAppName, explicitAppId: envAppId, }); @@ -1791,6 +2094,7 @@ async function resolveDeployAppSelection( options: { explicitAppName: string | undefined; explicitAppId: string | undefined; + configAppName: MergedDeployInput | undefined; firstDeploy: boolean; inferName: () => Promise; }, @@ -1846,6 +2150,32 @@ async function resolveDeployAppSelection( }; } + 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) { @@ -2274,6 +2604,7 @@ async function requireProviderAndProjectContext( branch?: ResolvedDeployBranch; commandName?: string; envProjectId?: string; + projectDir?: string; }, ): Promise<{ client: ManagementApiClient; @@ -2324,6 +2655,7 @@ async function resolveProjectContext( branch?: ResolvedDeployBranch; commandName?: string; envProjectId?: string; + projectDir?: string; }, ): Promise { const authState = await requireAuthenticatedAuthState(context); @@ -2336,6 +2668,7 @@ async function resolveProjectContext( workspace: authState.workspace, explicitProject, envProjectId: options?.envProjectId, + projectDir: options?.projectDir, listProjects: () => listRealWorkspaceProjects(client, authState.workspace!, context.runtime.signal), commandName: options?.commandName, }); @@ -2644,15 +2977,177 @@ 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"); + return frameworkFromUserFacingValue( + options.requestedFramework, + options.requestedFrameworkAnnotation ?? "set by --framework", + ); } if (options.entrypoint) { @@ -2660,26 +3155,27 @@ 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, context.runtime.signal); + const detected = await detectDeployFramework(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", }; } @@ -2717,11 +3213,11 @@ 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. @@ -2739,51 +3235,42 @@ async function resolveDeployEntrypoint( async function detectDeployFramework(cwd: string, 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(cwd: string, 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) { +async function detectFrameworkConfigFile( + cwd: string, + framework: FrameworkDescriptor, + signal: AbortSignal, +): Promise<{ exists: boolean; standalone: boolean; path: string | null }> { + for (const candidate of framework.detectConfigFiles) { const filePath = path.join(cwd, candidate); signal.throwIfAborted(); try { @@ -2791,6 +3278,7 @@ async function detectNextConfig(cwd: string, signal: AbortSignal): Promise<{ exi return { exists: true, standalone: /\boutput\s*:\s*["'`]standalone["'`]/.test(content), + path: filePath, }; } catch (error) { if (signal.aborted) throw error; @@ -2800,12 +3288,10 @@ async function detectNextConfig(cwd: string, signal: AbortSignal): Promise<{ exi } } - return { - exists: false, - standalone: false, - }; + return { exists: false, standalone: false, path: null }; } + function hasPackageDependency(packageJson: BunPackageJsonLike | null, dependencyName: string): boolean { return hasDependency(packageJson?.dependencies, dependencyName) || hasDependency(packageJson?.devDependencies, dependencyName); @@ -2824,43 +3310,17 @@ function hasDependency(dependencies: unknown, dependencyName: string): boolean { } 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, + }; } function frameworkNotDetectedError(cwd: string | undefined, requestedFramework?: string): CliError { @@ -2890,6 +3350,7 @@ async function maybeRenderDeploySetupBlock( context: CommandContext, details: { includeDirectory: boolean; + appDir: string; projectName: string; branchName: string; appName: string; @@ -2899,7 +3360,7 @@ 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"; context.output.stderr.write(`${prefix} ${details.projectName} / ${details.branchName} / ${details.appName}\n\n`); } @@ -2913,9 +3374,9 @@ function maybeRenderDeployBuildSettings( } const settings = resolution.settings; - const title = resolution.status === "created" - ? `Created ${resolution.relativeConfigPath}` - : `Using ${resolution.relativeConfigPath}`; + const title = resolution.status === "config" + ? `Using ${resolution.relativeConfigPath}` + : "Build settings"; context.output.stderr.write( `${title}\n` @@ -2994,13 +3455,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"); @@ -3058,17 +3519,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(value: string | undefined): string | undefined { @@ -3089,6 +3541,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 { const state = await context.stateStore.read(); if (state.auth?.workspaceId) { @@ -3135,7 +3596,7 @@ 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)}`, @@ -3162,26 +3623,41 @@ 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, context.runtime.signal); - if (resolvedBuildType) { - return resolvedBuildType; + 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 (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", ); diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index 6288762..f7c234b 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -15,9 +15,7 @@ import type { import { hasBranchDatabaseSignal, inspectBranchDatabaseSignal, - runBranchDatabaseSchemaSetup, type BranchDatabaseSchema, - type BranchDatabaseSchemaSetupResult, type BranchDatabaseSignal, type UnsupportedBranchDatabaseSchema, } from "./branch-database"; @@ -48,6 +46,8 @@ 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) { @@ -94,14 +94,13 @@ export async function maybeSetupBranchDatabase( status: "skipped", reason: existingDatabaseEnvReason(branch), envVars: targetEnvVars, - schema: null, } : undefined, warnings: warning ? [warning] : [], }; } - const localSignal = await inspectBranchDatabaseSignal(context.runtime.cwd, context.runtime.signal); + const localSignal = await inspectBranchDatabaseSignal(options.projectDir, context.runtime.signal); if (localSignal.unsupportedSchema) { if (options.db === true) { throw unsupportedBranchDatabaseSchemaError(localSignal.unsupportedSchema, branch, context); @@ -140,7 +139,7 @@ export async function maybeSetupBranchDatabase( throw nonInteractiveDatabaseSetupRequiresYesError(branch); } - return setupBranchDatabase(context, provider, projectId, branch, localSignal, envState); + return setupBranchDatabase(context, provider, projectId, branch, localSignal, envState, options.projectDir); } async function setupBranchDatabase( @@ -150,6 +149,7 @@ async function setupBranchDatabase( branch: BranchDatabaseDeployBranch, signal: BranchDatabaseSignal, envState: BranchDatabaseEnvState, + projectDir: string, ): Promise { emitBranchDatabaseProgress(context, "pending", "Creating database"); const database = await provider.createBranchDatabase({ @@ -163,30 +163,17 @@ async function setupBranchDatabase( emitBranchDatabaseProgress(context, "success", "Created database"); try { - let schemaSetup: BranchDatabaseSchemaSetupResult | null = null; - const warnings: string[] = []; - let skippedSchemaWarning: string | null = null; - if (signal.schema) { - emitBranchDatabaseProgress(context, "pending", `Applying database schema with ${formatSchemaSetupCommand(signal.schema.command)}`); - schemaSetup = await runBranchDatabaseSchemaSetup({ - context, - schema: signal.schema, - databaseUrl: database.databaseUrl, - directUrl: database.directUrl, - }).catch((error) => { - throw schemaSetupFailedError(error, signal.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, projectId, branch, database, envState); emitBranchDatabaseProgress(context, "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: { @@ -196,15 +183,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(context, provider, database, branch, error); @@ -475,7 +455,7 @@ function nonInteractiveDatabaseSetupRequiresYesError(branch: BranchDatabaseDeplo ); } -function formatSchemaSetupCommand(command: BranchDatabaseSchemaSetupResult["command"]): string { +function formatSchemaSetupCommand(command: BranchDatabaseSchema["command"]): string { switch (command) { case "migrate-deploy": return "prisma migrate deploy"; @@ -558,32 +538,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, @@ -619,20 +573,6 @@ function formatProjectEnvAddNextStep(branch: BranchDatabaseDeployBranch): string : `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" : "schema.prisma"; diff --git a/packages/cli/src/lib/app/branch-database.ts b/packages/cli/src/lib/app/branch-database.ts index 843af73..73b5b34 100644 --- a/packages/cli/src/lib/app/branch-database.ts +++ b/packages/cli/src/lib/app/branch-database.ts @@ -1,9 +1,7 @@ -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" | "prisma-next-db-init"; export type BranchDatabaseSchemaSourceKind = "prisma-orm" | "prisma-next"; @@ -35,12 +33,6 @@ export interface BranchDatabaseSignal { databaseUrlReferences: string[]; } -export interface BranchDatabaseSchemaSetupResult { - command: BranchDatabaseSchemaCommand; - source: BranchDatabaseSchemaSourceKind; - schemaPath: string; -} - const SKIPPED_DIRECTORIES = new Set([ ".git", ".next", @@ -127,33 +119,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; @@ -172,6 +137,7 @@ interface PrismaOrmSchemaSelection { unsupportedSchema: UnsupportedBranchDatabaseSchema | null; } + async function scanDirectory( cwd: string, directory: string, @@ -412,143 +378,8 @@ async function readTextFileIfSmall(filePath: string, signal: AbortSignal): Promi 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-discovery.ts b/packages/cli/src/lib/app/compute-config-discovery.ts new file mode 100644 index 0000000..30e4897 --- /dev/null +++ b/packages/cli/src/lib/app/compute-config-discovery.ts @@ -0,0 +1,53 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +import { sourceRootLineage } from "../fs/source-root"; + +export const COMPUTE_CONFIG_FILENAME = "prisma.compute.ts"; + +// Highest priority first. TypeScript is the canonical format; the rest exist +// so plain JavaScript projects are not forced into TypeScript. +export const COMPUTE_CONFIG_FILENAMES = [ + "prisma.compute.ts", + "prisma.compute.mts", + "prisma.compute.js", + "prisma.compute.mjs", + "prisma.compute.cjs", +] as const; + +/** + * Compute config files present in one directory, in filename priority order. + */ +export async function findComputeConfigCandidates(directory: string, signal?: AbortSignal): Promise { + const candidates: string[] = []; + for (const filename of COMPUTE_CONFIG_FILENAMES) { + const configPath = path.join(directory, filename); + signal?.throwIfAborted(); + try { + await access(configPath); + candidates.push(configPath); + } catch (error) { + if (signal?.aborted) throw error; + } + } + signal?.throwIfAborted(); + + return candidates; +} + +/** + * Locates the nearest directory holding a compute config file, searching from + * `cwd` up to the source root. This is location-only discovery — the config + * is not loaded or validated — so it is safe to run during CLI bootstrap. + * Returns null when no config exists inside the repository boundary. + */ +export async function findComputeConfigDir(cwd: string, signal?: AbortSignal): Promise { + for (const directory of await sourceRootLineage(cwd, signal)) { + const candidates = await findComputeConfigCandidates(directory, signal); + if (candidates.length > 0) { + return directory; + } + } + + return null; +} 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..94133be --- /dev/null +++ b/packages/cli/src/lib/app/compute-config.ts @@ -0,0 +1,717 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Result, TaggedError, matchError } from "better-result"; +import { createJiti } from "jiti"; + +import { COMPUTE_FRAMEWORKS, type ComputeFramework } from "../../config"; +import { frameworkByKey, type FrameworkBuildType } from "./frameworks"; +import { CliError } from "../../shell/errors"; +import { sourceRootLineage } from "../fs/source-root"; +import { COMPUTE_CONFIG_FILENAME, findComputeConfigCandidates } from "./compute-config-discovery"; +import { normalizeRelativePath } from "./preview-build-settings"; + +export { COMPUTE_CONFIG_FILENAME, COMPUTE_CONFIG_FILENAMES } from "./compute-config-discovery"; + +export interface ComputeDeployTargetBuild { + /** Build command, null to skip the build step, undefined when not configured. */ + command: string | null | undefined; + /** Normalized output path relative to the app root, undefined when not configured. */ + outputDirectory: string | undefined; +} + +export interface ComputeDeployTarget { + /** `apps` map key, or null for a single-app `app` config. */ + key: string | null; + name: string | null; + /** Normalized app directory relative to the config file, or null for the config directory. */ + root: string | null; + framework: ComputeFramework | null; + entry: string | null; + httpPort: number | null; + /** `--env`-shaped inputs: dotenv file paths first, then NAME=VALUE assignments. */ + envInputs: string[]; + /** Build settings; non-null means the config owns build configuration. */ + build: ComputeDeployTargetBuild | null; +} + +export interface LoadedComputeConfig { + configPath: string; + /** Directory containing the config file. Config-relative paths resolve from here. */ + configDir: string; + relativeConfigPath: string; + kind: "single" | "multi"; + targets: ComputeDeployTarget[]; +} + +export class ComputeConfigAmbiguousError extends TaggedError("ComputeConfigAmbiguousError")<{ + message: string; + configPaths: string[]; +}>() { + constructor(configPaths: string[]) { + super({ + message: `Multiple compute config files exist: ${configPaths.map((configPath) => path.basename(configPath)).join(", ")}. Keep exactly one.`, + configPaths, + }); + } +} + +export class ComputeConfigLoadError extends TaggedError("ComputeConfigLoadError")<{ + message: string; + cause: unknown; + configPath: string; +}>() { + constructor(configPath: string, cause: unknown) { + super({ + message: `Could not load ${path.basename(configPath)}: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + configPath, + }); + } +} + +export class ComputeConfigInvalidError extends TaggedError("ComputeConfigInvalidError")<{ + message: string; + configPath: string; + issues: string[]; +}>() { + constructor(configPath: string, issues: string[]) { + super({ + message: `${path.basename(configPath)} is invalid: ${issues.join(" ")}`, + configPath, + issues, + }); + } +} + +export type ComputeConfigError = + | ComputeConfigAmbiguousError + | ComputeConfigLoadError + | ComputeConfigInvalidError; + +export class ComputeConfigTargetRequiredError extends TaggedError("ComputeConfigTargetRequiredError")<{ + message: string; + configPath: string; + availableTargets: string[]; +}>() { + constructor(configPath: string, availableTargets: string[]) { + super({ + message: `${path.basename(configPath)} defines multiple apps. Pass a target: ${availableTargets.join(", ")}.`, + configPath, + availableTargets, + }); + } +} + +export class ComputeConfigTargetUnknownError extends TaggedError("ComputeConfigTargetUnknownError")<{ + message: string; + configPath: string; + requestedTarget: string; + availableTargets: string[]; +}>() { + constructor(configPath: string, requestedTarget: string, availableTargets: string[]) { + super({ + message: `${path.basename(configPath)} does not define an app named "${requestedTarget}". Available: ${availableTargets.join(", ")}.`, + configPath, + requestedTarget, + availableTargets, + }); + } +} + +export type ComputeConfigTargetError = + | ComputeConfigTargetRequiredError + | ComputeConfigTargetUnknownError; + +/** + * Loads the nearest compute config, searching from `cwd` up to the source + * root (repository or workspace boundary). Without such a boundary only + * `cwd` itself is checked, so discovery never escapes into unrelated + * directories. + */ +export async function loadComputeConfig( + cwd: string, + signal?: AbortSignal, +): Promise> { + return Result.gen(async function* () { + for (const directory of await sourceRootLineage(cwd, signal)) { + const candidates = await findComputeConfigCandidates(directory, signal); + if (candidates.length === 0) { + continue; + } + if (candidates.length > 1) { + return Result.err(new ComputeConfigAmbiguousError(candidates)); + } + + const configPath = candidates[0]!; + const exported = yield* Result.await(importComputeConfigModule(configPath)); + const normalized = yield* normalizeComputeConfig(exported, configPath); + return Result.ok(normalized); + } + + return Result.ok(null); + }); +} + +async function importComputeConfigModule( + configPath: string, +): Promise> { + return Result.tryPromise({ + try: async () => { + const ownConfigModulePath = resolveOwnConfigModulePath(); + const jiti = createJiti(import.meta.url, { + // Keep Node's standard interop so `.default` exists only for a real + // default export (ESM) or module.exports (CJS). + interopDefault: false, + // Re-import fresh so repeated loads in one process observe file edits. + moduleCache: false, + ...(ownConfigModulePath + ? { + alias: { + // Resolve the typed helper to this running CLI so config files + // work even when @prisma/cli is not installed in the project. + "@prisma/cli/config": ownConfigModulePath, + }, + } + : {}), + }); + const moduleNamespace = await jiti.import>(configPath); + // Require an explicit default export instead of jiti's namespace + // interop so named-export configs fail with a clear message. + return moduleNamespace?.default; + }, + catch: (cause) => new ComputeConfigLoadError(configPath, cause), + }); +} + +function resolveOwnConfigModulePath(): string | null { + // dist layout: dist/lib/app/compute-config.js -> dist/config.js + // src layout (tsx/vitest): src/lib/app/compute-config.ts -> src/config.ts + for (const candidate of ["../../config.js", "../../config.ts"]) { + const candidatePath = fileURLToPath(new URL(candidate, import.meta.url)); + if (existsSync(candidatePath)) { + return candidatePath; + } + } + + return null; +} + +const KNOWN_APP_KEYS = ["name", "root", "framework", "entry", "httpPort", "env", "build"] as const; +const KNOWN_ENV_KEYS = ["file", "vars"] as const; +const KNOWN_BUILD_KEYS = ["command", "outputDirectory"] as const; + + +export function normalizeComputeConfig( + exported: unknown, + configPath: string, +): Result { + const issues: string[] = []; + const targets: ComputeDeployTarget[] = []; + let kind: LoadedComputeConfig["kind"] = "single"; + + if (!isPlainObject(exported)) { + issues.push("The config must `export default defineComputeConfig({ ... })` with an object value."); + } else { + const hasApp = exported.app !== undefined; + const hasApps = exported.apps !== undefined; + + for (const key of Object.keys(exported)) { + if (key !== "app" && key !== "apps") { + issues.push(`Unknown top-level key "${key}". Expected "app" or "apps".`); + } + } + + if (hasApp && hasApps) { + issues.push("Use either `app` (single app) or `apps` (multi-app), not both."); + } else if (hasApp) { + const target = normalizeAppEntry(exported.app, "app", null, issues); + if (target) { + targets.push(target); + } + } else if (hasApps) { + kind = "multi"; + if (!isPlainObject(exported.apps)) { + issues.push("`apps` must be an object keyed by deploy target name."); + } else { + const entries = Object.entries(exported.apps); + if (entries.length === 0) { + issues.push("`apps` must define at least one app."); + } + for (const [key, value] of entries) { + if (key.trim().length === 0) { + issues.push("`apps` keys must be non-empty target names."); + continue; + } + const target = normalizeAppEntry(value, `apps.${key}`, key, issues); + if (target) { + targets.push(target); + } + } + } + } else { + issues.push("Define `app` for a single-app repository or `apps` for a multi-app repository."); + } + } + + if (issues.length > 0) { + return Result.err(new ComputeConfigInvalidError(configPath, issues)); + } + + return Result.ok({ + configPath, + configDir: path.dirname(configPath), + relativeConfigPath: path.basename(configPath), + kind, + targets, + }); +} + + +/** Absolute app directory of a config target. */ +export function computeTargetAppDir(config: LoadedComputeConfig, target: ComputeDeployTarget): string { + return path.resolve(config.configDir, target.root ?? "."); +} + +/** + * Infers the deploy target whose app directory contains `cwd`, so commands + * run from inside a target's root select it without a target argument. The + * deepest matching root wins; an ambiguous tie infers nothing and falls back + * to the explicit target-required error. + */ +export function inferComputeTargetFromCwd(config: LoadedComputeConfig, cwd: string): string | undefined { + if (config.kind !== "multi") { + return undefined; + } + + const resolvedCwd = path.resolve(cwd); + let bestKey: string | undefined; + let bestDepth = -1; + let bestIsTied = false; + + for (const target of config.targets) { + const appDir = computeTargetAppDir(config, target); + const relative = path.relative(appDir, resolvedCwd); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + continue; + } + + const depth = appDir.split(path.sep).length; + if (depth > bestDepth) { + bestKey = target.key ?? undefined; + bestDepth = depth; + bestIsTied = false; + } else if (depth === bestDepth) { + bestIsTied = true; + } + } + + return bestIsTied ? undefined : bestKey; +} + +function normalizeAppEntry( + value: unknown, + label: string, + key: string | null, + issues: string[], +): ComputeDeployTarget | null { + if (!isPlainObject(value)) { + issues.push(`\`${label}\` must be an object.`); + return null; + } + + for (const entryKey of Object.keys(value)) { + if (!(KNOWN_APP_KEYS as readonly string[]).includes(entryKey)) { + issues.push(`Unknown key "${entryKey}" in \`${label}\`. Expected one of: ${KNOWN_APP_KEYS.join(", ")}.`); + } + } + + const name = readOptionalNonEmptyString(value.name, `${label}.name`, issues); + const entry = readOptionalNonEmptyString(value.entry, `${label}.entry`, issues); + + let root: string | null = null; + if (value.root !== undefined) { + const rawRoot = readOptionalNonEmptyString(value.root, `${label}.root`, issues); + if (rawRoot) { + const normalized = normalizeRelativePath(rawRoot)?.replace(/\/+$/, ""); + if (!normalized) { + issues.push(`\`${label}.root\` must be a relative path inside the repository.`); + } else if (normalized !== ".") { + root = normalized; + } + } + } + + let framework: ComputeFramework | null = null; + if (value.framework !== undefined) { + if (typeof value.framework === "string" && (COMPUTE_FRAMEWORKS as readonly string[]).includes(value.framework)) { + framework = value.framework as ComputeFramework; + } else { + issues.push(`\`${label}.framework\` must be one of: ${COMPUTE_FRAMEWORKS.join(", ")}.`); + } + } + + if (entry && framework && !frameworkByKey(framework).usesEntrypoint) { + issues.push(`\`${label}.entry\` is not supported with the ${framework} framework; it derives its entrypoint from build output.`); + } + + let httpPort: number | null = null; + if (value.httpPort !== undefined) { + if (typeof value.httpPort === "number" && Number.isInteger(value.httpPort) && value.httpPort > 0 && value.httpPort <= 65535) { + httpPort = value.httpPort; + } else { + issues.push(`\`${label}.httpPort\` must be an integer between 1 and 65535.`); + } + } + + const envInputs = normalizeEnvConfig(value.env, `${label}.env`, issues); + const build = normalizeBuildConfig(value.build, `${label}.build`, issues); + + return { + key, + name, + root, + framework, + entry, + httpPort, + envInputs, + build, + }; +} + +function normalizeBuildConfig(value: unknown, label: string, issues: string[]): ComputeDeployTargetBuild | null { + if (value === undefined) { + return null; + } + + if (!isPlainObject(value)) { + issues.push(`\`${label}\` must be an object with \`command\` and/or \`outputDirectory\`.`); + return null; + } + + for (const buildKey of Object.keys(value)) { + if (!(KNOWN_BUILD_KEYS as readonly string[]).includes(buildKey)) { + issues.push(`Unknown key "${buildKey}" in \`${label}\`. Expected one of: ${KNOWN_BUILD_KEYS.join(", ")}.`); + } + } + + let command: string | null | undefined; + if (value.command !== undefined) { + if (value.command === null) { + command = null; + } else if (typeof value.command === "string" && value.command.trim().length > 0) { + command = value.command.trim(); + } else { + issues.push(`\`${label}.command\` must be a non-empty string, or null to skip the build step.`); + } + } + + let outputDirectory: string | undefined; + if (value.outputDirectory !== undefined) { + const normalized = typeof value.outputDirectory === "string" + ? normalizeRelativePath(value.outputDirectory)?.replace(/\/+$/, "") + : undefined; + if (!normalized) { + issues.push(`\`${label}.outputDirectory\` must be a relative path inside the app root.`); + } else { + outputDirectory = normalized; + } + } + + if (command === undefined && outputDirectory === undefined) { + issues.push(`\`${label}\` must set \`command\` and/or \`outputDirectory\`.`); + return null; + } + + return { command, outputDirectory }; +} + +function normalizeEnvConfig(value: unknown, label: string, issues: string[]): string[] { + if (value === undefined) { + return []; + } + + if (typeof value === "string") { + const file = value.trim(); + if (file.length === 0) { + issues.push(`\`${label}\` must be a non-empty dotenv file path when given as a string.`); + return []; + } + return [file]; + } + + if (!isPlainObject(value)) { + issues.push(`\`${label}\` must be a dotenv file path or an object with \`file\` and/or \`vars\`.`); + return []; + } + + for (const envKey of Object.keys(value)) { + if (!(KNOWN_ENV_KEYS as readonly string[]).includes(envKey)) { + issues.push(`Unknown key "${envKey}" in \`${label}\`. Expected one of: ${KNOWN_ENV_KEYS.join(", ")}.`); + } + } + + const envInputs: string[] = []; + + if (value.file !== undefined) { + const files = Array.isArray(value.file) ? value.file : [value.file]; + for (const file of files) { + if (typeof file !== "string" || file.trim().length === 0) { + issues.push(`\`${label}.file\` must be a non-empty dotenv file path or an array of them.`); + continue; + } + envInputs.push(file.trim()); + } + } + + if (value.vars !== undefined) { + if (!isPlainObject(value.vars)) { + issues.push(`\`${label}.vars\` must be an object of NAME: value pairs.`); + } else { + for (const [varName, varValue] of Object.entries(value.vars)) { + if (typeof varValue !== "string" || varValue.length === 0) { + issues.push(`\`${label}.vars.${varName}\` must be a non-empty string.`); + continue; + } + envInputs.push(`${varName}=${varValue}`); + } + } + } + + return envInputs; +} + +function readOptionalNonEmptyString(value: unknown, label: string, issues: string[]): string | null { + if (value === undefined) { + return null; + } + + if (typeof value !== "string" || value.trim().length === 0) { + issues.push(`\`${label}\` must be a non-empty string.`); + return null; + } + + return value.trim(); +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function selectComputeDeployTarget( + config: LoadedComputeConfig, + requestedTarget: string | undefined, +): Result { + if (config.kind === "single") { + const target = config.targets[0]!; + if (requestedTarget && requestedTarget !== target.name) { + return Result.err(new ComputeConfigTargetUnknownError( + config.configPath, + requestedTarget, + target.name ? [target.name] : [], + )); + } + return Result.ok(target); + } + + const availableTargets = config.targets.map((target) => target.key!); + if (!requestedTarget) { + if (config.targets.length === 1) { + return Result.ok(config.targets[0]!); + } + return Result.err(new ComputeConfigTargetRequiredError(config.configPath, availableTargets)); + } + + const matched = config.targets.find((target) => target.key === requestedTarget); + if (!matched) { + return Result.err(new ComputeConfigTargetUnknownError(config.configPath, requestedTarget, availableTargets)); + } + + return Result.ok(matched); +} + +/** 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/frameworks.ts b/packages/cli/src/lib/app/frameworks.ts new file mode 100644 index 0000000..56a810b --- /dev/null +++ b/packages/cli/src/lib/app/frameworks.ts @@ -0,0 +1,118 @@ +import type { ComputeFramework } from "../../config"; + +/** + * Framework capability registry — the single source of truth for what each + * supported framework is and can do. Commands, config validation, detection, + * and prompts all query this table; adding a framework means adding one + * entry here plus its build/run strategy implementation. + */ + +export type FrameworkBuildType = "nextjs" | "tanstack-start" | "bun"; + +export interface FrameworkDescriptor { + readonly key: ComputeFramework; + readonly displayName: string; + /** Build/deploy strategy this framework uses. */ + readonly buildType: FrameworkBuildType; + /** Accepted user-facing spellings, lowercased, including the key. */ + readonly aliases: readonly string[]; + /** Dependencies whose presence detects this framework. */ + readonly detectPackages: readonly string[]; + /** Config files whose presence detects this framework. */ + readonly detectConfigFiles: readonly string[]; + /** Consumes a user-provided source entrypoint instead of build output. */ + readonly usesEntrypoint: boolean; + /** Entrypoint assumed when the package defines none. */ + readonly defaultEntrypoint: string | null; + /** Has a local dev server (`app run`) in the current preview. */ + readonly hasLocalDevServer: boolean; +} + +export const NEXT_CONFIG_FILENAMES = [ + "next.config.js", + "next.config.mjs", + "next.config.ts", + "next.config.mts", +] as const; + +// Detection checks frameworks in this order; keep more specific signals first. +export const FRAMEWORKS: readonly FrameworkDescriptor[] = [ + { + key: "nextjs", + displayName: "Next.js", + buildType: "nextjs", + aliases: ["nextjs", "next", "next.js"], + detectPackages: ["next"], + detectConfigFiles: NEXT_CONFIG_FILENAMES, + usesEntrypoint: false, + defaultEntrypoint: null, + hasLocalDevServer: true, + }, + { + key: "hono", + displayName: "Hono", + buildType: "bun", + aliases: ["hono"], + detectPackages: ["hono"], + detectConfigFiles: [], + usesEntrypoint: true, + defaultEntrypoint: "src/index.ts", + hasLocalDevServer: true, + }, + { + key: "tanstack-start", + displayName: "TanStack Start", + buildType: "tanstack-start", + aliases: ["tanstack-start", "tanstack", "@tanstack/react-start", "@tanstack/solid-start"], + detectPackages: ["@tanstack/react-start", "@tanstack/solid-start"], + detectConfigFiles: [], + usesEntrypoint: false, + defaultEntrypoint: null, + hasLocalDevServer: false, + }, + { + key: "bun", + displayName: "Bun", + buildType: "bun", + aliases: ["bun"], + detectPackages: [], + detectConfigFiles: [], + usesEntrypoint: true, + defaultEntrypoint: null, + hasLocalDevServer: true, + }, +]; + +export const FRAMEWORK_KEYS = FRAMEWORKS.map((framework) => framework.key); + +/** Build types whose build settings are backed by committed config. */ +export const CONFIG_BACKED_BUILD_TYPES: readonly FrameworkBuildType[] = [ + ...new Set(FRAMEWORKS.map((framework) => framework.buildType)), +]; + +/** Build types that consume a user-provided source entrypoint. */ +export const ENTRYPOINT_BUILD_TYPES: readonly FrameworkBuildType[] = [ + ...new Set(FRAMEWORKS.filter((framework) => framework.usesEntrypoint).map((framework) => framework.buildType)), +]; + +/** Build types `app run` can start a local dev server for. */ +export const LOCAL_DEV_BUILD_TYPES: readonly FrameworkBuildType[] = [ + ...new Set(FRAMEWORKS.filter((framework) => framework.hasLocalDevServer).map((framework) => framework.buildType)), +]; + +export function frameworkByKey(key: ComputeFramework): FrameworkDescriptor { + const framework = FRAMEWORKS.find((candidate) => candidate.key === key); + if (!framework) { + throw new Error(`Unknown framework key "${key}".`); + } + return framework; +} + +export function frameworkFromAlias(value: string): FrameworkDescriptor | null { + const normalized = value.trim().toLowerCase(); + return FRAMEWORKS.find((framework) => framework.aliases.includes(normalized)) ?? null; +} + +export function isFrameworkBuildType(value: string): value is FrameworkBuildType { + return (CONFIG_BACKED_BUILD_TYPES as readonly string[]).includes(value); +} diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts index 2ce7970..7d5ee58 100644 --- a/packages/cli/src/lib/app/preview-build-settings.ts +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -1,18 +1,18 @@ 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 { parseModule, type ASTNode } from "magicast"; -import { CliError } from "../../shell/errors"; +import { sourceRootLineage } from "../fs/source-root"; import { readBunPackageJson, type BunPackageJsonLike } from "./bun-project"; import type { ResolvedPreviewBuildType } from "./preview-build"; type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; export type PreviewBuildSettingsBuildType = Extract; +/** 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; @@ -32,75 +32,114 @@ 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, - }, - }; + let content: string; + try { + options.signal?.throwIfAborted(); + content = await readFile(configPath, { encoding: "utf8", signal: options.signal }); + } catch (error) { + if (options.signal?.aborted) throw error; + return { kind: "absent" }; } - const settings = await resolvePreviewBuildSettings(options); - const config = { - $schema: PRISMA_APP_CONFIG_SCHEMA_URL, - buildCommand: settings.buildCommand, - outputDirectory: settings.outputDirectory, - }; - + let legacy: { buildCommand: string | null; outputDirectory: string }; try { - await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { - 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, - }, - }; - } + 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 }; } - - throw error; + 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: PreviewBuildSettingsBuildType; + signal?: AbortSignal; +}): Promise { + return { + status: "inferred", + configPath: null, + relativeConfigPath: null, + settings: await 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: "created", - configPath, - relativeConfigPath: PRISMA_APP_CONFIG_FILENAME, - settings, + 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: PreviewBuildSettingsBuildType; @@ -153,102 +192,9 @@ 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[], signal?: AbortSignal): Promise { let entries: string[]; @@ -333,25 +279,34 @@ 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"; + } } } @@ -374,12 +329,17 @@ 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 { @@ -389,7 +349,7 @@ function execBuildCommand( env: { ...process.env, PATH: [ - path.join(cwd, "node_modules", ".bin"), + ...binDirs, process.env.PATH, ].filter(Boolean).join(path.delimiter), }, @@ -631,7 +591,7 @@ 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; diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index 3a39788..ea89c9e 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -21,12 +21,15 @@ import { runResolvedBuildCommand, type PreviewBuildSettings, } from "./preview-build-settings"; +import { resolveSourceRoot } from "../fs/source-root"; export { PRISMA_APP_CONFIG_FILENAME, - PRISMA_APP_CONFIG_SCHEMA_URL, - resolveOrCreatePreviewBuildSettings, + detectLegacyBuildSettings, + resolveConfiguredPreviewBuildSettings, + resolveInferredPreviewBuildSettings, resolvePreviewBuildSettings, + type LegacyBuildSettingsDetection, type PreviewBuildSettingsBuildType, type PreviewBuildSettings, type PreviewBuildSettingsResolution, @@ -363,7 +366,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.`, ); } @@ -450,7 +453,7 @@ export async function stageNextjsStandaloneArtifact(options: { sourceRoot, signal: options.signal, }); - await hoistPnpmDependencies(path.join(artifactRoot, "node_modules"), options.signal); + await hoistIsolatedStoreDependencies(path.join(artifactRoot, "node_modules"), options.signal); } async function copyNextjsStaticAssets(options: { @@ -564,15 +567,25 @@ function nextjsServerSubpath(entrypoint: string): string { return dir === "." ? "" : dir; } -async function hoistPnpmDependencies(nodeModulesDir: string, signal?: AbortSignal): Promise { - const pnpmNodeModulesDir = path.join(nodeModulesDir, ".pnpm", "node_modules"); - if (!await directoryExists(pnpmNodeModulesDir, signal)) { +/** + * 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 { + 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 })); + const entries = await unsupportedFilesystemBoundary(signal, () => 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, () => readdir(sourcePath, { withFileTypes: true })); @@ -587,7 +600,7 @@ async function hoistPnpmDependencies(nodeModulesDir: string, signal?: AbortSigna path.join(sourcePath, scopedEntry.name), scopedDestination, { - standaloneRoot: pnpmNodeModulesDir, + standaloneRoot: storeNodeModulesDir, appRoot: nodeModulesDir, sourceRoot: nodeModulesDir, signal, @@ -603,7 +616,7 @@ async function hoistPnpmDependencies(nodeModulesDir: string, signal?: AbortSigna } await copyPathMaterializingSymlinks(sourcePath, destinationPath, { - standaloneRoot: pnpmNodeModulesDir, + standaloneRoot: storeNodeModulesDir, appRoot: nodeModulesDir, sourceRoot: nodeModulesDir, signal, @@ -796,41 +809,6 @@ async function directoryExists(targetPath: string, signal?: AbortSignal): Promis } } -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): Promise { // These Node fs promise APIs do not accept AbortSignal; check immediately before and after the boundary. signal?.throwIfAborted(); diff --git a/packages/cli/src/lib/app/production-deploy-gate.ts b/packages/cli/src/lib/app/production-deploy-gate.ts index abc98f4..83e7afd 100644 --- a/packages/cli/src/lib/app/production-deploy-gate.ts +++ b/packages/cli/src/lib/app/production-deploy-gate.ts @@ -159,12 +159,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 92aa1f7..04acc40 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/source-root.ts b/packages/cli/src/lib/fs/source-root.ts new file mode 100644 index 0000000..44d0f39 --- /dev/null +++ b/packages/cli/src/lib/fs/source-root.ts @@ -0,0 +1,77 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +/** + * Resolves the directories from `appPath` up to its source root, nearest + * first. The source root is the closest ancestor that looks like a repository + * or workspace root; a standalone app is its own source root. + * + * This module stays dependency-light: it is imported during CLI bootstrap. + */ +export 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; + } +} + +/** Directories from `appPath` up to its source root, nearest first. */ +export async function sourceRootLineage(appPath: string, signal?: AbortSignal): Promise { + const sourceRoot = await resolveSourceRoot(appPath, signal); + const lineage: string[] = []; + let current = path.resolve(appPath); + + while (true) { + lineage.push(current); + if (current === sourceRoot) { + return lineage; + } + + const parent = path.dirname(current); + if (parent === current) { + return lineage; + } + + 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 pathExists(targetPath: string, signal?: AbortSignal): Promise { + try { + signal?.throwIfAborted(); + await stat(targetPath); + signal?.throwIfAborted(); + return true; + } catch (error) { + if (signal?.aborted) throw error; + return false; + } +} diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 9b1b42b..1651c96 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -131,6 +131,8 @@ export interface ResolveProjectOptions { explicitProject?: string; envProjectId?: string; commandName?: string; + /** Directory holding `.prisma/local.json`. Defaults to the invocation directory. */ + projectDir?: string; listProjects(): Promise; } @@ -547,7 +549,7 @@ async function readImplicitLocalPin( return Result.ok(null); } - const localPinResult = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal); + const localPinResult = await readLocalResolutionPin(options.projectDir ?? options.context.runtime.cwd, options.context.runtime.signal); if (localPinResult.isErr()) { return Result.err(localPinReadErrorToProjectError(localPinResult.error)); } diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index dc7e73a..4170cf1 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -53,18 +53,19 @@ 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, { + yield* Result.await(writeLocalResolutionPin(directory, { workspaceId: workspace.id, projectId: project.id, }, context.runtime.signal)); - yield* Result.await(ensureLocalResolutionPinGitignore(context.runtime.cwd, context.runtime.signal)); + yield* Result.await(ensureLocalResolutionPinGitignore(directory, context.runtime.signal)); return Result.ok({ workspace, project, - directory: formatSetupDirectory(context.runtime.cwd), + directory: formatSetupDirectory(directory), localPin: { path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, written: true, diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index 5aaf715..9d96928 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -2,6 +2,7 @@ import type { CommandDescriptor } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import type { AppBuildResult, + AppDeployAllResult, AppDeploySettings, AppDeployResult, AppDomainAddResult, @@ -68,6 +69,39 @@ 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).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; @@ -99,26 +133,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) { @@ -202,12 +220,6 @@ function branchDatabaseRows(branchDatabase: AppDeployResult["branchDatabase"]): ...(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/shell/runtime.ts b/packages/cli/src/shell/runtime.ts index 945cd39..94b597c 100644 --- a/packages/cli/src/shell/runtime.ts +++ b/packages/cli/src/shell/runtime.ts @@ -4,6 +4,7 @@ import { Command } from "commander"; import { LocalStateStore } from "../adapters/local-state"; import { MockApi } from "../adapters/mock-api"; +import { findComputeConfigDir } from "../lib/app/compute-config-discovery"; import { renderHelp } from "./help"; import type { CliOutput } from "./output"; import type { GlobalFlags } from "./global-flags"; @@ -57,7 +58,7 @@ export async function createCommandContext( flags: GlobalFlags, ): 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; @@ -86,8 +87,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 eb6c0ab..669a153 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 5457892..b6e3204 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 } from "./helpers/mock-factories"; beforeEach(() => { @@ -45,220 +46,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"; @@ -296,11 +83,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, @@ -309,7 +91,6 @@ describe("app deploy branch database setup", () => { const actual = await importOriginal(); return { ...actual, - runBranchDatabaseSchemaSetup, }; }); vi.doMock("../src/lib/app/preview-provider", () => ({ @@ -360,13 +141,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", @@ -386,7 +160,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", @@ -394,18 +167,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", - }, }); }); @@ -448,11 +216,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, @@ -461,7 +224,6 @@ describe("app deploy branch database setup", () => { const actual = await importOriginal(); return { ...actual, - runBranchDatabaseSchemaSetup, }; }); vi.doMock("../src/lib/app/preview-provider", () => ({ @@ -513,12 +275,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", @@ -534,19 +290,13 @@ describe("app deploy branch database setup", () => { signal: context.runtime.signal, }); 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", - }, }); }); @@ -570,11 +320,6 @@ describe("app deploy branch database setup", () => { className: options.className, 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: { @@ -598,7 +343,6 @@ describe("app deploy branch database setup", () => { const actual = await importOriginal(); return { ...actual, - runBranchDatabaseSchemaSetup, }; }); vi.doMock("../src/lib/app/preview-provider", () => ({ @@ -649,38 +393,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", - }, }); }); @@ -772,11 +497,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, }); }); @@ -878,11 +602,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, }); }); @@ -982,11 +705,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, }); }); @@ -1112,7 +834,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"], }); @@ -1235,7 +957,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"], }); @@ -1417,7 +1139,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 () => { @@ -1543,90 +1265,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 c6b7d8c..2c04ab8 100644 --- a/packages/cli/tests/app-build.test.ts +++ b/packages/cli/tests/app-build.test.ts @@ -12,8 +12,8 @@ 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"); @@ -32,24 +32,20 @@ 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", outputDirectory: ".next/standalone", outputDirectorySource: "Next.js output", }); - 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`); + await expect(readFile(path.join(appPath, "prisma.app.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); it("packages the full tree with a next start launcher when the build produces no standalone output", async () => { @@ -112,8 +108,8 @@ describe("preview build strategy", () => { } }); - it("creates TanStack and Hono build config defaults", async () => { - const { resolveOrCreatePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + it("infers TanStack and Hono build defaults", async () => { + const { resolveInferredPreviewBuildSettings } = await import("../src/lib/app/preview-build"); const cwd = await createTempCwd(); const tanstackPath = path.join(cwd, "tanstack"); const honoPath = path.join(cwd, "hono"); @@ -139,21 +135,21 @@ describe("preview build strategy", () => { "utf8", ); - await expect(resolveOrCreatePreviewBuildSettings({ + await expect(resolveInferredPreviewBuildSettings({ appPath: tanstackPath, buildType: "tanstack-start", })).resolves.toMatchObject({ - status: "created", + status: "inferred", settings: { buildCommand: "vite build", outputDirectory: ".output", }, }); - await expect(resolveOrCreatePreviewBuildSettings({ + await expect(resolveInferredPreviewBuildSettings({ appPath: honoPath, buildType: "bun", })).resolves.toMatchObject({ - status: "created", + status: "inferred", settings: { buildCommand: null, outputDirectory: ".", @@ -161,79 +157,36 @@ describe("preview build strategy", () => { }); }); - it("uses an existing prisma.app.json without overwriting it", async () => { - const { resolveOrCreatePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + 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"); + await expect(detectLegacyBuildSettings({ appPath: cwd, effective })).resolves.toEqual({ kind: "absent" }); - await mkdir(invalidJsonPath, { recursive: true }); - await writeFile(path.join(invalidJsonPath, "prisma.app.json"), "{ nope\n", "utf8"); - await mkdir(escapingPath, { recursive: true }); - await writeFile( - path.join(escapingPath, "prisma.app.json"), - JSON.stringify({ - buildCommand: "bun run build", - outputDirectory: "../dist", - }, null, 2), - "utf8", - ); + await writeFile(path.join(cwd, "prisma.app.json"), JSON.stringify({ + buildCommand: "bun run build", + outputDirectory: ".next/standalone", + }), "utf8"); + await expect(detectLegacyBuildSettings({ appPath: cwd, effective })).resolves.toMatchObject({ kind: "matching" }); - await expect(resolveOrCreatePreviewBuildSettings({ - appPath: invalidJsonPath, - buildType: "nextjs", - })).rejects.toMatchObject({ - code: "APP_CONFIG_INVALID", - domain: "app", - }); - await expect(resolveOrCreatePreviewBuildSettings({ - appPath: escapingPath, - buildType: "nextjs", - })).rejects.toMatchObject({ - code: "APP_CONFIG_INVALID", - domain: "app", + await writeFile(path.join(cwd, "prisma.app.json"), JSON.stringify({ + buildCommand: "custom-build", + outputDirectory: "dist", + }), "utf8"); + await expect(detectLegacyBuildSettings({ appPath: cwd, effective })).resolves.toMatchObject({ + kind: "custom", + buildCommand: "custom-build", + outputDirectory: "dist", }); + + await writeFile(path.join(cwd, "prisma.app.json"), "{ nope\n", "utf8"); + await expect(detectLegacyBuildSettings({ appPath: cwd, effective })).resolves.toMatchObject({ kind: "invalid" }); }); it("resolves package.json build scripts and literal framework output directories", async () => { @@ -386,6 +339,119 @@ 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"); const cwd = await createTempCwd(); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index ea8eb6b..13df692 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { asSingleDeployResult } from "./helpers/deploy-result"; import { createProjectClient, createResolveBranch } from "./helpers/mock-factories"; beforeEach(() => { @@ -148,6 +149,165 @@ 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 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(asSingleDeployResult(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([ @@ -282,7 +442,7 @@ describe("app controller", () => { framework: "hono", }); - expect(result.result.branch).toEqual({ + expect(asSingleDeployResult(result).result.branch).toEqual({ id: "branch_production", name: "production", kind: "preview", @@ -502,7 +662,7 @@ describe("app controller", () => { hostname: "shop.acme.com", signal: context.runtime.signal, }); - expect(result.result.project.id).toBe("proj_123"); + expect(asSingleDeployResult(result).result.project.id).toBe("proj_123"); }); it("domain add requires Project setup instead of entering interactive setup", async () => { @@ -1173,8 +1333,8 @@ describe("app controller", () => { }, deploySettings: { config: { - path: "prisma.app.json", - status: "created", + path: null, + status: "inferred", }, buildCommand: { value: "next build", @@ -1193,16 +1353,12 @@ describe("app controller", () => { expect(stderr.buffer).toContain(`Linked "./${path.basename(cwd)}" to Project "my-app"`); 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", projectId: "proj_my_app", @@ -1257,7 +1413,64 @@ 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"'), + }); + 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([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, @@ -1282,7 +1495,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listEnvironmentVariables: vi.fn().mockResolvedValue([]), listApps, @@ -1304,16 +1517,14 @@ describe("app controller", () => { await writeFile( 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, 2)}\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, @@ -1326,36 +1537,11 @@ 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.result.deploySettings).toMatchObject({ - config: { - path: "prisma.app.json", - status: "used", - }, - buildCommand: { - value: "bun run build", - source: null, - }, - outputDirectory: { - value: ".next/standalone", - source: null, - }, + expect(result.warnings.join(" ")).toContain("prisma.app.json is no longer used"); + 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 () => { @@ -1816,9 +2002,9 @@ 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", @@ -2010,8 +2196,8 @@ 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, }); @@ -2508,7 +2694,7 @@ describe("app controller", () => { interaction: undefined, }), ); - expect(result.result.app).toEqual({ + expect(asSingleDeployResult(result).result.app).toEqual({ id: "app_new", name: path.basename(cwd), }); @@ -2803,7 +2989,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, }); @@ -2854,7 +3040,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(".prisma/\n"); @@ -3653,7 +3839,7 @@ describe("app controller", () => { const result = await runAppShowDeploy(context, "dep_123"); - expect(result.result.deployment.live).toBe(true); + expect(asSingleDeployResult(result).result.deployment.live).toBe(true); }); it("show-deploy ignores known live deployments from another workspace", async () => { @@ -3709,7 +3895,7 @@ describe("app controller", () => { const result = await runAppShowDeploy(context, "dep_123"); - expect(result.result.deployment.live).toBe(null); + expect(asSingleDeployResult(result).result.deployment.live).toBe(null); }); it("show-deploy surfaces provider failures instead of reporting not found", async () => { @@ -4402,7 +4588,7 @@ describe("app controller", () => { deploymentId: "dep_1", }), ); - expect(result.result.deployment.id).toBe("dep_1"); + expect(asSingleDeployResult(result).result.deployment.id).toBe("dep_1"); }); it("rollback returns NO_PREVIOUS_DEPLOYMENT when only one deployment exists", async () => { diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index ebfe964..b7c1ddf 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -546,8 +546,8 @@ describe("app env vars", () => { }, deploySettings: { config: { - path: "prisma.app.json", - status: "used", + path: null, + status: "inferred", }, buildCommand: { value: "bun run build", @@ -636,8 +636,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 a2bda4c..aba16f6 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"; @@ -38,7 +39,7 @@ 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, @@ -53,6 +54,103 @@ 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( + "../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( + "../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 accepts explicit SDK framework strategies", async () => { const executePreviewBuild = vi.fn().mockResolvedValue({ artifact: { @@ -81,7 +179,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, @@ -120,7 +218,7 @@ describe("app local dev commands", () => { stateDir, }); - await expect(runAppBuild(context, undefined, "auto")).rejects.toMatchObject({ + await expect(runAppBuild(context, { buildType: "auto" })).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", summary: "App build requires an explicit framework when detection is ambiguous", @@ -140,7 +238,7 @@ describe("app local dev commands", () => { }, }); - await expect(runAppRun(context, undefined, "auto", undefined)).rejects.toMatchObject({ + await expect(runAppRun(context, { buildType: "auto" })).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", summary: "App run does not support --json", @@ -169,7 +267,7 @@ describe("app local dev commands", () => { stateDir, }); - await expect(runAppRun(context, undefined, "auto", undefined)).rejects.toMatchObject({ + await expect(runAppRun(context, { buildType: "auto" })).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", summary: "App run requires an explicit framework when detection is ambiguous", @@ -186,7 +284,7 @@ describe("app local dev commands", () => { stateDir, }); - await expect(runAppRun(context, "server.ts", "nextjs", undefined)).rejects.toMatchObject({ + await expect(runAppRun(context, { entrypoint: "server.ts", buildType: "nextjs" })).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", summary: "App run does not accept --entry with --build-type nextjs", @@ -222,7 +320,7 @@ 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 ab426da..d00e16b 100644 --- a/packages/cli/tests/app-presenter.test.ts +++ b/packages/cli/tests/app-presenter.test.ts @@ -64,8 +64,8 @@ function createDeployResult(): AppDeployResult { }, deploySettings: { config: { - path: "prisma.app.json", - status: "used", + path: null, + status: "inferred", }, buildCommand: { value: "bun run build", @@ -221,8 +221,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..3ac935e --- /dev/null +++ b/packages/cli/tests/compute-config.test.ts @@ -0,0 +1,635 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + COMPUTE_CONFIG_FILENAME, + ComputeConfigAmbiguousError, + ComputeConfigInvalidError, + ComputeConfigLoadError, + ComputeConfigTargetRequiredError, + ComputeConfigTargetUnknownError, + computeConfigErrorToCliError, + computeFrameworkToBuildType, + inferComputeTargetFromCwd, + loadComputeConfig, + mergeComputeDeployInputs, + mergeComputeLocalInputs, + normalizeComputeConfig, + selectComputeDeployTarget, + type ComputeDeployTarget, + type LoadedComputeConfig, +} from "../src/lib/app/compute-config"; +import { defineComputeConfig } from "../src/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, 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("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/cli/config", async () => { + const dir = await createTempDir(); + await writeFile(path.join(dir, "prisma.compute.ts"), [ + 'import { defineComputeConfig } from "@prisma/cli/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("../src/lib/app/compute-config-discovery"); + 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("../src/lib/app/compute-config-discovery"); + 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[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[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 2da9db3..8f3b95b 100644 --- a/packages/cli/tests/helpers.ts +++ b/packages/cli/tests/helpers.ts @@ -165,7 +165,7 @@ async function seedRememberedProjectStateForTest(runtime: CliRuntime): Promise( + success: T, +): T & { result: Exclude } { + if (success.result && typeof success.result === "object" && "deployments" in success.result) { + throw new Error("Expected a single-app deploy result, got a deploy-all result."); + } + return success as T & { result: Exclude }; +} diff --git a/packages/cli/tests/production-deploy-gate.test.ts b/packages/cli/tests/production-deploy-gate.test.ts index e90c46e..fe2a633 100644 --- a/packages/cli/tests/production-deploy-gate.test.ts +++ b/packages/cli/tests/production-deploy-gate.test.ts @@ -72,12 +72,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 257a668..1403ed5 100644 --- a/packages/cli/tests/publish-prep.test.ts +++ b/packages/cli/tests/publish-prep.test.ts @@ -27,6 +27,13 @@ describe("prepare cli publish", () => { version: "3.0.0-development", description: "Command-line interface for the Prisma Developer Platform.", type: "module", + exports: { + "./config": { + types: "./dist/config.d.ts", + default: "./dist/config.js", + }, + "./package.json": "./package.json", + }, engines: { node: ">=20", }, @@ -65,6 +72,13 @@ describe("prepare cli publish", () => { bin: { "prisma-cli": "./dist/cli.js", }, + exports: { + "./config": { + types: "./dist/config.d.ts", + default: "./dist/config.js", + }, + "./package.json": "./package.json", + }, files: ["dist", "README.md", "LICENSE"], publishConfig: { access: "public", diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 303877e..72a826e 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: { cli: "src/bin.ts", + config: "src/config.ts", }, format: ["esm"], clean: true, @@ -10,5 +11,6 @@ export default defineConfig({ unbundle: true, fixedExtension: false, outDir: "dist", - dts: false, + // Declarations are needed for the public `@prisma/cli/config` entry. + dts: true, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec290da..19a6148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: dotenv: specifier: ^17.4.2 version: 17.4.2 + jiti: + specifier: ^2.7.0 + version: 2.7.0 magicast: specifier: ^0.5.3 version: 0.5.3 @@ -2108,8 +2111,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: diff --git a/scripts/prepare-cli-publish.mjs b/scripts/prepare-cli-publish.mjs index fa1813b..f2f478a 100644 --- a/scripts/prepare-cli-publish.mjs +++ b/scripts/prepare-cli-publish.mjs @@ -30,6 +30,7 @@ export async function stageCliPublishPackage(options = {}) { bin: { "prisma-cli": "./dist/cli.js", }, + exports: sourceManifest.exports, files: ["dist", "README.md", "LICENSE"], publishConfig: { access: "public", From 6383c70ab1e78c10024ca953fd7af60e539befe4 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 14:17:01 +0530 Subject: [PATCH 02/16] chore: drop unused c12 dependency, add cross-OS test workflow c12 was evaluated for config loading but the compute config loads via jiti directly; remove the dead dependency. Add a test workflow running the CLI suite and build on ubuntu and windows so Windows stays verified in CI. --- .github/workflows/test.yml | 57 ++++++++++++++++++++++++++++++++ packages/cli/package.json | 1 - pnpm-lock.yaml | 68 -------------------------------------- 3 files changed, 57 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/test.yml 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/packages/cli/package.json b/packages/cli/package.json index b6e1943..e47b514 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -52,7 +52,6 @@ "@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/pnpm-lock.yaml b/pnpm-lock.yaml index 19a6148..01dbcc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,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 @@ -874,26 +871,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'} @@ -909,9 +886,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==} @@ -930,9 +904,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'} @@ -978,9 +949,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'} @@ -1110,9 +1078,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} @@ -1124,9 +1089,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==} @@ -1937,19 +1899,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: {} @@ -1958,8 +1907,6 @@ snapshots: commander@14.0.3: {} - confbox@0.2.4: {} - convert-source-map@2.0.0: {} default-browser-id@5.0.1: {} @@ -1973,8 +1920,6 @@ snapshots: defu@6.1.7: {} - destr@2.0.5: {} - dotenv@17.4.2: {} dts-resolver@2.1.3: {} @@ -2055,8 +2000,6 @@ snapshots: expect-type@1.3.0: {} - exsolve@1.0.8: {} - extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -2163,12 +2106,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 @@ -2179,11 +2116,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): From b45f6d37cbf2ebd4423731199018073df1a16f07 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 14:26:43 +0530 Subject: [PATCH 03/16] ci: skip agent skills installation in CI installs The skills CLI is contributor-machine DX and its '*' matching is broken on Windows, which failed pnpm install before any tests ran. Guard the prepare script behind a CI check. --- package.json | 2 +- scripts/prepare-skills.mjs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 scripts/prepare-skills.mjs diff --git a/package.json b/package.json index bdc72d6..7f6aaba 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "build:cli": "pnpm --filter @prisma/cli build", "build:compute": "pnpm --filter @prisma/compute build", "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/scripts/prepare-skills.mjs b/scripts/prepare-skills.mjs new file mode 100644 index 0000000..8078d47 --- /dev/null +++ b/scripts/prepare-skills.mjs @@ -0,0 +1,15 @@ +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); From 22c1a80295a9a7c83a4259b9f16941c86bcb23b1 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 14:32:47 +0530 Subject: [PATCH 04/16] test: make the suite Windows-portable - load shebang-bearing scripts via runtime file-URL imports (the transform pipeline broke on Windows; also removes two implicit-any suppressions) - normalize readlink output before asserting symlink targets - replace a posix-only /usr/bin/true bin symlink with a portable no-op build script - expect native path separators in project show display assertions --- packages/cli/tests/app-build.test.ts | 10 ++++++---- packages/cli/tests/project.test.ts | 4 ++-- packages/cli/tests/publish-prep.test.ts | 6 +++++- packages/cli/tests/resolve-cli-version.test.ts | 6 ++++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/cli/tests/app-build.test.ts b/packages/cli/tests/app-build.test.ts index 2c04ab8..7e38cfa 100644 --- a/packages/cli/tests/app-build.test.ts +++ b/packages/cli/tests/app-build.test.ts @@ -97,7 +97,7 @@ describe("preview build strategy", () => { const linkPath = path.join(artifact.directory, "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"))).rejects.toThrow(); await expect(access(path.join(artifact.directory, ".env.local"))).rejects.toThrow(); @@ -578,17 +578,19 @@ 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 }); await mkdir(path.join(appPath, ".next", "static"), { recursive: true }); await writeFile(path.join(appPath, ".next", "static", "client.js"), "console.log('static');\n", "utf8"); await mkdir(path.join(appPath, "public"), { recursive: true }); await writeFile(path.join(appPath, "public", "hello.txt"), "hello\n", "utf8"); - await 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", "utf8"); await writeFile(path.join(standaloneDir, "server.js"), "console.log('next');\n", "utf8"); - await symlink("/usr/bin/true", nextBin); const { executePreviewBuild } = await import("../src/lib/app/preview-build"); const result = await executePreviewBuild({ diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index bf24652..240fa98 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -483,7 +483,7 @@ describe("project commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(stderr).toBe( - "project show → This directory is linked to the following platform project.\n\n│ local repo ~/code/apple\n│ platform Edith / orange\n│\n│ → https://prisma.build/edith/orange\n", + `project show → This directory is linked to the following platform project.\n\n│ local repo ~${path.sep}code${path.sep}apple\n│ platform Edith / orange\n│\n│ → https://prisma.build/edith/orange\n`, ); }); @@ -567,7 +567,7 @@ describe("project commands", () => { expect(stderr).toContain("Local context:"); expect(stderr).toContain("duration:"); expect(stderr).toContain("cwd:"); - expect(stderr).toContain("~/code/apple"); + expect(stderr).toContain(`~${path.sep}code${path.sep}apple`); expect(stderr).toContain("state file:"); expect(stderr).toContain("~"); }); diff --git a/packages/cli/tests/publish-prep.test.ts b/packages/cli/tests/publish-prep.test.ts index 1403ed5..c1bca79 100644 --- a/packages/cli/tests/publish-prep.test.ts +++ b/packages/cli/tests/publish-prep.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { stageCliPublishPackage } from "../../../scripts/prepare-cli-publish.mjs"; +// Runtime file-URL import keeps the shebang-bearing script outside the +// transform pipeline, which breaks on Windows. +const { stageCliPublishPackage } = await import( + new URL("../../../scripts/prepare-cli-publish.mjs", import.meta.url).href +); function createTempCwd(): Promise { return mkdtemp(path.join(os.tmpdir(), "prisma-cli-")); diff --git a/packages/cli/tests/resolve-cli-version.test.ts b/packages/cli/tests/resolve-cli-version.test.ts index 74550d0..a7bd36c 100644 --- a/packages/cli/tests/resolve-cli-version.test.ts +++ b/packages/cli/tests/resolve-cli-version.test.ts @@ -5,11 +5,13 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { +// Runtime file-URL import keeps the shebang-bearing script outside the +// transform pipeline, which breaks on Windows. +const { resolveDevVersion, resolveNextBetaVersion, resolvePrVersion, -} from "../../../scripts/resolve-cli-version.mjs"; +} = await import(new URL("../../../scripts/resolve-cli-version.mjs", import.meta.url).href); const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); From da8f546c206e6b6846671c0c86124300262b1448 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 14:41:08 +0530 Subject: [PATCH 05/16] test: run repo scripts as subprocesses; fix mixed-separator path display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Out-of-root shebang scripts fail vitest's Windows transform regardless of import style, so the publish-prep and resolve-cli-version suites now exercise the scripts as child processes — which also matches how CI invokes them. prepare-cli-publish gains --source-dir so tests can drive it fully from the CLI. Home-shortened display paths now render posix style uniformly instead of Windows' mixed '~/code\apple'. --- packages/cli/src/presenters/project.ts | 2 +- packages/cli/src/shell/diagnostics-output.ts | 2 +- packages/cli/tests/project.test.ts | 4 +- packages/cli/tests/publish-prep.test.ts | 29 +++++-- .../cli/tests/resolve-cli-version.test.ts | 78 ++++++++++--------- scripts/prepare-cli-publish.mjs | 16 +++- 6 files changed, 82 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index 56d5725..b6c4dac 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -217,7 +217,7 @@ function formatLocalRepoPath(cwd: string, env: NodeJS.ProcessEnv): string { const home = env.HOME ? path.resolve(env.HOME) : null; if (home && (resolved === home || resolved.startsWith(`${home}${path.sep}`))) { - const relative = path.relative(home, resolved); + const relative = path.relative(home, resolved).split(path.sep).join("/"); return relative ? `~/${relative}` : "~"; } diff --git a/packages/cli/src/shell/diagnostics-output.ts b/packages/cli/src/shell/diagnostics-output.ts index 7044522..bc6b959 100644 --- a/packages/cli/src/shell/diagnostics-output.ts +++ b/packages/cli/src/shell/diagnostics-output.ts @@ -39,7 +39,7 @@ export function formatLocalPath(value: string, env: NodeJS.ProcessEnv): string { const home = env.HOME ? path.resolve(env.HOME) : null; if (home && (resolved === home || resolved.startsWith(`${home}${path.sep}`))) { - const relative = path.relative(home, resolved); + const relative = path.relative(home, resolved).split(path.sep).join("/"); return relative ? `~/${relative}` : "~"; } diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 240fa98..bf24652 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -483,7 +483,7 @@ describe("project commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(stderr).toBe( - `project show → This directory is linked to the following platform project.\n\n│ local repo ~${path.sep}code${path.sep}apple\n│ platform Edith / orange\n│\n│ → https://prisma.build/edith/orange\n`, + "project show → This directory is linked to the following platform project.\n\n│ local repo ~/code/apple\n│ platform Edith / orange\n│\n│ → https://prisma.build/edith/orange\n", ); }); @@ -567,7 +567,7 @@ describe("project commands", () => { expect(stderr).toContain("Local context:"); expect(stderr).toContain("duration:"); expect(stderr).toContain("cwd:"); - expect(stderr).toContain(`~${path.sep}code${path.sep}apple`); + expect(stderr).toContain("~/code/apple"); expect(stderr).toContain("state file:"); expect(stderr).toContain("~"); }); diff --git a/packages/cli/tests/publish-prep.test.ts b/packages/cli/tests/publish-prep.test.ts index c1bca79..37e318a 100644 --- a/packages/cli/tests/publish-prep.test.ts +++ b/packages/cli/tests/publish-prep.test.ts @@ -1,14 +1,27 @@ +import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -// Runtime file-URL import keeps the shebang-bearing script outside the -// transform pipeline, which breaks on Windows. -const { stageCliPublishPackage } = await import( - new URL("../../../scripts/prepare-cli-publish.mjs", import.meta.url).href -); +// 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-")); @@ -64,7 +77,7 @@ describe("prepare cli publish", () => { await writeFile(path.join(sourceDir, "README.md"), "# Test package\n", "utf8"); await writeFile(path.join(sourceDir, "dist/cli.js"), "#!/usr/bin/env node\nconsole.log('ok')\n", "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")); expect(stagedPath).toBe(outputDir); @@ -133,7 +146,7 @@ describe("prepare cli publish", () => { await writeFile(path.join(sourceDir, "README.md"), "# Test package\n", "utf8"); await writeFile(path.join(sourceDir, "dist/cli.js"), "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8"); - const stagedPath = await stageCliPublishPackage({ + const stagedPath = await stagePackage({ sourceDir, outputDir, publishVersion: "3.0.0-beta.0", @@ -174,7 +187,7 @@ describe("prepare cli publish", () => { await writeFile(path.join(sourceDir, "tests/cli.test.ts"), "export {}\n", "utf8"); await writeFile(path.join(sourceDir, "fixtures/mock-api.json"), "{}\n", "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 a7bd36c..8966ea9 100644 --- a/packages/cli/tests/resolve-cli-version.test.ts +++ b/packages/cli/tests/resolve-cli-version.test.ts @@ -5,56 +5,64 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -// Runtime file-URL import keeps the shebang-bearing script outside the -// transform pipeline, which breaks on Windows. -const { - resolveDevVersion, - resolveNextBetaVersion, - resolvePrVersion, -} = await import(new URL("../../../scripts/resolve-cli-version.mjs", import.meta.url).href); - +// 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)), "../../.."); 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( - "Cannot compute the next beta from npm latest (3.0.0).", - ); + 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/scripts/prepare-cli-publish.mjs b/scripts/prepare-cli-publish.mjs index f2f478a..eef0235 100644 --- a/scripts/prepare-cli-publish.mjs +++ b/scripts/prepare-cli-publish.mjs @@ -76,11 +76,12 @@ 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`); @@ -89,10 +90,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("--")) { @@ -123,7 +135,7 @@ function parseCliArgs(args) { throw new Error("Publish version cannot be empty."); } - return { outputDir, publishVersion }; + return { outputDir, publishVersion, sourceDir }; } if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { From 17bf777b211d6608d3b629dfa9a8f39182da2e9e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 15:33:48 +0530 Subject: [PATCH 06/16] review: address CodeRabbit findings - drop the stale database block from the spec's canonical config example - add COMPUTE_CONFIG_TARGET_REQUIRED/UNKNOWN to error-conventions and the config-directory anchoring rule to resource-model - extract a shared shortenHomePath helper with Windows home fallbacks (USERPROFILE, HOMEDRIVE+HOMEPATH) for display paths - report the real bound directory when project binding targets an ancestor compute-config directory instead of a misleading basename - document the non-empty contract on ComputeEnvConfig.vars - lock in deploy-all fail-fast with a failing-target test, assert the full migrated build block, and type the deploy-result test helper --- docs/product/command-spec.md | 1 - docs/product/error-conventions.md | 2 + docs/product/resource-model.md | 2 +- packages/cli/src/config.ts | 2 +- packages/cli/src/lib/fs/home-path.ts | 34 ++++++++++++ packages/cli/src/lib/project/setup.ts | 15 ++++-- packages/cli/src/presenters/project.ts | 12 ++--- packages/cli/src/shell/diagnostics-output.ts | 12 ++--- packages/cli/tests/app-controller.test.ts | 56 +++++++++++++++++++- packages/cli/tests/helpers/deploy-result.ts | 14 ++--- 10 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/lib/fs/home-path.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 70033d2..ba268fe 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -846,7 +846,6 @@ export default defineComputeConfig({ // Multi-app repository: prisma-cli app deploy web export default defineComputeConfig({ - database: { schema: "packages/db/prisma/schema.prisma" }, apps: { web: { root: "apps/web", framework: "nextjs" }, worker: { root: "apps/worker", framework: "bun", entry: "src/index.ts" }, diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index c804202..c1ee950 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -224,6 +224,8 @@ Recommended meanings: - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context - `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate +- `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred +- `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app - `BUILD_SETTINGS_MIGRATION_REQUIRED`: a legacy `prisma.app.json` contains custom build settings that must move into the `build` block of `prisma.compute.ts` - `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 diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index d8e9428..ba7076e 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -37,7 +37,7 @@ 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/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, the pin and the local CLI state cache live in the config file's 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 diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 0605be6..ff87f5f 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -12,7 +12,7 @@ export type ComputeFramework = (typeof COMPUTE_FRAMEWORKS)[number]; export interface ComputeEnvConfig { /** Dotenv file path(s) resolved relative to the config file directory. */ file?: string | string[]; - /** Inline environment variable assignments. Values are deployed as-is. */ + /** Inline environment variable assignments. Values must be non-empty and are deployed as-is. */ vars?: Record; } 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..3b479e2 --- /dev/null +++ b/packages/cli/src/lib/fs/home-path.ts @@ -0,0 +1,34 @@ +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/project/setup.ts b/packages/cli/src/lib/project/setup.ts index 4170cf1..e66ed5f 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -1,8 +1,11 @@ +import path from "node:path"; + import type { AuthWorkspace } from "../../types/auth"; import type { ProjectSetupResult, ProjectSummary } from "../../types/project"; import { Result, matchError } from "better-result"; import { CliError, usageError } from "../../shell/errors"; import type { CommandContext } from "../../shell/runtime"; +import { shortenHomePath } from "../fs/home-path"; import { ensureLocalResolutionPinGitignore, LOCAL_RESOLUTION_PIN_RELATIVE_PATH, @@ -65,7 +68,7 @@ export async function bindProjectToDirectory( return Result.ok({ workspace, project, - directory: formatSetupDirectory(directory), + directory: formatSetupDirectory(directory, context), localPin: { path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, written: true, @@ -176,8 +179,14 @@ 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/project.ts b/packages/cli/src/presenters/project.ts index b6c4dac..a6f958b 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -1,5 +1,7 @@ import path from "node:path"; +import { shortenHomePath } from "../lib/fs/home-path"; + import stringWidth from "string-width"; import { formatCommandArgument } from "../shell/command-arguments"; @@ -213,15 +215,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).split(path.sep).join("/"); - return relative ? `~/${relative}` : "~"; - } - - return resolved; + return shortenHomePath(cwd, env); } function formatGitConnectionDetail(status: GitRepositoryConnection["status"]): string { diff --git a/packages/cli/src/shell/diagnostics-output.ts b/packages/cli/src/shell/diagnostics-output.ts index bc6b959..91d46dd 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"; @@ -35,15 +37,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).split(path.sep).join("/"); - return relative ? `~/${relative}` : "~"; - } - - return resolved; + return shortenHomePath(value, env); } function formatDirtyState(dirty: boolean | null): string { diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 13df692..bf26ccd 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -217,6 +217,60 @@ describe("app controller", () => { }); }); + 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"); @@ -1465,7 +1519,7 @@ describe("app controller", () => { framework: "nextjs", })).rejects.toMatchObject({ code: "BUILD_SETTINGS_MIGRATION_REQUIRED", - fix: expect.stringContaining('command: "bun run custom-build"'), + fix: expect.stringContaining('command: "bun run custom-build", outputDirectory: "dist",'), }); expect(deployApp).not.toHaveBeenCalled(); }); diff --git a/packages/cli/tests/helpers/deploy-result.ts b/packages/cli/tests/helpers/deploy-result.ts index 00df509..cf05e20 100644 --- a/packages/cli/tests/helpers/deploy-result.ts +++ b/packages/cli/tests/helpers/deploy-result.ts @@ -1,13 +1,15 @@ +import type { AppDeployAllResult, AppDeployResult } from "../../src/types/app"; + /** * Narrows a deploy result to the single-app shape; throws on deploy-all. - * Kept free of src imports so test files can import it statically without - * loading the CLI module graph before vi.doMock calls apply. + * 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( +export function asSingleDeployResult( success: T, -): T & { result: Exclude } { - if (success.result && typeof success.result === "object" && "deployments" in success.result) { +): 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: Exclude }; + return success as T & { result: AppDeployResult }; } From 31de4a5de7a8017372d40204b3c1ea9be782117f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 16:07:55 +0530 Subject: [PATCH 07/16] docs: clarify pin and state-cache anchoring semantics --- docs/product/resource-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index ba7076e..1c88c5d 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -37,7 +37,7 @@ 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. When a `prisma.compute.ts` is discovered, the pin and the local CLI state cache live in the config file's directory +- `.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 From 0d63d41c120feeb37b28818341d1b62c0b16bd79 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 17:20:57 +0530 Subject: [PATCH 08/16] fix: reject Windows drive-relative paths in normalizeRelativePath --- packages/cli/src/lib/app/preview-build-settings.ts | 5 +++++ packages/cli/tests/compute-config.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts index 7d5ee58..c56b5e4 100644 --- a/packages/cli/src/lib/app/preview-build-settings.ts +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -596,6 +596,11 @@ export function normalizeRelativePath(value: string): string | undefined { 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/tests/compute-config.test.ts b/packages/cli/tests/compute-config.test.ts index 3ac935e..abbd06b 100644 --- a/packages/cli/tests/compute-config.test.ts +++ b/packages/cli/tests/compute-config.test.ts @@ -144,6 +144,14 @@ describe("normalizeComputeConfig", () => { expect(issues.join(" ")).toContain("`apps.web.env.vars.EMPTY` must be a non-empty string."); }); + it("rejects roots that escape the config directory, including Windows drive-relative paths", () => { + for (const root of ["C:apps", "C:/apps", "/apps", "..\\apps"]) { + expect(normalizeIssues({ app: { root } }).join(" ")).toContain( + "`app.root` must be a relative path inside the repository.", + ); + } + }); + it("normalizes build blocks", () => { const config = normalizeOrThrow(defineComputeConfig({ apps: { From 570b21f6d20c16ccfb42789fbf45b1312dd49f9e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 19:23:20 +0530 Subject: [PATCH 09/16] feat: add nuxt and astro to the framework registry Registry entries make the CLI's deploy detection and config contract cover the same frameworks as the git-push path. Their build strategies own their builds, so config build blocks are rejected for them (BUILD_SETTINGS_UNSUPPORTED) and inferred settings describe exactly what the strategy runs. --- docs/product/command-spec.md | 3 +- docs/product/error-conventions.md | 1 + packages/cli/src/config.ts | 8 ++- packages/cli/src/controllers/app.ts | 42 ++++++++++++--- packages/cli/src/lib/app/compute-config.ts | 5 +- packages/cli/src/lib/app/frameworks.ts | 54 ++++++++++++++++--- .../cli/src/lib/app/preview-build-settings.ts | 23 ++++++-- packages/cli/tests/app-build.test.ts | 21 ++++++++ packages/cli/tests/compute-config.test.ts | 11 +++- 9 files changed, 147 insertions(+), 21 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index ba268fe..3520cb0 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -784,7 +784,7 @@ prisma-cli app run --build-type bun --entry server.ts --port 3000 prisma-cli app run api ``` -## `prisma-cli app deploy [app] --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: @@ -803,6 +803,7 @@ Compute config file (`prisma.compute.ts`): - 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) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index c1ee950..e30e7a3 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -227,6 +227,7 @@ Recommended meanings: - `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/packages/cli/src/config.ts b/packages/cli/src/config.ts index ff87f5f..4032b85 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -5,14 +5,18 @@ * runtime dependencies so user config files can import it cheaply. */ -export const COMPUTE_FRAMEWORKS = ["nextjs", "hono", "tanstack-start", "bun"] as const; +export const COMPUTE_FRAMEWORKS = ["nextjs", "nuxt", "astro", "hono", "tanstack-start", "bun"] as const; export type ComputeFramework = (typeof COMPUTE_FRAMEWORKS)[number]; export interface ComputeEnvConfig { /** Dotenv file path(s) resolved relative to the config file directory. */ file?: string | string[]; - /** Inline environment variable assignments. Values must be non-empty and are deployed as-is. */ + /** + * Inline environment variable assignments. Values must be non-empty and + * are deployed as-is. This file is committed — keep secrets in platform + * branch config; consumers may ignore inline vars on git-push deploys. + */ vars?: Record; } diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 62bac28..ea5d360 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -86,7 +86,6 @@ import { resolveConfiguredPreviewBuildSettings, resolveInferredPreviewBuildSettings, type PreviewBuildSettings, - type PreviewBuildSettingsBuildType, type PreviewBuildSettingsResolution, type ResolvedPreviewBuildType, type PreviewBuildType, @@ -128,8 +127,10 @@ import { FRAMEWORKS, frameworkByKey, frameworkFromAlias, - isFrameworkBuildType, + isConfigBackedBuildType, LOCAL_DEV_BUILD_TYPES, + type ConfigBackedBuildType, + type FrameworkBuildType, type FrameworkDescriptor, } from "../lib/app/frameworks"; import { formatDomainFailureFix } from "../lib/app/domain-guidance"; @@ -163,9 +164,12 @@ export async function runAppBuild( const buildType = normalizeBuildType(merged.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 && isFrameworkBuildType(buildType) + const buildSettings = compute.config && compute.target?.build && isConfigBackedBuildType(buildType) ? (await resolveConfiguredPreviewBuildSettings({ appPath: appDir, buildType, @@ -587,9 +591,12 @@ async function runSingleAppDeploy( const buildType = framework.buildType; assertSupportedEntrypoint(buildType, merged.entrypoint?.value, "deploy"); const entrypoint = await resolveDeployEntrypoint(appDir, framework, merged.entrypoint?.value, 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 + const buildSettingsResolution = computeConfig.config && computeConfig.target?.build && isConfigBackedBuildType(buildType) ? await resolveConfiguredPreviewBuildSettings({ appPath: appDir, buildType, @@ -2967,7 +2974,7 @@ async function resolveDeployBranch(context: CommandContext, explicitBranchName: interface ResolvedDeployFramework { key: string; - buildType: PreviewBuildSettingsBuildType; + buildType: FrameworkBuildType; displayName: string; annotation: string; } @@ -3323,8 +3330,29 @@ function frameworkFromUserFacingValue(value: string, annotation: string): Resolv }; } +/** + * 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({ @@ -3334,7 +3362,7 @@ function frameworkNotDetectedError(cwd: string | undefined, requestedFramework?: ? `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", diff --git a/packages/cli/src/lib/app/compute-config.ts b/packages/cli/src/lib/app/compute-config.ts index 94133be..f8f8405 100644 --- a/packages/cli/src/lib/app/compute-config.ts +++ b/packages/cli/src/lib/app/compute-config.ts @@ -6,7 +6,7 @@ import { Result, TaggedError, matchError } from "better-result"; import { createJiti } from "jiti"; import { COMPUTE_FRAMEWORKS, type ComputeFramework } from "../../config"; -import { frameworkByKey, type FrameworkBuildType } from "./frameworks"; +import { frameworkByKey, isConfigBackedBuildType, type FrameworkBuildType } from "./frameworks"; import { CliError } from "../../shell/errors"; import { sourceRootLineage } from "../fs/source-root"; import { COMPUTE_CONFIG_FILENAME, findComputeConfigCandidates } from "./compute-config-discovery"; @@ -367,6 +367,9 @@ function normalizeAppEntry( const envInputs = normalizeEnvConfig(value.env, `${label}.env`, issues); const build = normalizeBuildConfig(value.build, `${label}.build`, issues); + if (build && framework && !isConfigBackedBuildType(frameworkByKey(framework).buildType)) { + issues.push(`\`${label}.build\` is not supported with the ${framework} framework; its build runs automatically during deploy.`); + } return { key, diff --git a/packages/cli/src/lib/app/frameworks.ts b/packages/cli/src/lib/app/frameworks.ts index 56a810b..c0c7993 100644 --- a/packages/cli/src/lib/app/frameworks.ts +++ b/packages/cli/src/lib/app/frameworks.ts @@ -7,7 +7,7 @@ import type { ComputeFramework } from "../../config"; * entry here plus its build/run strategy implementation. */ -export type FrameworkBuildType = "nextjs" | "tanstack-start" | "bun"; +export type FrameworkBuildType = "nextjs" | "nuxt" | "astro" | "tanstack-start" | "bun"; export interface FrameworkDescriptor { readonly key: ComputeFramework; @@ -35,6 +35,22 @@ export const NEXT_CONFIG_FILENAMES = [ "next.config.mts", ] as const; +export const NUXT_CONFIG_FILENAMES = [ + "nuxt.config.js", + "nuxt.config.mjs", + "nuxt.config.cjs", + "nuxt.config.ts", + "nuxt.config.mts", +] as const; + +export const ASTRO_CONFIG_FILENAMES = [ + "astro.config.js", + "astro.config.mjs", + "astro.config.cjs", + "astro.config.ts", + "astro.config.mts", +] as const; + // Detection checks frameworks in this order; keep more specific signals first. export const FRAMEWORKS: readonly FrameworkDescriptor[] = [ { @@ -48,6 +64,28 @@ export const FRAMEWORKS: readonly FrameworkDescriptor[] = [ defaultEntrypoint: null, hasLocalDevServer: true, }, + { + key: "nuxt", + displayName: "Nuxt", + buildType: "nuxt", + aliases: ["nuxt", "nuxtjs", "nuxt.js"], + detectPackages: ["nuxt"], + detectConfigFiles: NUXT_CONFIG_FILENAMES, + usesEntrypoint: false, + defaultEntrypoint: null, + hasLocalDevServer: false, + }, + { + key: "astro", + displayName: "Astro", + buildType: "astro", + aliases: ["astro"], + detectPackages: ["astro"], + detectConfigFiles: ASTRO_CONFIG_FILENAMES, + usesEntrypoint: false, + defaultEntrypoint: null, + hasLocalDevServer: false, + }, { key: "hono", displayName: "Hono", @@ -85,10 +123,14 @@ export const FRAMEWORKS: readonly FrameworkDescriptor[] = [ export const FRAMEWORK_KEYS = FRAMEWORKS.map((framework) => framework.key); -/** Build types whose build settings are backed by committed config. */ -export const CONFIG_BACKED_BUILD_TYPES: readonly FrameworkBuildType[] = [ - ...new Set(FRAMEWORKS.map((framework) => framework.buildType)), -]; +/** + * Build types whose preview build consumes committed build settings. The + * others (nuxt, astro) run their framework CLI and stage fixed output, so a + * config `build` block has nothing to apply to. + */ +export const CONFIG_BACKED_BUILD_TYPES = ["nextjs", "tanstack-start", "bun"] as const satisfies readonly FrameworkBuildType[]; + +export type ConfigBackedBuildType = (typeof CONFIG_BACKED_BUILD_TYPES)[number]; /** Build types that consume a user-provided source entrypoint. */ export const ENTRYPOINT_BUILD_TYPES: readonly FrameworkBuildType[] = [ @@ -113,6 +155,6 @@ export function frameworkFromAlias(value: string): FrameworkDescriptor | null { return FRAMEWORKS.find((framework) => framework.aliases.includes(normalized)) ?? null; } -export function isFrameworkBuildType(value: string): value is FrameworkBuildType { +export function isConfigBackedBuildType(value: string): value is ConfigBackedBuildType { return (CONFIG_BACKED_BUILD_TYPES as readonly string[]).includes(value); } diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts index c56b5e4..5e2177a 100644 --- a/packages/cli/src/lib/app/preview-build-settings.ts +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -6,10 +6,11 @@ import { parseModule, type ASTNode } from "magicast"; import { sourceRootLineage } from "../fs/source-root"; import { readBunPackageJson, type BunPackageJsonLike } from "./bun-project"; +import type { ConfigBackedBuildType } from "./frameworks"; import type { ResolvedPreviewBuildType } from "./preview-build"; type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; -export type PreviewBuildSettingsBuildType = Extract; +export type PreviewBuildSettingsBuildType = Extract; /** Legacy build-settings file: no longer read or written, only detected for migration. */ export const PRISMA_APP_CONFIG_FILENAME = "prisma.app.json"; @@ -94,7 +95,7 @@ export async function detectLegacyBuildSettings(options: { /** Resolves build settings purely from framework inference; nothing is read or written. */ export async function resolveInferredPreviewBuildSettings(options: { appPath: string; - buildType: PreviewBuildSettingsBuildType; + buildType: ResolvedPreviewBuildType; signal?: AbortSignal; }): Promise { return { @@ -142,10 +143,26 @@ export async function resolveConfiguredPreviewBuildSettings(options: { export async function resolvePreviewBuildSettings(options: { appPath: string; - buildType: PreviewBuildSettingsBuildType; + 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, options.signal); const buildCommand = await resolveFrameworkBuildCommand(options.appPath, packageJson, { diff --git a/packages/cli/tests/app-build.test.ts b/packages/cli/tests/app-build.test.ts index 7e38cfa..8a9efa6 100644 --- a/packages/cli/tests/app-build.test.ts +++ b/packages/cli/tests/app-build.test.ts @@ -48,6 +48,27 @@ describe("preview build strategy", () => { await expect(readFile(path.join(appPath, "prisma.app.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + it("describes the strategy-owned builds for nuxt and astro", async () => { + const { 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 () => { const { PreviewBuildStrategy } = await import("../src/lib/app/preview-build"); const cwd = await createTempCwd(); diff --git a/packages/cli/tests/compute-config.test.ts b/packages/cli/tests/compute-config.test.ts index abbd06b..619ed60 100644 --- a/packages/cli/tests/compute-config.test.ts +++ b/packages/cli/tests/compute-config.test.ts @@ -138,12 +138,21 @@ describe("normalizeComputeConfig", () => { }); 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, hono, tanstack-start, bun."); + 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( From af6ae1493222218782dfddb9e9e8473b6f414e9f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 19:55:31 +0530 Subject: [PATCH 10/16] feat: consume the compute config contract from @prisma/compute-sdk/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract (types, framework registry, validation, discovery, jiti loading) now lives in @prisma/compute-sdk@0.23.0. The CLI deletes its copies (config.ts, compute-config-discovery.ts, frameworks.ts, fs/source-root.ts) and keeps only CLI concerns: flag/config precedence merging and CliError presentation. The @prisma/cli/config export is removed rather than re-exported — configs import the helper from @prisma/compute-sdk/config, which the loader resolves without a local install. jiti moves to the SDK. --- docs/product/command-spec.md | 4 +- packages/cli/package.json | 7 +- packages/cli/src/commands/app/index.ts | 2 +- packages/cli/src/config.ts | 71 --- packages/cli/src/controllers/app.ts | 4 +- .../src/lib/app/compute-config-discovery.ts | 53 -- packages/cli/src/lib/app/compute-config.ts | 559 ++---------------- packages/cli/src/lib/app/frameworks.ts | 160 ----- .../cli/src/lib/app/preview-build-settings.ts | 4 +- packages/cli/src/lib/app/preview-build.ts | 2 +- packages/cli/src/lib/fs/source-root.ts | 77 --- packages/cli/src/shell/runtime.ts | 2 +- packages/cli/tests/compute-config.test.ts | 10 +- packages/cli/tests/publish-prep.test.ts | 8 - packages/cli/tsdown.config.ts | 3 - pnpm-lock.yaml | 14 +- 16 files changed, 59 insertions(+), 921 deletions(-) delete mode 100644 packages/cli/src/config.ts delete mode 100644 packages/cli/src/lib/app/compute-config-discovery.ts delete mode 100644 packages/cli/src/lib/app/frameworks.ts delete mode 100644 packages/cli/src/lib/fs/source-root.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 3520cb0..2e5b5eb 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -796,7 +796,7 @@ Compute config file (`prisma.compute.ts`): - 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/cli/config`; the helper is an identity function, so plain object exports also work for JavaScript configs +- 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 @@ -833,7 +833,7 @@ same reasons they are excluded today. - settings sourced from the config are annotated `set by prisma.compute.ts` in human output and deploy settings metadata ```ts -import { defineComputeConfig } from "@prisma/cli/config"; +import { defineComputeConfig } from "@prisma/compute-sdk/config"; // Single-app repository: prisma-cli app deploy export default defineComputeConfig({ diff --git a/packages/cli/package.json b/packages/cli/package.json index e47b514..4347bd2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,10 +7,6 @@ "prisma-cli": "./dist/cli.js" }, "exports": { - "./config": { - "types": "./dist/config.d.ts", - "default": "./dist/config.js" - }, "./package.json": "./package.json" }, "files": [ @@ -48,14 +44,13 @@ }, "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", "colorette": "^2.0.20", "commander": "^14.0.3", "dotenv": "^17.4.2", - "jiti": "^2.7.0", "magicast": "^0.5.3", "open": "^11.0.0", "string-width": "^8.2.1", diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 2d6baa7..3b1fa1d 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -57,7 +57,7 @@ import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags" import { runCommand, runStreamingCommand } from "../../shell/command-runner"; import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; import { PREVIEW_BUILD_TYPES } from "../../lib/app/preview-build"; -import { FRAMEWORK_KEYS, LOCAL_DEV_BUILD_TYPES } from "../../lib/app/frameworks"; +import { FRAMEWORK_KEYS, LOCAL_DEV_BUILD_TYPES } from "@prisma/compute-sdk/config"; import type { AppBuildResult, AppDeployAllResult, diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts deleted file mode 100644 index 4032b85..0000000 --- a/packages/cli/src/config.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Public typed config surface for `prisma.compute.ts`. - * - * This module is published as `@prisma/cli/config` and must stay free of - * runtime dependencies so user config files can import it cheaply. - */ - -export const COMPUTE_FRAMEWORKS = ["nextjs", "nuxt", "astro", "hono", "tanstack-start", "bun"] as const; - -export type ComputeFramework = (typeof COMPUTE_FRAMEWORKS)[number]; - -export interface ComputeEnvConfig { - /** Dotenv file path(s) resolved relative to the config file directory. */ - file?: string | string[]; - /** - * Inline environment variable assignments. Values must be non-empty and - * are deployed as-is. This file is committed — keep secrets in platform - * branch config; consumers may ignore inline vars on git-push deploys. - */ - vars?: Record; -} - -export interface ComputeBuildConfig { - /** Build command run in the app root. `null` skips the build step. */ - command?: string | null; - /** Framework output path relative to the app root, e.g. ".next/standalone". */ - outputDirectory?: string; -} - - -export interface ComputeAppConfig { - /** Deployed app name. Defaults to the `apps` key, then package/directory inference. */ - name?: string; - /** App directory relative to the config file. Defaults to the config file directory. */ - root?: string; - /** Framework to deploy. Defaults to detection from the app directory. */ - framework?: ComputeFramework; - /** Entrypoint path for Bun (and Hono) deploys, relative to the app root. */ - entry?: string; - /** HTTP port the deployed app listens on. Defaults to the framework default. */ - httpPort?: number; - /** Environment variables for the deploy. A string is shorthand for `{ file }`. */ - env?: string | ComputeEnvConfig; - /** Build settings. When present, these own the app's build configuration. */ - build?: ComputeBuildConfig; -} - -/** - * `prisma.compute.ts` accepts exactly one of: - * - * - `app` — a repository that deploys a single app - * - `apps` — a monorepo or multi-app repository, keyed by deploy target - */ -export type ComputeConfig = - | { app: ComputeAppConfig; apps?: never } - | { apps: Record; app?: never }; - -/** - * Identity helper that gives `prisma.compute.ts` full type checking: - * - * ```ts - * import { defineComputeConfig } from "@prisma/cli/config"; - * - * export default defineComputeConfig({ - * app: { framework: "hono", httpPort: 8080 }, - * }); - * ``` - */ -export function defineComputeConfig(config: ComputeConfig): ComputeConfig { - return config; -} diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index ea5d360..3d206f4 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -121,7 +121,6 @@ import { type LoadedComputeConfig, type MergedDeployInput, } from "../lib/app/compute-config"; -import type { ComputeFramework } from "../config"; import { ENTRYPOINT_BUILD_TYPES, FRAMEWORKS, @@ -129,10 +128,11 @@ import { frameworkFromAlias, isConfigBackedBuildType, LOCAL_DEV_BUILD_TYPES, + type ComputeFramework, type ConfigBackedBuildType, type FrameworkBuildType, type FrameworkDescriptor, -} from "../lib/app/frameworks"; +} from "@prisma/compute-sdk/config"; import { formatDomainFailureFix } from "../lib/app/domain-guidance"; import { requireAuthenticatedAuthState } from "./auth"; import { listRealWorkspaceProjects } from "./project"; diff --git a/packages/cli/src/lib/app/compute-config-discovery.ts b/packages/cli/src/lib/app/compute-config-discovery.ts deleted file mode 100644 index 30e4897..0000000 --- a/packages/cli/src/lib/app/compute-config-discovery.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { access } from "node:fs/promises"; -import path from "node:path"; - -import { sourceRootLineage } from "../fs/source-root"; - -export const COMPUTE_CONFIG_FILENAME = "prisma.compute.ts"; - -// Highest priority first. TypeScript is the canonical format; the rest exist -// so plain JavaScript projects are not forced into TypeScript. -export const COMPUTE_CONFIG_FILENAMES = [ - "prisma.compute.ts", - "prisma.compute.mts", - "prisma.compute.js", - "prisma.compute.mjs", - "prisma.compute.cjs", -] as const; - -/** - * Compute config files present in one directory, in filename priority order. - */ -export async function findComputeConfigCandidates(directory: string, signal?: AbortSignal): Promise { - const candidates: string[] = []; - for (const filename of COMPUTE_CONFIG_FILENAMES) { - const configPath = path.join(directory, filename); - signal?.throwIfAborted(); - try { - await access(configPath); - candidates.push(configPath); - } catch (error) { - if (signal?.aborted) throw error; - } - } - signal?.throwIfAborted(); - - return candidates; -} - -/** - * Locates the nearest directory holding a compute config file, searching from - * `cwd` up to the source root. This is location-only discovery — the config - * is not loaded or validated — so it is safe to run during CLI bootstrap. - * Returns null when no config exists inside the repository boundary. - */ -export async function findComputeConfigDir(cwd: string, signal?: AbortSignal): Promise { - for (const directory of await sourceRootLineage(cwd, signal)) { - const candidates = await findComputeConfigCandidates(directory, signal); - if (candidates.length > 0) { - return directory; - } - } - - return null; -} diff --git a/packages/cli/src/lib/app/compute-config.ts b/packages/cli/src/lib/app/compute-config.ts index f8f8405..e1723c8 100644 --- a/packages/cli/src/lib/app/compute-config.ts +++ b/packages/cli/src/lib/app/compute-config.ts @@ -1,537 +1,54 @@ -import { existsSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { Result, TaggedError, matchError } from "better-result"; -import { createJiti } from "jiti"; +import { + COMPUTE_CONFIG_FILENAME, + frameworkByKey, + loadComputeConfig as loadComputeConfigFromSdk, + type ComputeConfigError, + type ComputeConfigTargetError, + type ComputeDeployTarget, + type ComputeFramework, + type FrameworkBuildType, + type LoadedComputeConfig, +} from "@prisma/compute-sdk/config"; +import { matchError, type Result } from "better-result"; -import { COMPUTE_FRAMEWORKS, type ComputeFramework } from "../../config"; -import { frameworkByKey, isConfigBackedBuildType, type FrameworkBuildType } from "./frameworks"; import { CliError } from "../../shell/errors"; -import { sourceRootLineage } from "../fs/source-root"; -import { COMPUTE_CONFIG_FILENAME, findComputeConfigCandidates } from "./compute-config-discovery"; -import { normalizeRelativePath } from "./preview-build-settings"; -export { COMPUTE_CONFIG_FILENAME, COMPUTE_CONFIG_FILENAMES } from "./compute-config-discovery"; - -export interface ComputeDeployTargetBuild { - /** Build command, null to skip the build step, undefined when not configured. */ - command: string | null | undefined; - /** Normalized output path relative to the app root, undefined when not configured. */ - outputDirectory: string | undefined; -} - -export interface ComputeDeployTarget { - /** `apps` map key, or null for a single-app `app` config. */ - key: string | null; - name: string | null; - /** Normalized app directory relative to the config file, or null for the config directory. */ - root: string | null; - framework: ComputeFramework | null; - entry: string | null; - httpPort: number | null; - /** `--env`-shaped inputs: dotenv file paths first, then NAME=VALUE assignments. */ - envInputs: string[]; - /** Build settings; non-null means the config owns build configuration. */ - build: ComputeDeployTargetBuild | null; -} - -export interface LoadedComputeConfig { - configPath: string; - /** Directory containing the config file. Config-relative paths resolve from here. */ - configDir: string; - relativeConfigPath: string; - kind: "single" | "multi"; - targets: ComputeDeployTarget[]; -} - -export class ComputeConfigAmbiguousError extends TaggedError("ComputeConfigAmbiguousError")<{ - message: string; - configPaths: string[]; -}>() { - constructor(configPaths: string[]) { - super({ - message: `Multiple compute config files exist: ${configPaths.map((configPath) => path.basename(configPath)).join(", ")}. Keep exactly one.`, - configPaths, - }); - } -} - -export class ComputeConfigLoadError extends TaggedError("ComputeConfigLoadError")<{ - message: string; - cause: unknown; - configPath: string; -}>() { - constructor(configPath: string, cause: unknown) { - super({ - message: `Could not load ${path.basename(configPath)}: ${cause instanceof Error ? cause.message : String(cause)}`, - cause, - configPath, - }); - } -} - -export class ComputeConfigInvalidError extends TaggedError("ComputeConfigInvalidError")<{ - message: string; - configPath: string; - issues: string[]; -}>() { - constructor(configPath: string, issues: string[]) { - super({ - message: `${path.basename(configPath)} is invalid: ${issues.join(" ")}`, - configPath, - issues, - }); - } -} - -export type ComputeConfigError = - | ComputeConfigAmbiguousError - | ComputeConfigLoadError - | ComputeConfigInvalidError; - -export class ComputeConfigTargetRequiredError extends TaggedError("ComputeConfigTargetRequiredError")<{ - message: string; - configPath: string; - availableTargets: string[]; -}>() { - constructor(configPath: string, availableTargets: string[]) { - super({ - message: `${path.basename(configPath)} defines multiple apps. Pass a target: ${availableTargets.join(", ")}.`, - configPath, - availableTargets, - }); - } -} - -export class ComputeConfigTargetUnknownError extends TaggedError("ComputeConfigTargetUnknownError")<{ - message: string; - configPath: string; - requestedTarget: string; - availableTargets: string[]; -}>() { - constructor(configPath: string, requestedTarget: string, availableTargets: string[]) { - super({ - message: `${path.basename(configPath)} does not define an app named "${requestedTarget}". Available: ${availableTargets.join(", ")}.`, - configPath, - requestedTarget, - availableTargets, - }); - } -} - -export type ComputeConfigTargetError = - | ComputeConfigTargetRequiredError - | ComputeConfigTargetUnknownError; +// 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, + ComputeConfigInvalidError, + ComputeConfigLoadError, + ComputeConfigTargetRequiredError, + ComputeConfigTargetUnknownError, + computeTargetAppDir, + inferComputeTargetFromCwd, + normalizeComputeConfig, + selectComputeDeployTarget, + type ComputeConfigError, + type ComputeConfigTargetError, + type ComputeDeployTarget, + type ComputeDeployTargetBuild, + type LoadedComputeConfig, +} from "@prisma/compute-sdk/config"; /** * Loads the nearest compute config, searching from `cwd` up to the source - * root (repository or workspace boundary). Without such a boundary only - * `cwd` itself is checked, so discovery never escapes into unrelated - * directories. + * 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 Result.gen(async function* () { - for (const directory of await sourceRootLineage(cwd, signal)) { - const candidates = await findComputeConfigCandidates(directory, signal); - if (candidates.length === 0) { - continue; - } - if (candidates.length > 1) { - return Result.err(new ComputeConfigAmbiguousError(candidates)); - } - - const configPath = candidates[0]!; - const exported = yield* Result.await(importComputeConfigModule(configPath)); - const normalized = yield* normalizeComputeConfig(exported, configPath); - return Result.ok(normalized); - } - - return Result.ok(null); - }); -} - -async function importComputeConfigModule( - configPath: string, -): Promise> { - return Result.tryPromise({ - try: async () => { - const ownConfigModulePath = resolveOwnConfigModulePath(); - const jiti = createJiti(import.meta.url, { - // Keep Node's standard interop so `.default` exists only for a real - // default export (ESM) or module.exports (CJS). - interopDefault: false, - // Re-import fresh so repeated loads in one process observe file edits. - moduleCache: false, - ...(ownConfigModulePath - ? { - alias: { - // Resolve the typed helper to this running CLI so config files - // work even when @prisma/cli is not installed in the project. - "@prisma/cli/config": ownConfigModulePath, - }, - } - : {}), - }); - const moduleNamespace = await jiti.import>(configPath); - // Require an explicit default export instead of jiti's namespace - // interop so named-export configs fail with a clear message. - return moduleNamespace?.default; - }, - catch: (cause) => new ComputeConfigLoadError(configPath, cause), - }); -} - -function resolveOwnConfigModulePath(): string | null { - // dist layout: dist/lib/app/compute-config.js -> dist/config.js - // src layout (tsx/vitest): src/lib/app/compute-config.ts -> src/config.ts - for (const candidate of ["../../config.js", "../../config.ts"]) { - const candidatePath = fileURLToPath(new URL(candidate, import.meta.url)); - if (existsSync(candidatePath)) { - return candidatePath; - } - } - - return null; -} - -const KNOWN_APP_KEYS = ["name", "root", "framework", "entry", "httpPort", "env", "build"] as const; -const KNOWN_ENV_KEYS = ["file", "vars"] as const; -const KNOWN_BUILD_KEYS = ["command", "outputDirectory"] as const; - - -export function normalizeComputeConfig( - exported: unknown, - configPath: string, -): Result { - const issues: string[] = []; - const targets: ComputeDeployTarget[] = []; - let kind: LoadedComputeConfig["kind"] = "single"; - - if (!isPlainObject(exported)) { - issues.push("The config must `export default defineComputeConfig({ ... })` with an object value."); - } else { - const hasApp = exported.app !== undefined; - const hasApps = exported.apps !== undefined; - - for (const key of Object.keys(exported)) { - if (key !== "app" && key !== "apps") { - issues.push(`Unknown top-level key "${key}". Expected "app" or "apps".`); - } - } - - if (hasApp && hasApps) { - issues.push("Use either `app` (single app) or `apps` (multi-app), not both."); - } else if (hasApp) { - const target = normalizeAppEntry(exported.app, "app", null, issues); - if (target) { - targets.push(target); - } - } else if (hasApps) { - kind = "multi"; - if (!isPlainObject(exported.apps)) { - issues.push("`apps` must be an object keyed by deploy target name."); - } else { - const entries = Object.entries(exported.apps); - if (entries.length === 0) { - issues.push("`apps` must define at least one app."); - } - for (const [key, value] of entries) { - if (key.trim().length === 0) { - issues.push("`apps` keys must be non-empty target names."); - continue; - } - const target = normalizeAppEntry(value, `apps.${key}`, key, issues); - if (target) { - targets.push(target); - } - } - } - } else { - issues.push("Define `app` for a single-app repository or `apps` for a multi-app repository."); - } - } - - if (issues.length > 0) { - return Result.err(new ComputeConfigInvalidError(configPath, issues)); - } - - return Result.ok({ - configPath, - configDir: path.dirname(configPath), - relativeConfigPath: path.basename(configPath), - kind, - targets, - }); -} - - -/** Absolute app directory of a config target. */ -export function computeTargetAppDir(config: LoadedComputeConfig, target: ComputeDeployTarget): string { - return path.resolve(config.configDir, target.root ?? "."); -} - -/** - * Infers the deploy target whose app directory contains `cwd`, so commands - * run from inside a target's root select it without a target argument. The - * deepest matching root wins; an ambiguous tie infers nothing and falls back - * to the explicit target-required error. - */ -export function inferComputeTargetFromCwd(config: LoadedComputeConfig, cwd: string): string | undefined { - if (config.kind !== "multi") { - return undefined; - } - - const resolvedCwd = path.resolve(cwd); - let bestKey: string | undefined; - let bestDepth = -1; - let bestIsTied = false; - - for (const target of config.targets) { - const appDir = computeTargetAppDir(config, target); - const relative = path.relative(appDir, resolvedCwd); - if (relative.startsWith("..") || path.isAbsolute(relative)) { - continue; - } - - const depth = appDir.split(path.sep).length; - if (depth > bestDepth) { - bestKey = target.key ?? undefined; - bestDepth = depth; - bestIsTied = false; - } else if (depth === bestDepth) { - bestIsTied = true; - } - } - - return bestIsTied ? undefined : bestKey; -} - -function normalizeAppEntry( - value: unknown, - label: string, - key: string | null, - issues: string[], -): ComputeDeployTarget | null { - if (!isPlainObject(value)) { - issues.push(`\`${label}\` must be an object.`); - return null; - } - - for (const entryKey of Object.keys(value)) { - if (!(KNOWN_APP_KEYS as readonly string[]).includes(entryKey)) { - issues.push(`Unknown key "${entryKey}" in \`${label}\`. Expected one of: ${KNOWN_APP_KEYS.join(", ")}.`); - } - } - - const name = readOptionalNonEmptyString(value.name, `${label}.name`, issues); - const entry = readOptionalNonEmptyString(value.entry, `${label}.entry`, issues); - - let root: string | null = null; - if (value.root !== undefined) { - const rawRoot = readOptionalNonEmptyString(value.root, `${label}.root`, issues); - if (rawRoot) { - const normalized = normalizeRelativePath(rawRoot)?.replace(/\/+$/, ""); - if (!normalized) { - issues.push(`\`${label}.root\` must be a relative path inside the repository.`); - } else if (normalized !== ".") { - root = normalized; - } - } - } - - let framework: ComputeFramework | null = null; - if (value.framework !== undefined) { - if (typeof value.framework === "string" && (COMPUTE_FRAMEWORKS as readonly string[]).includes(value.framework)) { - framework = value.framework as ComputeFramework; - } else { - issues.push(`\`${label}.framework\` must be one of: ${COMPUTE_FRAMEWORKS.join(", ")}.`); - } - } - - if (entry && framework && !frameworkByKey(framework).usesEntrypoint) { - issues.push(`\`${label}.entry\` is not supported with the ${framework} framework; it derives its entrypoint from build output.`); - } - - let httpPort: number | null = null; - if (value.httpPort !== undefined) { - if (typeof value.httpPort === "number" && Number.isInteger(value.httpPort) && value.httpPort > 0 && value.httpPort <= 65535) { - httpPort = value.httpPort; - } else { - issues.push(`\`${label}.httpPort\` must be an integer between 1 and 65535.`); - } - } - - const envInputs = normalizeEnvConfig(value.env, `${label}.env`, issues); - const build = normalizeBuildConfig(value.build, `${label}.build`, issues); - if (build && framework && !isConfigBackedBuildType(frameworkByKey(framework).buildType)) { - issues.push(`\`${label}.build\` is not supported with the ${framework} framework; its build runs automatically during deploy.`); - } - - return { - key, - name, - root, - framework, - entry, - httpPort, - envInputs, - build, - }; -} - -function normalizeBuildConfig(value: unknown, label: string, issues: string[]): ComputeDeployTargetBuild | null { - if (value === undefined) { - return null; - } - - if (!isPlainObject(value)) { - issues.push(`\`${label}\` must be an object with \`command\` and/or \`outputDirectory\`.`); - return null; - } - - for (const buildKey of Object.keys(value)) { - if (!(KNOWN_BUILD_KEYS as readonly string[]).includes(buildKey)) { - issues.push(`Unknown key "${buildKey}" in \`${label}\`. Expected one of: ${KNOWN_BUILD_KEYS.join(", ")}.`); - } - } - - let command: string | null | undefined; - if (value.command !== undefined) { - if (value.command === null) { - command = null; - } else if (typeof value.command === "string" && value.command.trim().length > 0) { - command = value.command.trim(); - } else { - issues.push(`\`${label}.command\` must be a non-empty string, or null to skip the build step.`); - } - } - - let outputDirectory: string | undefined; - if (value.outputDirectory !== undefined) { - const normalized = typeof value.outputDirectory === "string" - ? normalizeRelativePath(value.outputDirectory)?.replace(/\/+$/, "") - : undefined; - if (!normalized) { - issues.push(`\`${label}.outputDirectory\` must be a relative path inside the app root.`); - } else { - outputDirectory = normalized; - } - } - - if (command === undefined && outputDirectory === undefined) { - issues.push(`\`${label}\` must set \`command\` and/or \`outputDirectory\`.`); - return null; - } - - return { command, outputDirectory }; -} - -function normalizeEnvConfig(value: unknown, label: string, issues: string[]): string[] { - if (value === undefined) { - return []; - } - - if (typeof value === "string") { - const file = value.trim(); - if (file.length === 0) { - issues.push(`\`${label}\` must be a non-empty dotenv file path when given as a string.`); - return []; - } - return [file]; - } - - if (!isPlainObject(value)) { - issues.push(`\`${label}\` must be a dotenv file path or an object with \`file\` and/or \`vars\`.`); - return []; - } - - for (const envKey of Object.keys(value)) { - if (!(KNOWN_ENV_KEYS as readonly string[]).includes(envKey)) { - issues.push(`Unknown key "${envKey}" in \`${label}\`. Expected one of: ${KNOWN_ENV_KEYS.join(", ")}.`); - } - } - - const envInputs: string[] = []; - - if (value.file !== undefined) { - const files = Array.isArray(value.file) ? value.file : [value.file]; - for (const file of files) { - if (typeof file !== "string" || file.trim().length === 0) { - issues.push(`\`${label}.file\` must be a non-empty dotenv file path or an array of them.`); - continue; - } - envInputs.push(file.trim()); - } - } - - if (value.vars !== undefined) { - if (!isPlainObject(value.vars)) { - issues.push(`\`${label}.vars\` must be an object of NAME: value pairs.`); - } else { - for (const [varName, varValue] of Object.entries(value.vars)) { - if (typeof varValue !== "string" || varValue.length === 0) { - issues.push(`\`${label}.vars.${varName}\` must be a non-empty string.`); - continue; - } - envInputs.push(`${varName}=${varValue}`); - } - } - } - - return envInputs; -} - -function readOptionalNonEmptyString(value: unknown, label: string, issues: string[]): string | null { - if (value === undefined) { - return null; - } - - if (typeof value !== "string" || value.trim().length === 0) { - issues.push(`\`${label}\` must be a non-empty string.`); - return null; - } - - return value.trim(); -} - -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -export function selectComputeDeployTarget( - config: LoadedComputeConfig, - requestedTarget: string | undefined, -): Result { - if (config.kind === "single") { - const target = config.targets[0]!; - if (requestedTarget && requestedTarget !== target.name) { - return Result.err(new ComputeConfigTargetUnknownError( - config.configPath, - requestedTarget, - target.name ? [target.name] : [], - )); - } - return Result.ok(target); - } - - const availableTargets = config.targets.map((target) => target.key!); - if (!requestedTarget) { - if (config.targets.length === 1) { - return Result.ok(config.targets[0]!); - } - return Result.err(new ComputeConfigTargetRequiredError(config.configPath, availableTargets)); - } - - const matched = config.targets.find((target) => target.key === requestedTarget); - if (!matched) { - return Result.err(new ComputeConfigTargetUnknownError(config.configPath, requestedTarget, availableTargets)); - } - - return Result.ok(matched); + return loadComputeConfigFromSdk(cwd, { signal }); } /** Local build/run strategy implied by a configured framework. */ diff --git a/packages/cli/src/lib/app/frameworks.ts b/packages/cli/src/lib/app/frameworks.ts deleted file mode 100644 index c0c7993..0000000 --- a/packages/cli/src/lib/app/frameworks.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { ComputeFramework } from "../../config"; - -/** - * Framework capability registry — the single source of truth for what each - * supported framework is and can do. Commands, config validation, detection, - * and prompts all query this table; adding a framework means adding one - * entry here plus its build/run strategy implementation. - */ - -export type FrameworkBuildType = "nextjs" | "nuxt" | "astro" | "tanstack-start" | "bun"; - -export interface FrameworkDescriptor { - readonly key: ComputeFramework; - readonly displayName: string; - /** Build/deploy strategy this framework uses. */ - readonly buildType: FrameworkBuildType; - /** Accepted user-facing spellings, lowercased, including the key. */ - readonly aliases: readonly string[]; - /** Dependencies whose presence detects this framework. */ - readonly detectPackages: readonly string[]; - /** Config files whose presence detects this framework. */ - readonly detectConfigFiles: readonly string[]; - /** Consumes a user-provided source entrypoint instead of build output. */ - readonly usesEntrypoint: boolean; - /** Entrypoint assumed when the package defines none. */ - readonly defaultEntrypoint: string | null; - /** Has a local dev server (`app run`) in the current preview. */ - readonly hasLocalDevServer: boolean; -} - -export const NEXT_CONFIG_FILENAMES = [ - "next.config.js", - "next.config.mjs", - "next.config.ts", - "next.config.mts", -] as const; - -export const NUXT_CONFIG_FILENAMES = [ - "nuxt.config.js", - "nuxt.config.mjs", - "nuxt.config.cjs", - "nuxt.config.ts", - "nuxt.config.mts", -] as const; - -export const ASTRO_CONFIG_FILENAMES = [ - "astro.config.js", - "astro.config.mjs", - "astro.config.cjs", - "astro.config.ts", - "astro.config.mts", -] as const; - -// Detection checks frameworks in this order; keep more specific signals first. -export const FRAMEWORKS: readonly FrameworkDescriptor[] = [ - { - key: "nextjs", - displayName: "Next.js", - buildType: "nextjs", - aliases: ["nextjs", "next", "next.js"], - detectPackages: ["next"], - detectConfigFiles: NEXT_CONFIG_FILENAMES, - usesEntrypoint: false, - defaultEntrypoint: null, - hasLocalDevServer: true, - }, - { - key: "nuxt", - displayName: "Nuxt", - buildType: "nuxt", - aliases: ["nuxt", "nuxtjs", "nuxt.js"], - detectPackages: ["nuxt"], - detectConfigFiles: NUXT_CONFIG_FILENAMES, - usesEntrypoint: false, - defaultEntrypoint: null, - hasLocalDevServer: false, - }, - { - key: "astro", - displayName: "Astro", - buildType: "astro", - aliases: ["astro"], - detectPackages: ["astro"], - detectConfigFiles: ASTRO_CONFIG_FILENAMES, - usesEntrypoint: false, - defaultEntrypoint: null, - hasLocalDevServer: false, - }, - { - key: "hono", - displayName: "Hono", - buildType: "bun", - aliases: ["hono"], - detectPackages: ["hono"], - detectConfigFiles: [], - usesEntrypoint: true, - defaultEntrypoint: "src/index.ts", - hasLocalDevServer: true, - }, - { - key: "tanstack-start", - displayName: "TanStack Start", - buildType: "tanstack-start", - aliases: ["tanstack-start", "tanstack", "@tanstack/react-start", "@tanstack/solid-start"], - detectPackages: ["@tanstack/react-start", "@tanstack/solid-start"], - detectConfigFiles: [], - usesEntrypoint: false, - defaultEntrypoint: null, - hasLocalDevServer: false, - }, - { - key: "bun", - displayName: "Bun", - buildType: "bun", - aliases: ["bun"], - detectPackages: [], - detectConfigFiles: [], - usesEntrypoint: true, - defaultEntrypoint: null, - hasLocalDevServer: true, - }, -]; - -export const FRAMEWORK_KEYS = FRAMEWORKS.map((framework) => framework.key); - -/** - * Build types whose preview build consumes committed build settings. The - * others (nuxt, astro) run their framework CLI and stage fixed output, so a - * config `build` block has nothing to apply to. - */ -export const CONFIG_BACKED_BUILD_TYPES = ["nextjs", "tanstack-start", "bun"] as const satisfies readonly FrameworkBuildType[]; - -export type ConfigBackedBuildType = (typeof CONFIG_BACKED_BUILD_TYPES)[number]; - -/** Build types that consume a user-provided source entrypoint. */ -export const ENTRYPOINT_BUILD_TYPES: readonly FrameworkBuildType[] = [ - ...new Set(FRAMEWORKS.filter((framework) => framework.usesEntrypoint).map((framework) => framework.buildType)), -]; - -/** Build types `app run` can start a local dev server for. */ -export const LOCAL_DEV_BUILD_TYPES: readonly FrameworkBuildType[] = [ - ...new Set(FRAMEWORKS.filter((framework) => framework.hasLocalDevServer).map((framework) => framework.buildType)), -]; - -export function frameworkByKey(key: ComputeFramework): FrameworkDescriptor { - const framework = FRAMEWORKS.find((candidate) => candidate.key === key); - if (!framework) { - throw new Error(`Unknown framework key "${key}".`); - } - return framework; -} - -export function frameworkFromAlias(value: string): FrameworkDescriptor | null { - const normalized = value.trim().toLowerCase(); - return FRAMEWORKS.find((framework) => framework.aliases.includes(normalized)) ?? null; -} - -export function isConfigBackedBuildType(value: string): value is ConfigBackedBuildType { - return (CONFIG_BACKED_BUILD_TYPES as readonly string[]).includes(value); -} diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts index 5e2177a..0ecf40e 100644 --- a/packages/cli/src/lib/app/preview-build-settings.ts +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -4,9 +4,9 @@ import path from "node:path"; import { parseModule, type ASTNode } from "magicast"; -import { sourceRootLineage } from "../fs/source-root"; +import { sourceRootLineage, type ConfigBackedBuildType } from "@prisma/compute-sdk/config"; + import { readBunPackageJson, type BunPackageJsonLike } from "./bun-project"; -import type { ConfigBackedBuildType } from "./frameworks"; import type { ResolvedPreviewBuildType } from "./preview-build"; type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index ea89c9e..01bd979 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -21,7 +21,7 @@ import { runResolvedBuildCommand, type PreviewBuildSettings, } from "./preview-build-settings"; -import { resolveSourceRoot } from "../fs/source-root"; +import { resolveSourceRoot } from "@prisma/compute-sdk/config"; export { PRISMA_APP_CONFIG_FILENAME, diff --git a/packages/cli/src/lib/fs/source-root.ts b/packages/cli/src/lib/fs/source-root.ts deleted file mode 100644 index 44d0f39..0000000 --- a/packages/cli/src/lib/fs/source-root.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { readFile, stat } from "node:fs/promises"; -import path from "node:path"; - -/** - * Resolves the directories from `appPath` up to its source root, nearest - * first. The source root is the closest ancestor that looks like a repository - * or workspace root; a standalone app is its own source root. - * - * This module stays dependency-light: it is imported during CLI bootstrap. - */ -export 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; - } -} - -/** Directories from `appPath` up to its source root, nearest first. */ -export async function sourceRootLineage(appPath: string, signal?: AbortSignal): Promise { - const sourceRoot = await resolveSourceRoot(appPath, signal); - const lineage: string[] = []; - let current = path.resolve(appPath); - - while (true) { - lineage.push(current); - if (current === sourceRoot) { - return lineage; - } - - const parent = path.dirname(current); - if (parent === current) { - return lineage; - } - - 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 pathExists(targetPath: string, signal?: AbortSignal): Promise { - try { - signal?.throwIfAborted(); - await stat(targetPath); - signal?.throwIfAborted(); - return true; - } catch (error) { - if (signal?.aborted) throw error; - return false; - } -} diff --git a/packages/cli/src/shell/runtime.ts b/packages/cli/src/shell/runtime.ts index 94b597c..cf61562 100644 --- a/packages/cli/src/shell/runtime.ts +++ b/packages/cli/src/shell/runtime.ts @@ -4,7 +4,7 @@ import { Command } from "commander"; import { LocalStateStore } from "../adapters/local-state"; import { MockApi } from "../adapters/mock-api"; -import { findComputeConfigDir } from "../lib/app/compute-config-discovery"; +import { findComputeConfigDir } from "@prisma/compute-sdk/config"; import { renderHelp } from "./help"; import type { CliOutput } from "./output"; import type { GlobalFlags } from "./global-flags"; diff --git a/packages/cli/tests/compute-config.test.ts b/packages/cli/tests/compute-config.test.ts index 619ed60..ec8b9e3 100644 --- a/packages/cli/tests/compute-config.test.ts +++ b/packages/cli/tests/compute-config.test.ts @@ -22,7 +22,7 @@ import { type ComputeDeployTarget, type LoadedComputeConfig, } from "../src/lib/app/compute-config"; -import { defineComputeConfig } from "../src/config"; +import { defineComputeConfig } from "@prisma/compute-sdk/config"; import { CliError } from "../src/shell/errors"; const CONFIG_PATH = "/repo/prisma.compute.ts"; @@ -411,10 +411,10 @@ describe("loadComputeConfig", () => { expect((await loadComputeConfig(dir)).unwrap()).toBeNull(); }); - it("loads a TypeScript config that imports @prisma/cli/config", async () => { + 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/cli/config";', + 'import { defineComputeConfig } from "@prisma/compute-sdk/config";', "", "export default defineComputeConfig({", ' app: { name: "api", framework: "hono", httpPort: 8080 satisfies number },', @@ -535,7 +535,7 @@ describe("compute config discovery and state location", () => { }); it("locates the nearest config directory without loading the config", async () => { - const { findComputeConfigDir } = await import("../src/lib/app/compute-config-discovery"); + 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 }); @@ -547,7 +547,7 @@ describe("compute config discovery and state location", () => { }); it("returns null without a config or outside the repository boundary", async () => { - const { findComputeConfigDir } = await import("../src/lib/app/compute-config-discovery"); + 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 }); diff --git a/packages/cli/tests/publish-prep.test.ts b/packages/cli/tests/publish-prep.test.ts index 37e318a..325b4c2 100644 --- a/packages/cli/tests/publish-prep.test.ts +++ b/packages/cli/tests/publish-prep.test.ts @@ -45,10 +45,6 @@ describe("prepare cli publish", () => { description: "Command-line interface for the Prisma Developer Platform.", type: "module", exports: { - "./config": { - types: "./dist/config.d.ts", - default: "./dist/config.js", - }, "./package.json": "./package.json", }, engines: { @@ -90,10 +86,6 @@ describe("prepare cli publish", () => { "prisma-cli": "./dist/cli.js", }, exports: { - "./config": { - types: "./dist/config.d.ts", - default: "./dist/config.js", - }, "./package.json": "./package.json", }, files: ["dist", "README.md", "LICENSE"], diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 72a826e..9322554 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -3,7 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: { cli: "src/bin.ts", - config: "src/config.ts", }, format: ["esm"], clean: true, @@ -11,6 +10,4 @@ export default defineConfig({ unbundle: true, fixedExtension: false, outDir: "dist", - // Declarations are needed for the public `@prisma/cli/config` entry. - dts: true, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01dbcc7..65f62a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,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 @@ -47,9 +47,6 @@ importers: dotenv: specifier: ^17.4.2 version: 17.4.2 - jiti: - specifier: ^2.7.0 - version: 2.7.0 magicast: specifier: ^0.5.3 version: 0.5.3 @@ -488,8 +485,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' @@ -1624,10 +1621,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 From fc3edb44883b09215e84f9cb3f4b99dc28827a3d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 20:37:53 +0530 Subject: [PATCH 11/16] fix: apply committed build blocks under auto detection, doc fixes from review app build with a config build block but no declared framework now detects the framework the way deploy does instead of silently ignoring the block (FRAMEWORK_NOT_DETECTED when nothing is detectable). Docs: retire APP_CONFIG_INVALID in favor of the COMPUTE_CONFIG_*/BUILD_SETTINGS_* codes and scope auto-creation (resolution step 7) to app deploy only. --- docs/product/command-spec.md | 5 +- docs/product/error-conventions.md | 6 +- packages/cli/src/controllers/app.ts | 12 +++- packages/cli/tests/app-local-dev.test.ts | 76 ++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 2e5b5eb..f4709e4 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -113,7 +113,7 @@ Preview app commands that need an app resolve it in this order: 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. create the inferred app in the resolved branch when no existing app matches +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 @@ -123,7 +123,8 @@ 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. +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` diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index e30e7a3..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` diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 3d206f4..d49f687 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -161,7 +161,17 @@ export async function runAppBuild( target: compute.target, }); const appDir = await resolveComputeAppDir(context, compute); - const buildType = normalizeBuildType(merged.buildType); + 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") { diff --git a/packages/cli/tests/app-local-dev.test.ts b/packages/cli/tests/app-local-dev.test.ts index aba16f6..d7ae43d 100644 --- a/packages/cli/tests/app-local-dev.test.ts +++ b/packages/cli/tests/app-local-dev.test.ts @@ -151,6 +151,82 @@ describe("app local dev commands", () => { }); }); + 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( + "../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: { From eb44109283394e3bc5ae2bac20cfc01ce2e6ee40 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 20:48:15 +0530 Subject: [PATCH 12/16] fix: clear type errors gated by the new CI Type Check job selectPrismaNextConfig gains mode-specific overloads so supported and unsupported selections carry their actual target unions, and the deploy-specific asSingleDeployResult helper is no longer misapplied to show/domain/show-deploy/rollback results in tests. --- packages/cli/src/lib/app/branch-database.ts | 14 ++++++++++++++ packages/cli/tests/app-controller.test.ts | 10 +++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/app/branch-database.ts b/packages/cli/src/lib/app/branch-database.ts index d41e362..aae6a71 100644 --- a/packages/cli/src/lib/app/branch-database.ts +++ b/packages/cli/src/lib/app/branch-database.ts @@ -258,6 +258,20 @@ async function selectPrismaOrmSchema( }; } +function selectPrismaNextConfig( + cwd: string, + candidates: ClassifiedPrismaNextConfig[], + mode: "supported", +): (ClassifiedPrismaNextConfig & { target: "postgresql" | "unknown" }) | null; +function selectPrismaNextConfig( + cwd: string, + candidates: ClassifiedPrismaNextConfig[], + mode: "unsupported", +): + | (ClassifiedPrismaNextConfig & { + target: UnsupportedBranchDatabaseSchemaTarget; + }) + | null; function selectPrismaNextConfig( cwd: string, candidates: ClassifiedPrismaNextConfig[], diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index e0eff53..0c9ab4c 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -456,7 +456,7 @@ describe("app controller", () => { const result = await runAppShow(context, undefined, undefined, undefined); expect(listDeployments).toHaveBeenCalledWith("app_api", expect.anything()); - expect(asSingleDeployResult(result).result.app).toEqual({ + expect(result.result.app).toEqual({ id: "app_api", name: "api", }); @@ -883,7 +883,7 @@ describe("app controller", () => { hostname: "shop.acme.com", signal: context.runtime.signal, }); - expect(asSingleDeployResult(result).result.project.id).toBe("proj_123"); + expect(result.result.project.id).toBe("proj_123"); }); it("domain add requires Project setup instead of entering interactive setup", async () => { @@ -4526,7 +4526,7 @@ describe("app controller", () => { const result = await runAppShowDeploy(context, "dep_123"); - expect(asSingleDeployResult(result).result.deployment.live).toBe(true); + expect(result.result.deployment.live).toBe(true); }); it("show-deploy ignores known live deployments from another workspace", async () => { @@ -4590,7 +4590,7 @@ describe("app controller", () => { const result = await runAppShowDeploy(context, "dep_123"); - expect(asSingleDeployResult(result).result.deployment.live).toBe(null); + expect(result.result.deployment.live).toBe(null); }); it("show-deploy surfaces provider failures instead of reporting not found", async () => { @@ -5358,7 +5358,7 @@ describe("app controller", () => { deploymentId: "dep_1", }), ); - expect(asSingleDeployResult(result).result.deployment.id).toBe("dep_1"); + expect(result.result.deployment.id).toBe("dep_1"); }); it("rollback returns NO_PREVIOUS_DEPLOYMENT when only one deployment exists", async () => { From ac2c517181c50124daccf92f3d7a950ad0ff5f4b Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 12 Jun 2026 21:34:49 +0530 Subject: [PATCH 13/16] fix: resolve the Git branch from the nearest repository upward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readLocalGitBranch only looked at cwd/.git, so a deploy run from inside a monorepo package found no repository and silently fell back to the main branch — deploying feature-branch code to main. It now walks up like git does, with the nearest repository as a hard boundary (detached HEAD stays null rather than escaping to an outer repo). Also fixes the command spec to say the local pin anchors at the config directory, matching resource-model.md and the implementation. --- docs/product/command-spec.md | 2 +- packages/cli/src/lib/git/local-branch.ts | 31 +++++++++-- packages/cli/tests/local-branch.test.ts | 68 ++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 packages/cli/tests/local-branch.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index f4709e4..ca4a174 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -818,7 +818,7 @@ 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 invocation directory +- `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 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/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(); + }); +}); From 42aaa72595cd1430ebb49e78101a8aab24b7f8ff Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Sat, 13 Jun 2026 03:53:01 +0530 Subject: [PATCH 14/16] fix: drop repo-internal docs path from app logs output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Read more line pointed at docs/product/command-spec.md — a file in this repository, meaningless to anyone running the published CLI. Removed until a public docs URL exists; the project/app/deployment header stays. --- packages/cli/src/controllers/app.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 16f4d11..69a4892 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -1540,8 +1540,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 }, From d6b8ad6875c4dc675f58f19159ee34c6e05e91c6 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Sat, 13 Jun 2026 03:58:08 +0530 Subject: [PATCH 15/16] fix: name the target in deploy-all per-app logs hints After a deploy-all, bare app logs follows the remembered selection (the last target deployed), so suggesting it under every app pointed at the wrong logs for all but one. Each block now suggests prisma-cli app logs . --- packages/cli/src/presenters/app.ts | 14 ++++++++++---- packages/cli/tests/app-presenter.test.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index 3183ab9..feeeb70 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -59,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), @@ -91,9 +97,9 @@ export function renderAppDeployAll( for (const deployment of result.deployments) { lines.push(deployment.target); lines.push( - ...renderAppDeploy(context, descriptor, deployment.result).map((line) => - line ? ` ${line}` : line, - ), + ...renderAppDeploy(context, descriptor, deployment.result, { + logsTarget: deployment.target, + }).map((line) => (line ? ` ${line}` : line)), ); lines.push(""); } diff --git a/packages/cli/tests/app-presenter.test.ts b/packages/cli/tests/app-presenter.test.ts index 28f3fa5..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, @@ -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())), From 3f122a204a743fa414119e5f739d998c7b41c265 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Sat, 13 Jun 2026 04:01:29 +0530 Subject: [PATCH 16/16] fix: name a target in the deploy-all list-deploys suggestion --- packages/cli/src/controllers/app.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 69a4892..b82d89c 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -448,7 +448,9 @@ async function runAppDeployAll( command: "app.deploy", result: { deployments }, warnings, - nextSteps: ["prisma-cli app list-deploys"], + // 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 "], }; }