From 30de44e90923d9dc754d8f22dd13c2a8098077e3 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 01:11:18 +0200 Subject: [PATCH 01/67] drive(prisma-cli-v8): S2 overview + S2a foundations contract and dispatch plan S2 ships as four area PRs (>=1k LOC floor). S2a: engine publish metadata + production dependency, ctx.api on the command context, auth module extraction, the auth family port, update check, cli-telemetry package move with the RunHooks.onSettled amendment, and the clack prompt renderer from the spike. Contracts pin every design decision; dispatches stop rather than improvise. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../prisma-cli-v8/plans/s2a-foundations.md | 101 ++++++++ .../prisma-cli-v8/specs/s2-overview.md | 76 ++++++ .../prisma-cli-v8/specs/s2a-foundations.md | 226 ++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 .drive/projects/prisma-cli-v8/plans/s2a-foundations.md create mode 100644 .drive/projects/prisma-cli-v8/specs/s2-overview.md create mode 100644 .drive/projects/prisma-cli-v8/specs/s2a-foundations.md diff --git a/.drive/projects/prisma-cli-v8/plans/s2a-foundations.md b/.drive/projects/prisma-cli-v8/plans/s2a-foundations.md new file mode 100644 index 00000000..6d041e41 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/plans/s2a-foundations.md @@ -0,0 +1,101 @@ +# S2a dispatch plan — foundations + +Contract: `../specs/s2a-foundations.md` (normative for this PR; the +overview `../specs/s2-overview.md` carries the standing rulings). One +branch `s2a-foundations` off `main`; the PR targets `main`. Every +dispatch verifies: engine + cli suites, `pnpm typecheck`, `pnpm lint` +exit 0 (measured as pnpm's own exit code), before its commit lands. +Commit discipline per the repo's standing rules (bot identity, dual +sign-off, explicit staging). + +The contract leaves no design decisions to dispatches. Where a +dispatch meets a fact the contract did not pin, it STOPS and returns +the question to the orchestrator — never improvises. + +## Dispatches (sequential) + +### D1 — Publish metadata + production dependency + +**Outcome:** contract §1 exactly: cli-engine publish metadata +(version 0.1.0, license file, README, publishConfig, repository, +prepack), `@prisma/cli-engine` in the cli's `dependencies`. +**Builds on:** merged S1. +**Hands to:** the operator's manual `npm publish`; every later +dispatch. +**Completed when:** `npm pack --dry-run` in packages/cli-engine lists +dist + README + LICENSE and nothing else; cli builds green with the +dependency move. THIS DISPATCH LANDS FIRST AND ALONE — the operator +publishes from it while later dispatches proceed. + +### D2 — Auth module extraction + +**Outcome:** contract §3: the three moves, `src/auth/index.ts` as the +single face with the exact export list, workspace operations extracted +from the controller, `makeGetCredentials` relocated, every importer +updated. Zero behavior change — the full existing cli suite passes +unmodified except for import paths in tests that mock the moved +modules. +**Builds on:** D1. +**Hands to:** D3 (getCredentials seam), D6 (the family port's +operations layer). +**Completed when:** legacy shell + v8 bin green; no import of +token-storage/auth-ops/client outside `src/auth/`. + +### D3 — `ctx.api` + harness client override + +**Outcome:** contract §2: SDK dependency exact-pinned in engine and +cli, `Runtime.managementApi`, lazy `ctx.api`, `CLI.CREDENTIALS_REQUIRED` +reuse on unauthenticated use, harness `managementApi.client` override, +draft amendments (§4, §10, §11), the four contract-listed tests. +**Builds on:** D2 (baseUrl source moved to `src/auth/client.ts`). +**Hands to:** D6 and every S2b/S2c port. +**Completed when:** contract §2 tests green; dist `.d.ts` shows +`api: ManagementApiClient` and no direct SDK type names beyond the +re-export alias. + +### D4 — Clack prompt renderer + +**Outcome:** contract §7: the adapter, the branch condition, the +draft notes, the fixture-driven clack-path tests. Reference: spike +branch `spike/clack-prompts` commit 903b25a (reimplement cleanly). +**Builds on:** D1 only (independent of D2/D3 — may run after D1 in +parallel with D2 if the orchestrator chooses; file overlap is nil). +**Hands to:** D6's `workspace use` prompt; S2d's wizard. +**Completed when:** all engine prompt tests green including the new +clack fixture suite; scripted/non-TTY paths proven clack-free. + +### D5 — Telemetry package + engine hook + bin wiring + +**Outcome:** contract §6: `packages/cli-telemetry` ported with the +preserved invariants, `EngineCommandSnapshot`, `RunHooks.onSettled` + +draft amendment, bin gating + detached sender, `telemetry +status|enable|disable` commands, the four contract-listed test areas. +**Builds on:** D3 (hook shape rides the same engine surface); D4 not +required. +**Hands to:** S2b/S2c ports (every command reports automatically). +**Completed when:** contract §6 tests green; a manual smoke run shows +the sender spawn under an enabled config and NO spawn under CI env. + +### D6 — `auth *` family + update check + slice closure + +**Outcome:** contract §4 (six commands, semantic tests, fixture-flag +removal, AUTH.* error mapping) and §5 (update-check move + both-shell +wiring). The S1 whoami handler rewires to `src/auth/index.ts`. +Divergence list updated (new: login flag removals, error-code +mapping, any update-check json-mode finding per §5). The PR +description is drafted per the operator's PR-description structure +(grounding example first, decision, narrative, alternatives last). +**Builds on:** D2, D3, D4 (prompt path), D5 (telemetry observes the +new commands automatically). +**Hands to:** operator review of the S2a PR; S2b. +**Completed when:** every acceptance box in the contract checks +except the operator-publish box (checked when the operator publishes); +review loop (architect + principal-engineer per the drive process) +run and findings fixed; PR opened non-draft. + +## Completeness check + +D1 → §1; D2 → §3; D3 → §2; D4 → §7; D5 → §6; D6 → §4 + §5 + closure. +Every contract section is owned by exactly one dispatch; the +acceptance boxes map 1:1 onto dispatch completion criteria plus the +review loop. diff --git a/.drive/projects/prisma-cli-v8/specs/s2-overview.md b/.drive/projects/prisma-cli-v8/specs/s2-overview.md new file mode 100644 index 00000000..88257f50 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/specs/s2-overview.md @@ -0,0 +1,76 @@ +# S2 — Platform family port (slice overview) + +S2 ports the platform CLI onto `@prisma/cli-engine` and retires the +commander shell. It ships as FOUR stacked PRs, split by area, each its +own contract + dispatch plan, each ≥1k LOC (operator floor, applies to +the port PRs; infrastructure PRs may be smaller where inherently so): + +| PR | Contract | Content | +| --- | --- | --- | +| S2a | `s2a-foundations.md` | Engine publishable + production dep; `ctx.api`; auth module extraction; `auth *` family; update check; telemetry package move + wiring; clack prompt renderer | +| S2b | `s2b-resources.md` | `project *`, `postgres *` (database), `bucket *` (incl. keys), `branch list` | +| S2c | `s2c-services.md` | `service *` (renamed from `app`, incl. env + domain subgroups), `build *`, `git *`, `agent *`, `feedback` | +| S2d | `s2d-init-and-retirement.md` | `init` wizard; commander-shell deletion; fixture-mode machinery deletion; final parity review | + +Branch mechanics: each PR branches off `main` (S1 merged as PR #129) +and lands into `main`. S2b depends on S2a; S2c on S2b; S2d on S2c. + +## Standing rulings that govern every S2 PR + +All operator-ruled; none are open to implementer judgment. + +1. **Vocabulary**: the contribution/ownership entity is `CommandFamily` + (never "product"/"manifest"; `commandFamily`/`commandFamilies` never + shortened in identifiers). A subgroup is owned by exactly one + command family. `project` belongs to the platform family; Composer + parks under a `composer` root in S3 (TML-3189 holds the final + grammar). +2. **Renames**: the deployable-unit noun is Service — the `app` group + ports as `service` (S2c). No other renames are ruled. +3. **Types**: no conditional properties on stored types — `define*` + inputs may be optional, normalized definitions are total (`T | + undefined` or a natural empty). +4. **Testing**: semantic-first. Commands are tested through + `createTestCli` (`@prisma/cli-engine/testing`) with the management + API faked at `ctx.api` and auth stubbed at the auth-module seam. + Assertions target the envelope, presented data, events, and exit + codes — NOT output bytes. A single small golden suite per output + surface pins human rendering and channel discipline globally. + Fixture-mode tests are deleted batch-by-batch as their commands + port; no fixture machinery survives S2d. +5. **`ctx.api`**: the management API client lives directly on + `CommandContext` (operator: no extension mechanisms — this is + Prisma's engine). Spec in S2a. +6. **Auth**: an internal module (`packages/cli/src/auth/`), not a + workspace package. Spec in S2a. +7. **Telemetry is essential**: this CLI reports exactly the way the + ORM CLI does today; the `@internal/cli-telemetry` implementation + moves to this repo (prisma/prisma retires it with its CLI at S5). + Spec in S2a. +8. **`--trace` is dropped** (log levels cover it). The update + notification is ported in S2a (not deferred). +9. **Prompts**: interactive rendering is backed by `@clack/prompts` + 1.5.0 (exact-pinned, fully internal, prompts only — never its + spinners; progress stays engine events). Spike-verified + (2026-08-10); landing spec in S2a. +10. **Parity**: divergences from the shipping CLI are enumerated per + PR in a divergence list for operator review, not discovered. + Maintainability outranks byte parity. + +## Grounding inventory + +`assets/s2/command-inventory.md` catalogues every current command +(flags, positionals, auth requirement, API calls, behavior class, +output, prompts, side effects, tests, engine mapping). S2b–S2d +contracts enumerate their commands FROM that inventory; the inventory +is the single source for "what exists today". + +## Definition of done (whole slice) + +- Every platform command runs on the engine; the commander shell and + fixture machinery are deleted; `prisma-v8` naming is retired in + favor of the real bin wiring (final naming ruled in S2d). +- Per-PR divergence lists reviewed by the operator. +- Engine published and consumed as a production dependency. +- Telemetry reporting live with the ORM-identical client and shared + installation id. diff --git a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md new file mode 100644 index 00000000..3581197c --- /dev/null +++ b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md @@ -0,0 +1,226 @@ +# S2a — Foundations (slice contract) + +One PR into `main`, branch `s2a-foundations`. First of S2's four PRs +(`s2-overview.md`). Everything the later port PRs depend on lands here. +The v8 draft (`../assets/engine/engine-interface-draft.ts`) is +normative; the amendments this contract specifies are operator-ruled — +apply them to the draft in the same PR. Nothing in this contract is +open to implementer judgment; where a detail is not stated, the +reference implementation cited for it is the specification. + +## 1. Engine publishable + production dependency + +- `packages/cli-engine/package.json` gains publish metadata: + `version: "0.1.0"`, `description` (one line: the execution engine of + the unified Prisma CLI), `license: "Apache-2.0"`, `files: ["dist", + "README.md", "LICENSE"]`, `repository` (type git, url + https://github.com/prisma/prisma-cli.git, directory + packages/cli-engine), `homepage`, `bugs`, `publishConfig: { access: + "public" }`, `engines.node: ">=22.12.0"` (match `@prisma/cli`). + Add `LICENSE` (copy root/cli Apache-2.0) and a minimal `README.md` + (what the package is, the three subpaths, link to the repo). The + operator publishes manually — no publish workflow in this PR. +- `@prisma/cli-engine` moves from `devDependencies` to `dependencies` + of `packages/cli` (still `workspace:*`). +- `prepack` on cli-engine: `pnpm run build` (match sibling packages). + +## 2. `ctx.api` — the management API client on the context + +Operator-ruled: the client sits directly on `CommandContext`; no +extension mechanism. + +- Engine gains dependency `@prisma/management-api-sdk` at the EXACT + version `packages/cli` currently resolves (pin the resolved version, + not a range; update the cli's own range to the same exact pin in + this PR — committed-versions discipline). +- `Runtime` gains `readonly managementApi: { readonly baseUrl: + string }`. The bin computes `baseUrl` exactly as + `getApiBaseUrl(env)` does today (module moves in §3); the harness + defaults it to `"https://test.invalid"` and `createTestCli`'s spec + gains `managementApi?: { baseUrl?: string; client?: + ManagementApiClient }` — when `client` is supplied, `ctx.api` IS + that object (the uniform mock seam). +- `CommandContext` gains `readonly api: ManagementApiClient`. + `ManagementApiClient` is a type alias the engine re-exports for the + SDK's client type; consumers never import the SDK directly. +- Construction: lazy, once per run, on first property access (Proxy or + getter — match how `execution/command-context.ts` builds the rest of + the context; the client construction itself copies the current + shell's construction in `packages/cli/src/controllers/auth.ts` + (`createManagementApiSdk` call sites) with the token source backed + by `ctx.getCredentials` so refresh during long runs is picked up + per-request. That call site is the reference implementation; do not + redesign it. +- Unauthenticated use: a command WITHOUT `needs.credentials` that + touches `ctx.api` while `getCredentials()` resolves undefined gets a + thrown `CliStructuredError` `CLI.CREDENTIALS_REQUIRED` — the same + code, summary, and nextActions the needs-check failure uses (single + source: the constructor already in `execution/needs.ts` — export and + reuse it; no second phrasing). +- Draft amendment: §4 CommandContext (+`api`), §10 Runtime + (+`managementApi`), §11 harness spec. +- Tests: context exposes the injected fake; lazy construction (no + SDK construction when `api` untouched — assert via a throwing + factory fake); unauthenticated throw path; refresh pickup (two + `getCredentials` values across two `ctx.api` calls). + +## 3. Auth module extraction + +An internal module — NOT a workspace package (operator-ruled). + +Moves (git mv; update every importer; zero behavior change): + +| From | To | +| --- | --- | +| `packages/cli/src/adapters/token-storage.ts` | `packages/cli/src/auth/token-storage.ts` | +| `packages/cli/src/lib/auth/auth-ops.ts` | `packages/cli/src/auth/operations.ts` | +| `packages/cli/src/lib/auth/client.ts` | `packages/cli/src/auth/client.ts` | + +New `packages/cli/src/auth/index.ts` — the module's ONLY public face; +everything else in `src/auth/` is internal to it. Exports exactly: +`readAuthState`, `performLogin`, `performLogout`, `FileTokenStorage`, +`EmptyServiceTokenError`, `isEmptyServiceTokenError`, +`SERVICE_TOKEN_ENV_VAR`, `getApiBaseUrl`, `CLIENT_ID`, the workspace +list/use/logout operations currently in `controllers/auth.ts`'s +real-mode helpers (`listRealAuthWorkspaces`, `useRealAuthWorkspace`, +`logoutRealAuthWorkspace` — extracted from the controller into +`src/auth/workspaces.ts`, controller delegates), and +`makeGetCredentials` (moved from `src/v8/runtime.ts`; the v8 runtime +imports it from here). The legacy shell and controllers import ONLY +via `src/auth/index.ts`. The `Credentials` shape stays the engine's +`{ token: string }` — S2a does not redesign it. + +## 4. `auth *` family port + +Mounted in the v8 bin under the existing `auth` group. All commands +are result commands in the platform command family. Fixture-mode-only +surface does not port (fixture machinery dies in S2d): `auth login` +loses `--provider`, `--user`, `--workspace` (mock-selection flags). + +| Command | Args | needs | Behavior | +| --- | --- | --- | --- | +| `auth login` | none | none | Real OAuth flow via `performLogin` (browser open + poll), then `readAuthState`; events: `step-started/finished` for the flow, `endpoint` for the verification URL; presented like whoami's card plus the agent-setup tip line when `resolveAgentSetupTipCommand` fires (port that helper's real-mode path); nextActions: `auth whoami`, `project list`, the tip command when present | +| `auth logout` | none | none | `performLogout` + `readAuthState`; card matching current logout copy; nextAction: `auth login` | +| `auth whoami` | none | none | Already ported (S1) — moves from `src/v8/auth/whoami.ts` handler calling `readAuthState` directly to the auth module import; otherwise untouched | +| `auth workspace list` | none | none | `listRealAuthWorkspaces`; table Block (name, id, status; source column only when mixed — port the exact column rules from `presenters/auth.ts`); json serializer ports `serializeAuthWorkspaceList` | +| `auth workspace use [workspace]` | optional positional | none | Resolves by id or case-insensitive name; ambiguous → `AUTH.WORKSPACE_AMBIGUOUS` errored (map the current error's content to nextActions form); absent positional + interactive → `prompt.select` over workspaces (clack path); absent + non-interactive → structural prompt failure (engine default) | +| `auth workspace logout ` | required positional | none | Port current semantics incl. was-active handling | + +Error mapping: the current shell's flat codes port to dotted +`AUTH.*` codes, enumerated in the divergence list (pattern set by S1: +`AUTH_CONFIG_INVALID` → `AUTH.CONFIG_INVALID`, exit 1 → 2 for errored +paths). No documented 4–99 codes in this family. + +Tests: semantic, per ruling — auth module stubbed at +`src/auth/index.ts` seam (vi.mock), `ctx.api` fake where workspaces +call the SDK; every command × (success, errored, json, unauth where +meaningful); prompt path for `workspace use` via scripted answers. +Delete `packages/cli/tests/auth.test.ts` fixture-mode cases that cover +ported commands; keep the file's untouched-shell cases until S2d. + +## 5. Update check port + +- `packages/cli/src/shell/update-check.ts` moves to + `packages/cli/src/update-check.ts`; its `CliRuntime` parameter + narrows to the exact fields it uses (type them structurally so both + shells satisfy it). The legacy shell keeps consuming it; the v8 bin + (`src/v8/main.ts`) wires it identically to the legacy shell's two + touchpoints: read-and-notify before the run's output settles is NOT + the current behavior — copy the CURRENT sequencing exactly (cached + notify + detached refresh spawn; consult the legacy call sites as + the reference implementation). Notification line goes to stderr. +- Tests: notify-when-cached-newer, refresh-spawn arguments, silence + inside the notification interval, silence in json format (decide by + the current behavior — if the legacy shell prints it in json mode + today, KEEP that and record it in the divergence list; do not + invent a new rule). + +## 6. Telemetry + +Operator-ruled: essential, identical to the ORM CLI's mechanism; the +implementation moves to this repo. + +- New workspace package `packages/cli-telemetry`, name + `@repo/cli-telemetry`, `private: true` (bundled into the cli — it + must appear in the cli's tsdown bundle, not as a published dep). + Source ported from prisma/prisma `packages/1-framework/3-tooling/ + cli-telemetry` (reference clone: `wip/repos/prisma`). Preserve + UNCHANGED: the user-config path and format (shared installation id + with the ORM CLI), gating resolution (consent state, CI detection, + env opt-outs), the detached-subprocess sender, endpoint and wire + protocol, the sanitizer's value-free discipline. +- Replace the Commander snapshot type with the engine shape: + `EngineCommandSnapshot { commandPath: readonly string[]; flags: + ReadonlyArray<{ name: string; source: "cli" | "env" | "default" }>; + positionalCount: number }` — no values, ever. +- Engine amendment: `RunHooks` gains `onSettled?: (summary: + RunSummary) => void` where `RunSummary { commandId: string; + exitCode: number; durationMs: number; snapshot: + EngineCommandSnapshot }`, fired exactly once per run after + settlement, never for `--help`/`--version`, errors in the hook are + swallowed (a telemetry bug must not break a command). Draft §10 + amendment. `durationMs` from the injectable clock. +- Bin wiring (`src/v8/main.ts`): resolve gating; when enabled, pass an + `onSettled` hook that spawns the detached sender — sequencing and + spawn semantics copied from the ORM CLI's `preAction`/util wiring + (reference: `wip/repos/prisma/.../cli/src/utils/telemetry.ts`). +- Commands `telemetry status|enable|disable` port from the ORM CLI's + consent surface as engine result commands, mounted shell-owned (no + family), group `telemetry`. Copy the ORM's semantics and copy; + presented as cards; json serializers included. +- Tests: sanitizer (engine snapshot → wire shape), gating matrix + (consent × CI × env), hook firing (once, correct summary, swallowed + throw), consent commands. + +## 7. Clack prompt renderer + +Land the spike design (spike branch `spike/clack-prompts`, commit +903b25a — reference implementation; reimplement cleanly, do not +cherry-pick): + +- `@clack/prompts` exact-pinned `1.5.0`, engine dependency, loaded by + dynamic import only on the interactive path. +- New `packages/cli-engine/src/execution/clack-renderer.ts`: stream + adapters (`Readable.from` over `Runtime.stdin` with `setRawMode` + forwarded; `Writable` over stderr `OutputStream`), prompt mapping + for confirm/consent/select/text with `{ input, output }` injection. +- Branch condition in `execution/prompts.ts`: clack renders IFF no + scripted answers AND `runtime.isTty.stdin` AND + `runtime.stdin.setRawMode` is present; otherwise the existing plain + line renderer. Structural failures and `--yes` resolution stay + BEFORE the branch. Cancellation maps to the existing + `CLI.PROMPT_CANCELLED` path. Clack spinners/log helpers are + forbidden (process-global handlers): progress remains engine + events. +- Draft amendments: two-tier rendering note (§4a); select's + Enter-picks-highlighted note; the accepted + `process.stdout.columns` read quirk. +- Tests: fake raw-mode stdin fixture driving confirm/select/text + through the clack path (assert resolved values + stderr-only + writes); cancellation byte (`\x03`) → exit 3; harness/scripted path + proven clack-free (dynamic import spy). + +## Out of scope + +`project`/`postgres`/`bucket`/`branch` (S2b), `service`/`build`/`git`/ +`agent`/`feedback` (S2c), `init` + shell deletion + fixture removal +(S2d), engine version bumps beyond 0.1.0, Credentials shape redesign, +`composer` root (S3). + +## Acceptance + +- [ ] Operator has published `@prisma/cli-engine@0.1.0` (metadata PR + landed first; publish is the operator's single action). +- [ ] `ctx.api` on the context with the harness `client` override; + draft amended; refresh-pickup test green. +- [ ] Auth module extracted; legacy shell green against it; v8 runtime + consumes `makeGetCredentials` from it. +- [ ] All six `auth *` commands on the engine with semantic tests; + fixture-only flags gone; divergence list updated. +- [ ] Update check ported to both shells; sequencing matches legacy. +- [ ] Telemetry: package ported, hook amendment landed, bin wired, + consent commands mounted, sanitizer value-free by test. +- [ ] Clack renderer landed per spike; all prompt tests green + including the clack-path fixture suite. +- [ ] Root verification: engine + cli suites, typecheck, lint exit 0. +- [ ] PR ≥1k LOC (expected: well above), divergence list reviewed. From 2241fbd2f8437ba66202217117035af628802635 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 01:17:03 +0200 Subject: [PATCH 02/67] chore(cli-engine): publish metadata for 0.1.0; engine becomes a production dependency Give @prisma/cli-engine everything npm publish needs: version 0.1.0, the one-line description, Apache-2.0 license file, files list (dist, README, LICENSE), repository/homepage/bugs pointing at prisma/prisma-cli, publishConfig access public, engines.node >=22.12.0, and a prepack build script, all shaped like packages/cli's package.json. Drop private: true so the package can actually be published, and add a terse README covering the package and its three entry points. Move @prisma/cli-engine from the cli's devDependencies to dependencies (still workspace:*) and settle the lockfile. npm pack --dry-run ships exactly dist + README.md + LICENSE + package.json. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/LICENSE | 158 +++++++++++++++++++++++++++++++ packages/cli-engine/README.md | 11 +++ packages/cli-engine/package.json | 17 +++- packages/cli/package.json | 2 +- pnpm-lock.yaml | 6 +- 5 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 packages/cli-engine/LICENSE create mode 100644 packages/cli-engine/README.md diff --git a/packages/cli-engine/LICENSE b/packages/cli-engine/LICENSE new file mode 100644 index 00000000..c6732f40 --- /dev/null +++ b/packages/cli-engine/LICENSE @@ -0,0 +1,158 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work. + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work by +the copyright owner or by an individual or Legal Entity authorized to submit on +behalf of the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent to the +Licensor or its representatives, including but not limited to communication on +electronic mailing lists, source code control systems, and issue tracking +systems that are managed by, or on behalf of, the Licensor for the purpose of +discussing and improving the Work, but excluding communication that is +conspicuously marked or otherwise designated in writing by the copyright owner +as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or Object +form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, +each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) patent +license to make, have made, use, offer to sell, sell, import, and otherwise +transfer the Work, where such license applies only to those patent claims +licensable by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) with the Work to +which such Contribution(s) was submitted. If You institute patent litigation +against any entity (including a cross-claim or counterclaim in a lawsuit) +alleging that the Work or a Contribution incorporated within the Work +constitutes direct or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate as of the date +such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy of +this License; and + +(b) You must cause any modified files to carry prominent notices stating that You +changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any part +of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, then +any Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the Derivative +Works; within the Source form or documentation, if provided along with the +Derivative Works; or, within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents of the NOTICE +file are for informational purposes only and do not modify the License. You may +add Your own attribution notices within Derivative Works that You distribute, +alongside or as an addendum to the NOTICE text from the Work, provided that such +additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein shall +supersede or modify the terms of any separate license agreement you may have +executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied, including, without limitation, any warranties or +conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any risks +associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to in +writing, shall any Contributor be liable to You for damages, including any +direct, indirect, special, incidental, or consequential damages of any character +arising as a result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, work stoppage, +computer failure or malfunction, or any and all other commercial damages or +losses), even if such Contributor has been advised of the possibility of such +damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or +Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations and/or +rights consistent with this License. However, in accepting such obligations, You +may act only on Your own behalf and on Your sole responsibility, not on behalf of +any other Contributor, and only if You agree to indemnify, defend, and hold each +Contributor harmless for any liability incurred by, or claims asserted against, +such Contributor by reason of your accepting any such warranty or additional +liability. + +END OF TERMS AND CONDITIONS diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md new file mode 100644 index 00000000..11c9f56a --- /dev/null +++ b/packages/cli-engine/README.md @@ -0,0 +1,11 @@ +# @prisma/cli-engine + +The execution engine of the unified Prisma CLI: it owns the path from argv to exit code — parsing, execution, rendering, and error handling. + +## Entry points + +- `@prisma/cli-engine` — the engine: command definitions, context, and the runner. +- `@prisma/cli-engine/protocol` — the wire types for machine-readable (JSON) output. +- `@prisma/cli-engine/testing` — the test harness for running commands in-process. + +Part of [prisma/prisma-cli](https://github.com/prisma/prisma-cli). diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 2dba0063..621667ef 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,8 +1,7 @@ { "name": "@prisma/cli-engine", - "version": "0.0.0", - "description": "The unified Prisma CLI engine.", - "private": true, + "version": "0.1.0", + "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { ".": { @@ -20,8 +19,13 @@ } }, "files": [ - "dist" + "dist", + "README.md", + "LICENSE" ], + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=22.12.0" }, @@ -30,9 +34,14 @@ "url": "https://github.com/prisma/prisma-cli.git", "directory": "packages/cli-engine" }, + "homepage": "https://github.com/prisma/prisma-cli#readme", + "bugs": { + "url": "https://github.com/prisma/prisma-cli/issues" + }, "license": "Apache-2.0", "scripts": { "build": "tsdown", + "prepack": "pnpm run build", "typecheck": "tsc --noEmit", "test": "pnpm run build && pnpm run typecheck && vitest run" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index aa470bec..adc5a9cd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@clack/prompts": "^1.5.0", + "@prisma/cli-engine": "workspace:*", "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "^1.55.0", @@ -60,7 +61,6 @@ "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@prisma/cli-engine": "workspace:*", "@repo/tsconfig": "workspace:*", "@types/node": "^22.19.19", "tsdown": "^0.21.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8cfedc4..0b4921db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@clack/prompts': specifier: ^1.5.0 version: 1.5.0 + '@prisma/cli-engine': + specifier: workspace:* + version: link:../cli-engine '@prisma/compute-sdk': specifier: 0.39.0 version: 0.39.0(@prisma/management-api-sdk@1.55.0)(rollup@4.62.2) @@ -66,9 +69,6 @@ importers: specifier: ^10.0.0 version: 10.0.0 devDependencies: - '@prisma/cli-engine': - specifier: workspace:* - version: link:../cli-engine '@repo/tsconfig': specifier: workspace:* version: link:../tsconfig From fa56bbbb6e6bab8389d599f8711f87c0c7534c5c Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 01:30:04 +0200 Subject: [PATCH 03/67] refactor(cli): extract the auth module behind src/auth/index.ts Move token-storage, auth-ops (now operations), and the auth client into src/auth/, extract the real-mode workspace helpers out of the auth controller into src/auth/workspaces.ts, and relocate makeGetCredentials from the v8 runtime into src/auth/credentials.ts. src/auth/index.ts is the module's only public face; controllers, the legacy shell, and the v8 code import through it. No behavior change: both suites pass with only import and mock path updates. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/{lib => }/auth/client.ts | 0 packages/cli/src/auth/credentials.ts | 23 ++ packages/cli/src/auth/index.ts | 19 ++ .../auth/auth-ops.ts => auth/operations.ts} | 8 +- .../src/{adapters => auth}/token-storage.ts | 2 +- packages/cli/src/auth/workspaces.ts | 311 +++++++++++++++++ packages/cli/src/controllers/app.ts | 9 +- packages/cli/src/controllers/auth.ts | 317 +----------------- packages/cli/src/controllers/project.ts | 4 +- packages/cli/src/lib/auth/guard.ts | 8 +- packages/cli/src/lib/auth/login.ts | 3 +- packages/cli/src/lib/auth/recipient.ts | 5 +- packages/cli/src/shell/command-runner.ts | 2 +- packages/cli/src/v8/auth/whoami.ts | 5 +- packages/cli/src/v8/runtime.ts | 24 +- .../cli/tests/app-branch-database.test.ts | 5 +- packages/cli/tests/app-controller.test.ts | 5 +- packages/cli/tests/app-env-vars.test.ts | 5 +- packages/cli/tests/app-env.test.ts | 5 +- packages/cli/tests/auth-ops.test.ts | 71 ++-- packages/cli/tests/auth-real-mode.test.ts | 22 +- packages/cli/tests/auth.test.ts | 2 +- packages/cli/tests/branch-controller.test.ts | 5 +- packages/cli/tests/project-controller.test.ts | 14 +- packages/cli/tests/project-real-mode.test.ts | 35 +- packages/cli/tests/token-storage.test.ts | 2 +- packages/cli/tests/v8-bin.test.ts | 5 +- packages/cli/tests/v8-whoami.test.ts | 9 +- 28 files changed, 497 insertions(+), 428 deletions(-) rename packages/cli/src/{lib => }/auth/client.ts (100%) create mode 100644 packages/cli/src/auth/credentials.ts create mode 100644 packages/cli/src/auth/index.ts rename packages/cli/src/{lib/auth/auth-ops.ts => auth/operations.ts} (97%) rename packages/cli/src/{adapters => auth}/token-storage.ts (99%) create mode 100644 packages/cli/src/auth/workspaces.ts diff --git a/packages/cli/src/lib/auth/client.ts b/packages/cli/src/auth/client.ts similarity index 100% rename from packages/cli/src/lib/auth/client.ts rename to packages/cli/src/auth/client.ts diff --git a/packages/cli/src/auth/credentials.ts b/packages/cli/src/auth/credentials.ts new file mode 100644 index 00000000..00dd8a72 --- /dev/null +++ b/packages/cli/src/auth/credentials.ts @@ -0,0 +1,23 @@ +import type { Credentials } from "@prisma/cli-engine"; +import { SERVICE_TOKEN_ENV_VAR } from "./client"; +import { EmptyServiceTokenError } from "./operations"; +import { FileTokenStorage } from "./token-storage"; + +/** Token reads ignore the run's abort signal so they still work during + * teardown after the first Ctrl-C. */ +export function makeGetCredentials( + env: NodeJS.ProcessEnv, +): () => Promise { + return async () => { + const rawServiceToken = env[SERVICE_TOKEN_ENV_VAR]; + if (rawServiceToken !== undefined) { + const serviceToken = rawServiceToken.trim(); + if (serviceToken.length === 0) { + throw new EmptyServiceTokenError(); + } + return { token: serviceToken }; + } + const tokens = await new FileTokenStorage(env).getTokens(); + return tokens ? { token: tokens.accessToken } : undefined; + }; +} diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts new file mode 100644 index 00000000..b1cad878 --- /dev/null +++ b/packages/cli/src/auth/index.ts @@ -0,0 +1,19 @@ +export { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +export { makeGetCredentials } from "./credentials"; +export { + EmptyServiceTokenError, + isEmptyServiceTokenError, + performLogin, + performLogout, + readAuthState, +} from "./operations"; +export { + FileTokenStorage, + type StoredAuthWorkspace, + WorkspaceSelectionError, +} from "./token-storage"; +export { + listRealAuthWorkspaces, + logoutRealAuthWorkspace, + useRealAuthWorkspace, +} from "./workspaces"; diff --git a/packages/cli/src/lib/auth/auth-ops.ts b/packages/cli/src/auth/operations.ts similarity index 97% rename from packages/cli/src/lib/auth/auth-ops.ts rename to packages/cli/src/auth/operations.ts index 66cad835..b8a4651a 100644 --- a/packages/cli/src/lib/auth/auth-ops.ts +++ b/packages/cli/src/auth/operations.ts @@ -1,9 +1,9 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { FileTokenStorage } from "../../adapters/token-storage"; -import type { AuthStateResult } from "../../types/auth"; +import { requireComputeAuth } from "../lib/auth/guard"; +import { login } from "../lib/auth/login"; +import type { AuthStateResult } from "../types/auth"; import { SERVICE_TOKEN_ENV_VAR } from "./client"; -import { requireComputeAuth } from "./guard"; -import { login } from "./login"; +import { FileTokenStorage } from "./token-storage"; const WORKSPACE_SUB_PREFIX = "workspace:"; diff --git a/packages/cli/src/adapters/token-storage.ts b/packages/cli/src/auth/token-storage.ts similarity index 99% rename from packages/cli/src/adapters/token-storage.ts rename to packages/cli/src/auth/token-storage.ts index 7435d5fd..416425e4 100644 --- a/packages/cli/src/adapters/token-storage.ts +++ b/packages/cli/src/auth/token-storage.ts @@ -4,7 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { CredentialsStore } from "@prisma/credentials-store"; import type { TokenStorage, Tokens } from "@prisma/management-api-sdk"; -import { getAuthFilePath } from "../lib/auth/client"; +import { getAuthFilePath } from "./client"; interface StoredCredential { workspaceId?: unknown; diff --git a/packages/cli/src/auth/workspaces.ts b/packages/cli/src/auth/workspaces.ts new file mode 100644 index 00000000..3e08cb45 --- /dev/null +++ b/packages/cli/src/auth/workspaces.ts @@ -0,0 +1,311 @@ +import { + createManagementApiSdk, + type TokenStorage, + type Tokens, +} from "@prisma/management-api-sdk"; +import { + workspaceAmbiguousError, + workspaceNotAuthenticatedError, + workspaceSwitchUnavailableError, +} from "../shell/errors"; +import type { CommandContext } from "../shell/runtime"; +import type { + AuthWorkspace, + AuthWorkspaceListResult, + AuthWorkspaceLogoutResult, + AuthWorkspaceUseResult, +} from "../types/auth"; +import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +import { readAuthState } from "./operations"; +import { + FileTokenStorage, + type StoredAuthWorkspace, + WorkspaceSelectionError, +} from "./token-storage"; + +export async function listRealAuthWorkspaces( + context: CommandContext, +): Promise { + const rawServiceToken = context.runtime.env[SERVICE_TOKEN_ENV_VAR]; + const storage = new FileTokenStorage( + context.runtime.env, + context.runtime.signal, + ); + const localWorkspaces = await hydrateLocalAuthWorkspaces( + context, + storage, + await storage.listWorkspaces(), + ); + + if (rawServiceToken !== undefined) { + const authState = await readAuthState( + context.runtime.env, + context.runtime.signal, + ); + return { + authSource: authState.authenticated ? "service_token" : "none", + activeWorkspace: authState.workspace, + workspaces: [ + ...(authState.workspace + ? [ + { + ...authState.workspace, + credentialWorkspaceId: null, + active: true, + source: "service_token" as const, + switchable: false, + lastSeenAt: null, + }, + ] + : []), + ...localWorkspaces.map((workspace) => ({ + ...toAuthWorkspace(workspace), + credentialWorkspaceId: workspace.credentialWorkspaceId, + active: false, + source: "oauth" as const, + switchable: false, + lastSeenAt: workspace.lastSeenAt, + })), + ], + }; + } + + const active = localWorkspaces.find((workspace) => workspace.active) ?? null; + return { + authSource: localWorkspaces.length > 0 ? "oauth" : "none", + activeWorkspace: active ? toAuthWorkspace(active) : null, + workspaces: localWorkspaces.map((workspace) => ({ + ...toAuthWorkspace(workspace), + credentialWorkspaceId: workspace.credentialWorkspaceId, + active: workspace.active, + source: "oauth" as const, + switchable: true, + lastSeenAt: workspace.lastSeenAt, + })), + }; +} + +export async function useRealAuthWorkspace( + context: CommandContext, + workspaceRef: string, +): Promise { + if (context.runtime.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { + throw workspaceSwitchUnavailableError(); + } + + const storage = new FileTokenStorage( + context.runtime.env, + context.runtime.signal, + ); + await hydrateLocalAuthWorkspaces( + context, + storage, + await storage.listWorkspaces(), + ); + + try { + const result = await storage.useWorkspace(workspaceRef); + return { + previousWorkspace: result.previous + ? toAuthWorkspace(result.previous) + : null, + workspace: toAuthWorkspace(result.selected), + }; + } catch (error) { + if (error instanceof WorkspaceSelectionError) { + if (error.reason === "ambiguous") { + throw workspaceAmbiguousError( + error.workspaceRef ?? workspaceRef, + error.matches.map((match) => ({ + id: match.id, + name: match.name, + credentialWorkspaceId: match.credentialWorkspaceId, + })), + ); + } + + throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef); + } + + throw error; + } +} + +export async function logoutRealAuthWorkspace( + context: CommandContext, + workspaceRef: string, +): Promise { + const storage = new FileTokenStorage( + context.runtime.env, + context.runtime.signal, + ); + await hydrateLocalAuthWorkspaces( + context, + storage, + await storage.listWorkspaces(), + ); + + try { + const result = await storage.logoutWorkspace(workspaceRef); + return { + workspace: toAuthWorkspace(result.workspace), + wasActive: result.wasActive, + activeWorkspace: result.activeWorkspace + ? toAuthWorkspace(result.activeWorkspace) + : null, + }; + } catch (error) { + if (error instanceof WorkspaceSelectionError) { + if (error.reason === "ambiguous") { + throw workspaceAmbiguousError( + error.workspaceRef ?? workspaceRef, + error.matches.map((match) => ({ + id: match.id, + name: match.name, + credentialWorkspaceId: match.credentialWorkspaceId, + })), + ); + } + + throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef); + } + + throw error; + } +} + +async function hydrateLocalAuthWorkspaces( + context: CommandContext, + storage: FileTokenStorage, + workspaces: StoredAuthWorkspace[], +): Promise { + const candidates = workspaces.filter(needsWorkspaceMetadataHydration); + if (candidates.length === 0) return workspaces; + + const tokensByCredentialWorkspaceId = new Map( + (await storage.listWorkspaceTokens()).map((tokens) => [ + tokens.workspaceId, + tokens, + ]), + ); + let nextWorkspaces = workspaces; + + for (const workspace of candidates) { + const tokens = tokensByCredentialWorkspaceId.get( + workspace.credentialWorkspaceId, + ); + if (!tokens) continue; + + const resolved = await resolveOAuthWorkspaceMetadata(context, tokens); + if (!resolved) continue; + + await rememberResolvedWorkspaceMetadata(context, storage, tokens, resolved); + nextWorkspaces = nextWorkspaces.map((candidate) => + candidate.credentialWorkspaceId === workspace.credentialWorkspaceId + ? { + ...candidate, + id: resolved.id, + name: resolved.name, + lastSeenAt: new Date().toISOString(), + } + : candidate, + ); + } + + return nextWorkspaces; +} + +async function rememberResolvedWorkspaceMetadata( + context: CommandContext, + storage: FileTokenStorage, + tokens: Tokens, + resolved: { id: string; name: string }, +): Promise { + try { + await storage.rememberWorkspace(tokens.workspaceId, resolved); + } catch { + context.runtime.signal?.throwIfAborted(); + } +} + +function needsWorkspaceMetadataHydration(workspace: StoredAuthWorkspace) { + return ( + workspace.id === workspace.credentialWorkspaceId || + workspace.name === "Unknown workspace" || + workspace.name === workspace.credentialWorkspaceId + ); +} + +async function resolveOAuthWorkspaceMetadata( + context: CommandContext, + tokens: Tokens, +): Promise<{ id: string; name: string } | null> { + const refreshStorage = new FileTokenStorage( + context.runtime.env, + context.runtime.signal, + { activateOnSetTokens: false }, + ); + const tokenStorage = createSingleWorkspaceTokenStorage( + refreshStorage, + tokens, + ); + const sdk = createManagementApiSdk({ + clientId: CLIENT_ID, + redirectUri: "http://localhost:0/auth/callback", + tokenStorage, + apiBaseUrl: getApiBaseUrl(context.runtime.env), + }); + + try { + const { data } = await sdk.client.GET("/v1/workspaces/{id}", { + params: { path: { id: tokens.workspaceId } }, + signal: context.runtime.signal, + }); + const id = stringOrNull(data?.data?.id) ?? tokens.workspaceId; + const name = stringOrNull(data?.data?.name) ?? id; + + if (id === tokens.workspaceId && name === tokens.workspaceId) { + return null; + } + + return { id, name }; + } catch { + context.runtime.signal?.throwIfAborted(); + return null; + } +} + +function createSingleWorkspaceTokenStorage( + storage: FileTokenStorage, + initialTokens: Tokens, +): TokenStorage { + let currentTokens: Tokens | null = initialTokens; + + return { + getTokens: async () => currentTokens, + setTokens: async (tokens) => { + currentTokens = tokens; + await storage.setTokens(tokens); + }, + clearTokens: async () => { + const tokens = currentTokens; + currentTokens = null; + if (tokens) { + await storage.clearTokensIfCurrent(tokens); + } + }, + }; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function toAuthWorkspace(workspace: StoredAuthWorkspace): AuthWorkspace { + return { + id: workspace.id, + name: workspace.name, + }; +} diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 51238f75..6bf839f3 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -17,7 +17,12 @@ import { detectComputeAppFromDirectory } from "@prisma/compute-sdk/config/direct import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { matchError, Result } from "better-result"; import open from "open"; -import { FileTokenStorage } from "../adapters/token-storage"; +import { + FileTokenStorage, + getApiBaseUrl, + readAuthState, + SERVICE_TOKEN_ENV_VAR, +} from "../auth"; import { type AppRecord, createAppProvider, @@ -86,8 +91,6 @@ import { } from "../lib/app/local-dev"; import { enforceProductionDeployGate } from "../lib/app/production-deploy-gate"; import { resolveReadBranch } from "../lib/app/read-branch"; -import { readAuthState } from "../lib/auth/auth-ops"; -import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; import { requireComputeAuth } from "../lib/auth/guard"; import { readLocalGitBranch } from "../lib/git/local-branch"; import { promptForProjectSetupChoice } from "../lib/project/interactive-setup"; diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index b557ff5b..acac5dee 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -1,13 +1,12 @@ import { - createManagementApiSdk, - type TokenStorage, - type Tokens, -} from "@prisma/management-api-sdk"; -import { - FileTokenStorage, - type StoredAuthWorkspace, - WorkspaceSelectionError, -} from "../adapters/token-storage"; + listRealAuthWorkspaces, + logoutRealAuthWorkspace, + performLogin, + performLogout, + readAuthState, + SERVICE_TOKEN_ENV_VAR, + useRealAuthWorkspace, +} from "../auth"; import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; import { PRISMA_AGENT_INSTALL_ARGS } from "../lib/agent/constants"; import { @@ -16,28 +15,15 @@ import { resolvePrismaAgentSetupCwd, shouldOfferPrismaAgentSetup, } from "../lib/agent/setup-status"; -import { - performLogin, - performLogout, - readAuthState, -} from "../lib/auth/auth-ops"; -import { - CLIENT_ID, - getApiBaseUrl, - SERVICE_TOKEN_ENV_VAR, -} from "../lib/auth/client"; import { authRequiredError, usageError, - workspaceAmbiguousError, - workspaceNotAuthenticatedError, workspaceSwitchUnavailableError, } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; import { type CommandContext, canPrompt } from "../shell/runtime"; import type { AuthStateResult, - AuthWorkspace, AuthWorkspaceListResult, AuthWorkspaceLogoutResult, AuthWorkspaceUseResult, @@ -237,157 +223,6 @@ export async function requireAuthenticatedAuthState( return loginWithSelectionFlow(context, useCases, {}); } -async function listRealAuthWorkspaces( - context: CommandContext, -): Promise { - const rawServiceToken = context.runtime.env[SERVICE_TOKEN_ENV_VAR]; - const storage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - ); - const localWorkspaces = await hydrateLocalAuthWorkspaces( - context, - storage, - await storage.listWorkspaces(), - ); - - if (rawServiceToken !== undefined) { - const authState = await readAuthState( - context.runtime.env, - context.runtime.signal, - ); - return { - authSource: authState.authenticated ? "service_token" : "none", - activeWorkspace: authState.workspace, - workspaces: [ - ...(authState.workspace - ? [ - { - ...authState.workspace, - credentialWorkspaceId: null, - active: true, - source: "service_token" as const, - switchable: false, - lastSeenAt: null, - }, - ] - : []), - ...localWorkspaces.map((workspace) => ({ - ...toAuthWorkspace(workspace), - credentialWorkspaceId: workspace.credentialWorkspaceId, - active: false, - source: "oauth" as const, - switchable: false, - lastSeenAt: workspace.lastSeenAt, - })), - ], - }; - } - - const active = localWorkspaces.find((workspace) => workspace.active) ?? null; - return { - authSource: localWorkspaces.length > 0 ? "oauth" : "none", - activeWorkspace: active ? toAuthWorkspace(active) : null, - workspaces: localWorkspaces.map((workspace) => ({ - ...toAuthWorkspace(workspace), - credentialWorkspaceId: workspace.credentialWorkspaceId, - active: workspace.active, - source: "oauth" as const, - switchable: true, - lastSeenAt: workspace.lastSeenAt, - })), - }; -} - -async function useRealAuthWorkspace( - context: CommandContext, - workspaceRef: string, -): Promise { - if (context.runtime.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { - throw workspaceSwitchUnavailableError(); - } - - const storage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - ); - await hydrateLocalAuthWorkspaces( - context, - storage, - await storage.listWorkspaces(), - ); - - try { - const result = await storage.useWorkspace(workspaceRef); - return { - previousWorkspace: result.previous - ? toAuthWorkspace(result.previous) - : null, - workspace: toAuthWorkspace(result.selected), - }; - } catch (error) { - if (error instanceof WorkspaceSelectionError) { - if (error.reason === "ambiguous") { - throw workspaceAmbiguousError( - error.workspaceRef ?? workspaceRef, - error.matches.map((match) => ({ - id: match.id, - name: match.name, - credentialWorkspaceId: match.credentialWorkspaceId, - })), - ); - } - - throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef); - } - - throw error; - } -} - -async function logoutRealAuthWorkspace( - context: CommandContext, - workspaceRef: string, -): Promise { - const storage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - ); - await hydrateLocalAuthWorkspaces( - context, - storage, - await storage.listWorkspaces(), - ); - - try { - const result = await storage.logoutWorkspace(workspaceRef); - return { - workspace: toAuthWorkspace(result.workspace), - wasActive: result.wasActive, - activeWorkspace: result.activeWorkspace - ? toAuthWorkspace(result.activeWorkspace) - : null, - }; - } catch (error) { - if (error instanceof WorkspaceSelectionError) { - if (error.reason === "ambiguous") { - throw workspaceAmbiguousError( - error.workspaceRef ?? workspaceRef, - error.matches.map((match) => ({ - id: match.id, - name: match.name, - credentialWorkspaceId: match.credentialWorkspaceId, - })), - ); - } - - throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef); - } - - throw error; - } -} - async function selectWorkspaceSession( context: CommandContext, ): Promise { @@ -441,142 +276,6 @@ async function selectWorkspaceSession( return selected.id; } -async function hydrateLocalAuthWorkspaces( - context: CommandContext, - storage: FileTokenStorage, - workspaces: StoredAuthWorkspace[], -): Promise { - const candidates = workspaces.filter(needsWorkspaceMetadataHydration); - if (candidates.length === 0) return workspaces; - - const tokensByCredentialWorkspaceId = new Map( - (await storage.listWorkspaceTokens()).map((tokens) => [ - tokens.workspaceId, - tokens, - ]), - ); - let nextWorkspaces = workspaces; - - for (const workspace of candidates) { - const tokens = tokensByCredentialWorkspaceId.get( - workspace.credentialWorkspaceId, - ); - if (!tokens) continue; - - const resolved = await resolveOAuthWorkspaceMetadata(context, tokens); - if (!resolved) continue; - - await rememberResolvedWorkspaceMetadata(context, storage, tokens, resolved); - nextWorkspaces = nextWorkspaces.map((candidate) => - candidate.credentialWorkspaceId === workspace.credentialWorkspaceId - ? { - ...candidate, - id: resolved.id, - name: resolved.name, - lastSeenAt: new Date().toISOString(), - } - : candidate, - ); - } - - return nextWorkspaces; -} - -async function rememberResolvedWorkspaceMetadata( - context: CommandContext, - storage: FileTokenStorage, - tokens: Tokens, - resolved: { id: string; name: string }, -): Promise { - try { - await storage.rememberWorkspace(tokens.workspaceId, resolved); - } catch { - context.runtime.signal?.throwIfAborted(); - } -} - -function needsWorkspaceMetadataHydration(workspace: StoredAuthWorkspace) { - return ( - workspace.id === workspace.credentialWorkspaceId || - workspace.name === "Unknown workspace" || - workspace.name === workspace.credentialWorkspaceId - ); -} - -async function resolveOAuthWorkspaceMetadata( - context: CommandContext, - tokens: Tokens, -): Promise<{ id: string; name: string } | null> { - const refreshStorage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - { activateOnSetTokens: false }, - ); - const tokenStorage = createSingleWorkspaceTokenStorage( - refreshStorage, - tokens, - ); - const sdk = createManagementApiSdk({ - clientId: CLIENT_ID, - redirectUri: "http://localhost:0/auth/callback", - tokenStorage, - apiBaseUrl: getApiBaseUrl(context.runtime.env), - }); - - try { - const { data } = await sdk.client.GET("/v1/workspaces/{id}", { - params: { path: { id: tokens.workspaceId } }, - signal: context.runtime.signal, - }); - const id = stringOrNull(data?.data?.id) ?? tokens.workspaceId; - const name = stringOrNull(data?.data?.name) ?? id; - - if (id === tokens.workspaceId && name === tokens.workspaceId) { - return null; - } - - return { id, name }; - } catch { - context.runtime.signal?.throwIfAborted(); - return null; - } -} - -function createSingleWorkspaceTokenStorage( - storage: FileTokenStorage, - initialTokens: Tokens, -): TokenStorage { - let currentTokens: Tokens | null = initialTokens; - - return { - getTokens: async () => currentTokens, - setTokens: async (tokens) => { - currentTokens = tokens; - await storage.setTokens(tokens); - }, - clearTokens: async () => { - const tokens = currentTokens; - currentTokens = null; - if (tokens) { - await storage.clearTokensIfCurrent(tokens); - } - }, - }; -} - -function stringOrNull(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 - ? value.trim() - : null; -} - -function toAuthWorkspace(workspace: StoredAuthWorkspace): AuthWorkspace { - return { - id: workspace.id, - name: workspace.name, - }; -} - async function loginWithSelectionFlow( context: CommandContext, useCases: ReturnType, diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 983a1175..1d406dc4 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -12,14 +12,14 @@ import { } from "../adapters/git"; import { FileTokenStorage, + SERVICE_TOKEN_ENV_VAR, WorkspaceSelectionError, -} from "../adapters/token-storage"; +} from "../auth"; import { type PrismaCliPackageCommandFormatter, resolvePrismaCliPackageCommandFormatterSync, } from "../lib/agent/cli-command"; import { createAppProvider } from "../lib/app/app-provider"; -import { SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; import { requireComputeAuth } from "../lib/auth/guard"; import { RecipientSessionInvalidError, diff --git a/packages/cli/src/lib/auth/guard.ts b/packages/cli/src/lib/auth/guard.ts index 7259966c..8f35a7bd 100644 --- a/packages/cli/src/lib/auth/guard.ts +++ b/packages/cli/src/lib/auth/guard.ts @@ -4,8 +4,12 @@ import { type ManagementApiClient, } from "@prisma/management-api-sdk"; -import { FileTokenStorage } from "../../adapters/token-storage"; -import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +import { + CLIENT_ID, + FileTokenStorage, + getApiBaseUrl, + SERVICE_TOKEN_ENV_VAR, +} from "../../auth"; /** * Resolve authentication and return a ManagementApiClient. diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/lib/auth/login.ts index 1f962ade..99c5800e 100644 --- a/packages/cli/src/lib/auth/login.ts +++ b/packages/cli/src/lib/auth/login.ts @@ -12,8 +12,7 @@ import { type TokenStorage, } from "@prisma/management-api-sdk"; import open from "open"; -import { FileTokenStorage } from "../../adapters/token-storage"; -import { CLIENT_ID, getApiBaseUrl } from "./client"; +import { CLIENT_ID, FileTokenStorage, getApiBaseUrl } from "../../auth"; export class AuthError extends Error { constructor(message: string) { diff --git a/packages/cli/src/lib/auth/recipient.ts b/packages/cli/src/lib/auth/recipient.ts index fe946201..db9e7900 100644 --- a/packages/cli/src/lib/auth/recipient.ts +++ b/packages/cli/src/lib/auth/recipient.ts @@ -1,10 +1,11 @@ import { createManagementApiSdk } from "@prisma/management-api-sdk"; import { + CLIENT_ID, FileTokenStorage, + getApiBaseUrl, type StoredAuthWorkspace, -} from "../../adapters/token-storage"; -import { CLIENT_ID, getApiBaseUrl } from "./client"; +} from "../../auth"; export interface RecipientWorkspaceSession { workspace: StoredAuthWorkspace; diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index 74807631..36f33eac 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -1,5 +1,5 @@ import { AuthError as SDKAuthError } from "@prisma/management-api-sdk"; -import { isEmptyServiceTokenError } from "../lib/auth/auth-ops"; +import { isEmptyServiceTokenError } from "../auth"; import { collectCommandDiagnostics } from "../lib/diagnostics"; import type { CommandDescriptor } from "./command-meta"; import { getCommandDescriptor } from "./command-meta"; diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index 40c7aad2..c63d9b1a 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -5,10 +5,7 @@ import { notOk, ok, } from "@prisma/cli-engine/protocol"; -import { - isEmptyServiceTokenError, - readAuthState, -} from "../../lib/auth/auth-ops"; +import { isEmptyServiceTokenError, readAuthState } from "../../auth"; import type { AuthProviderId, AuthStateResult } from "../../types/auth"; const TITLE = "Showing the current authenticated identity."; diff --git a/packages/cli/src/v8/runtime.ts b/packages/cli/src/v8/runtime.ts index e465ca6f..2692a18b 100644 --- a/packages/cli/src/v8/runtime.ts +++ b/packages/cli/src/v8/runtime.ts @@ -1,13 +1,10 @@ import { - type Credentials, type HostProcess, type InputStream, loadConfig, type Runtime, } from "@prisma/cli-engine"; -import { FileTokenStorage } from "../adapters/token-storage"; -import { EmptyServiceTokenError } from "../lib/auth/auth-ops"; -import { SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; +import { makeGetCredentials } from "../auth"; export type SignalProcess = Pick; @@ -40,25 +37,6 @@ export function detectPackageManager( return "unknown"; } -/** Token reads ignore the run's abort signal so they still work during - * teardown after the first Ctrl-C. */ -export function makeGetCredentials( - env: NodeJS.ProcessEnv, -): () => Promise { - return async () => { - const rawServiceToken = env[SERVICE_TOKEN_ENV_VAR]; - if (rawServiceToken !== undefined) { - const serviceToken = rawServiceToken.trim(); - if (serviceToken.length === 0) { - throw new EmptyServiceTokenError(); - } - return { token: serviceToken }; - } - const tokens = await new FileTokenStorage(env).getTokens(); - return tokens ? { token: tokens.accessToken } : undefined; - }; -} - export async function assembleRuntime(proc: HostProcess): Promise { const stdin: InputStream = { setRawMode: diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index eae974f0..6b1b4a6c 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -14,7 +14,8 @@ beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -36,7 +37,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.doUnmock("../src/lib/app/branch-database"); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index c72ab9ba..9cc51f51 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -17,7 +17,8 @@ beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -39,7 +40,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/controllers/agent"); vi.doUnmock("../src/lib/app/app-provider"); diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index 0c1fcb30..6a931123 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -8,7 +8,8 @@ beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -30,7 +31,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index f2b7a3e4..ce46c2fc 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -15,7 +15,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); @@ -108,7 +108,8 @@ async function loadControllers(client: MockClient, projectId: string) { vi.resetModules(); void projectId; - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, diff --git a/packages/cli/tests/auth-ops.test.ts b/packages/cli/tests/auth-ops.test.ts index 008be258..6b0d56e2 100644 --- a/packages/cli/tests/auth-ops.test.ts +++ b/packages/cli/tests/auth-ops.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; afterEach(() => { - vi.doUnmock("../src/adapters/token-storage"); + vi.doUnmock("../src/auth/token-storage"); vi.doUnmock("../src/lib/auth/guard"); vi.resetModules(); vi.restoreAllMocks(); @@ -71,14 +71,15 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); await expect(readAuthState({} as NodeJS.ProcessEnv)).resolves.toEqual({ authenticated: true, @@ -143,8 +144,8 @@ describe("readAuthState", () => { requireComputeAuth, })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); - const { FileTokenStorage } = await import("../src/adapters/token-storage"); + const { readAuthState } = await import("../src/auth/operations"); + const { FileTokenStorage } = await import("../src/auth/token-storage"); await expect( readAuthState({ @@ -205,14 +206,15 @@ describe("readAuthState", () => { ), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); await expect(readAuthState({} as NodeJS.ProcessEnv)).resolves.toEqual({ authenticated: true, @@ -246,14 +248,15 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); await expect(readAuthState({} as NodeJS.ProcessEnv)).resolves.toMatchObject( { @@ -283,14 +286,15 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); await expect(readAuthState({} as NodeJS.ProcessEnv)).resolves.toMatchObject( { @@ -350,14 +354,15 @@ describe("readAuthState", () => { ), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy", email: "service@example.com", @@ -407,12 +412,13 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); const result = await readAuthState({ @@ -439,12 +445,13 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); await expect( @@ -471,12 +478,13 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); await expect( @@ -498,12 +506,13 @@ describe("readAuthState", () => { GET: vi.fn().mockRejectedValue(new Error("network down")), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); await expect( @@ -530,14 +539,15 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: vi.fn().mockImplementation(() => ({ getTokens: vi.fn(), })), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); await expect( @@ -562,14 +572,15 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: vi.fn().mockImplementation(() => ({ getTokens: vi.fn(), })), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); await expect( @@ -582,14 +593,15 @@ describe("readAuthState", () => { it("returns signed-out state when PRISMA_SERVICE_TOKEN does not carry a workspace subject", async () => { const getTokens = vi.fn(); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth: vi.fn(), })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "user:usr_123" }); await expect( @@ -607,14 +619,15 @@ describe("readAuthState", () => { it("treats an empty PRISMA_SERVICE_TOKEN as invalid and does not fall back to FileTokenStorage", async () => { const getTokens = vi.fn().mockResolvedValue(null); - vi.doMock("../src/adapters/token-storage", () => ({ + vi.doMock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth: vi.fn(), })); - const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const { readAuthState } = await import("../src/auth/operations"); await expect( readAuthState({ PRISMA_SERVICE_TOKEN: " " } as NodeJS.ProcessEnv), diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index e100a297..0069cb20 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -13,7 +13,7 @@ import { getCommandDescriptor } from "../src/shell/command-meta"; const fixturePath = path.resolve("fixtures/mock-api.json"); afterEach(() => { - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("@prisma/management-api-sdk"); vi.resetModules(); vi.restoreAllMocks(); @@ -35,7 +35,8 @@ describe("real auth mode", () => { }); const performLogout = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), performLogin, readAuthState, performLogout, @@ -80,7 +81,8 @@ describe("real auth mode", () => { const readAuthState = vi.fn().mockResolvedValue(null); const performLogout = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), performLogin, readAuthState, performLogout, @@ -152,7 +154,8 @@ describe("real auth mode", () => { }, }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), performLogin: vi.fn(), readAuthState, performLogout: vi.fn(), @@ -213,7 +216,8 @@ describe("real auth mode", () => { }, }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), performLogin: vi.fn(), readAuthState, performLogout: vi.fn(), @@ -222,7 +226,7 @@ describe("real auth mode", () => { const { createTempCwd, createTestCommandContext } = await import( "./helpers" ); - const { FileTokenStorage } = await import("../src/adapters/token-storage"); + const { FileTokenStorage } = await import("../src/auth"); const { runAuthWorkspaceList } = await import("../src/controllers/auth"); const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -295,7 +299,7 @@ describe("real auth mode", () => { }), })); - const { FileTokenStorage } = await import("../src/adapters/token-storage"); + const { FileTokenStorage } = await import("../src/auth"); const { createTempCwd, createTestCommandContext } = await import( "./helpers" ); @@ -371,7 +375,7 @@ describe("real auth mode", () => { }), })); - const { FileTokenStorage } = await import("../src/adapters/token-storage"); + const { FileTokenStorage } = await import("../src/auth"); const { createTempCwd, createTestCommandContext } = await import( "./helpers" ); @@ -459,7 +463,7 @@ describe("real auth mode", () => { }), })); - const { FileTokenStorage } = await import("../src/adapters/token-storage"); + const { FileTokenStorage } = await import("../src/auth"); const { createTempCwd, executeCli } = await import("./helpers"); const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 2f25cdda..3bd0087d 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import stripAnsi from "strip-ansi"; import { describe, expect, it } from "vitest"; -import { FileTokenStorage } from "../src/adapters/token-storage"; +import { FileTokenStorage } from "../src/auth"; import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index 778d88d5..db3758a0 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTempCwd, createTestCommandContext } from "./helpers"; afterEach(() => { - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth"); vi.doUnmock("../src/lib/auth/guard"); vi.resetModules(); vi.restoreAllMocks(); @@ -102,7 +102,8 @@ function expectedBranchVerboseContext() { async function loadController(client: ReturnType) { vi.resetModules(); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 04a69804..ab392e0f 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -7,7 +7,7 @@ import { createTempCwd, createTestCommandContext } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); afterEach(() => { - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); @@ -162,7 +162,8 @@ describe("project controller", () => { name: "New Dashboard", }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -232,7 +233,8 @@ describe("project controller", () => { defaultRegion: "us-east-1", }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -302,7 +304,8 @@ describe("project controller", () => { name: "Interactive Project", }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -377,7 +380,8 @@ describe("project controller", () => { .fn() .mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index f47dbfdb..82852f7a 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -11,7 +11,7 @@ type ApiGetMock = Mock< type ApiMutationMock = Mock<(pathName: string, request?: unknown) => unknown>; afterEach(() => { - vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/auth"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("open"); vi.resetModules(); @@ -259,7 +259,8 @@ describe("real project mode", () => { const readAuthState = mockAuthState(); const requireComputeAuth = vi.fn().mockResolvedValue(mockClient()); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState, performLogin: vi.fn(), performLogout: vi.fn(), @@ -325,7 +326,8 @@ describe("real project mode", () => { }); it("resolves an explicit project in real mode", async () => { - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -400,7 +402,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -484,7 +487,8 @@ describe("real project mode", () => { }); const post = vi.fn(); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -568,7 +572,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -679,7 +684,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -771,7 +777,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -905,7 +912,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -974,7 +982,8 @@ describe("real project mode", () => { }); const post = vi.fn(); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -1049,7 +1058,8 @@ describe("real project mode", () => { }); const post = vi.fn(); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -1122,7 +1132,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/lib/auth/auth-ops", () => ({ + vi.doMock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), diff --git a/packages/cli/tests/token-storage.test.ts b/packages/cli/tests/token-storage.test.ts index 90c87863..32a56e8a 100644 --- a/packages/cli/tests/token-storage.test.ts +++ b/packages/cli/tests/token-storage.test.ts @@ -7,7 +7,7 @@ import { FileTokenStorage, getAuthContextFilePath, RefreshLockTimeoutError, -} from "../src/adapters/token-storage"; +} from "../src/auth/token-storage"; import { createTempCwd } from "./helpers"; async function writeAuthFile( diff --git a/packages/cli/tests/v8-bin.test.ts b/packages/cli/tests/v8-bin.test.ts index 53c501be..e5ba1eaa 100644 --- a/packages/cli/tests/v8-bin.test.ts +++ b/packages/cli/tests/v8-bin.test.ts @@ -1,16 +1,17 @@ import { describe, expect, it, vi } from "vitest"; +import { makeGetCredentials } from "../src/auth"; import { buildCli } from "../src/v8/cli"; import { main } from "../src/v8/main"; import { assembleRuntime, detectPackageManager, type HostProcess, - makeGetCredentials, makeOnSignal, } from "../src/v8/runtime"; -vi.mock("../src/adapters/token-storage", () => ({ +vi.mock("../src/auth/token-storage", async (importOriginal) => ({ + ...(await importOriginal()), FileTokenStorage: class { getTokens() { return Promise.resolve({ diff --git a/packages/cli/tests/v8-whoami.test.ts b/packages/cli/tests/v8-whoami.test.ts index 3d116f94..247ed5d6 100644 --- a/packages/cli/tests/v8-whoami.test.ts +++ b/packages/cli/tests/v8-whoami.test.ts @@ -3,15 +3,12 @@ import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli } from "@prisma/cli-engine/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - EmptyServiceTokenError, - readAuthState, -} from "../src/lib/auth/auth-ops"; +import { EmptyServiceTokenError, readAuthState } from "../src/auth"; import type { AuthStateResult } from "../src/types/auth"; import { authWhoamiCommand } from "../src/v8/auth/whoami"; -vi.mock("../src/lib/auth/auth-ops", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn(), })); From cc575333aba2dfd9ae9fd12b18fa5dedaf4a2230 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 01:30:50 +0200 Subject: [PATCH 04/67] drive(prisma-cli-v8): S2b-d contracts and plans; command inventory; S2 question ledger The 60-command inventory grounds the S2b (resources), S2c (services), and S2d (init + shell retirement) contracts. The overview gains the operator question ledger (Q1 auto-login, Q2 service-run passthrough, Q3 rm alias, Q4 config evaluation for the shipped bin, Q5 exit-code unification) with the defaults the contracts build to. S2a contract errata from D2/D6 grounding: auth logout --workspace, the auth index export list, the white-box test exception. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/s2/command-inventory.md | 701 ++++++++++++++++++ .../prisma-cli-v8/plans/s2b-resources.md | 33 + .../prisma-cli-v8/plans/s2c-services.md | 26 + .../plans/s2d-init-and-retirement.md | 30 + .../prisma-cli-v8/specs/s2-overview.md | 27 + .../prisma-cli-v8/specs/s2a-foundations.md | 10 +- .../prisma-cli-v8/specs/s2b-resources.md | 113 +++ .../prisma-cli-v8/specs/s2c-services.md | 79 ++ .../specs/s2d-init-and-retirement.md | 76 ++ 9 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 .drive/projects/prisma-cli-v8/assets/s2/command-inventory.md create mode 100644 .drive/projects/prisma-cli-v8/plans/s2b-resources.md create mode 100644 .drive/projects/prisma-cli-v8/plans/s2c-services.md create mode 100644 .drive/projects/prisma-cli-v8/plans/s2d-init-and-retirement.md create mode 100644 .drive/projects/prisma-cli-v8/specs/s2b-resources.md create mode 100644 .drive/projects/prisma-cli-v8/specs/s2c-services.md create mode 100644 .drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md diff --git a/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md b/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md new file mode 100644 index 00000000..420c9ae0 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/s2/command-inventory.md @@ -0,0 +1,701 @@ +# S2 Command Inventory — @prisma/cli commander shell (grounding for v8 port specs) + +Source of truth read on branch `claude/prisma-cli-s1-d6-013cea` at repo +`/Users/will/Projects/prisma/prisma-cli/.claude/worktrees/prisma-cli-s1-d6-013cea`. +All paths below are relative to `packages/cli/` unless prefixed. + +Registration lives in `src/cli.ts` (root program) + `src/commands/*/index.ts` + +`src/commands/env.ts`. Descriptions/examples live centrally in +`src/shell/command-meta.ts` (the `DESCRIPTORS` array). Cross-checked against +`docs/product/command-spec.md`; discrepancies are recorded inline and in the +"Spec discrepancies" list at the end of section 3. + +--- + +## 1. Command index + +Behavior classes: sync = single result envelope; poll = loops on remote status; stream = emits records until the remote side ends; interactive = can prompt. "auth" column: none / local (credential store only) / platform (fails unauthenticated with AUTH_REQUIRED; "platform+login" means an interactive login is triggered on a TTY instead of failing). + +| path | group | behavior | auth | API surface (real mode) | proposed engine kind | +|---|---|---|---|---|---| +| `version` | top | sync | none | none | result | +| `init` | top | sync + interactive, file-writing | none (auth only if link step runs) | none directly; link → GET /v1/projects, POST /v1/projects | session (prompts) or result with needs.consent | +| `feedback` | top | sync | none | external feedback service (not Management API) | result | +| `agent install` | agent | sync, spawns child proc | none | none (runs skills CLI) | result | +| `agent update` | agent | sync, spawns child proc | none | none | result | +| `agent status` | agent | sync, spawns child proc | none | none | result | +| `auth login` | auth | interactive, browser-opening, server-hosting | n/a (creates auth) | OAuth (sdk.getLoginUrl/handleCallback), GET /v1/workspaces/{id} | session | +| `auth logout` | auth | sync | local | GET /v1/me (state readback) | result | +| `auth whoami` | auth | sync | none (reports state) | GET /v1/me, GET /v1/workspaces/{id} | result | +| `auth workspace list` | auth | sync | local | GET /v1/workspaces/{id} (best-effort hydration) | result | +| `auth workspace use` | auth | sync + interactive picker | local | GET /v1/workspaces/{id} (hydration) | result / session | +| `auth workspace logout` | auth | sync | local | GET /v1/workspaces/{id} (hydration) | result | +| `project list` | project | sync | platform+login | GET /v1/projects | result | +| `project show` | project | sync | platform+login | GET /v1/projects | result | +| `project create` | project | sync, file-writing | platform+login | POST /v1/projects (ComputeClient.createProject) | result | +| `project link` | project | sync + interactive picker, file-writing | platform+login | GET /v1/projects (+ POST /v1/projects if "create new" chosen) | session / result | +| `project rename` | project | sync | platform+login | PATCH /v1/projects/{id} | result | +| `project remove` | project | sync, file-deleting | platform+login | DELETE /v1/projects/{id} | result (needs --confirm) | +| `project transfer` | project | sync, file-writing | platform+login | POST /v1/projects/{id}/transfer, GET /v1/workspaces (recipient probe) | result (needs --confirm) | +| `project env add` | project.env | sync | platform+login | GET/POST /v1/environment-variables, branches endpoints | result | +| `project env update` | project.env | sync | platform+login | GET /v1/environment-variables, PATCH /v1/environment-variables/{id} | result | +| `project env list` | project.env | sync | platform+login | GET /v1/environment-variables, GET /v1/projects/{id}/branches | result | +| `project env remove` (alias `rm`) | project.env | sync | platform+login | GET /v1/environment-variables, DELETE /v1/environment-variables/{id} | result | +| `git connect` | git | sync or poll (waits for GitHub App install), browser-opening | platform+login | GET/POST /v1/source-repositories, GET /v1/scm-installations(+/repositories), POST /v1/scm-installations/install-intents | session (poll + browser) | +| `git disconnect` | git | sync | platform+login | GET /v1/source-repositories, DELETE /v1/source-repositories/{id} | result | +| `branch list` | branch | sync | platform+login | GET /v1/projects/{projectId}/branches (paginated) | result | +| `build logs` | build | stream | platform (no login fallback) | GET /v1/builds/{buildId}/logs (NDJSON stream) | stream | +| `database list` | database | sync | platform+login | GET /v1/databases | result | +| `database show` | database | sync | platform+login | GET /v1/databases, GET /v1/databases/{id}, GET /v1/databases/{id}/connections | result | +| `database create` | database | sync | platform+login | POST /v1/databases | result (secret on stdout) | +| `database usage` | database | sync | platform+login | GET /v1/databases/{id}/usage | result | +| `database restore` | database | sync | platform+login | POST (restore endpoint at provider.ts:473) | result (needs --confirm) | +| `database remove` | database | sync | platform+login | DELETE /v1/databases/{id} | result (needs --confirm) | +| `database backup list` | database.backup | sync | platform+login | GET /v1/databases/{id}/backups | result | +| `database connection list` | database.connection | sync | platform+login | GET /v1/databases/{id}/connections | result | +| `database connection create` | database.connection | sync | platform+login | POST /v1/databases/{id}/connections | result (secret on stdout) | +| `database connection rotate` | database.connection | sync | platform+login | POST /v1/connections/{id}/rotate | result (needs --confirm, secret on stdout) | +| `database connection remove` | database.connection | sync | platform+login | DELETE /v1/connections/{id} | result (needs --confirm) | +| `bucket list` | bucket | sync | platform+login | GET /v1/buckets | result | +| `bucket create` | bucket | sync | platform+login | POST /v1/buckets | result | +| `bucket delete` | bucket | sync | platform+login | DELETE /v1/buckets/{bucketId} | result (needs --confirm) | +| `bucket key list` | bucket.key | sync | platform+login | GET /v1/buckets/{bucketId}/keys | result | +| `bucket key create` | bucket.key | sync | platform+login | POST /v1/buckets/{bucketId}/keys | result (secret on stdout) | +| `bucket key delete` | bucket.key | sync | platform+login | DELETE /v1/buckets/{bucketId}/keys/{keyId} | result | +| `app build` | app | sync, local build, file-writing (build artifact) | none | none | result (long-running → progress events) | +| `app run` | app | long-running local process, pass-through output | none | none | server-ish (local dev); rejects --json | +| `app deploy` | app | long-running + progress + interactive, file-writing | platform (no login fallback) | ComputeClient.deployApp, POST /v1/projects, branches, env vars, POST /v1/databases (--db) | session (steps/progress) | +| `app show` | app | sync (+ picker) | platform | GET /v1/apps, listDeployments | result | +| `app open` | app | sync, browser-opening | platform | GET /v1/apps, listDeployments | result + local browser action | +| `app domain add` | app.domain | sync | platform | POST /v1/apps/{appId}/domains | result | +| `app domain show` | app.domain | sync | platform | GET /v1/apps/{appId}/domains, GET /v1/domains/{id} | result | +| `app domain remove` | app.domain | sync + confirm prompt | platform | DELETE /v1/domains/{id} | result (consent) | +| `app domain retry` | app.domain | sync | platform | POST /v1/domains/{id}/retry | result | +| `app domain wait` | app.domain | poll (status until active/failed/timeout) | platform | GET /v1/domains/{id} loop | stream/status events | +| `app logs` | app | stream | platform | ComputeClient.streamDeploymentLogs | stream | +| `app list-deploys` | app | sync (+ picker) | platform | GET /v1/apps, listDeployments | result | +| `app show-deploy` | app | sync | platform | ComputeClient.showDeployment | result | +| `app promote` | app | remote operation w/ progress | platform | ComputeClient.promoteDeployment | session (progress) | +| `app rollback` | app | remote operation w/ progress | platform | ComputeClient.promoteDeployment (promote of older deploy) | session (progress) | +| `app remove` | app | remote destroy (SDK polls), type-to-confirm prompt | platform | ComputeClient.showApp + destroyApp (poll 2s / 120s) | session (consent + progress) | + +Group nodes (print help when invoked bare; no action of their own): root `prisma`, `agent`, `auth`, `auth workspace`, `project`, `project env`, `git`, `branch`, `build`, `database`, `database backup`, `database connection`, `bucket`, `bucket key`, `app`, `app domain`. + +## 2. Group census + +| group | leaf commands | +|---|---| +| top-level (version, init, feedback) | 3 | +| agent | 3 | +| auth (incl. workspace) | 6 (login, logout, whoami, workspace list/use/logout) | +| project (incl. env) | 11 (list, show, create, link, rename, remove, transfer, env add/update/list/remove) | +| git | 2 | +| branch | 1 | +| build | 1 | +| database (incl. backup, connection) | 11 (list, show, create, usage, restore, remove, backup list, connection list/create/rotate/remove) | +| bucket (incl. key) | 6 (list, create, delete, key list/create/delete) | +| app (incl. domain) | 16 (build, run, deploy, show, open, logs, list-deploys, show-deploy, promote, rollback, remove, domain add/show/remove/retry/wait) | +| **Total leaf commands** | **60** | + +Plus 16 group/help nodes and two program-level utilities: `--version` (handled before parse in `src/cli.ts:51`) and `--help` (commander). + +## 3. Shared shell machinery + +### 3.1 Global flags + +Two sets (`src/shell/global-flags.ts`): + +- **Full set** (`addGlobalFlags`, attached to every leaf command): + - `--json` — structured JSON envelope on stdout. Success: `{ok:true, command, result, warnings, nextSteps, nextActions}` (pretty-printed, `src/shell/output.ts:22`). Error: `{ok:false, command, error:{code,domain,severity,summary,why,fix,where,meta,docsUrl}, warnings, nextSteps, nextActions}`. Streaming commands emit one JSON event per line (`writeJsonEvent`) plus a wrapper success event unless opted out (`build logs` opts out, `src/commands/build/index.ts:54`). + - `-q, --quiet` — suppress human stderr rendering; stdout payloads (renderStdout) still print. + - `-v, --verbose` — appends a "Local context" diagnostics block (duration, cwd, state file, git ref/sha/dirty; `src/shell/diagnostics-output.ts`). + - `--trace` — include debug/stack in human error output. + - `-y, --yes` — accept supported confirmation prompts. + - `--interactive` / `--no-interactive` — force/disable prompting. + - `--color` / `--no-color` — force/disable color. +- **Compact set** (`addCompactGlobalFlags`, attached to the root program and every group node): `--json`, `-q/--quiet`, `-v/--verbose`, `--trace`, `--no-interactive`, `-y/--yes`. **`--interactive`, `--color`, `--no-color` are missing from the compact set** even though `docs/product/command-spec.md:55-66` lists them as shared global flags — a flag placed before the subcommand can be rejected by commander. Mitigation: `resolveGlobalFlags` (`global-flags.ts:81`) also scans raw argv, because commander v12 can swallow duplicate parent/child options. +- Program-level `--version` (exits 0, honors `--json`; `src/cli.ts:51,103`). + +**There is no `--fixture` flag.** Fixture mode is enabled only by the `PRISMA_CLI_MOCK_FIXTURE_PATH` env var or the programmatic `runtime.fixturePath` option (`src/shell/runtime.ts:61-68`). Every controller has an `isRealMode()` check on exactly those two inputs. + +Prompting rule `canPrompt` (`src/shell/runtime.ts:105`): false when `--json`, `--no-interactive`, `CI` env without `--interactive`, or stdin/stderr not TTYs. + +### 3.2 Command runner + +`src/shell/command-runner.ts`: `runCommand` (result commands: build context → run handler → render human/stdout/json; maps thrown `CliError`, SDK `AuthError` → `AUTH_REQUIRED`, empty-service-token → `AUTH_CONFIG_INVALID`, aborts → `COMMAND_CANCELED` exit 130) and `runStreamingCommand` (app logs, build logs, domain wait; same error mapping, optional trailing JSON success event). Success human output goes to **stderr**; only `renderStdout` payloads and JSON go to **stdout**. + +### 3.3 Error taxonomy + +`src/shell/errors.ts`: `CliError {code, domain, summary, why, fix, debug, where, meta, docsUrl, exitCode, nextSteps, nextActions, humanLines}`. Domains: `cli | auth | project | branch | app | database | bucket`. Codes are FLAT_UPPER_SNAKE (no dots). Exit codes in use: + +- 0 success — and one deliberate oddity: canceling the production-deploy confirmation throws `CONFIRMATION_REQUIRED` with **exitCode 0** (`src/lib/app/production-deploy-gate.ts:209-219`). +- 1 default error. +- 2 usage errors (`USAGE_ERROR`), commander parse errors (`src/cli.ts:66`), exact-id confirmation failures in project/database/bucket (`CONFIRMATION_REQUIRED` exit 2), `PROJECT_LINK_TARGET_REQUIRED`, `PROJECT_AMBIGUOUS`-family, `APP_AMBIGUOUS`, `WORKSPACE_AMBIGUOUS`, `PROD_DEPLOY_REQUIRES_FLAG`, `BRANCH_NOT_DEPLOYABLE`, `DOMAIN_HOSTNAME_INVALID`. +- 130 `COMMAND_CANCELED` (SIGINT/abort). +- Inconsistency to note for the port: `CONFIRMATION_REQUIRED` is exit **2** for project/database/bucket exact-id confirms but exit **1** for app remove / domain remove / prod-deploy non-interactive confirms (`src/controllers/app.ts:3053-3062, 2368-2381`; `production-deploy-gate.ts:189-207`). + +Full code census (grep `code: "` over src): USAGE_ERROR, AUTH_REQUIRED, AUTH_CONFIG_INVALID, COMMAND_CANCELED, WORKSPACE_SWITCH_UNAVAILABLE, WORKSPACE_NOT_AUTHENTICATED, WORKSPACE_AMBIGUOUS, FEATURE_UNAVAILABLE, UNEXPECTED_ERROR, VERSION_UNAVAILABLE, FEEDBACK_SEND_FAILED, AGENT_SKILLS_INSTALL_FAILED, INIT_CONVERT_INCOMPLETE, INIT_CONVERT_UNSUPPORTED, INIT_CONFIG_EXISTS, INIT_DETECTION_FAILED, COMPUTE_CONFIG_INVALID, COMPUTE_CONFIG_TARGET_REQUIRED, COMPUTE_CONFIG_TARGET_UNKNOWN, PROJECT_NOT_FOUND, PROJECT_AMBIGUOUS, PROJECT_SETUP_REQUIRED, PROJECT_LINK_TARGET_REQUIRED, PROJECT_CREATE_FAILED, PROJECT_RENAME_FAILED, PROJECT_REMOVE_BLOCKED, PROJECT_TRANSFER_REJECTED, TRANSFER_RECIPIENT_REQUIRED, TRANSFER_RECIPIENT_UNAVAILABLE, CONFIRMATION_REQUIRED, LOCAL_STATE_STALE, LOCAL_STATE_WRITE_FAILED, LOCAL_PROJECT_WORKSPACE_MISMATCH, REPO_PROVIDER_UNSUPPORTED, REPO_NOT_CONNECTED, REPO_INSTALLATION_REQUIRED, REPO_NOT_ACCESSIBLE, REPO_ALREADY_CONNECTED, REPO_CONNECTION_FAILED, BRANCH_API_ERROR (or API-provided code), BRANCH_NOT_FOUND, BRANCH_NOT_DEPLOYABLE, BRANCH_DATABASE_SETUP_FAILED, DATABASE_NOT_FOUND, DATABASE_AMBIGUOUS, DATABASE_BACKUP_NOT_FOUND, DATABASE_CONNECTION_NOT_FOUND, DATABASE_CONNECTION_MISSING, DATABASE_CONNECTION_STRING_MISSING, DATABASE_API_ERROR (or API code), DATABASE_BACKUPS_UNSUPPORTED, DATABASE_RESTORE_CONFLICT, PLAN_LIMIT_REACHED, BUCKET_NOT_FOUND, BUCKET_KEY_NOT_FOUND, BUCKET_KEY_SECRET_MISSING, ENV_VARIABLE_ALREADY_EXISTS, ENV_VARIABLE_NOT_FOUND, ENV_BRANCH_SCOPE_IS_PRODUCTION, ENV_BRANCH_NOT_FOUND, ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH, ENV_FILE_APPLY_FAILED, DEPLOY_FAILED, BUILD_FAILED, RUN_FAILED, REMOVE_FAILED, NO_DEPLOYMENTS, NO_PREVIOUS_DEPLOYMENT, DEPLOYMENT_NOT_FOUND, APP_AMBIGUOUS, BUILD_SETTINGS_MIGRATION_REQUIRED, BUILD_SETTINGS_UNSUPPORTED, FRAMEWORK_NOT_DETECTED, PROD_DEPLOY_REQUIRES_FLAG, DOMAIN_HOSTNAME_INVALID, DOMAIN_QUOTA_EXCEEDED, DOMAIN_ALREADY_REGISTERED, DOMAIN_DNS_NOT_CONFIGURED, DOMAIN_NOT_FOUND, DOMAIN_RETRY_NOT_ELIGIBLE, DOMAIN_VERIFICATION_FAILED, DOMAIN_VERIFICATION_TIMEOUT, BUILD_NOT_FOUND, BUILD_LOGS_FAILED. + +`nextActions` (`src/shell/next-actions.ts`): kinds `run-command | user-choice | edit-file | done`, journeys `project-setup | deploy-app | inspect | recover`. Crashes always produce a structured `UNEXPECTED_ERROR` JSON envelope under `--json` with a pre-filled `prisma-cli feedback "..."` recover action (`src/shell/output.ts:120`). + +### 3.4 Auth machinery + +- Precedence: `PRISMA_SERVICE_TOKEN` env var (empty → `AUTH_CONFIG_INVALID`) then stored OAuth via `FileTokenStorage` (`src/adapters/token-storage.ts`, built on `@prisma/credentials-store`; multi-workspace grants + one active-workspace pointer; file lock for refresh coordination). +- `requireComputeAuth` (`src/lib/auth/guard.ts`) returns a `ManagementApiClient` or null. +- `requireAuthenticatedAuthState` (`src/controllers/auth.ts:206`): used by project/database/bucket/branch/env/git — if unauthenticated **and a TTY is available it launches the full interactive OAuth login** before proceeding; otherwise throws `AUTH_REQUIRED`. The app group and `build logs` instead use `requireComputeAuth` directly and never auto-login. +- Real login (`src/lib/auth/login.ts`): local HTTP callback server on `localhost:`, PKCE via `@prisma/management-api-sdk`, opens browser (`open`), TTY paste-the-callback-URL fallback, HTML success page, `GET /v1/workspaces/{id}` for the workspace name. +- API base URL override: `getApiBaseUrl(env)` in `src/lib/auth/client.ts` (env-driven). + +### 3.5 Fixture-mode machinery (dies with the mock) + +- `src/adapters/mock-api.ts` (696 lines, `MockApi.load(fixturePath)`) — the whole in-memory platform. +- `context.api` getter in `src/shell/runtime.ts:71-80` (throws if touched in real mode). +- `isRealMode()` duplicated in `src/controllers/{auth,project,database,bucket,branch,app,app-env}.ts` and each `else` branch below it. +- `src/use-cases/` (auth.ts, branch.ts, project.ts, contracts.ts, create-cli-gateways.ts) — only reachable from fixture branches. +- Fixture providers: `createFixtureProjectProvider` (`project.ts:908`), `createFixtureDatabaseProvider` (`database.ts:790`), `createFixtureBucketProvider` (`bucket.ts:349`). +- Fixture-only flags: `auth login --provider/--user/--workspace` (hidden via `.hideHelp()`, `src/commands/auth/index.ts:59-61`; real mode ignores them entirely — `runAuthLogin` does not read options when `isRealMode`). +- Fixture-only refusals: `project create` (`FEATURE_UNAVAILABLE` in fixture mode), all `app` commands (`ensurePreviewAppMode`, app.ts:4678: fixture mode → `FEATURE_UNAVAILABLE`). +- Fixture conventions: transfer recipient token = workspace id (`project.ts:757-797`); git connect/disconnect persist a pending connection in the local state store instead of the API. +- Env var: `PRISMA_CLI_MOCK_FIXTURE_PATH`. +- Tests passing `fixturePath` (see §5 census): auth.test.ts, project.test.ts, project-mutations.test.ts, project-controller.test.ts, branch.test.ts, database.test.ts, bucket.test.ts, app.test.ts (for refusal paths), init.test.ts, shell.test.ts, version.test.ts, update-check.test.ts, auth-real-mode.test.ts (asserting the boundary), auth-controller.test.ts. + +### 3.6 Update-check integration + +`src/shell/update-check.ts`, wired in `runCli` before parsing (`src/cli.ts:49`) and in `src/bin.ts` as a detached worker process: + +- Cache file `update-check.json` in a cache dir; stderr-only notification "Update available: prisma-cli X -> Y" at most every 24h. +- Skipped when `NO_UPDATE_NOTIFIER` set, CI/GITHUB_ACTIONS, non-TTY stderr, `--json`, `--quiet`/`-q`, `--version`, or test runtime (unless `PRISMA_CLI_TEST_ENABLE_UPDATE_CHECK=1`). +- Remote discovery runs in a **spawned detached child process** re-invoking the CLI binary with `PRISMA_CLI_RUN_UPDATE_CHECK_WORKER=1` (+ `PRISMA_CLI_UPDATE_CHECK_DIR`, `_INSTALLED_VERSION`, `_REGISTRY_URL`); fetches `https://registry.npmjs.org/@prisma%2fcli` with 3s timeout. +- Install instruction is invocation-aware (pnpm/bun/npm dev-dep, npm global, or docs URL for npx/bunx). + +### 3.7 Agent-setup tip machinery + +Two pieces, both driven by `src/lib/agent/setup-status.ts` state kept in the local state store: + +- `resolveAgentSetupTipCommand` (`src/controllers/auth.ts:707`): after `auth login` (human TTY mode only) appends a one-line tip suggesting the skills install command; suppressed by `--json`, `--quiet`, CI, non-TTY. +- `maybePromptForAgentSetup` (`src/controllers/agent-setup.ts`): one-time confirm prompt "Install the Prisma Compute skill for this project?" run by `init` and `app deploy`; a "no" is remembered via `stateStore.setAgentSetupPromptDismissedAt`; an install failure downgrades to a warning. +- Command strings are rendered through `resolvePrismaCliPackageCommand(FormatterSync)` (`src/lib/agent/cli-command.ts`), which picks `pnpm dlx | bunx | npx -y @prisma/cli@latest ...` per project package manager (overridable via `PRISMA_CLI_PACKAGE_RUNNER`, `PRISMA_CLI_PACKAGE_NAME`, `PRISMA_CLI_PACKAGE_SPEC`, `PRISMA_CLI_BINARY`). + +### 3.8 Local files & env vars (shared) + +- `.prisma/local.json` — project pin `{workspaceId, projectId}` (`src/lib/project/local-pin.ts`); writer also appends `.prisma/` to `.gitignore`. +- `/.prisma/cli/state.json` — local state store (`src/adapters/local-state.ts`; dir override `PRISMA_CLI_STATE_DIR` or runtime.stateDir; project dir located by walking up to the compute config, `src/shell/runtime.ts:92-103`): selected app per project, known live deployment per app, agent-setup status, fixture git connections. +- OAuth tokens — OS credentials store via `@prisma/credentials-store` (token-storage.ts). +- Compute config: `prisma.compute.ts` / `prisma.compute.json` (read by init/app group via `@prisma/compute-sdk/config`). +- Env var census: `PRISMA_SERVICE_TOKEN`, `PRISMA_PROJECT_ID`, `PRISMA_APP_ID`, `PRISMA_CLI_MOCK_FIXTURE_PATH`, `PRISMA_CLI_STATE_DIR`, `PRISMA_CLI_FEEDBACK_URL`, `PRISMA_CLI_DOMAIN_WAIT_POLL_MS`, `PRISMA_CLI_GITHUB_INSTALL_POLL_INTERVAL_MS`, `PRISMA_CLI_GITHUB_INSTALL_TIMEOUT_MS`, `PRISMA_CLI_INIT_INSTALL_COMMAND`, `PRISMA_CLI_PACKAGE_{RUNNER,NAME,SPEC}`, `PRISMA_CLI_BINARY`, `PRISMA_CLI_RUN_UPDATE_CHECK_WORKER`, `PRISMA_CLI_UPDATE_CHECK_{DIR,INSTALLED_VERSION,REGISTRY_URL}`, `PRISMA_CLI_TEST_ENABLE_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `CI`, `INIT_CWD`. + +### 3.9 Spec discrepancies (code vs docs/product/command-spec.md) + +1. Spec documents `build list` (line 2074) and `build show` (line 2105); neither exists in code. Spec marks both "blocked on Management API rollout" — planned, not drift, but the shell registers only `build logs` (`src/commands/build/index.ts`). +2. Spec Global Rules (lines 55-66) list `--interactive`, `--color`, `--no-color` as shared flags; the compact flag set on the root and group nodes omits them (`src/shell/global-flags.ts:48-61`), so their acceptance depends on flag position. +3. `auth login --provider/--user/--workspace` exist in code (hidden, fixture-only) but not in the spec's `auth login` section (line 547). Real mode silently ignores them (`src/controllers/auth.ts:73-75`) rather than erroring. +4. `project env remove` has an undocumented alias `rm` (`src/commands/env.ts:206`); the spec section (line 1849) names only `remove`. +5. Spec's `app remove` heading (line 2232) lists `-y --yes` as if command-specific; in code it is the shared global flag. +6. The env group descriptor description strings end with periods (`project.env.*` in command-meta.ts) while every other descriptor has none — cosmetic inconsistency in help output. + +--- + +## 4. Per-command inventory + +Legend for flag tables: "global" flags (§3.1) are not repeated per command; every leaf command has the full global set. `[F]` = fixture-mode-only. + +### `prisma version` +- **Summary**: "Show CLI build and environment" (command-meta.ts:44). +- **Flags**: globals only. +- **Positionals**: none. +- **Auth**: none. +- **API calls**: none. +- **Behavior**: sync. +- **Output**: human lines with CLI name/version, node version, os platform/arch, invocation kind (`dev|npx|bunx|global|unknown`); no separate `renderJson` serializer (raw result used in JSON). No distinct stdout payload. Errors: `VERSION_UNAVAILABLE` (exit 1) if package.json version missing. +- **Prompts**: none. **Side effects**: none. +- **Tests**: `tests/version.test.ts` (some fixture refs for shell wiring). +- **Engine notes**: pure result kind; also duplicated as the program-level `--version` fast path (`src/cli.ts:123-160`) which bypasses the command runner — the port should unify these. + +### `prisma init` +- **Summary**: "Write a committed compute config for this app". +- **Flags**: + +| name | alias | type | default | required | description | +|---|---|---|---|---|---| +| `--framework ` | — | string (alias-resolved to nextjs/nuxt/astro/hono/nestjs/tanstack-start/bun/custom) | detected | no | Framework override; detected when omitted | +| `--entry ` | — | string | derived (bun/hono: src/index.ts style) | no | Source entrypoint for entrypoint frameworks | +| `--http-port ` | — | string→int | framework default | no | HTTP port the app listens on | +| `--region ` | — | string (COMPUTE_REGIONS) | none | no | Region used when deploy creates the app | +| `--name ` | — | string | inferred (package.json / directory) | no | App name | +| `--link` / `--no-link` | — | boolean | prompt (TTY) / skip | no | Link this directory to a Project / skip | +| `--project ` | — | string | — | no | Project to link to | +| `--install` / `--no-install` | — | boolean | prompt (TTY) / skip | no | Install @prisma/compute-sdk dev dep for types | +| `--format ` | — | enum | ts | no | Config format; explicit `--format ts` over an existing prisma.compute.json performs a conversion | + +- **Positionals**: none. +- **Auth**: none for the write itself; the link step (when taken) goes through `runProjectLink` → `requireAuthenticatedAuthState` (interactive login possible). +- **API calls**: only via link step (GET /v1/projects, optional POST /v1/projects). +- **Behavior**: sync + interactive; file-writing. +- **Output**: settings preview + written path + types/link status; `serializeInit` JSON serializer. Errors: `INIT_CONFIG_EXISTS`, `INIT_CONVERT_INCOMPLETE`, `INIT_CONVERT_UNSUPPORTED`, `INIT_DETECTION_FAILED`, `COMPUTE_CONFIG_INVALID`, USAGE_ERROR variants (exit 2), custom-framework-JSON refusal. +- **Prompts** (TTY, not --yes/--json): "Customize settings?" (framework select + port text), install-types confirm, link confirm (→ project picker via `runProjectLink`), agent-setup skill confirm. +- **Side effects**: writes `prisma.compute.ts` or `prisma.compute.json` (flag `wx`, never clobbers); conversion also deletes the old JSON config; optional `npm/pnpm/bun add -D @prisma/compute-sdk` child process (override `PRISMA_CLI_INIT_INSTALL_COMMAND`); link writes `.prisma/local.json` (+ `.gitignore`); skills installer child process. +- **Tests**: `tests/init.test.ts`, `tests/init-agent-setup.test.ts`. +- **Engine notes**: multi-step with prompts and child processes — session kind with step events; the conversion path is a distinct sub-behavior selected by flag+filesystem state. + +### `prisma feedback ` +- **Summary**: "Send feedback to the Prisma CLI team". Anonymous unless `--email`. +- **Flags**: `--email
` (string, optional, ≤320 chars, regex-validated) + globals. +- **Positionals**: `` required, ≤4000 chars. +- **Auth**: none. +- **API calls**: none (Management). POSTs to feedback service `https://hiieirp2pwqnjvq9axzyg6d0.fra.prisma.build/feedback` (override `PRISMA_CLI_FEEDBACK_URL`), 3s timeout; payload = message, optional email, meta {cliVersion, nodeVersion, platform, arch}. +- **Behavior**: sync. **Output**: confirmation line; no renderJson serializer. Errors: USAGE_ERROR (empty/too long/bad email, exit 2), `FEEDBACK_SEND_FAILED` (exit 1). +- **Prompts**: none. **Side effects**: outbound HTTP only. +- **Tests**: `tests/feedback.test.ts`. +- **Engine notes**: clean result kind; the crash-recovery flow pre-fills this command (`src/shell/output.ts:104`), so the v8 shell must keep an equivalent. + +### `prisma agent install` / `prisma agent update` +- **Summary**: "Install/Refresh Prisma skills for AI coding agents". Same flags, same controller (`runAgentInstall`, operation differs). +- **Flags**: + +| name | type | default | description | +|---|---|---|---| +| `--agent ` | repeatable string[] | claude-family defaults (`DEFAULT_PRISMA_AGENT_TARGETS`) | agent target; repeat for multiple | +| `--all-agents` | boolean | false | pass `*` to the skills CLI | +| `--skill ` | repeatable string[] | `DEFAULT_PRISMA_AGENT_SKILLS` | skill to install; repeat | +| `--global` | boolean | false | install into user dir instead of project | +| `--copy` | boolean | false (forced true on win32) | copy instead of symlink | +| `--dry-run` | boolean | false | show the command without running | + +- **Positionals**: none. **Auth**: none. **API calls**: none. +- **Behavior**: sync; spawns `pnpm dlx|bunx|npx -y skills-cli add --skill … --agent … [--global] [--copy] --yes` via execa (stdin ignored). +- **Output**: install summary; `serializeAgentInstall`. Error: `AGENT_SKILLS_INSTALL_FAILED` (exit 1, nextStep = the raw installer command). +- **Prompts**: none. **Side effects**: child process writes skill files into the project or user dir. +- **Tests**: `tests/agent.test.ts`. +- **Engine notes**: local child-process command; result kind; `--dry-run` returns `{status:"would-install", command}`. + +### `prisma agent status` +- **Summary**: "Show installed Prisma skills". +- **Flags**: `--global` (check user-dir skills instead of project) + globals. +- **Auth**: none. **API**: none. Runs `skills-cli list [-g] --json`, filters names `prisma`/`prisma-*`; falls back to the skills lock file for project scope with a warning when the CLI call fails. +- **Behavior**: sync + child process. **Output**: skills table, statusSource (`skills-cli|skills-lock|unavailable`); `serializeAgentStatus`. +- **Prompts**: none. **Side effects**: child process (read-only). +- **Tests**: `tests/agent.test.ts`. +- **Engine notes**: result kind. + +### `prisma auth login` +- **Summary**: "Log in to your Prisma platform account". +- **Flags**: `--provider `, `--user `, `--workspace ` — all hidden (`hideHelp`) and **[F] fixture-only** (real mode never reads them; fixture mode uses them to skip select prompts). Plus globals. +- **Positionals**: none. +- **Auth**: n/a (creates the session). Never fails for being unauthenticated. +- **API calls** (real): OAuth authorize URL via `sdk.getLoginUrl` (scope `workspace:admin offline_access`), token exchange `sdk.handleCallback`, `GET /v1/workspaces/{id}` for the success page/workspace name; then `readAuthState` → `GET /v1/me` + `GET /v1/workspaces/{id}`. +- **Behavior**: interactive + browser-opening + hosts a localhost HTTP callback server; TTY paste-URL fallback loop. Non-TTY real mode still opens/points at the URL but a failed browser launch is fatal without the paste fallback. +- **Output**: auth state lines (user, workspace) + optional agent-setup tip; **no renderJson serializer** (raw AuthStateResult in JSON). nextSteps: whoami, project list, optional skills install. +- **Prompts**: fixture mode: provider/user/workspace select prompts (usage error if non-interactive without the fixture flags). Real mode: browser + paste fallback. +- **Side effects**: writes OAuth tokens to the OS credentials store; opens browser; binds a localhost TCP port; agent-setup tip reads local state. +- **Tests**: `tests/auth.test.ts`, `tests/auth-login.test.ts` (real login flow), `tests/auth-real-mode.test.ts`, `tests/auth-controller.test.ts`, `tests/auth-ops.test.ts`, `tests/auth-usecases.test.ts` (fixture use-cases). +- **Engine notes**: session kind (browser hand-off, long wait, cancellation); the fixture selection flow and its flags die with the mock. The localhost callback server is machinery the engine must own or replace. + +### `prisma auth logout` +- **Summary**: "Clear stored authentication credentials". +- **Flags**: `--workspace ` — when present the command internally dispatches to `auth.workspace.logout` (same controller/envelope as that command). Plus globals. +- **Auth**: local store. **API calls**: `readAuthState` (GET /v1/me best-effort) after clearing. +- **Behavior**: sync. Clears **all** local OAuth sessions (`FileTokenStorage.clearTokens`); does not touch `PRISMA_SERVICE_TOKEN`. +- **Output**: signed-out state; no renderJson serializer. +- **Prompts**: none. **Side effects**: credentials store mutation. +- **Tests**: auth.test.ts / auth-real-mode.test.ts / auth-ops.test.ts. +- **Engine notes**: result kind. Note the argv-level dispatch: one registered command produces two command ids (`auth.logout` vs `auth.workspace.logout`) depending on the flag. + +### `prisma auth whoami` +- **Summary**: "Show the authenticated user and accessible workspace". +- **Flags**: globals only. **Auth**: none required — reports `authenticated: false` rather than failing (nextSteps suggests login). +- **API calls**: GET /v1/me (principal), fallback JWT-claims + GET /v1/workspaces/{id}; a 401 from either → signed-out state, not an error. +- **Behavior**: sync. **Output**: user/workspace/credential lines; no renderJson serializer. Error path: `AUTH_CONFIG_INVALID` for empty service token. +- **Tests**: auth.test.ts, auth-real-mode.test.ts, v8-whoami.test.ts (v8 parity test, concurrent slice). +- **Engine notes**: result kind; already the S1 v8 pilot command. + +### `prisma auth workspace list` +- **Summary**: "List locally authenticated workspaces". +- **Flags**: globals only. **Positionals**: none. +- **Auth**: local store; works while signed out (empty list, nextStep login). +- **API calls**: best-effort `GET /v1/workspaces/{id}` per stale workspace record to hydrate id/name (failures silent). +- **Behavior**: sync. **Output**: table id/name/active/source(`oauth|service_token`)/switchable/lastSeenAt; `serializeAuthWorkspaceList`. With `PRISMA_SERVICE_TOKEN` set, the token workspace is listed active and all OAuth entries as non-switchable. +- **Prompts**: none. **Side effects**: may rewrite hydrated workspace metadata into the credentials store. +- **Tests**: auth.test.ts, auth-real-mode.test.ts. +- **Engine notes**: result kind. + +### `prisma auth workspace use [id-or-name]` +- **Summary**: "Switch the local CLI workspace". +- **Positionals**: `[id-or-name]` optional; omitted → single workspace auto-selected, multiple → interactive select, non-interactive multiple → USAGE_ERROR. +- **Flags**: globals only. +- **Auth**: local store. Fails `WORKSPACE_SWITCH_UNAVAILABLE` (exit 1) when `PRISMA_SERVICE_TOKEN` set. +- **API calls**: hydration GETs only. **Behavior**: sync + optional select prompt. +- **Output**: previous/selected workspace; `serializeAuthWorkspaceUse`. Errors: `WORKSPACE_NOT_AUTHENTICATED` (1), `WORKSPACE_AMBIGUOUS` (2), USAGE_ERROR "No authenticated workspaces" (2). +- **Side effects**: active-workspace pointer in credentials store. +- **Tests**: auth.test.ts, auth-real-mode.test.ts. +- **Engine notes**: result kind with an optional selection prompt (session if interactive). + +### `prisma auth workspace logout ` +- **Summary**: "Remove one local OAuth workspace session". +- **Positionals**: `` required (controller-level usage error when blank, exit 2). +- **Flags**: globals only. **Auth**: local store; works even with service token set (cleans local state only). +- **API calls**: hydration GETs. **Behavior**: sync. +- **Output**: removed workspace, wasActive, remaining active workspace (never auto-falls-through — user must `workspace use` next); `serializeAuthWorkspaceLogout`. Errors: `WORKSPACE_NOT_AUTHENTICATED`, `WORKSPACE_AMBIGUOUS`. +- **Side effects**: credentials store mutation. +- **Tests**: auth.test.ts, auth-real-mode.test.ts. +- **Engine notes**: result kind. + +### `prisma project list` +- **Summary**: "List all projects in your workspace". +- **Flags**: globals only. **Positionals**: none. +- **Auth**: platform, via `requireAuthenticatedAuthState` (interactive login on TTY, else AUTH_REQUIRED); `WORKSPACE_REQUIRED` usage error if no workspace. +- **API calls**: `GET /v1/projects` (filtered client-side to the active workspace). +- **Behavior**: sync. **Output**: workspace header + project table + localBinding status (`linked|not-linked|invalid` from `.prisma/local.json`); `serializeProjectList`; nextActions steer setup when unlinked. +- **Prompts**: only the auto-login. **Side effects**: none. +- **Tests**: project.test.ts, project-controller.test.ts, project-real-mode.test.ts, project-usecases.test.ts. +- **Engine notes**: result kind; localBinding is a local-filesystem read blended into a remote result. + +### `prisma project show` +- **Summary**: "Show this directory's Project binding". +- **Flags**: `--project ` + globals. +- **Auth**: platform+login. **API**: GET /v1/projects. +- **Behavior**: sync. **Output**: binding status, resolved project or null with `suggestedProjectName` + setup nextActions; `serializeProjectShow`. Errors: resolution family (`PROJECT_NOT_FOUND` 1, `PROJECT_AMBIGUOUS` 2, `LOCAL_STATE_STALE`, `LOCAL_PROJECT_WORKSPACE_MISMATCH`). +- **Tests**: project.test.ts, project-resolution.test.ts, project-real-mode.test.ts. +- **Engine notes**: result kind. + +### `prisma project create ` +- **Summary**: "Create a Project and link this directory". +- **Flags**: `--region ` (Compute region id) + globals. **Positionals**: `` required, validated non-empty (`projectSetupNameRequiredError`). +- **Auth**: platform+login. Fixture mode: refused with `FEATURE_UNAVAILABLE`. +- **API calls**: `ComputeClient.createProject` (POST /v1/projects). +- **Behavior**: sync; file-writing. **Output**: created project + link confirmation; `serializeProjectSetup`. Errors: `PROJECT_CREATE_FAILED` (permission-aware fix text), `LOCAL_STATE_WRITE_FAILED`. +- **Side effects**: writes `.prisma/local.json`, appends `.prisma/` to `.gitignore`. +- **Tests**: project.test.ts, project-real-mode.test.ts, project-mutations.test.ts. +- **Engine notes**: result kind; local pin write is part of the contract. + +### `prisma project link [id-or-name]` +- **Summary**: "Link this directory to a Project". +- **Positionals**: `[id-or-name]` optional. **Flags**: globals only. +- **Auth**: platform+login. **API**: GET /v1/projects (+ POST /v1/projects when the picker's "create new" is chosen; fixture refuses creation). +- **Behavior**: with arg → sync; without arg on TTY (and not `--yes`) → interactive setup picker (`promptForProjectSetupChoice`: select existing / create new via text prompt / cancel); non-interactive without arg → `PROJECT_LINK_TARGET_REQUIRED` (exit 2, carries candidates + suggested name in meta/nextActions). +- **Output**: `serializeProjectSetup`. **Side effects**: `.prisma/local.json` + `.gitignore`. +- **Tests**: project.test.ts, project-mutations.test.ts, project-resolution.test.ts. +- **Engine notes**: session (picker) or result (explicit arg); the error meta is agent-oriented (candidate list) — preserve. + +### `prisma project rename ` +- **Summary**: "Rename the resolved Project". +- **Flags**: `--project ` + globals. **Positionals**: `` required non-empty. +- **Auth**: platform+login. **API**: PATCH /v1/projects/{id}. +- **Behavior**: sync. **Output**: renamed project + previousName; `serializeProjectRename`. Errors: `PROJECT_RENAME_FAILED`, resolution family. +- **Tests**: project-mutations.test.ts. +- **Engine notes**: result kind. + +### `prisma project remove ` +- **Summary**: "Remove a Project permanently after exact id confirmation". +- **Flags**: `--confirm ` (must equal the resolved project id) + globals. **Positionals**: `` id or name, required. +- **Auth**: platform+login. **API**: DELETE /v1/projects/{id}. +- **Behavior**: sync; no interactive prompt — confirmation is flag-only. `CONFIRMATION_REQUIRED` (exit 2, meta.expectedConfirm/receivedConfirm) when missing/mismatched. +- **Output**: removed project + `localPin.cleared`; `serializeProjectRemove`. Errors: `PROJECT_REMOVE_BLOCKED`, `PROJECT_NOT_FOUND`. Warning (not error) if the stale local pin cannot be deleted. +- **Side effects**: deletes `.prisma/local.json` when it pointed at the removed project. +- **Tests**: project-mutations.test.ts. +- **Engine notes**: consent-grade confirmation via exact-id flag; maps to needs.consent in the engine. + +### `prisma project transfer ` +- **Summary**: "Transfer a Project to another workspace after exact id confirmation". +- **Flags**: `--to-workspace ` (locally authenticated recipient) XOR `--recipient-token `; `--confirm `; globals. Mutual exclusion and at-least-one enforced (USAGE_ERROR 2 / `TRANSFER_RECIPIENT_REQUIRED` 2). +- **Auth**: platform+login; `--to-workspace` additionally resolves a second OAuth session locally (`resolveRecipientWorkspaceSession` probes `GET /v1/workspaces` with the recipient tokens). With `PRISMA_SERVICE_TOKEN` set, `--to-workspace` fails `TRANSFER_RECIPIENT_UNAVAILABLE` (exit 1). +- **API**: POST /v1/projects/{id}/transfer (recipient access token in body). +- **Behavior**: sync. **Output**: project, recipient {workspaceId/name/source}, `localPin.action` (`rewritten|cleared|none`); `serializeProjectTransfer`. Errors: `PROJECT_TRANSFER_REJECTED`, `WORKSPACE_NOT_AUTHENTICATED`, `WORKSPACE_AMBIGUOUS`, `CONFIRMATION_REQUIRED` (2). +- **Side effects**: rewrites `.prisma/local.json` to the recipient workspace or deletes it. +- **Tests**: project-mutations.test.ts. +- **Engine notes**: exact-id consent + dual-credential use — the most complex needs.credentials story in the CLI. + +### `prisma project env add` +- **Summary**: "Create a new environment variable." +- **Flags**: + +| name | type | required | description | +|---|---|---|---| +| `--file ` | string | no | read KEY=VALUE assignments from a dotenv file (bulk mode) | +| `--role ` | enum | one of --role/--branch required | project template scope | +| `--branch ` | string | ″ | preview branch override scope | +| `--project ` | string | no | project override | + +- **Positionals**: `[assignment]` — `KEY=VALUE` or bare `KEY` (value pulled from the caller's environment); mutually exclusive with `--file`. +- **Auth**: platform+login. **API**: GET /v1/environment-variables (dup check), POST /v1/environment-variables; `--branch` may create the branch (POST /v1/projects/{projectId}/branches) — `ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH` guards that. +- **Behavior**: sync. **Output**: metadata of the created var(s) (no values echoed); `serializeEnvAdd`. Errors: `ENV_VARIABLE_ALREADY_EXISTS`, `ENV_BRANCH_SCOPE_IS_PRODUCTION`, `ENV_BRANCH_NOT_FOUND`, `ENV_FILE_APPLY_FAILED` (partial-failure report for file mode), scope USAGE_ERRORs. +- **Prompts**: none. **Side effects**: none local. +- **Tests**: app-env.test.ts, app-env-vars.test.ts, app-env-presenter.test.ts. +- **Engine notes**: result kind; file mode is a batch with per-key partial failure semantics. + +### `prisma project env update` +- Same flags/positional/auth/API family as `add` but replaces an existing value (PATCH /v1/environment-variables/{envVarId}); missing var → `ENV_VARIABLE_NOT_FOUND`; `--branch` never creates a branch here (resolveExistingBranch). Serializer `serializeEnvUpdate`. Tests as above. + +### `prisma project env list` +- **Summary**: "List environment variable metadata for a scope (no values)." +- **Flags**: `--role`, `--branch`, `--project` (+ globals). No scope → overview across scopes (production, preview template, current branch overrides via `readLocalGitBranch`). +- **Auth**: platform+login. **API**: GET /v1/environment-variables (paginated), GET /v1/projects/{projectId}/branches. +- **Output**: metadata table (key, scope, updatedAt; never values); `serializeEnvList`. +- **Tests**: app-env.test.ts. **Engine notes**: result kind. + +### `prisma project env remove KEY` (alias: `rm`) +- **Flags**: `--role`, `--branch`, `--project`. **Positionals**: `` required. +- **Auth**: platform+login. **API**: GET /v1/environment-variables (resolve id), DELETE /v1/environment-variables/{envVarId}. +- **Output**: removed key metadata; `serializeEnvRm`. Errors: `ENV_VARIABLE_NOT_FOUND`, scope errors. +- **Tests**: app-env.test.ts. **Engine notes**: result kind. Alias `rm` is undocumented (spec discrepancy #4). + +### `prisma git connect [git-url]` +- **Summary**: "Connect the resolved project to a GitHub repository". +- **Flags**: `--project ` + globals. **Positionals**: `[git-url]` optional; falls back to the local `origin` remote (`readGitOriginRemote`); non-GitHub URL → `REPO_PROVIDER_UNSUPPORTED` (2); none at all → USAGE_ERROR (2). +- **Auth**: platform+login. +- **API calls**: GET /v1/source-repositories (existing check), GET /v1/scm-installations + GET /v1/scm-installations/{id}/repositories (paginated, per installation), POST /v1/scm-installations/install-intents (install URL), POST /v1/source-repositories. +- **Behavior**: sync when the repo is already reachable; otherwise browser-opening (install URL via `open` when interactive) + **polling**: re-lists installations every 2s (env `PRISMA_CLI_GITHUB_INSTALL_POLL_INTERVAL_MS`) up to 120s (`PRISMA_CLI_GITHUB_INSTALL_TIMEOUT_MS`) waiting for the GitHub App installation/repo access; terminal states: match found / `REPO_NOT_ACCESSIBLE` / `REPO_INSTALLATION_REQUIRED` (both exit 1, meta carries installUrl + opened). +- **Output**: repository connection record; **no renderJson serializer** (raw result). Errors also: `REPO_ALREADY_CONNECTED` (1), `REPO_CONNECTION_FAILED` (1, status-aware fix text; 401/403 → AUTH_REQUIRED). +- **Prompts**: none beyond the browser wait status line. **Side effects**: opens browser; fixture mode writes a pending connection into local state instead. +- **Tests**: project.test.ts, project-real-mode.test.ts (plus git-adapter.test.ts for URL parsing). +- **Engine notes**: session kind — browser hand-off + poll loop with progress ("Waiting for GitHub App installation…"), non-interactive short-circuit. + +### `prisma git disconnect` +- **Flags**: `--project ` + globals. **Positionals**: none. +- **Auth**: platform+login. **API**: GET /v1/source-repositories, DELETE /v1/source-repositories/{id}. +- **Behavior**: sync. **Output**: the removed connection; no renderJson serializer. Error: `REPO_NOT_CONNECTED` (1), `REPO_CONNECTION_FAILED`. +- **Tests**: project.test.ts, project-real-mode.test.ts. **Engine notes**: result kind. + +### `prisma branch list` +- **Summary**: "List Platform branches for the resolved project". +- **Flags**: globals only. **Positionals**: none. (No `--project` flag — resolution is pin/durable only; spec heading agrees.) +- **Auth**: platform+login. **API**: GET /v1/projects/{projectId}/branches, cursor-paginated to exhaustion. +- **Behavior**: sync. **Output**: branch table (name, role production/preview, envMap), production first; `serializeBranchList`. Errors: `BRANCH_API_ERROR` (or API code), resolution family. +- **Tests**: branch.test.ts, branch-controller.test.ts, branch-usecases.test.ts, read-branch.test.ts. +- **Engine notes**: result kind. + +### `prisma build logs ` +- **Summary**: "Stream the logs for a build". +- **Flags**: `--follow` (keep the connection open for a running build), `--cursor ` (resume from a prior terminal cursor) + globals. +- **Positionals**: `` required — a git-push/Console Build id, not a deployment id. +- **Auth**: platform via `requireComputeAuth` only — **no interactive login fallback**; unauthenticated → AUTH_REQUIRED (1). +- **API**: `GET /v1/builds/{buildId}/logs` with `parseAs: "stream"`, NDJSON records `{type:"log"| "terminal"}`. +- **Behavior**: stream. Human mode: log text to stdout (stderr for stderr-source/error-level), terminal non-`end` message to stderr; JSON mode: one event per record, **no wrapper success event** (`emitJsonSuccessEvent: false`). A `terminal error` record sets exit code 1 without throwing. +- **Output**: raw log lines on stdout — the only command whose primary human output is stdout line passthrough. Errors: `BUILD_NOT_FOUND` (404, indistinguishable for foreign builds), `BUILD_LOGS_FAILED`. +- **Prompts/side effects**: none. +- **Tests**: **none** (no test file references runBuildLogs/build.logs). +- **Engine notes**: stream kind with its own terminal-record protocol; the exit-code-via-record pattern must map onto engine stream termination status. + +### `prisma database list` +- **Flags**: `--project `, `--branch ` + globals. +- **Auth**: platform+login (all database commands: `requireAuthenticatedAuthState` + `requireComputeAuth`). +- **API**: GET /v1/databases (provider `createManagementDatabaseProvider`). +- **Behavior**: sync. **Output**: databases sorted branch→name→id; `serializeDatabaseList`. Plan-limit failures map to `PLAN_LIMIT_REACHED` with plan/upgrade info pulled from GET /v1/workspaces/{id}/subscription. +- **Tests**: database.test.ts, database-plan-limit.test.ts. +- **Engine notes**: result kind. + +### `prisma database show ` +- **Flags**: `--project`, `--branch` + globals. **Positionals**: `` id or name (resolved via list; `DATABASE_NOT_FOUND` 1 / `DATABASE_AMBIGUOUS` 1). +- **API**: GET /v1/databases (resolve), GET /v1/databases/{id}, GET /v1/databases/{id}/connections. +- **Output**: metadata + connection metadata, **no secret values**; `serializeDatabaseShow`. **Engine notes**: result kind. + +### `prisma database create ` +- **Flags**: `--region `, `--project`, `--branch` + globals. **Positionals**: `` required non-empty. +- **API**: POST /v1/databases. +- **Behavior**: sync. **Output**: has a **renderStdout** payload — the one-time connection URL is printed to stdout (`renderDatabaseCreateStdout`), separate from the human summary on stderr; `serializeDatabaseCreate` includes connection + connectionString. Errors: `PLAN_LIMIT_REACHED`, `DATABASE_API_ERROR`, USAGE_ERROR. +- **Engine notes**: result kind with a distinct machine-consumable stdout secret — engine needs a "sensitive stdout payload" concept. + +### `prisma database usage ` +- **Flags**: `--from `, `--to ` (date-only expanded to UTC day start/end; invalid calendar dates rejected; from ≤ to enforced), `--project`, `--branch`. +- **API**: GET /v1/databases/{id}/usage. **Output**: period + metrics + generatedAt; `serializeDatabaseUsage`. **Engine notes**: result kind. + +### `prisma database restore ` +- **Flags**: `--backup ` (required — USAGE_ERROR without it), `--source-database ` (backup owner, defaults to target), `--confirm ` (must equal target id), `--project`, `--branch`. +- **API**: restore POST (provider.ts:473). **Behavior**: sync (restore is immediate & irreversible per the confirm copy). Errors: `CONFIRMATION_REQUIRED` (2), `DATABASE_BACKUP_NOT_FOUND`, `DATABASE_RESTORE_CONFLICT`. `serializeDatabaseRestore`. +- **Engine notes**: exact-id consent; destructive. + +### `prisma database remove ` +- **Flags**: `--confirm `, `--project`, `--branch`. **API**: DELETE /v1/databases/{id}. `CONFIRMATION_REQUIRED` exit 2. `serializeDatabaseRemove`. Result kind + consent. + +### `prisma database backup list ` +- **Flags**: `--limit ` (integer 1–100, else USAGE_ERROR), `--project`, `--branch`. **API**: GET /v1/databases/{id}/backups. **Output**: backups + retentionDays + hasMore; `serializeDatabaseBackupList`. Errors: `DATABASE_BACKUPS_UNSUPPORTED`. Result kind. + +### `prisma database connection list ` +- **Flags**: `--project`, `--branch`. **API**: GET /v1/databases/{id}/connections. Metadata only, no secrets; `serializeDatabaseConnectionList`. Result kind. + +### `prisma database connection create ` +- **Flags**: `--name ` (default `cli--`), `--project`, `--branch`. **API**: POST /v1/databases/{id}/connections. **Output**: renderStdout one-time connection URL + `serializeDatabaseConnectionCreate`. Errors: `DATABASE_CONNECTION_STRING_MISSING`. Result kind + sensitive stdout. + +### `prisma database connection rotate ` +- **Flags**: `--confirm ` (exact id; exit 2 otherwise). **Positionals**: `` connection **id** (no project/branch flags; provider-only auth path). **API**: POST /v1/connections/{id}/rotate. **Output**: renderStdout new one-time URL; `serializeDatabaseConnectionRotate`. Errors: `DATABASE_CONNECTION_NOT_FOUND`. Result kind + consent + sensitive stdout. + +### `prisma database connection remove ` +- **Flags**: `--confirm `. **API**: DELETE /v1/connections/{id}. `serializeDatabaseConnectionRemove`. Result kind + consent. + +### `prisma bucket list` +- **Flags**: `--project `, `--branch ` + globals. +- **Auth**: platform+login. **API**: GET /v1/buckets. +- **Output**: bucket table; `serializeBucketList`. Tests: bucket.test.ts. Result kind. + +### `prisma bucket create` +- **Flags**: `--name ` (auto-generated if omitted), `--project`, `--branch`. **API**: POST /v1/buckets. Errors: `BRANCH_NOT_FOUND`. `serializeBucketCreate`. Result kind. + +### `prisma bucket delete ` +- **Summary**: "Delete a bucket and all its access keys" (cascade documented in the confirm copy: permanently removes all objects and access keys). +- **Flags**: `--confirm ` (exact id; `CONFIRMATION_REQUIRED` exit 2). **Positionals**: `` required (id, not name). +- **API**: DELETE /v1/buckets/{bucketId}. Errors: `BUCKET_NOT_FOUND`. `serializeBucketDelete`. +- **Engine notes**: the canonical consent-grade example named in the S2 brief; exact-id flag, no prompt. + +### `prisma bucket key list ` +- **Positionals**: ``. **API**: GET /v1/buckets/{bucketId}/keys. Metadata only. `serializeBucketKeyList`. Result kind. + +### `prisma bucket key create ` +- **Summary**: "Create a bucket access key and print its one-time credentials". +- **Flags**: `--role ` (default read_write — anything not exactly `read` becomes read_write), `--name ` (auto-generated if omitted). +- **API**: POST /v1/buckets/{bucketId}/keys. **Output**: renderStdout one-time credentials (accessKeyId/secretAccessKey/endpoint/bucketName) + `serializeBucketKeyCreate`. Errors: `BUCKET_KEY_SECRET_MISSING`. Result kind + sensitive stdout. + +### `prisma bucket key delete ` +- **Positionals**: both required (USAGE_ERROR 2 when blank). **No --confirm** (revocation is not id-confirmed — inconsistent with bucket delete; note for grammar review). **API**: DELETE /v1/buckets/{bucketId}/keys/{keyId}. Errors: `BUCKET_KEY_NOT_FOUND`. `serializeBucketKeyDelete`. Result kind. + +### `prisma app build [app]` +- **Summary**: "Build the app locally into a deployable artifact". +- **Flags**: `--entry ` (Bun/auto), `--build-type ` (choices `APP_BUILD_TYPES` incl. `auto` default; auto+committed build block resolves via deploy's framework detection) + globals. +- **Positionals**: `[app]` — target key in a multi-app `prisma.compute.ts`. +- **Auth**: none (fully local) — but fixture mode refuses (`ensurePreviewAppMode`? No: app build does NOT call ensurePreviewAppMode; it is local-only and works in any mode). +- **API**: none. **Behavior**: sync local build (`executeAppBuild`). +- **Output**: artifact directory/entrypoint/buildType; `serializeAppBuild`. Errors: `BUILD_FAILED` (1), `FRAMEWORK_NOT_DETECTED`, `BUILD_SETTINGS_UNSUPPORTED`, `COMPUTE_CONFIG_*`, USAGE_ERROR for ambiguous auto detection. +- **Side effects**: writes the build artifact directory; runs framework build tooling as child processes. +- **Tests**: app-build.test.ts, app-bun-compat.test.ts, compute-config.test.ts. +- **Engine notes**: long-running local work → progress events; no credentials. + +### `prisma app run [app]` +- **Summary**: "Run your app locally". +- **Flags**: `--entry `, `--build-type ` (default auto; currently nextjs/bun have dev servers), `--port ` + globals. **Rejects `--json`** with USAGE_ERROR (exit 2) — it streams the framework dev server output directly. +- **Positionals**: `[app]` config target. +- **Auth/API**: none. **Behavior**: long-running local child process until exit/SIGINT; SIGINT → COMMAND_CANCELED (130); non-zero child exit → `RUN_FAILED` **with the child's exit code as the CLI exit code** (app.ts:349-355, runFailedError exitCode param). +- **Output**: pass-through dev-server output; on clean exit a summary (framework, entrypoint, port, command); `serializeAppRun` exists but is unreachable with --json rejected. +- **Tests**: app-local-dev.test.ts. +- **Engine notes**: closest thing to a "server" kind in the current CLI; exit-code passthrough is unique. + +### `prisma app deploy [app]` +- **Summary**: "Creates a new deployment for the app". +- **Flags**: + +| name | type | notes | +|---|---|---| +| `--app ` | string | app selector (create-if-missing semantics) | +| `--project ` | string | explicit project; mutually exclusive with --create-project and PRISMA_PROJECT_ID | +| `--create-project ` | string | create+link a Project first | +| `--branch ` | string | branch override (default: local git branch, else production) | +| `--framework ` | enum | nextjs/nuxt/astro/hono/nestjs/tanstack-start/custom/bun | +| `--entry ` | string | Bun deploys | +| `--http-port ` | string→int validated | port override | +| `--region ` | string | only for newly created apps; mismatch with an existing app's region → USAGE_ERROR | +| `--env ` | repeatable string[] | assignment or dotenv file path | +| `--db` / `--no-db` | boolean | create+wire a branch database / skip; passing both → USAGE_ERROR (checked against raw argv) | +| `--prod` | boolean | confirm intent to replace the live production deployment | +| `--no-promote` | boolean | build without promoting; skips the production confirmation entirely | + +- **Positionals**: `[app]` config target; with a multi-app config and no target, deploys **all** targets sequentially (deploy-all mode) and rejects per-app inputs (`--app/--framework/--entry/--http-port/--region/--env`, `PRISMA_APP_ID`) with USAGE_ERROR. +- **Auth**: platform via `requireComputeAuth` (no interactive login). Env overrides: `PRISMA_PROJECT_ID` (skips/never writes the local pin), `PRISMA_APP_ID`. +- **API calls**: ComputeClient `deployApp` (upload/build/deploy/promote with progress callbacks); POST /v1/projects (--create-project); GET/POST /v1/projects/{id}/branches (branch resolve/create); GET /v1/apps (selection); `--db`: GET/POST /v1/environment-variables + POST /v1/databases (+ DELETE on rollback of a failed setup). +- **Behavior**: long-running with step progress; interactive on first deploy (customize-settings confirm → framework select + port text), ambiguous app name select prompt, `--db` confirm prompt when a Prisma schema signal is found, production-deploy confirmation prompt, agent-setup prompt. Production rules (`enforceProductionDeployGate`): second-and-later production deploys need `--prod` (`PROD_DEPLOY_REQUIRES_FLAG` exit 2), plus `--yes` or an interactive confirm; cancel exits 0. +- **Output**: workspace/project/branch/app/deployment/deploySettings/durationMs; deploy-all wraps per-target results; `serializeAppDeploy` / `serializeAppDeployAll`. Errors: `DEPLOY_FAILED`, `BUILD_FAILED` (build-phase aware, Next standalone-output hint with edit-file nextAction), `APP_AMBIGUOUS` (2), `PROJECT_SETUP_REQUIRED`, `LOCAL_STATE_STALE`, `BRANCH_DATABASE_SETUP_FAILED`, `BUILD_SETTINGS_MIGRATION_REQUIRED`, `COMPUTE_CONFIG_*`, `FRAMEWORK_NOT_DETECTED`; deploy-all failures are re-wrapped with completed/not-attempted context in meta.deployAll. +- **Side effects**: may write `.prisma/local.json` (+ `.gitignore`); writes selected-app + known-live-deployment into state.json; uploads code; may create project/branch/database/env vars; runs local build child processes. +- **Tests**: app.test.ts, app-controller.test.ts, deploy-plan.test.ts, production-deploy-gate.test.ts, app-branch-database.test.ts, app-provider.test.ts, app-state.test.ts, app-env-vars.test.ts. +- **Engine notes**: the flagship session command: step/progress/status events, multiple consent points (--prod, --db, customize), env-var credential injection, and a deploy-all composite. The progress callbacks (`createDeployProgress`) are the natural source of engine progress events. + +### `prisma app show [app]` +- **Flags**: `--app `, `--project ` + globals. **Positionals**: `[app]` config target. +- **Auth**: platform. **API**: GET /v1/apps, ComputeClient.listDeployments. +- **Behavior**: sync; may select-prompt when several apps and no saved selection (non-interactive → USAGE_ERROR "App selection required"). Live deployment resolved via provider liveDeploymentId, falling back to the locally cached known-live id (a72f34a fix: never assumes newest is live). +- **Output**: app, liveDeployment, liveUrl, 5 recent deployments; `serializeAppShow`. Null app (none deployed) is a success with nextStep deploy. +- **Side effects**: caches selected app in state.json. +- **Tests**: app.test.ts, app-controller.test.ts, app-presenter.test.ts, app-state.test.ts. +- **Engine notes**: result kind (+ optional picker). + +### `prisma app open [app]` +- **Flags**: `--app`, `--project`. **Auth**: platform. **API**: GET /v1/apps + listDeployments. +- **Behavior**: sync + browser-opening: opens the live URL with `open` only when `canPrompt`; otherwise reports `opened: false` and prints the URL. +- **Output**: url + opened flag; `serializeAppOpen`. Errors: `NO_DEPLOYMENTS` (1), `FEATURE_UNAVAILABLE` when no live URL. +- **Engine notes**: result + local browser action; the engine needs a "open URL on the client" effect. + +### `prisma app domain add [app]` +- **Flags** (shared domain target set): `--app `, `--project `, `--branch ` + globals. +- **Positionals**: `` (normalized/validated → `DOMAIN_HOSTNAME_INVALID` 2), `[app]` config target. +- **Auth**: platform. Custom domains restricted to the production branch: non-production `--branch` → `BRANCH_NOT_DEPLOYABLE` (2). Env overrides PRISMA_PROJECT_ID/PRISMA_APP_ID honored. +- **API**: POST /v1/apps/{appId}/domains. +- **Output**: domain summary (status, dns records, certificate) + `existing` flag (idempotent re-add); `serializeAppDomainAdd`; nextSteps wait/show. Errors: `DOMAIN_ALREADY_REGISTERED` (registered to another app), `DOMAIN_QUOTA_EXCEEDED`, `DOMAIN_DNS_NOT_CONFIGURED`, `NO_DEPLOYMENTS`, `DEPLOY_FAILED` fallback. +- **Tests**: app.test.ts / app-controller.test.ts / app-provider.test.ts (domain sections). +- **Engine notes**: result kind. + +### `prisma app domain show [app]` +- Same target flags. **API**: list domains → GET /v1/domains/{domainId}. Output `serializeAppDomainShow`; `DOMAIN_NOT_FOUND` (1). Result kind. + +### `prisma app domain remove [app]` +- Same target flags. Confirmation: `--yes` skips; interactive confirm "Detach from App …?" (default No); non-interactive without --yes → `CONFIRMATION_REQUIRED` **exit 1**; declining → USAGE_ERROR "Custom domain removal canceled" (2). **API**: DELETE /v1/domains/{domainId}. `serializeAppDomainRemove`. Consent-grade (yes/no, not exact-id). + +### `prisma app domain retry [app]` +- Same target flags. **API**: POST /v1/domains/{domainId}/retry. Errors: `DOMAIN_RETRY_NOT_ELIGIBLE`. `serializeAppDomainRetry`. Result kind. + +### `prisma app domain wait [app]` +- **Flags**: target set + `--timeout ` (default "15m"; `0` = single check then timeout error). +- **Behavior**: **polling stream** via `runStreamingCommand`: emits a status line/JSON event on every status change (poll interval `PRISMA_CLI_DOMAIN_WAIT_POLL_MS`), GET /v1/domains/{id} each cycle. Terminal states: `active` (success, prints live URL), `failed` → `DOMAIN_VERIFICATION_FAILED` (1), deadline → `DOMAIN_VERIFICATION_TIMEOUT` (1). +- **Output**: status events; no result envelope beyond the streaming success event. +- **Engine notes**: canonical poll→status-events mapping case for the engine. + +### `prisma app logs [app]` +- **Flags**: `--app `, `--project `, `--deployment ` + globals. **Positionals**: `[app]` config target. +- **Auth**: platform (log stream re-authenticates via `createPreviewLogAuthOptions` — service token or stored access token directly). +- **API**: deployment resolution (listApps/listDeployments/showDeployment) then `ComputeClient.streamDeploymentLogs`. +- **Behavior**: stream; without `--deployment` streams the live deployment (NO_DEPLOYMENTS when none). JSON mode: per-record events + wrapper success event. +- **Output**: log text to stdout; header block to stderr. Errors: `DEPLOYMENT_NOT_FOUND` (three variants: unknown id / detached app / foreign project), `NO_DEPLOYMENTS`, `DEPLOY_FAILED`. +- **Tests**: app.test.ts, app-controller.test.ts. +- **Engine notes**: stream kind. + +### `prisma app list-deploys [app]` +- **Flags**: `--app`, `--project`. **Auth**: platform. **API**: GET /v1/apps + listDeployments. +- **Output**: deployments newest-first with live hint; null app = success; `serializeAppListDeploys`. Side effect: caches selected app. Result kind (+ optional picker). + +### `prisma app show-deploy ` +- **Positionals**: `` id required. **Flags**: globals only. +- **Auth**: platform. **API**: ComputeClient.showDeployment. No project resolution — the id is global. +- **Output**: deployment detail with corrected `live` flag (provider live id > cached known-live > record flag); `serializeAppShowDeploy`. Error: `DEPLOYMENT_NOT_FOUND` (1). Result kind. + +### `prisma app promote [app]` +- **Summary**: "Promote a deployment to production by rebuilding with production env vars". +- **Flags**: `--app`, `--project`. **Positionals**: `` required, `[app]` config target. +- **Auth**: platform. **API**: listApps, listDeployments, ComputeClient.promoteDeployment (with progress rendering). +- **Behavior**: remote operation with progress; already-live target short-circuits with a warning instead of an error. +- **Output**: promoted deployment (status running, live true); `serializeAppPromote`. Errors: `DEPLOYMENT_NOT_FOUND`, USAGE_ERROR "App promote requires an existing app", `DEPLOY_FAILED`. +- **Side effects**: caches selected app + known live deployment. +- **Engine notes**: session (progress events); note the local known-live cache is part of correctness for later `show`/`rollback`. + +### `prisma app rollback [app]` +- **Summary**: "Roll back production to a previous deployment". +- **Flags**: `--app`, `--project`, `--to ` (explicit target; default = deployment immediately before the current live one). +- **Auth**: platform. **API**: same promote machinery (rollback = promote of an older deployment). +- **Output**: new live deployment + previousLiveDeploymentId; `serializeAppRollback`. Errors: `NO_PREVIOUS_DEPLOYMENT` (1), `DEPLOYMENT_NOT_FOUND`, `DEPLOY_FAILED`. +- **Engine notes**: session (progress); no confirmation prompt at all today (worth flagging: destructive-ish but unconfirmed). + +### `prisma app remove [app]` +- **Summary**: "Remove the app from the resolved branch". +- **Flags**: `--app `, `--project `, `--branch ` (scopes teardown; empty string rejected with USAGE_ERROR so it cannot silently fall back to production — commit 484c60a) + globals (`--yes` is the documented confirm). +- **Positionals**: `[app]` config target. +- **Auth**: platform. **API**: ComputeClient.showApp + destroyApp (SDK polls status, 2s interval, 120s timeout). +- **Behavior**: destructive with **type-the-app-name** confirmation prompt on TTY; `--yes` skips; non-interactive without --yes → `CONFIRMATION_REQUIRED` **exit 1**. +- **Output**: removed app; `serializeAppRemove`; warnings if local state cleanup fails. Errors: `REMOVE_FAILED` (1), USAGE_ERROR "App remove requires an existing app". +- **Side effects**: clears selected-app and known-live-deployment from state.json. +- **Engine notes**: consent (typed-name — strongest grade in the CLI) + SDK-internal polling → progress events. + +--- + +## 5. Current tests census + +Fixture-mode counts are references to `fixturePath` per file (see command sections for the mapping): + +- Heavy fixture users (fixture-mode CLI-level tests): project.test.ts (47), database.test.ts (40), init.test.ts (35), auth.test.ts (26), bucket.test.ts (25), app.test.ts (23), project-mutations.test.ts (16), shell.test.ts (15), update-check.test.ts (12), auth-real-mode.test.ts (6), branch.test.ts (5), project-controller.test.ts (5), version.test.ts (3), auth-controller.test.ts (2). +- Real-mode / unit tests (no fixture): app-controller, app-build, app-bun-compat, app-branch-database, app-local-dev, app-presenter, app-provider, app-state, app-env*, auth-login, auth-ops, auth-usecases, branch-controller, branch-usecases, command-runner(+auth), compute-config, database-plan-limit, deploy-plan, feedback, git-adapter, init-agent-setup, local-branch, output, production-deploy-gate, project-real-mode, project-resolution, project-usecases, prompt, read-branch, resolve-package-version, token-storage, v8-bin, v8-whoami. +- **Commands with no direct test coverage**: `build logs` (nothing references it), `agent update` (only via shared install path in agent.test.ts), `database usage`/`backup list`/`restore` real-mode paths are covered only through database.test.ts fixtures + provider unit tests. + +## 6. Renames and grammar (v8) + +- **app → service**: per the ruled grammar the deployable unit is **Service**. Affected surface: the entire `app` group (16 leaf commands), the `--app` flag on 9 commands, the `[app]` config-target positional on 14 commands, `PRISMA_APP_ID`, result fields `app{id,name}`, state-store keys (`setSelectedApp`), error copy ("App remove requires an existing app"), and `prisma.compute.ts`'s `app:` block (SDK-owned; rename coordination needed with @prisma/compute-sdk). `app build`/`app run` are local-dev verbs that may belong under the service noun or a dev namespace — flag for the spec author. +- **`project` stays platform-owned** (ruled 2026-08-10). The composer work parks under a separate `composer` root in S3; nothing in the current tree collides with that name. +- **`database` vs `postgres`**: the S2 brief says "database/postgres", but the current shell has **no `postgres` command or alias** — only `database`, described as "Manage Prisma Postgres databases". If the v8 grammar wants `postgres` as the resource noun, that is a pure rename (no alias exists to preserve). +- Grammar conflicts / irregularities in the current tree: + - `app list-deploys` / `app show-deploy` break the ` ` shape with hyphenated compound verbs; a Deployment resource noun (`deployment list/show`) would be regular. + - `build` is a resource group (git builds) whose only verb is `logs`, while `app build` is a verb — same word, two meanings. The spec already plans `build list`/`build show`; the rename should disambiguate Service build (local) from platform Build (resource). + - Deletion verbs are split: `remove` (project, database, app, env, connection) vs `delete` (bucket, bucket key). Confirmation styles are also split three ways: exact-id `--confirm` flag (project/database/bucket), `--yes`/interactive confirm (domain remove, prod deploy), typed-name prompt (app remove). The engine's consent grades should normalize these. + - `auth logout --workspace X` duplicating `auth workspace logout X` is a compat shim worth collapsing. + - `project env` is the env surface (moved off `app`); the S2 brief's "app (incl. env…)" reflects the old layout — env controllers still live in files named `app-env*.ts` and types in `types/app-env.ts` even though the commands are `project env *`. + - Group descriptor `branch` says "View your Platform branches" — read-only group with one verb; fine, but the deploy path creates branches implicitly (POST branches), which the grammar should own explicitly. diff --git a/.drive/projects/prisma-cli-v8/plans/s2b-resources.md b/.drive/projects/prisma-cli-v8/plans/s2b-resources.md new file mode 100644 index 00000000..0bbcdc55 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/plans/s2b-resources.md @@ -0,0 +1,33 @@ +# S2b dispatch plan — resources + +Contract: `../specs/s2b-resources.md`. Branch `s2b-resources` off +`main` after S2a merges. Sequential dispatches; each verifies the full +root suite before its commit; standing commit/verification rules as in +`s2a-foundations.md`'s plan. Unpinned fact → STOP. + +### D1 — project group +`project list|show|create|link|rename|remove|transfer` + +`project env add|update|list|remove` per contract rules over inventory +entries. Includes the R-S2b-6 picker (`project link`) and two consent +commands. Hands the group-porting pattern (file layout, presentation +helpers, test matrix template) to every later dispatch — this +dispatch's structure IS the template; later dispatches copy it. + +### D2 — postgres group (rename included) +All 11 database→postgres commands incl. backup + connection; three +consent commands; two secret-bearing (R-S2b-4). + +### D3 — bucket + branch + git +`bucket *` (6, one consent, one secret), `branch list`, `git +connect|disconnect` (poll + browser event pattern per R-S2b-7/R-S2c-6 +precursor). + +### D4 — slice closure +Divergence list (per-command conformance rows), legacy fixture-test +deletion for ported groups, review loop (architect + principal +engineer), findings fixed, PR opened non-draft with the ruled +description structure. + +Completeness: D1→project+env; D2→postgres; D3→bucket/branch/git; +D4→closure boxes. Every contract acceptance box maps to exactly one +dispatch. diff --git a/.drive/projects/prisma-cli-v8/plans/s2c-services.md b/.drive/projects/prisma-cli-v8/plans/s2c-services.md new file mode 100644 index 00000000..0bf585f5 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/plans/s2c-services.md @@ -0,0 +1,26 @@ +# S2c dispatch plan — services + +Contract: `../specs/s2c-services.md`. Branch `s2c-services` off `main` +after S2b merges. Standing rules as in S2b's plan. + +### D1 — service group core (rename included) +`service build|show|open|list-deploys|show-deploy` + `service domain +add|show|remove|retry|wait` — sync/poll commands first, establishing +the renamed group and the S2b template in this codebase area. + +### D2 — progress operations +`service deploy|promote|rollback|remove` per R-S2c-3 (step/progress/ +status event sequences, SDK polling on the injectable clock, consent +on remove). + +### D3 — streams +`service logs` + `build logs` per R-S2c-2 (session commands, output +events, channel routing; `build logs` gains its first tests). + +### D4 — agent + feedback + closure +`agent install|update|status`, `feedback`; divergence list; legacy +fixture-test deletion for ported groups; review loop; PR. `service +run` stays parked unless ledger Q2 was ruled — if ruled "S2c", it +becomes D3b per the ruling's mechanism. + +Completeness: D1→sync surface; D2→progress; D3→streams; D4→closure. diff --git a/.drive/projects/prisma-cli-v8/plans/s2d-init-and-retirement.md b/.drive/projects/prisma-cli-v8/plans/s2d-init-and-retirement.md new file mode 100644 index 00000000..bb0bdd2d --- /dev/null +++ b/.drive/projects/prisma-cli-v8/plans/s2d-init-and-retirement.md @@ -0,0 +1,30 @@ +# S2d dispatch plan — init and shell retirement + +Contract: `../specs/s2d-init-and-retirement.md`. Branch +`s2d-init-and-retirement` off `main` after S2c merges. Standing rules +as in S2b's plan. R-S2d-3 (bin cutover) is BLOCKED on ledger Q4 — +sequence it last and confirm the ruling before starting D3. + +### D1 — init + version +R-S2d-1 and R-S2d-2: the wizard on the engine prompt surface (full +prompt matrix incl. `--yes`, non-interactive, cancel; byte-asserted +templates), the `version` command port. + +### D2 — deletions +R-S2d-4's checklist from the inventory: commander shell, fixture +machinery, fixture tests, `--trace`, env surface. Survivor list +enumerated. Legacy suite shrinks to zero fixture tests; everything +remaining is engine-side. + +### D3 — bin cutover (after Q4 ruling) +R-S2d-3 per the ruling; tarball smoke on plain Node (`npm pack` → +install into a temp dir → run `prisma-cli --version`, `auth whoami`, +config-bearing command). + +### D4 — closure +R-S2d-5 grammar completeness test; R-S2d-6 consolidated divergence +document; review loop; PR; S2 closed in the project plan (health +check + retro trigger per the drive process). + +Completeness: D1→wizard/version boxes; D2→deletion boxes; D3→cutover +box; D4→grammar/parity/closure boxes. diff --git a/.drive/projects/prisma-cli-v8/specs/s2-overview.md b/.drive/projects/prisma-cli-v8/specs/s2-overview.md index 88257f50..1bc13eb9 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2-overview.md +++ b/.drive/projects/prisma-cli-v8/specs/s2-overview.md @@ -65,6 +65,33 @@ output, prompts, side effects, tests, engine mapping). S2b–S2d contracts enumerate their commands FROM that inventory; the inventory is the single source for "what exists today". +## Open questions for the operator (S2 ledger) + +Contracts build to the stated default where one is given; the ruling +can overrule before the affected dispatch runs. + +- **Q1 — auto-login.** ~30 legacy commands auto-launch interactive + OAuth on a TTY when unauthenticated; the engine's `needs.credentials` + fails early instead. Default built to: sign-in structured error + + `auth login` nextAction (consistent, agent-friendly). Ratify or + reinstate auto-login as an engine feature. +- **Q2 — `service run` child-exit passthrough.** The legacy command + passes the dev server's exit code through; session commands have no + exit-code channel; the same exception is already ruled for + Composer's S3. Decide: build the passthrough mechanism in S2c, or + defer `service run` to ride S3's mechanism (S2d then keeps a minimal + legacy path for it). +- **Q3 — `project env remove`'s `rm` alias** (the only alias in the + tree) does not port. Ratify the drop or rule alias support. +- **Q4 — config evaluation in the shipped bin.** The S1 loader + requires a TS-capable runtime; the S2d bin cutover cannot. Choose + the evaluation strategy (e.g. jiti, esbuild-register, or "TS-capable + runtimes only" documented). Blocks S2d's R-S2d-3 only. +- **Q5 — exit-code unification.** R-S2b-3 changes user-visible codes: + legacy consent failures split 1/2 and prod-deploy cancel exits 0; + engine rules make these 2 (structural) and 3 (cancel). Built to + engine rules; ratify. + ## Definition of done (whole slice) - Every platform command runs on the engine; the commander shell and diff --git a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md index 3581197c..1551c465 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md +++ b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md @@ -86,8 +86,12 @@ real-mode helpers (`listRealAuthWorkspaces`, `useRealAuthWorkspace`, `logoutRealAuthWorkspace` — extracted from the controller into `src/auth/workspaces.ts`, controller delegates), and `makeGetCredentials` (moved from `src/v8/runtime.ts`; the v8 runtime -imports it from here). The legacy shell and controllers import ONLY -via `src/auth/index.ts`. The `Credentials` shape stays the engine's +imports it from here), plus `WorkspaceSelectionError` and +`StoredAuthWorkspace` (production consumers exist). The legacy shell +and controllers import ONLY via `src/auth/index.ts` — production code +rule; white-box TESTS of the module's own internals (and `vi.mock` +targets, which must name the module the code under test imports) are +the permitted exception. The `Credentials` shape stays the engine's `{ token: string }` — S2a does not redesign it. ## 4. `auth *` family port @@ -100,7 +104,7 @@ loses `--provider`, `--user`, `--workspace` (mock-selection flags). | Command | Args | needs | Behavior | | --- | --- | --- | --- | | `auth login` | none | none | Real OAuth flow via `performLogin` (browser open + poll), then `readAuthState`; events: `step-started/finished` for the flow, `endpoint` for the verification URL; presented like whoami's card plus the agent-setup tip line when `resolveAgentSetupTipCommand` fires (port that helper's real-mode path); nextActions: `auth whoami`, `project list`, the tip command when present | -| `auth logout` | none | none | `performLogout` + `readAuthState`; card matching current logout copy; nextAction: `auth login` | +| `auth logout` | flag `--workspace ` (optional) | none | Without the flag: `performLogout` + `readAuthState`, card matching current logout copy, nextAction `auth login`. With the flag: the workspace-logout operation (same semantics as `auth workspace logout `, same presentation, same command — call the shared operation directly; the current shell's internal re-dispatch hack does not port) | | `auth whoami` | none | none | Already ported (S1) — moves from `src/v8/auth/whoami.ts` handler calling `readAuthState` directly to the auth module import; otherwise untouched | | `auth workspace list` | none | none | `listRealAuthWorkspaces`; table Block (name, id, status; source column only when mixed — port the exact column rules from `presenters/auth.ts`); json serializer ports `serializeAuthWorkspaceList` | | `auth workspace use [workspace]` | optional positional | none | Resolves by id or case-insensitive name; ambiguous → `AUTH.WORKSPACE_AMBIGUOUS` errored (map the current error's content to nextActions form); absent positional + interactive → `prompt.select` over workspaces (clack path); absent + non-interactive → structural prompt failure (engine default) | diff --git a/.drive/projects/prisma-cli-v8/specs/s2b-resources.md b/.drive/projects/prisma-cli-v8/specs/s2b-resources.md new file mode 100644 index 00000000..e83fc577 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/specs/s2b-resources.md @@ -0,0 +1,113 @@ +# S2b — Resources (slice contract) + +One PR into `main`, branch `s2b-resources`, after S2a merges. Ports the +resource-administration groups onto the engine: `project *` (incl. +`env`), `postgres *` (renamed from `database`, incl. `backup` and +`connection`), `bucket *` (incl. `key`), `branch list`, `git *`. + +Normative sources, in precedence order: (1) this contract's mapping +rules and per-command decisions; (2) the command inventory +`../assets/s2/command-inventory.md` for every current-behavior fact +(flags, positionals, API calls, output shapes, prompts, side effects) +— port behavior is the inventory's record EXCEPT where a mapping rule +below changes it, and every such change is a divergence-list entry; +(3) the v8 draft. Unpinned fact → STOP, never improvise. + +## Mapping rules (apply to every command in this PR) + +R-S2b-1 **Rename**: the `database` group ports as `postgres` +(target grammar). All paths, help, ids (`postgres.connection.rotate`), +and docs update; no alias to the old name. Divergence entry. + +R-S2b-2 **Auth**: every command the inventory marks `platform` or +`platform+login` declares `needs.credentials`. The legacy +auto-launched interactive OAuth login (`platform+login`) DOES NOT +port: unauthenticated invocations settle with the engine's sign-in +error and a `run-command` nextAction for `auth login`. (Operator +ratification pending — morning-questions ledger Q1; build to this +rule unless overruled.) + +R-S2b-3 **Consent**: destructive operations (`remove`/`delete`/ +`restore`/`rotate`/`transfer` — exactly the inventory's +"needs --confirm"/consent rows) keep their CURRENT confirmation flag +(name and value semantics per inventory, e.g. exact-id `--confirm +`); interactively, absent the flag, they use `prompt.consent` +with the inventory's current question text. Non-interactive without +the flag → the engine's `CLI.CONSENT_REQUIRED` (exit 2). Cancel → +exit 3. The legacy split (exit 1 vs 2 vs 0-on-cancel) unifies to +engine codes; each changed code is a divergence entry. + +R-S2b-4 **Secrets**: commands the inventory marks "secret on stdout" +(`postgres create`, `postgres connection create|rotate`, `bucket key +create`) present the secret as the `stdout` payload lines (pipe-clean +by Option A) and mask it in human Blocks via `sensitive: true`. The +json envelope carries it in `result` exactly as today. + +R-S2b-5 **Errors**: legacy flat codes map to dotted codes under the +group's namespace (`PROJECT.*`, `POSTGRES.*`, `BUCKET.*`, `GIT.*`, +`BRANCH.*`), preserving summary/why text; every mapping is enumerated +in the divergence list. Errored paths exit 2 (legacy 1 → 2 +divergences enumerated once as a class). + +R-S2b-6 **Interactive pickers** (`project link`, others per +inventory): `prompt.select` (clack path) with the inventory's option +labels; non-interactive without the disambiguating arg → structural +prompt failure. + +R-S2b-7 **Polling** (`git connect` install wait): result command +emitting `status` events per poll transition, engine-clock injectable; +timeout behavior per inventory. + +R-S2b-8 **Aliases**: `project env remove`'s `rm` alias does not port +(the tree has exact paths; it is the only alias in the shell). +Divergence entry. (Ledger Q3.) + +R-S2b-9 **Tests**: semantic per the S2 ruling — `ctx.api` faked with +recorded SDK-shaped responses; every command × (success, errored, +json envelope, unauthenticated, consent grant/deny/non-interactive +where applicable, picker path where applicable). No fixture mode. Each +command's test asserts envelope + presented data + events + exit code, +not bytes. + +R-S2b-10 **Files**: v8 command modules live at +`packages/cli/src/v8//.ts`, one command per file, +definitions + handler colocated (S1 whoami pattern); shared per-group +presentation helpers in `packages/cli/src/v8//presentation.ts`. +Handlers call the existing controllers'/providers' operation layer +(inventory names the exact functions) — S2b does NOT rewrite the +operations, only re-homes invocation behind `ctx.api`-built clients +where the operation takes an SDK/client argument (inventory's "API +surface" column names it). + +## Commands in scope (30) + +`project list|show|create|link|rename|remove|transfer`, +`project env add|update|list|remove`, `git connect|disconnect`, +`branch list`, `postgres list|show|create|usage|restore|remove`, +`postgres backup list`, +`postgres connection list|create|rotate|remove`, +`bucket list|create|delete`, `bucket key list|create|delete`. + +Every command: one inventory entry = its behavior contract; the +mapping rules above are the only deltas. The implementer builds a +per-command conformance row (command → inventory entry → applied +rules → divergences) in the PR's divergence list. + +## Out of scope + +`service`/`app`, `build`, `agent`, `feedback` (S2c); `init`, shell +deletion (S2d); auto-login reinstatement (ledger Q1); command aliases +(ledger Q3). + +## Acceptance + +- [ ] All 30 commands mounted in the v8 bin under groups + `project`, `git`, `branch`, `postgres`, `bucket` (+ declared + subgroup help), passing R-S2b-9's test matrix. +- [ ] `postgres` rename complete; no `database` path survives in v8. +- [ ] Consent matrix proven for every destructive command. +- [ ] Secrets pipe-clean and masked per R-S2b-4. +- [ ] Divergence list updated with every R-S2b-1/2/3/5/8 delta. +- [ ] Legacy fixture tests for ported commands deleted; legacy shell + still green for unported groups. +- [ ] Root verification green; PR ≥1k LOC; review loop run. diff --git a/.drive/projects/prisma-cli-v8/specs/s2c-services.md b/.drive/projects/prisma-cli-v8/specs/s2c-services.md new file mode 100644 index 00000000..0d8e1bc9 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/specs/s2c-services.md @@ -0,0 +1,79 @@ +# S2c — Services (slice contract) + +One PR into `main`, branch `s2c-services`, after S2b merges. Ports the +deployment-and-delivery groups: `service *` (renamed from `app`, incl. +`domain`), `build logs`, `agent *`, `feedback`. + +Normative sources and precedence as in `s2b-resources.md`; S2b's +mapping rules R-S2b-2/3/4/5/6/9/10 apply here unchanged (with +namespace `SERVICE.*`, `BUILD.*`, `AGENT.*`, `FEEDBACK.*`). Additional +rules: + +R-S2c-1 **Rename**: `app` ports as `service` (ruled: the deployable +unit's noun is Service). All paths, ids, help, presenters. No alias. +Divergence entry per command. + +R-S2c-2 **Streams** (`service logs`, `build logs`): session commands. +Records map to `output` events — the inventory's per-record +`source`/`level` routing maps channel `data` (stdout) vs `diagnostic` +(stderr); json mode frames them (the legacy JSON wrapper-event opt-out +for `build logs` does not port — the engine stream IS the json +surface; divergence entry). `build logs` gains its first tests ever +(inventory finding): the full R-S2b-9 matrix. + +R-S2c-3 **Progress operations** (`service deploy`, `promote`, +`rollback`, `remove`, `domain wait`): result commands emitting +`step-started/finished`, `progress`, and `status` events per the +inventory's step structure; SDK polling drives events through the +injectable clock. `service remove`'s type-the-name confirmation ports +to `prompt.consent` + its current flag per R-S2b-3. + +R-S2c-4 **`service run`** — PARKED (morning-questions ledger Q2). The +legacy command passes the child dev-server's exit code through as the +CLI's exit code; engine session commands have no exit-code channel, +and the same passthrough exception is already ruled for Composer's +S3 adoption. Decision needed: build the engine's child-status +passthrough mechanism here (S2c) or defer `service run`'s port to +ride S3's mechanism. DO NOT port `service run` until ruled; the +legacy shell keeps serving it meanwhile (shell deletion is S2d — if +Q2 resolves "defer to S3", S2d keeps a minimal legacy path for +`service run` only, recorded there). + +R-S2c-5 **`service build`**: result command; local build; progress +events from the SDK build reporter; no `ctx.api`. + +R-S2c-6 **Browser opening** (`service open`, inherited by S2b's +`git connect`): the URL is presented as an `endpoint` event + opened +via the operation layer's existing opener; `--no-open`-style flags +per inventory. + +R-S2c-7 **Update notification + shell parity**: no new work — S2a +landed both shells on the shared module; S2c only confirms the v8 bin +covers the newly ported groups (no per-command wiring exists). + +## Commands in scope (24 + 1 parked) + +`service build|deploy|show|open|logs|list-deploys|show-deploy|promote| +rollback|remove`, `service domain add|show|remove|retry|wait`, +`service env *` — NOTE: the inventory places env under `project env` +(S2b) only; `app` has a `domain` subgroup and no `env` subgroup — +scope follows the inventory. `build logs`, `agent +install|update|status`, `feedback`. Parked: `service run` (Q2). + +## Out of scope + +`init`, shell deletion (S2d); `service run` until Q2; Composer (S3). + +## Acceptance + +- [ ] All in-scope commands mounted and green on the R-S2b-9 matrix + (streams included); `build logs` covered for the first time. +- [ ] `service` rename complete; no `app` path survives in v8. +- [ ] Deploy/promote/rollback/remove event sequences pinned by + semantic tests (step/progress/status ordering). +- [ ] Divergence list updated (rename class, stream-wrapper drop, + consent/exit unifications, error-code map). +- [ ] Q2 either ruled and implemented or still parked with the legacy + path intact and S2d's contract updated accordingly. +- [ ] Legacy fixture tests for ported commands deleted; root + verification green; PR ≥1k LOC; review loop run. diff --git a/.drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md b/.drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md new file mode 100644 index 00000000..cd7945f3 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md @@ -0,0 +1,76 @@ +# S2d — Init and shell retirement (slice contract) + +One PR into `main`, branch `s2d-init-and-retirement`, after S2c +merges. Ports `init` and `version`, deletes the commander shell and +fixture machinery, and makes the v8 bin the shipped binary. Closes +slice S2. + +Sources and precedence as in `s2b-resources.md`. S2b rules apply where +relevant. Additional rules: + +R-S2d-1 **`init`**: result command; the wizard runs on the engine +prompt surface (clack path from S2a): the inventory's step list is the +contract (project name, template/framework selection, linking +question, env write-out, agent-setup offer). Consent semantics: file +writes into a non-empty directory follow the current confirmation +behavior via `prompt.confirm`; `--yes` accepts defaults per engine +rules; every prompt has the inventory's current default. File-writing +side effects byte-match current templates (template files are data, +not rendering — they stay byte-asserted). The auth-linking step uses +`needs`-free auth probing like `auth whoami` (state read, no forced +login) + a sign-in nextAction when unauthenticated (R-S2b-2 spirit; +enumerate divergence from any legacy auto-login). + +R-S2d-2 **`version`**: the engine's `--version` surface already +exists; the `version` COMMAND ports as a result command presenting +the inventory's current fields (version, node, platform) with a json +serializer. Trivial but user-visible; test matrix applies. + +R-S2d-3 **The bin cutover**: `packages/cli/package.json` `bin` +(`prisma-cli`) points at the v8 entry; the tsdown build bundles the +v8 tree; the `prisma-v8` working name and root script are deleted. +The config-loader plain-Node constraint (S1 deferral) must be +resolved HERE: the shipped bin cannot require tsx. Resolution is +pinned: the loader gains the jiti-style evaluation the S3 plan +expected — STOP: that is not pinned anywhere. Morning-questions +ledger Q4 records the decision needed (config evaluation strategy for +the published bin: jiti, esbuild-register, or native TS supported +runtimes only). DO NOT start D-dispatches for R-S2d-3 until Q4 is +ruled; everything else in S2d can proceed. + +R-S2d-4 **Deletions** (after all ports green; single dedicated +commit series): the commander shell (`src/cli.ts` program wiring, +`src/shell/*` minus the modules S2a relocated), fixture machinery +(`src/adapters/mock-api.ts`, `src/use-cases/**`, fixture providers, +`isRealMode` branches — the inventory's "what dies" list is the +deletion checklist), all remaining fixture-mode tests, the +`PRISMA_CLI_MOCK_FIXTURE_PATH` env surface, and `--trace`. Legacy +presenters/controllers survive ONLY where S2b/S2c handlers still call +them as operation layers (enumerate survivors in the PR). + +R-S2d-5 **Grammar completeness check**: a build-time test asserts the +mounted tree equals the S2 target grammar exactly (every inventory +command minus ruled removals plus ruled renames; `service run` per +Q2's outcome). This is the platform slice of the S7 grammar check. + +R-S2d-6 **Final parity review**: the cumulative S2 divergence list +(S2a+S2b+S2c+S2d) is consolidated into one document for operator +sign-off: `../assets/s2/parity-divergences.md`. + +## Out of scope + +Composer (S3), ORM (S5), publish pipeline (S7), auto-login +reinstatement (Q1 unless ruled meanwhile). + +## Acceptance + +- [ ] `init` wizard green on the full prompt matrix (interactive, + `--yes`, non-interactive, cancel) with byte-asserted templates. +- [ ] Bin cutover complete per Q4's ruling; `prisma-cli` runs the + engine shell from a packed tarball on plain Node. +- [ ] Commander shell + fixture machinery deleted per R-S2d-4's + checklist; survivor list enumerated. +- [ ] Grammar completeness test green. +- [ ] Consolidated divergence document reviewed by the operator. +- [ ] Root verification green; PR ≥1k LOC; review loop run; S2 slice + closed in the project plan. From f62556f0ac74c8ba4812e920c96d1da815875a89 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 01:43:48 +0200 Subject: [PATCH 05/67] =?UTF-8?q?feat(cli-engine):=20ctx.api=20=E2=80=94?= =?UTF-8?q?=20the=20management=20API=20client=20on=20the=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract s2a-foundations §2 (dispatch D3). - @prisma/management-api-sdk pinned exact (1.55.0) in the engine and the cli - Runtime gains managementApi.baseUrl; the v8 bin derives it via getApiBaseUrl(env) - CommandContext.api: lazy, once per run, constructed on first access by copying the shell's createManagementApiSdk call site with the token source backed by ctx.getCredentials, so refresh is picked up per request; the client is proxied to rethrow structured errors the SDK's onError middleware would otherwise wrap in FetchError - Unauthenticated use throws CLI.CREDENTIALS_REQUIRED via the single constructor now exported from execution/needs.ts - Harness spec gains managementApi { baseUrl?, client? }; an injected client IS ctx.api; baseUrl defaults to https://test.invalid - Draft amendments: §4 CommandContext, §10 Runtime, §11 harness spec Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/engine-interface-draft.ts | 21 +++ packages/cli-engine/package.json | 1 + packages/cli-engine/src/context.ts | 10 ++ .../cli-engine/src/execution/api-client.ts | 81 +++++++++ .../src/execution/command-context.ts | 9 + packages/cli-engine/src/execution/engine.ts | 8 + packages/cli-engine/src/execution/needs.ts | 32 ++-- packages/cli-engine/src/exports/index.ts | 1 + packages/cli-engine/src/management-api.ts | 7 + packages/cli-engine/src/runtime.ts | 2 + packages/cli-engine/src/testing.ts | 14 ++ packages/cli-engine/tests/config.test.ts | 1 + packages/cli-engine/tests/engine.type-test.ts | 2 + packages/cli-engine/tests/execution.test.ts | 3 + packages/cli-engine/tests/lifetimes.test.ts | 3 + .../cli-engine/tests/management-api.test.ts | 161 ++++++++++++++++++ packages/cli-engine/tests/prompts.test.ts | 1 + packages/cli/package.json | 2 +- packages/cli/src/v8/runtime.ts | 3 +- packages/cli/tests/v8-bin.test.ts | 11 ++ pnpm-lock.yaml | 5 +- 21 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 packages/cli-engine/src/execution/api-client.ts create mode 100644 packages/cli-engine/src/management-api.ts create mode 100644 packages/cli-engine/tests/management-api.test.ts diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index fbd24c20..24335c08 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -373,6 +373,14 @@ export interface CommandContext Promise + /** The Management API client. Constructed lazily on first access, + * once per run, with its token source backed by ctx.getCredentials + * so refresh during long runs is picked up per request. A request + * made while getCredentials() resolves undefined throws the + * structured CLI.CREDENTIALS_REQUIRED error (the same constructor + * the needs.credentials check uses). */ + readonly api: ManagementApiClient + /** The one way to emit while running (§1). */ readonly report: (event: EngineEvent) => void @@ -412,6 +420,10 @@ export interface Credentials { readonly token: string } +/** The SDK's typed client, re-exported by the engine so consumers + * never import @prisma/management-api-sdk directly. */ +export type ManagementApiClient = import('@prisma/management-api-sdk').ManagementApiClient + /** * §4a Prompts (operator ruling, 2026-08-09: prompts return their answer * value, or throw). Every prompt resolves to its answered value @@ -939,6 +951,9 @@ export interface Runtime { * the unified loader (R10). Tests hand in fixtures. */ readonly config: LoadedConfig readonly getCredentials: () => Promise + /** Management API endpoint config; the bin derives baseUrl from env + * (getApiBaseUrl). */ + readonly managementApi: { readonly baseUrl: string } /** Used by the ENGINE to phrase install commands (handlers never * do — see needs.dependencies and ctx.requireDependency). */ readonly packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' @@ -988,6 +1003,12 @@ export declare function createTestCli(spec: { readonly groups?: Readonly> readonly config?: Readonly> readonly credentials?: Credentials + /** baseUrl defaults to "https://test.invalid"; when `client` is + * supplied, ctx.api IS that object (the uniform mock seam). */ + readonly managementApi?: { + readonly baseUrl?: string + readonly client?: ManagementApiClient + } readonly packageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' /** Fixed clock for deterministic stream timestamps. */ readonly now?: () => Date diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 621667ef..59abf7ad 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -46,6 +46,7 @@ "test": "pnpm run build && pnpm run typecheck && vitest run" }, "dependencies": { + "@prisma/management-api-sdk": "1.55.0", "@stricli/core": "1.3.0" }, "devDependencies": { diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 62e823b2..e10584d4 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -1,4 +1,5 @@ import type { EngineEvent } from "./events"; +import type { ManagementApiClient } from "./management-api"; import type { Outcome, Presentations, PresentedResult } from "./presentation"; import type { CliStructuredError, Result } from "./protocol"; @@ -35,6 +36,15 @@ export interface CommandContext< */ readonly getCredentials: () => Promise; + /** + * The Management API client. Constructed lazily on first access, once + * per run, with its token source backed by ctx.getCredentials so + * refresh during long runs is picked up per request. A request made + * while getCredentials() resolves undefined throws the structured + * CLI.CREDENTIALS_REQUIRED error. + */ + readonly api: ManagementApiClient; + /** The one way to emit while running. */ readonly report: (event: EngineEvent) => void; diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts new file mode 100644 index 00000000..0e71ed91 --- /dev/null +++ b/packages/cli-engine/src/execution/api-client.ts @@ -0,0 +1,81 @@ +import { createManagementApiSdk } from "@prisma/management-api-sdk"; +import type { ManagementApiClient } from "../management-api"; +import { CliStructuredError } from "../protocol"; +import type { Invocation } from "./engine"; +import { credentialsRequiredError } from "./needs"; + +/* Copied from the shell's createManagementApiSdk call site + * (packages/cli/src/auth/workspaces.ts). The OAuth fields are inert + * here: the token source is ctx.getCredentials and never carries a + * refresh token, so the SDK's own OAuth flow can never run. */ +const CLIENT_ID = "cmm3lndn701oo0uefvxzo0ivw"; +const REDIRECT_URI = "http://localhost:0/auth/callback"; + +export type CreateManagementApiSdk = typeof createManagementApiSdk; + +/** ctx.api construction: the SDK reads tokens per request, so a token + * refreshed mid-run by the credentials source is picked up on the next + * request. A request while getCredentials() resolves undefined throws + * CLI.CREDENTIALS_REQUIRED. */ +export function buildManagementApiClient( + invocation: Invocation, +): ManagementApiClient { + const createSdk = + invocation.hooks.managementApi?.createSdk ?? createManagementApiSdk; + const sdk = createSdk({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + tokenStorage: { + getTokens: async () => { + const credentials = await invocation.runtime.getCredentials(); + if (credentials === undefined) { + throw credentialsRequiredError(); + } + return { workspaceId: "", accessToken: credentials.token }; + }, + setTokens: async () => {}, + clearTokens: async () => {}, + }, + apiBaseUrl: invocation.runtime.managementApi.baseUrl, + }); + return restoreStructuredThrows(sdk.client); +} + +/** The SDK wraps every request failure in its FetchError; a structured + * error raised inside the request pipeline (the unauthenticated + * CLI.CREDENTIALS_REQUIRED throw) is rethrown unwrapped so it settles + * as itself. */ +function restoreStructuredThrows( + client: ManagementApiClient, +): ManagementApiClient { + return new Proxy(client, { + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + if (typeof value !== "function") { + return value; + } + return (...args: unknown[]): unknown => { + const result: unknown = Reflect.apply(value, target, args); + if (result instanceof Promise) { + return result.catch((cause: unknown) => { + throw structuredCause(cause) ?? cause; + }); + } + return result; + }; + }, + }); +} + +function structuredCause(error: unknown): CliStructuredError | undefined { + for ( + let current: unknown = error; + current instanceof Error; + current = current.cause + ) { + if (CliStructuredError.is(current)) { + return current; + } + } + return undefined; +} diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 32766354..31a9fd44 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -1,4 +1,5 @@ import type { CommandContext, Credentials } from "../context"; +import type { ManagementApiClient } from "../management-api"; import { PRESENTED, type Presentations, @@ -6,6 +7,7 @@ import { type Ui, } from "../presentation"; import { type Diagnostic, notOk, okVoid } from "../protocol"; +import { buildManagementApiClient } from "./api-client"; import type { Invocation, RunState } from "./engine"; import { dependencyResolvable, missingDependencyError } from "./needs"; import { makePromptSurface } from "./prompts"; @@ -81,11 +83,18 @@ export function makeContext( presentation: materializePresentation(state, ui, presentations), }); }; + let api: ManagementApiClient | undefined; return { config, present: present as CommandContext["present"], getCredentials: (): Promise => invocation.runtime.getCredentials(), + get api(): ManagementApiClient { + api ??= + invocation.hooks.managementApi?.client ?? + buildManagementApiClient(invocation); + return api; + }, report: (event) => reportEvent(invocation, event), prompt: makePromptSurface(invocation), signal: invocation.signal, diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 098ec960..227dac9a 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -8,9 +8,11 @@ import type { CommandFamily, MountedTree } from "../command-family"; import type { AnyCommand } from "../commands"; import type { CommandContext } from "../context"; import type { EngineEvent, Severity, StreamEvent } from "../events"; +import type { ManagementApiClient } from "../management-api"; import type { Format, PresentedResult } from "../presentation"; import type { CliStructuredError, Result } from "../protocol"; import type { InputStream, Runtime } from "../runtime"; +import type { CreateManagementApiSdk } from "./api-client"; import { makeContext } from "./command-context"; import { buildCommandTree, type CommandTreeEntry } from "./command-tree"; import { checkNeeds, type NeedsOutcome } from "./needs"; @@ -50,6 +52,12 @@ export interface RunHooks { readonly onPresented?: (presented: PresentedResult) => void; readonly onStreamEvent?: (frame: StreamEvent) => void; readonly answers?: ReadonlyArray; + /** Test seams: an injected `client` becomes ctx.api verbatim; an + * injected `createSdk` replaces the SDK factory. */ + readonly managementApi?: { + readonly client?: ManagementApiClient; + readonly createSdk?: CreateManagementApiSdk; + }; } export interface Engine { diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 7f65ad66..0bea96ff 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -131,24 +131,28 @@ async function checkCredentials( ); } if (credentials === undefined) { - return needsErrored( - new CliStructuredError( - "CLI.CREDENTIALS_REQUIRED", - "You must be signed in to run this command.", - { - nextActions: [ - { - kind: "user-choice", - label: "Sign in, then run the command again.", - }, - ], - }, - ), - ); + return needsErrored(credentialsRequiredError()); } return undefined; } +/** The single source of the sign-in error: raised by the needs check + * and by an unauthenticated ctx.api request. */ +export function credentialsRequiredError(): CliStructuredError { + return new CliStructuredError( + "CLI.CREDENTIALS_REQUIRED", + "You must be signed in to run this command.", + { + nextActions: [ + { + kind: "user-choice", + label: "Sign in, then run the command again.", + }, + ], + }, + ); +} + function checkConfiguration( section: ConfigSection, invocation: Invocation, diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 3415e78f..078454e3 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -58,6 +58,7 @@ export type { StreamEvent, StreamMeta, } from "../events"; +export type { ManagementApiClient } from "../management-api"; export { type Block, type Format, diff --git a/packages/cli-engine/src/management-api.ts b/packages/cli-engine/src/management-api.ts new file mode 100644 index 00000000..1c755249 --- /dev/null +++ b/packages/cli-engine/src/management-api.ts @@ -0,0 +1,7 @@ +import type { ManagementApiClient as SdkClient } from "@prisma/management-api-sdk"; + +/** + * The SDK's typed client, re-exported so consumers never import + * @prisma/management-api-sdk directly. + */ +export type ManagementApiClient = SdkClient; diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index 81c5baec..3efa53ca 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -45,6 +45,8 @@ export interface Runtime { */ readonly config: LoadedConfig; readonly getCredentials: () => Promise; + /** Management API endpoint config; the bin derives baseUrl from env. */ + readonly managementApi: { readonly baseUrl: string }; /** * Used by the ENGINE to phrase install commands (handlers never do — * see needs.dependencies and ctx.requireDependency). diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index 7b0b5cc1..cc5b2b75 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -2,6 +2,7 @@ import type { CommandFamily, MountedTree } from "./command-family"; import type { Credentials } from "./context"; import type { EngineEvent, StreamEvent } from "./events"; import { buildEngine } from "./execution/engine"; +import type { ManagementApiClient } from "./management-api"; import type { PresentedResult } from "./presentation"; import type { Runtime } from "./runtime"; @@ -67,6 +68,12 @@ export function createTestCli(spec: { readonly groups?: Readonly>; readonly config?: Readonly>; readonly credentials?: Credentials; + /** baseUrl defaults to "https://test.invalid"; when `client` is + * supplied, ctx.api IS that object. */ + readonly managementApi?: { + readonly baseUrl?: string; + readonly client?: ManagementApiClient; + }; readonly packageManager?: "npm" | "pnpm" | "yarn" | "bun" | "unknown"; /** Fixed clock for deterministic stream timestamps. */ readonly now?: () => Date; @@ -127,6 +134,9 @@ export function createTestCli(spec: { }, config: { sections: spec.config ?? {}, diagnostics: [] }, getCredentials: async () => spec.credentials, + managementApi: { + baseUrl: spec.managementApi?.baseUrl ?? "https://test.invalid", + }, packageManager: spec.packageManager ?? "unknown", }; const running = engine.execute(argv, runtime, { @@ -141,6 +151,10 @@ export function createTestCli(spec: { frames.push(frame); }, answers: opts?.answers, + managementApi: + spec.managementApi?.client === undefined + ? undefined + : { client: spec.managementApi.client }, }); const abort = opts?.abort; if (abort !== undefined) { diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 8500f5fc..07fc73d9 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -341,6 +341,7 @@ describe("needs.config", () => { onSignal: () => () => {}, config, getCredentials: async () => undefined, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; return { runtime, stdout: () => stdoutText }; diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index d0ec0f4d..e5bb5fc0 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -308,6 +308,7 @@ export const createTestCliSpec: Parameters[0] = { commands: tree, config: { check: { strict: true } }, credentials: { token: "t" }, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "pnpm", now: () => new Date(0), }; @@ -385,5 +386,6 @@ export const runtimeShape: Runtime = { onSignal: () => () => {}, config: loadedConfig, getCredentials: async () => undefined, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "pnpm", }; diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index 65b4a6f5..9e522832 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -551,6 +551,7 @@ describe("needs preconditions", () => { ], }, getCredentials: async () => opts.credentials, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; const exitCode = await cli.run(["demanding", "--format", "human"], runtime); @@ -715,6 +716,7 @@ describe("report() after the handler resolved", () => { onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, getCredentials: async () => undefined, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; const exitCode = await cli.run(["leaky", "--format", "human"], runtime); @@ -767,6 +769,7 @@ describe("credentials that cannot be read", () => { getCredentials: async () => { throw new Error("token file corrupt: unexpected end of JSON input"); }, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; const exitCode = await cli.run(["locked"], runtime); diff --git a/packages/cli-engine/tests/lifetimes.test.ts b/packages/cli-engine/tests/lifetimes.test.ts index e70249ae..07541c80 100644 --- a/packages/cli-engine/tests/lifetimes.test.ts +++ b/packages/cli-engine/tests/lifetimes.test.ts @@ -185,6 +185,7 @@ describe("the engine owns the double-signal policy", () => { }, config: { sections: {}, diagnostics: [] }, getCredentials: async () => undefined, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; return { @@ -334,6 +335,7 @@ describe("optional dependencies", () => { }); const cli = createTestCli({ commands: { command }, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "pnpm", now: EPOCH, }); @@ -375,6 +377,7 @@ describe("optional dependencies", () => { }); const cli = createTestCli({ commands: { command }, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "npm", now: EPOCH, }); diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts new file mode 100644 index 00000000..2f4063ca --- /dev/null +++ b/packages/cli-engine/tests/management-api.test.ts @@ -0,0 +1,161 @@ +/** + * ctx.api: injected fakes, lazy construction, the unauthenticated + * throw path, and per-request credential pickup. + */ +import { + defineCommand, + type ManagementApiClient, + type Runtime, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; +import type { AnyCommand } from "../src/commands"; +import { buildEngine, type RunHooks } from "../src/execution/engine"; + +function makeRuntime(overrides?: { + readonly getCredentials?: Runtime["getCredentials"]; +}): Runtime & { readonly stderrText: () => string } { + let stderrText = ""; + return { + stdout: { write: () => {} }, + stderr: { + write: (text) => { + stderrText += text; + }, + }, + stdin: { + async *[Symbol.asyncIterator]() {}, + }, + cwd: "/", + env: {}, + isTty: { stdin: false, stdout: false, stderr: false }, + exit: (code: number): never => { + throw new Error(`runtime.exit(${code})`); + }, + onSignal: () => () => {}, + config: { sections: {}, diagnostics: [] }, + getCredentials: overrides?.getCredentials ?? (async () => undefined), + managementApi: { baseUrl: "https://test.invalid" }, + packageManager: "unknown", + stderrText: () => stderrText, + }; +} + +async function runEngine( + command: AnyCommand, + runtime: Runtime, + hooks: RunHooks, +): Promise { + const engine = buildEngine({ + name: "t", + version: "0.0.0", + commandFamilies: [], + groups: {}, + commands: { toy: command }, + }); + return engine.execute(["toy"], runtime, hooks); +} + +const succeed = ( + body?: (ctx: { readonly api: ManagementApiClient }) => Promise, +) => + defineCommand({ + help: { summary: "toy" }, + handler: async (_args, ctx) => { + await body?.(ctx); + return ok(ctx.present({ data: null }, { human: () => [] })); + }, + }); + +describe("ctx.api", () => { + test("the harness client override IS ctx.api", async () => { + const fake = { GET: async () => ({}) } as unknown as ManagementApiClient; + let seen: ManagementApiClient | undefined; + const cli = createTestCli({ + commands: { + toy: succeed(async (ctx) => { + seen = ctx.api; + }), + }, + managementApi: { client: fake }, + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(seen).toBe(fake); + }); + + test("no SDK construction when api is untouched", async () => { + const exitCode = await runEngine(succeed(), makeRuntime(), { + managementApi: { + createSdk: () => { + throw new Error( + "the SDK factory ran for a run that never touched ctx.api", + ); + }, + }, + }); + expect(exitCode).toBe(0); + }); + + test("unauthenticated api use throws CLI.CREDENTIALS_REQUIRED and settles errored, exit 2", async () => { + const cli = createTestCli({ + commands: { + toy: succeed(async (ctx) => { + await ctx.api.GET("/v1/workspaces/{id}", { + params: { path: { id: "w1" } }, + }); + }), + }, + }); + const { exitCode, stderr, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + expect(stderr).toBe(""); + const result = json.find((frame) => frame.kind === "result"); + expect(result).toMatchObject({ + envelope: { + ok: false, + error: { + code: "CLI.CREDENTIALS_REQUIRED", + summary: "You must be signed in to run this command.", + }, + }, + }); + }); + + test("constructed once per run; credential refresh is picked up per request", async () => { + const tokens = ["token-1", "token-2"]; + const observed: string[] = []; + let constructions = 0; + const exitCode = await runEngine( + succeed(async (ctx) => { + const client = ctx.api as unknown as { call: () => Promise }; + await client.call(); + await client.call(); + }), + makeRuntime({ + getCredentials: async () => ({ token: tokens.shift() ?? "spent" }), + }), + { + managementApi: { + createSdk: (config) => { + constructions += 1; + const call = async (): Promise => { + const stored = await config.tokenStorage.getTokens(); + observed.push(stored?.accessToken ?? "none"); + }; + return { + client: { call } as unknown as ManagementApiClient, + getLoginUrl: () => Promise.reject(new Error("unused")), + handleCallback: () => Promise.reject(new Error("unused")), + logout: () => Promise.reject(new Error("unused")), + }; + }, + }, + }, + ); + expect(exitCode).toBe(0); + expect(constructions).toBe(1); + expect(observed).toEqual(["token-1", "token-2"]); + }); +}); diff --git a/packages/cli-engine/tests/prompts.test.ts b/packages/cli-engine/tests/prompts.test.ts index 4e3c3e04..49d01ae8 100644 --- a/packages/cli-engine/tests/prompts.test.ts +++ b/packages/cli-engine/tests/prompts.test.ts @@ -494,6 +494,7 @@ describe("stdin cleanup", () => { onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, getCredentials: async () => undefined, + managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; diff --git a/packages/cli/package.json b/packages/cli/package.json index adc5a9cd..1bd91a85 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,7 +48,7 @@ "@prisma/cli-engine": "workspace:*", "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", - "@prisma/management-api-sdk": "^1.55.0", + "@prisma/management-api-sdk": "1.55.0", "better-result": "^2.9.2", "colorette": "^2.0.20", "commander": "^14.0.3", diff --git a/packages/cli/src/v8/runtime.ts b/packages/cli/src/v8/runtime.ts index 2692a18b..2f2877ef 100644 --- a/packages/cli/src/v8/runtime.ts +++ b/packages/cli/src/v8/runtime.ts @@ -4,7 +4,7 @@ import { loadConfig, type Runtime, } from "@prisma/cli-engine"; -import { makeGetCredentials } from "../auth"; +import { getApiBaseUrl, makeGetCredentials } from "../auth"; export type SignalProcess = Pick; @@ -70,6 +70,7 @@ export async function assembleRuntime(proc: HostProcess): Promise { onSignal: makeOnSignal(proc), config: await loadConfig(proc.cwd()), getCredentials: makeGetCredentials(proc.env), + managementApi: { baseUrl: getApiBaseUrl(proc.env) }, packageManager: detectPackageManager(proc.env), }; } diff --git a/packages/cli/tests/v8-bin.test.ts b/packages/cli/tests/v8-bin.test.ts index e5ba1eaa..a6bbd403 100644 --- a/packages/cli/tests/v8-bin.test.ts +++ b/packages/cli/tests/v8-bin.test.ts @@ -171,6 +171,7 @@ describe("assembleRuntime", () => { expect(runtime.isTty).toEqual({ stdin: true, stdout: true, stderr: false }); expect(runtime.packageManager).toBe("pnpm"); expect(runtime.config).toEqual({ sections: {}, diagnostics: [] }); + expect(runtime.managementApi).toEqual({ baseUrl: "https://api.prisma.io" }); runtime.stdout.write("out"); runtime.stderr.write("err"); @@ -178,6 +179,16 @@ describe("assembleRuntime", () => { expect(proc.stderrText).toBe("err"); }); + it("derives managementApi.baseUrl from PRISMA_MANAGEMENT_API_URL", async () => { + const proc = makeProcess({ + env: { PRISMA_MANAGEMENT_API_URL: "https://api.example.test" }, + }); + const runtime = await assembleRuntime(proc); + expect(runtime.managementApi).toEqual({ + baseUrl: "https://api.example.test", + }); + }); + it("proxies exit to process.exit and signals to the process listeners", async () => { const proc = makeProcess(); const runtime = await assembleRuntime(proc); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b4921db..6b8b425f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: specifier: ^7.8.0 version: 7.8.0 '@prisma/management-api-sdk': - specifier: ^1.55.0 + specifier: 1.55.0 version: 1.55.0 better-result: specifier: ^2.9.2 @@ -90,6 +90,9 @@ importers: packages/cli-engine: dependencies: + '@prisma/management-api-sdk': + specifier: 1.55.0 + version: 1.55.0 '@stricli/core': specifier: 1.3.0 version: 1.3.0 From 057f8ff7b2551fc9e8df0003bcf5525ded563b18 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 01:52:18 +0200 Subject: [PATCH 06/67] feat(cli-engine): interactive prompts render through clack on real TTYs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt rendering is now two-tier. Real TTYs — isTty.stdin AND stdin.setRawMode present, no scripted answers — render through @clack/prompts (exact-pinned 1.5.0, loaded by dynamic import only on that path); scripted answers, piped stdin, and the test harness stay on the plain line renderer. --yes resolution and structural failures are decided before the tier branch, so both tiers share identical semantics. clack-renderer.ts adapts Runtime streams for clack: Readable.from over Runtime.stdin presenting isTTY with setRawMode forwarded, a Writable over the stderr OutputStream, and { input, output } injected per prompt, so all prompt UI stays on stderr. Clack's cancel symbol (including the \x03 byte path) maps to the existing CLI.PROMPT_CANCELLED exit-3 settlement. consent maps to clack confirm starting on No: Enter-through returns false, only explicit Yes grants. Clack's spinner/log helpers are never used (process-global handlers); progress remains engine events. Draft notes: §4a two-tier rendering, select's Enter-picks-highlighted behavior, and the accepted quirk that clack reads process.stdout.columns for wrap width. Tests: a fake raw-mode stdin fixture drives confirm/consent/select/ text through the clack tier, asserting resolved values, stderr-only UI bytes, setRawMode forwarding, and \x03 -> exit 3; a module-load spy (with a canary validating the spy) proves the scripted and non-TTY paths never load @clack/prompts. Dist .d.ts stays clack-free. Reimplements spike/clack-prompts (903b25a) on the current module layout. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/engine-interface-draft.ts | 21 ++ packages/cli-engine/package.json | 1 + .../src/execution/clack-renderer.ts | 116 ++++++++ packages/cli-engine/src/execution/prompts.ts | 53 ++++ .../cli-engine/tests/clack-isolation.test.ts | 138 +++++++++ .../cli-engine/tests/clack-prompts.test.ts | 270 ++++++++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 602 insertions(+) create mode 100644 packages/cli-engine/src/execution/clack-renderer.ts create mode 100644 packages/cli-engine/tests/clack-isolation.test.ts create mode 100644 packages/cli-engine/tests/clack-prompts.test.ts diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index 24335c08..787ffc96 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -442,6 +442,21 @@ export type ManagementApiClient = import('@prisma/management-api-sdk').Managemen * interactivity) the same default rule applies; the prompt UI writes to * stderr, so an interactive json run prompts without touching the * stdout stream. + * + * Rendering is two-tier (S2a D4). A plain line renderer serves + * scripted answers and any stdin that cannot enter raw mode (piped + * stdin, the test harness); real TTYs — Runtime.isTty.stdin AND + * Runtime.stdin.setRawMode present, no scripted answers — render + * through @clack/prompts, loaded by dynamic import only on that path. + * Both tiers write prompt UI to stderr and share the same structural + * rules: --yes resolution and structural failures are decided before + * the tier branch. Clack's cancel symbol (including the \x03 byte + * path) maps to the same CLI.PROMPT_CANCELLED exit-3 settlement. + * Clack's spinner/log helpers are forbidden (process-global handlers); + * progress remains engine events. + * + * Accepted quirk: clack reads process.stdout.columns for wrap width — + * the one process-global read on the interactive path. */ export interface PromptSurface { readonly confirm: ( @@ -456,6 +471,12 @@ export interface PromptSurface { * flag that grants consent non-interactively. */ readonly consent: (question: string) => Promise + /** + * On the clack tier, Enter picks the HIGHLIGHTED option — the + * declared default when present, else the first option — so moving + * the highlight and pressing Enter selects the highlighted value, + * not the declared default. + */ readonly select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 59abf7ad..0691e546 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -46,6 +46,7 @@ "test": "pnpm run build && pnpm run typecheck && vitest run" }, "dependencies": { + "@clack/prompts": "1.5.0", "@prisma/management-api-sdk": "1.55.0", "@stricli/core": "1.3.0" }, diff --git a/packages/cli-engine/src/execution/clack-renderer.ts b/packages/cli-engine/src/execution/clack-renderer.ts new file mode 100644 index 00000000..079d48a6 --- /dev/null +++ b/packages/cli-engine/src/execution/clack-renderer.ts @@ -0,0 +1,116 @@ +/** + * Interactive prompt rendering backed by @clack/prompts, driven + * entirely by Runtime streams. Selected only when the runtime's stdin + * can enter raw mode (a real TTY); scripted answers, piped stdin, and + * the test harness stay on the plain line renderer (prompts.ts owns the + * branch). @clack/prompts is loaded by dynamic import here and nowhere + * else, so the module never touches non-interactive runs. Clack's + * spinner/log helpers install process-global handlers and must never be + * used: progress remains engine events. + * + * Accepted quirk: clack reads process.stdout.columns for wrap width — + * the one process-global read on the interactive path. + */ +import { Readable, Writable } from "node:stream"; + +import type { InputStream, OutputStream, Runtime } from "../runtime"; + +export function clackCapable(runtime: Runtime): boolean { + return runtime.isTty.stdin && runtime.stdin.setRawMode !== undefined; +} + +/** Presents the runtime's stdin to clack as a raw-mode-capable TTY. */ +function toReadable(stdin: InputStream): Readable { + const readable = Readable.from(stdin, { objectMode: false }); + Object.assign(readable, { + isTTY: true, + setRawMode: (enabled: boolean) => { + stdin.setRawMode?.(enabled); + return readable; + }, + }); + return readable; +} + +function toWritable(out: OutputStream): Writable { + return new Writable({ + write(chunk: Buffer | string, _encoding, callback) { + out.write(typeof chunk === "string" ? chunk : chunk.toString("utf8")); + callback(); + }, + }); +} + +/** + * Cancellation surfaces as clack's cancel symbol (isCancel), which + * covers the \x03 byte path; prompts.ts maps it to CLI.PROMPT_CANCELLED. + */ +export interface ClackRenderer { + confirm( + question: string, + initial: boolean | undefined, + ): Promise; + /** Starts on No: Enter-through returns false; only explicit Yes grants. */ + consent(question: string): Promise; + select( + question: string, + options: ReadonlyArray<{ value: T; label: string }>, + initial: T | undefined, + ): Promise; + text( + question: string, + placeholder: string | undefined, + fallback: string | undefined, + ): Promise; + isCancel(value: unknown): boolean; +} + +export async function makeClackRenderer( + stdin: InputStream, + stderr: OutputStream, +): Promise { + const clack = await import("@clack/prompts"); + const input = toReadable(stdin); + const output = toWritable(stderr); + + return { + confirm: (question, initial) => + clack.confirm({ + input, + output, + message: question, + initialValue: initial ?? false, + }), + consent: (question) => + clack.confirm({ + input, + output, + message: question, + initialValue: false, + }), + select: ( + question: string, + options: ReadonlyArray<{ value: T; label: string }>, + initial: T | undefined, + ) => + clack.select({ + input, + output, + message: question, + options: options.map((option) => ({ + value: option.value, + label: option.label, + })) as Parameters>[0]["options"], + initialValue: initial, + }), + text: (question, placeholder, fallback) => + clack.text({ + input, + output, + message: question, + placeholder, + defaultValue: fallback, + }), + isCancel: (value) => clack.isCancel(value), + }; +} diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index f163e829..d683c65c 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -7,10 +7,21 @@ * errored envelope, exit 2). consent is structurally undefaultable and * always halts in those contexts. Cancellation (EOF at the prompt) is a * distinct structured error mapped to exit 3. + * + * Rendering is two-tier: real TTYs (isTty.stdin AND stdin.setRawMode + * present, no scripted answers) render through @clack/prompts via + * clack-renderer.ts; everything else uses the plain line renderer + * below. --yes resolution and structural failures are decided before + * the tier branch, so both tiers share identical semantics. */ import type { PromptSurface } from "../context"; import { CliStructuredError } from "../protocol"; import type { InputStream } from "../runtime"; +import { + type ClackRenderer, + clackCapable, + makeClackRenderer, +} from "./clack-renderer"; import type { Invocation, RunState } from "./engine"; function makeLineReader( @@ -136,6 +147,31 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { const { runtime, hooks, state } = invocation; let readLine: (() => Promise) | undefined; let answerCursor = 0; + let renderer: Promise | undefined; + + const useClack = (): boolean => + hooks.answers === undefined && clackCapable(runtime); + + const renderWithClack = async ( + question: string, + run: (r: ClackRenderer) => Promise, + ): Promise => { + renderer ??= (() => { + const iterator = runtime.stdin[Symbol.asyncIterator](); + invocation.state.stdinIterator = iterator; + const stdin: InputStream = { + [Symbol.asyncIterator]: () => iterator, + setRawMode: (enabled) => runtime.stdin.setRawMode?.(enabled), + }; + return makeClackRenderer(stdin, runtime.stderr); + })(); + const r = await renderer; + const value = await run(r); + if (r.isCancel(value)) { + throw promptCancelled(question); + } + return value as T; + }; const ask = async ( question: string, @@ -170,6 +206,9 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { } return fallback; } + if (useClack()) { + return renderWithClack(question, (r) => r.confirm(question, fallback)); + } let hint = "(y/n)"; if (fallback === true) { hint = "(Y/n)"; @@ -183,6 +222,9 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { if (state.yes || !state.interactive) { throw consentUnavailable(question, state); } + if (useClack()) { + return renderWithClack(question, (r) => r.consent(question)); + } const raw = await ask(question, `? ${question} (y/n) `); return isExplicitYes(raw); }, @@ -194,6 +236,11 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { } return fallback; } + if (useClack()) { + return renderWithClack(question, (r) => + r.select(question, options, fallback), + ); + } const rendered = [ `? ${question}`, ...options.map( @@ -227,6 +274,12 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { } return fallback; } + if (useClack()) { + const value = await renderWithClack(question, (r) => + r.text(question, opts?.placeholder, fallback), + ); + return value === "" ? (fallback ?? "") : value; + } const hint = fallback === undefined ? "" : ` (${fallback})`; const raw = await ask(question, `? ${question}${hint} `); if (typeof raw !== "string") { diff --git a/packages/cli-engine/tests/clack-isolation.test.ts b/packages/cli-engine/tests/clack-isolation.test.ts new file mode 100644 index 00000000..6d88b3f4 --- /dev/null +++ b/packages/cli-engine/tests/clack-isolation.test.ts @@ -0,0 +1,138 @@ +/** + * Proves the scripted and non-TTY prompt paths never load + * @clack/prompts. The module is mocked with a factory that records the + * load and throws, so any run that reaches the dynamic import both + * flips the flag and fails. A clack-capable run validates the spy + * mechanism itself (the canary). + */ +import { + type Block, + createCli, + defineCommand, + type PromptSurface, + type Runtime, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test, vi } from "vitest"; + +const clackLoad = vi.hoisted(() => ({ attempted: false })); + +vi.mock("@clack/prompts", () => { + clackLoad.attempted = true; + throw new Error("@clack/prompts must not load on this path"); +}); + +const EPOCH = () => new Date(0); + +function promptCommand(run: (prompt: PromptSurface) => Promise) { + return defineCommand({ + help: { summary: "Prompt probe" }, + handler: async (_args, ctx) => { + const answer = await run(ctx.prompt); + return ok( + ctx.present( + { data: { answer } }, + { + human: (): readonly Block[] => [ + { kind: "summary", tone: "ok", text: `answer=${answer}` }, + ], + }, + ), + ); + }, + }); +} + +const confirming = (prompt: PromptSurface) => + prompt.confirm("Proceed?", { default: true }); + +describe("scripted and non-TTY paths are clack-free", () => { + test("scripted answers on a TTY use the plain renderer, no clack load", async () => { + const cli = createTestCli({ + commands: { probe: promptCommand(confirming) }, + now: EPOCH, + }); + const result = await cli.run(["probe"], { + isTty: { stdin: true, stdout: true }, + answers: ["n"], + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: false }); + expect(clackLoad.attempted).toBe(false); + }); + + test("a piped TTY-flagged stdin without setRawMode stays plain", async () => { + const cli = createTestCli({ + commands: { probe: promptCommand(confirming) }, + now: EPOCH, + }); + const result = await cli.run(["probe"], { + isTty: { stdin: true, stdout: true }, + stdin: "y\n", + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: true }); + expect(result.stderr).toContain("? Proceed? (Y/n) "); + expect(clackLoad.attempted).toBe(false); + }); + + test("a non-TTY run resolves defaults without any renderer", async () => { + const cli = createTestCli({ + commands: { probe: promptCommand(confirming) }, + now: EPOCH, + }); + const result = await cli.run(["probe", "--json"]); + + expect(result.exitCode).toBe(0); + expect(clackLoad.attempted).toBe(false); + }); + + test("canary: a raw-mode-capable TTY run does reach the clack import", async () => { + let stderr = ""; + const runtime: Runtime = { + stdout: { write: () => {} }, + stderr: { + write: (text) => { + stderr += text; + }, + }, + stdin: { + setRawMode: () => {}, + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: async (): Promise> => ({ + done: true, + value: undefined, + }), + }), + }, + cwd: "/", + env: {}, + isTty: { stdin: true, stdout: true, stderr: true }, + exit: (code: number): never => { + throw new Error(`runtime.exit(${code})`); + }, + onSignal: () => () => {}, + config: { sections: {}, diagnostics: [] }, + getCredentials: async () => undefined, + managementApi: { baseUrl: "https://test.invalid" }, + packageManager: "unknown", + }; + const cli = createCli({ + name: "probe", + version: "0.0.0", + commandFamilies: [], + groups: {}, + commands: { probe: promptCommand(confirming) }, + }); + + const exitCode = await cli.run(["probe"], runtime); + + expect(clackLoad.attempted).toBe(true); + expect(exitCode).toBe(1); + expect(stderr).toContain("CLI.INTERNAL_ERROR"); + }); +}); diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts new file mode 100644 index 00000000..f6ca73cf --- /dev/null +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -0,0 +1,270 @@ +/** + * The clack rendering tier: a raw-mode-capable fake TTY stdin drives + * confirm/consent/select/text through @clack/prompts. Asserts resolved + * values, that every prompt UI byte goes to stderr and none to stdout, + * setRawMode forwarding, and \x03 cancellation mapping to exit 3. + */ +import { + type Block, + createCli, + defineCommand, + type PromptSurface, + type Runtime, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { describe, expect, test } from "vitest"; + +const DOWN = "\x1b[B"; +const ENTER = "\r"; +const CTRL_C = "\x03"; + +function keystrokeStdin(keys: readonly string[]) { + let cursor = 0; + const encoder = new TextEncoder(); + const rawModeCalls: boolean[] = []; + const stdin = { + setRawMode: (enabled: boolean) => { + rawModeCalls.push(enabled); + }, + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise>((resolve) => { + if (cursor >= keys.length) { + return; // a held-open TTY: no more keystrokes, no EOF + } + const value = encoder.encode(keys[cursor]); + cursor += 1; + setTimeout(() => resolve({ done: false, value }), 5); + }), + return: async (): Promise> => ({ + done: true, + value: undefined, + }), + }), + }; + return { stdin, rawModeCalls }; +} + +function promptCli(run: (prompt: PromptSurface) => Promise) { + const probe = defineCommand({ + help: { summary: "Prompt probe" }, + handler: async (_args, ctx) => { + const answer = await run(ctx.prompt); + return ok( + ctx.present( + { data: { answer } }, + { + human: (): readonly Block[] => [ + { + kind: "summary", + tone: "ok", + text: `answer=${JSON.stringify(answer)}`, + }, + ], + }, + ), + ); + }, + }); + return createCli({ + name: "probe", + version: "0.0.0", + commandFamilies: [], + groups: {}, + commands: { probe }, + }); +} + +async function runInteractive( + run: (prompt: PromptSurface) => Promise, + keys: readonly string[], +) { + const { stdin, rawModeCalls } = keystrokeStdin(keys); + let stdout = ""; + let stderr = ""; + const runtime: Runtime = { + stdout: { + write: (text) => { + stdout += text; + }, + }, + stderr: { + write: (text) => { + stderr += text; + }, + }, + stdin, + cwd: "/", + env: {}, + isTty: { stdin: true, stdout: true, stderr: true }, + exit: (code: number): never => { + throw new Error(`runtime.exit(${code})`); + }, + onSignal: () => () => {}, + config: { sections: {}, diagnostics: [] }, + getCredentials: async () => undefined, + managementApi: { baseUrl: "https://test.invalid" }, + packageManager: "unknown", + }; + const exitCode = await promptCli(run).run(["probe"], runtime); + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI stripping + const plainStderr = stderr.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + return { exitCode, stdout, stderr, plainStderr, rawModeCalls }; +} + +function answerIn(plainStderr: string): string | undefined { + const match = plainStderr.match(/answer=(.*)\n/); + return match?.[1]; +} + +describe("the clack tier resolves prompt values", () => { + test("confirm: Enter accepts the declared default", async () => { + const result = await runInteractive( + (prompt) => prompt.confirm("Proceed?", { default: true }), + [ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe("true"); + }); + + test("confirm: 'n' answers false over a true default", async () => { + const result = await runInteractive( + (prompt) => prompt.confirm("Proceed?", { default: true }), + ["n", ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe("false"); + }); + + test("select: Enter picks the highlighted default", async () => { + const result = await runInteractive( + (prompt) => + prompt.select( + "Pick one", + [ + { value: "alpha", label: "First" }, + { value: "beta", label: "Second" }, + { value: "gamma", label: "Third" }, + ], + { default: "beta" }, + ), + [ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe('"beta"'); + }); + + test("select: moving the highlight then Enter picks the highlighted option", async () => { + const result = await runInteractive( + (prompt) => + prompt.select( + "Pick one", + [ + { value: "alpha", label: "First" }, + { value: "beta", label: "Second" }, + { value: "gamma", label: "Third" }, + ], + { default: "beta" }, + ), + [DOWN, ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe('"gamma"'); + }); + + test("text: typed value wins", async () => { + const result = await runInteractive( + (prompt) => prompt.text("Name?", { default: "world" }), + ["m", "o", "o", "n", ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe('"moon"'); + }); + + test("text: Enter accepts the default", async () => { + const result = await runInteractive( + (prompt) => prompt.text("Name?", { default: "world" }), + [ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe('"world"'); + }); + + test("consent: Enter-through stays false", async () => { + const result = await runInteractive( + (prompt) => prompt.consent("Delete everything?"), + [ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe("false"); + }); + + test("consent: an explicit Yes grants", async () => { + const result = await runInteractive( + (prompt) => prompt.consent("Delete everything?"), + ["y", ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe("true"); + }); + + test("a multi-step wizard reuses the one renderer and stdin iterator", async () => { + const result = await runInteractive( + async (prompt) => { + const first = await prompt.confirm("Step one?", { default: true }); + const second = await prompt.select("Step two", [ + { value: "x", label: "Ex" }, + { value: "y", label: "Why" }, + ]); + return `${first}/${second}`; + }, + [ENTER, DOWN, ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe('"true/y"'); + }); +}); + +describe("clack tier channels and raw mode", () => { + test("every prompt UI byte goes to stderr; stdout stays empty", async () => { + const result = await runInteractive( + (prompt) => prompt.confirm("Proceed?", { default: true }), + [ENTER], + ); + + expect(result.stdout).toBe(""); + expect(result.plainStderr).toContain("Proceed?"); + }); + + test("setRawMode is forwarded to the runtime stdin and released", async () => { + const result = await runInteractive( + (prompt) => prompt.confirm("Proceed?", { default: true }), + [ENTER], + ); + + expect(result.rawModeCalls[0]).toBe(true); + expect(result.rawModeCalls[result.rawModeCalls.length - 1]).toBe(false); + }); +}); + +describe("clack tier cancellation", () => { + test("\\x03 during a prompt maps to CLI.PROMPT_CANCELLED, exit 3", async () => { + const result = await runInteractive( + (prompt) => prompt.confirm("Proceed?", { default: true }), + [CTRL_C], + ); + + expect(result.exitCode).toBe(3); + expect(result.stderr).toContain("CLI.PROMPT_CANCELLED"); + expect(result.stdout).toBe(""); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b8b425f..a85cb465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: packages/cli-engine: dependencies: + '@clack/prompts': + specifier: 1.5.0 + version: 1.5.0 '@prisma/management-api-sdk': specifier: 1.55.0 version: 1.55.0 From 80f58963771e8c6429777f90ebfdadcb03286481 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 02:09:36 +0200 Subject: [PATCH 07/67] feat(cli-telemetry): port the ORM CLI telemetry client as @repo/cli-telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports packages/1-framework/3-tooling/cli-telemetry from prisma/prisma into this repo as a private workspace package, per the S2a contract (§6). Preserved unchanged: the shared prisma-next user-config path and format (one installation id with the ORM CLI), gating resolution with the exact env var names and precedence (PRISMA_NEXT_DISABLE_TELEMETRY truthy, DO_NOT_TRACK=1, stored consent, opt-out default-on), the detached fork/IPC/unref sender mechanism with its silence and exit-0 contract, the production endpoint and wire protocol, and the sanitizer's value-free discipline. Adapted for this repo: the Commander snapshot is replaced by the engine shape EngineCommandSnapshot (command path, flag names with value source, positional count — no values, ever) and the sanitizer projects it to the same wire fields; the arktype payload schema is spelled out as a hand-rolled guard with identical semantics (no arktype dependency here); the ORM's @internal/config validator is replaced by a structural extraction of target.targetId and extensions[].id with the same empty-on-invalid outcome; pathe and @internal/utils are replaced by node:path and an inline spread. The DB-backed integration harness is replaced by a local mock HTTP backend driven through the same endpoint override the reference suite used; the production endpoint is never contacted from tests. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- biome.jsonc | 5 +- packages/cli-telemetry/package.json | 42 ++ packages/cli-telemetry/src/endpoint.ts | 37 ++ packages/cli-telemetry/src/enrich.ts | 284 ++++++++++++++ packages/cli-telemetry/src/exports/index.ts | 25 ++ packages/cli-telemetry/src/gating.ts | 71 ++++ packages/cli-telemetry/src/payload.ts | 112 ++++++ packages/cli-telemetry/src/sanitize.ts | 69 ++++ packages/cli-telemetry/src/sender.ts | 80 ++++ packages/cli-telemetry/src/spawn.ts | 147 +++++++ packages/cli-telemetry/src/user-config.ts | 139 +++++++ packages/cli-telemetry/tests/endpoint.test.ts | 42 ++ packages/cli-telemetry/tests/enrich.test.ts | 362 ++++++++++++++++++ packages/cli-telemetry/tests/gating.test.ts | 77 ++++ .../tests/no-spawn-in-tests.test.ts | 70 ++++ packages/cli-telemetry/tests/payload.test.ts | 103 +++++ packages/cli-telemetry/tests/sanitize.test.ts | 123 ++++++ .../tests/sender-integration.test.ts | 271 +++++++++++++ packages/cli-telemetry/tests/spawn.test.ts | 110 ++++++ .../cli-telemetry/tests/user-config.test.ts | 178 +++++++++ packages/cli-telemetry/tsconfig.json | 4 + packages/cli-telemetry/tsdown.config.ts | 13 + packages/cli-telemetry/vitest.config.ts | 14 + pnpm-lock.yaml | 118 ++++++ 24 files changed, 2495 insertions(+), 1 deletion(-) create mode 100644 packages/cli-telemetry/package.json create mode 100644 packages/cli-telemetry/src/endpoint.ts create mode 100644 packages/cli-telemetry/src/enrich.ts create mode 100644 packages/cli-telemetry/src/exports/index.ts create mode 100644 packages/cli-telemetry/src/gating.ts create mode 100644 packages/cli-telemetry/src/payload.ts create mode 100644 packages/cli-telemetry/src/sanitize.ts create mode 100644 packages/cli-telemetry/src/sender.ts create mode 100644 packages/cli-telemetry/src/spawn.ts create mode 100644 packages/cli-telemetry/src/user-config.ts create mode 100644 packages/cli-telemetry/tests/endpoint.test.ts create mode 100644 packages/cli-telemetry/tests/enrich.test.ts create mode 100644 packages/cli-telemetry/tests/gating.test.ts create mode 100644 packages/cli-telemetry/tests/no-spawn-in-tests.test.ts create mode 100644 packages/cli-telemetry/tests/payload.test.ts create mode 100644 packages/cli-telemetry/tests/sanitize.test.ts create mode 100644 packages/cli-telemetry/tests/sender-integration.test.ts create mode 100644 packages/cli-telemetry/tests/spawn.test.ts create mode 100644 packages/cli-telemetry/tests/user-config.test.ts create mode 100644 packages/cli-telemetry/tsconfig.json create mode 100644 packages/cli-telemetry/tsdown.config.ts create mode 100644 packages/cli-telemetry/vitest.config.ts diff --git a/biome.jsonc b/biome.jsonc index 5beb955c..59efeafd 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -56,7 +56,10 @@ "overrides": [ { // The tsdown entrypoints: re-export hubs are their intended shape. - "includes": ["packages/cli-engine/src/exports/**"], + "includes": [ + "packages/cli-engine/src/exports/**", + "packages/cli-telemetry/src/exports/**" + ], "linter": { "rules": { "performance": { diff --git a/packages/cli-telemetry/package.json b/packages/cli-telemetry/package.json new file mode 100644 index 00000000..0786213f --- /dev/null +++ b/packages/cli-telemetry/package.json @@ -0,0 +1,42 @@ +{ + "name": "@repo/cli-telemetry", + "private": true, + "version": "0.0.0", + "description": "CLI telemetry client: detached subprocess sender, gating resolution, user-config store, and the value-free command sanitizer", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./sender": { + "types": "./dist/sender.d.ts", + "import": "./dist/sender.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22.12.0" + }, + "license": "Apache-2.0", + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "test": "pnpm run build && pnpm run typecheck && vitest run" + }, + "dependencies": { + "@vercel/detect-agent": "^1.2.3", + "c12": "^3.3.4" + }, + "devDependencies": { + "@repo/tsconfig": "workspace:*", + "@types/node": "^22.19.19", + "tsdown": "^0.21.10", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + } +} diff --git a/packages/cli-telemetry/src/endpoint.ts b/packages/cli-telemetry/src/endpoint.ts new file mode 100644 index 00000000..f97a4a16 --- /dev/null +++ b/packages/cli-telemetry/src/endpoint.ts @@ -0,0 +1,37 @@ +/** + * Production endpoint pinned to the deployed Prisma Compute backend. + * Compiled as a build-time constant; not user-configurable. + */ +export const TELEMETRY_BACKEND_URL = + "https://cmpbfbsdp09hr3jf7pojjs5qs.ewr.prisma.build"; + +/** + * Path within the backend that accepts telemetry POSTs. + */ +export const TELEMETRY_ENDPOINT_PATH = "/events"; + +/** + * Resolve the full POST URL the sender targets. The + * `PRISMA_NEXT_TELEMETRY_ENDPOINT` env var is an integration-testing + * affordance only — it lets the test suite spin up a mock HTTP server + * on an ephemeral port and point the spawned sender at it. The override + * is intentionally undocumented in user-facing material. + * + * Fail-open: a malformed override (typo in a dev shell, bad CI config) + * silently falls back to the production backend rather than throwing, + * matching the telemetry layer's broader silent-on-failure contract. + */ +export function resolveTelemetryEndpoint( + env: Readonly> = process.env, +): string { + const override = env["PRISMA_NEXT_TELEMETRY_ENDPOINT"]; + const base = + override !== undefined && override.length > 0 + ? override + : TELEMETRY_BACKEND_URL; + try { + return new URL(TELEMETRY_ENDPOINT_PATH, base).toString(); + } catch { + return new URL(TELEMETRY_ENDPOINT_PATH, TELEMETRY_BACKEND_URL).toString(); + } +} diff --git a/packages/cli-telemetry/src/enrich.ts b/packages/cli-telemetry/src/enrich.ts new file mode 100644 index 00000000..570e0f44 --- /dev/null +++ b/packages/cli-telemetry/src/enrich.ts @@ -0,0 +1,284 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { determineAgent } from "@vercel/detect-agent"; +import type { ParentToSenderPayload, TelemetryEvent } from "./payload"; + +/** + * Subset of the user's `prisma-next.config.*` the telemetry event + * surfaces. Loaded inside the detached child via {@link loadProjectConfig} + * — see the design rationale on {@link ParentToSenderPayload} for why + * this side runs c12 instead of the parent CLI. + */ +export interface ProjectConfigFields { + readonly databaseTarget: string | null; + readonly extensions: readonly string[]; +} + +const EMPTY_PROJECT_CONFIG: ProjectConfigFields = { + databaseTarget: null, + extensions: [], +}; + +/** + * Structural extraction of the two telemetry fields from a raw + * `prisma-next.config.*` object: `target.targetId` and the + * `extensions[].id` list. The ORM CLI ran the full canonical + * `@internal/config` validator here; that package lives in + * prisma/prisma and is not consumable from this repo, so the same + * outcome is reproduced structurally — a config whose read fields are + * not the expected shapes yields the empty result, exactly like a + * validator rejection did. + */ +function extractProjectConfig( + config: Record, +): ProjectConfigFields { + const target = config["target"]; + if (target === null || typeof target !== "object") { + return EMPTY_PROJECT_CONFIG; + } + const targetId = (target as Record)["targetId"]; + if (typeof targetId !== "string" || targetId.length === 0) { + return EMPTY_PROJECT_CONFIG; + } + const rawExtensions = config["extensions"] ?? []; + if (!Array.isArray(rawExtensions)) { + return EMPTY_PROJECT_CONFIG; + } + const extensions: string[] = []; + for (const pack of rawExtensions) { + if (pack === null || typeof pack !== "object") { + return EMPTY_PROJECT_CONFIG; + } + const id = (pack as Record)["id"]; + if (typeof id !== "string" || id.length === 0) { + return EMPTY_PROJECT_CONFIG; + } + extensions.push(id); + } + return { databaseTarget: targetId, extensions }; +} + +/** + * Best-effort load of `prisma-next.config.*` from `projectRoot`. + * Returns `{ databaseTarget: null, extensions: [] }` on any failure + * mode — missing config file, c12 throws while evaluating user TS, a + * malformed shape, etc. Telemetry is non-blocking and best-effort; an + * empty result is the only downside of an unloadable or invalid config. + * + * `c12` is imported lazily so the detached sender's cold-start cost is + * paid only when telemetry actually fires, not on every fork even when + * the checks short-circuit before reaching this code path. + */ +export async function loadProjectConfig( + projectRoot: string, +): Promise { + try { + const { loadConfig } = await import("c12"); + const result = await loadConfig>({ + name: "prisma-next", + cwd: projectRoot, + dotenv: false, + rcFile: false, + globalRc: false, + }); + const config = result.config ?? null; + // c12 returns an empty object when no config file exists in the + // search path — distinct from "file existed but parsed to an empty + // object". Either way the extraction below would come back empty, + // so short-circuit. + if (config === null || Object.keys(config).length === 0) { + return EMPTY_PROJECT_CONFIG; + } + return extractProjectConfig(config); + } catch { + return EMPTY_PROJECT_CONFIG; + } +} + +/** + * Versions surface the enrichment cares about. Modelled as a structural + * record with a required `node` field so tests can pass a literal object + * without faking every field of `NodeJS.ProcessVersions` (which adds + * properties between Node versions and includes a long tail the + * enrichment never touches). Both `bun` and `deno` are read on the + * runtime-resolution path; everything else is ignored. + */ +export interface VersionsSnapshot { + readonly node: string; + readonly bun?: string; + readonly deno?: string; +} + +/** + * Snapshot of process-level inputs the enrichment reads. Tests pass an + * explicit snapshot so the enrichment is deterministic per case; the + * sender entry point passes a fresh snapshot from `process`. + */ +export interface EnrichEnvironment { + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly versions: VersionsSnapshot; + /** + * Included because package-manager detection intentionally reads + * environment variables from the same process snapshot as platform/versions. + */ + readonly env: Readonly>; + /** + * Pre-resolved AI coding-agent label, or `null` for a human session. + * Detection lives in `@vercel/detect-agent`, whose `determineAgent()` + * reads the live `process.env` and is async (it probes the filesystem + * for Devin), so it cannot run inside the pure event builder; the + * sender entry resolves it via {@link resolveAgentLabel} and passes + * the label here. Detection runs in the **child** sender process, + * never the parent. Best-effort: false negatives are expected. + */ + readonly agent: string | null; + /** + * Best-effort reader for the project's `package.json`, used only to derive + * the optional `tsVersion` telemetry field. Returning `null` means unknown. + */ + readonly readProjectPackageJson: () => string | null; +} + +/** + * Identify the runtime the sender is running in. Same-runtime as the + * parent is a correctness requirement: the parent forked us via + * `child_process.fork`, which inherits the parent's runtime. Detection + * keys on the runtime-specific version field rather than env vars so a + * spoofed env can't lie about the actual interpreter. + */ +function resolveRuntime(versions: VersionsSnapshot): { + readonly name: "node" | "bun" | "deno"; + readonly version: string; +} { + if (versions.bun !== undefined) { + return { name: "bun", version: versions.bun }; + } + if (versions.deno !== undefined) { + return { name: "deno", version: versions.deno }; + } + return { name: "node", version: versions.node }; +} + +/** + * Parse `npm_config_user_agent` into a `/` token. The + * value, when present, looks like + * `"pnpm/10.27.0 npm/? node/v24.13.0 darwin arm64"` — we take the first + * whitespace-separated token. Any failure → `null`. + */ +export function parsePackageManager( + userAgent: string | undefined, +): string | null { + if (userAgent === undefined) return null; + const first = userAgent.split(/\s+/)[0]; + if (first === undefined || first.length === 0) return null; + if (!first.includes("/")) return null; + return first; +} + +/** + * Read the user's project `package.json` and resolve a TypeScript + * version from `devDependencies.typescript` (preferred) or + * `dependencies.typescript`. Strips a leading `^` or `~` semver + * prefix. Returns `null` on any failure mode — file missing, + * unreadable, malformed JSON, key absent, not a string. + */ +export function readTsVersionFromPackageJson(raw: string | null): string | null { + if (raw === null) return null; + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + return null; + } + const candidate = + pickStringDep(parsed["devDependencies"]) ?? + pickStringDep(parsed["dependencies"]); + if (candidate === null) return null; + return candidate.replace(/^[\^~]/, ""); +} + +function pickStringDep(deps: unknown): string | null { + if (deps === null || typeof deps !== "object" || Array.isArray(deps)) { + return null; + } + const value = (deps as Record)["typescript"]; + return typeof value === "string" ? value : null; +} + +/** + * Build the full backend event from the parent's payload, the + * c12-loaded project-config slice, and the child's per-process + * snapshot. Pure given a `projectConfig` + `EnrichEnvironment`. + */ +export function buildTelemetryEvent( + payload: ParentToSenderPayload, + projectConfig: ProjectConfigFields, + env: EnrichEnvironment, +): TelemetryEvent { + const runtime = resolveRuntime(env.versions); + return { + installationId: payload.installationId, + version: payload.version, + command: payload.command, + flags: payload.flags, + runtimeName: runtime.name, + runtimeVersion: runtime.version, + os: env.platform, + arch: env.arch, + packageManager: parsePackageManager(env.env["npm_config_user_agent"]), + databaseTarget: projectConfig.databaseTarget, + tsVersion: readTsVersionFromPackageJson(env.readProjectPackageJson()), + agent: env.agent, + extensions: projectConfig.extensions, + }; +} + +/** + * Resolve the agent label for the telemetry event via + * `@vercel/detect-agent`, collapsing its discriminated result to the + * event's `string | null` shape. Any detection failure counts as + * "no agent" — telemetry is best-effort and non-blocking. + */ +async function resolveAgentLabel(): Promise { + try { + const result = await determineAgent(); + return result.isAgent ? result.agent.name : null; + } catch { + return null; + } +} + +/** + * Convenience for the sender entry: build the event from the live + * `process` plus a c12 load of `prisma-next.config.*` from + * `payload.projectRoot` plus a real project-package.json reader, + * swallowing any I/O errors in the file read. + * + * The parent's `payload.databaseTarget` (when present) wins over the + * c12-derived value; every other invocation leaves it unset and the + * c12 load supplies the value. + */ +export async function buildTelemetryEventFromProcess( + payload: ParentToSenderPayload, +): Promise { + const loadedConfig = await loadProjectConfig(payload.projectRoot); + const projectConfig: ProjectConfigFields = { + databaseTarget: payload.databaseTarget ?? loadedConfig.databaseTarget, + extensions: loadedConfig.extensions, + }; + return buildTelemetryEvent(payload, projectConfig, { + platform: process.platform, + arch: process.arch, + versions: process.versions, + env: process.env, + agent: await resolveAgentLabel(), + readProjectPackageJson: () => { + try { + return readFileSync(join(payload.projectRoot, "package.json"), "utf-8"); + } catch { + return null; + } + }, + }); +} diff --git a/packages/cli-telemetry/src/exports/index.ts b/packages/cli-telemetry/src/exports/index.ts new file mode 100644 index 00000000..e20c0a40 --- /dev/null +++ b/packages/cli-telemetry/src/exports/index.ts @@ -0,0 +1,25 @@ +export { + resolveTelemetryEndpoint, + TELEMETRY_BACKEND_URL, + TELEMETRY_ENDPOINT_PATH, +} from "../endpoint"; +export type { ProjectConfigFields } from "../enrich"; +export { loadProjectConfig } from "../enrich"; +export type { + GatingDisabledReason, + GatingInputs, + GatingResolution, +} from "../gating"; +export { resolveGating } from "../gating"; +export type { ParentToSenderPayload, TelemetryEvent } from "../payload"; +export type { EngineCommandSnapshot, SanitisedCommand } from "../sanitize"; +export { sanitizeEngineSnapshot } from "../sanitize"; +export type { RunTelemetryInputs, TelemetryRunOutcome } from "../spawn"; +export { runTelemetry, senderModuleUrl } from "../spawn"; +export type { UserConfig } from "../user-config"; +export { + ensureInstallationId, + readUserConfig, + userConfigPath, + writeUserConfig, +} from "../user-config"; diff --git a/packages/cli-telemetry/src/gating.ts b/packages/cli-telemetry/src/gating.ts new file mode 100644 index 00000000..903578d9 --- /dev/null +++ b/packages/cli-telemetry/src/gating.ts @@ -0,0 +1,71 @@ +import type { UserConfig } from "./user-config"; + +/** + * Why telemetry was disabled. Useful for debug-mode logging in the + * parent; never surfaces to users. + */ +export type GatingDisabledReason = "env-override" | "stored-opt-out"; + +export type GatingResolution = + | { readonly enabled: true } + | { readonly enabled: false; readonly reason: GatingDisabledReason }; + +export interface GatingInputs { + /** + * Environment-variable lookups the resolver consults. Tests pass a + * literal record; production passes `process.env`. The two opt-out + * signals are `PRISMA_NEXT_DISABLE_TELEMETRY` (Prisma-specific) and + * `DO_NOT_TRACK` (community convention). + */ + readonly env: Readonly>; + /** Result of `readUserConfig()` — file-missing tolerated as `{}`. */ + readonly config: UserConfig; +} + +/** + * A `PRISMA_NEXT_DISABLE_TELEMETRY` value counts as an opt-out only if + * it parses as a truthy string. The set-but-falsy spellings (`''`, + * `'0'`, `'false'`) are intentionally treated as not-set so a parent + * shell that exports the variable to a benign value doesn't accidentally + * disable telemetry for child processes. + */ +function isTruthyOptOut(raw: string | undefined): boolean { + if (raw === undefined) return false; + const normalised = raw.trim().toLowerCase(); + if (normalised === "") return false; + if (normalised === "0") return false; + if (normalised === "false") return false; + return true; +} + +/** + * Pure-function resolution of the gating decision. Same input → same + * output; no I/O. The caller is responsible for reading the env and the + * user config. + * + * Decision order: + * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or + * `DO_NOT_TRACK=1`) → disabled. The env check runs first, so an + * opt-out env var wins over any stored or unset preference. + * 2. Stored `enableTelemetry === false` → disabled (`stored-opt-out`). + * 3. Stored `enableTelemetry === true` → enabled. + * 4. Stored `enableTelemetry === undefined` (file missing, or field + * not set) → ENABLED. This is the opt-out default: absence of an + * explicit choice means telemetry is on. This branch carries the + * whole opt-out model — do not "fix" it to default-off. + * + * Telemetry is disabled only when an env override is active or + * `enableTelemetry` is explicitly `false`. + */ +export function resolveGating(inputs: GatingInputs): GatingResolution { + if ( + isTruthyOptOut(inputs.env["PRISMA_NEXT_DISABLE_TELEMETRY"]) || + inputs.env["DO_NOT_TRACK"] === "1" + ) { + return { enabled: false, reason: "env-override" }; + } + if (inputs.config.enableTelemetry === false) { + return { enabled: false, reason: "stored-opt-out" }; + } + return { enabled: true }; +} diff --git a/packages/cli-telemetry/src/payload.ts b/packages/cli-telemetry/src/payload.ts new file mode 100644 index 00000000..68640ce6 --- /dev/null +++ b/packages/cli-telemetry/src/payload.ts @@ -0,0 +1,112 @@ +/** + * Wire-shape payload the parent IPC-sends to the forked child sender. + * Mirrors only the fields the parent has naturally in hand at command + * settlement: installation id, sanitised command + flags, CLI version, + * and the project root the child uses to discover everything else. The + * child probes its own process (runtime/os/arch, package manager, ts + * version, agent) and reads the user's `prisma-next.config.*` via + * c12 to derive `databaseTarget` and `extensions`. + * + * Loading c12 on the parent side would put a `loadConfig()` await on + * the command's hot path between gating resolution and `fork()`, + * opening a race against a parent that exits before the await resolves + * (the telemetry event is lost). Moving the load into the detached + * child eliminates that race; the trade is that the child now + * evaluates user config code, so it runs behind the same privacy + * checks the parent already resolved before forking. + * + * `databaseTarget` is an optional parent-side override for the + * c12-derived value, kept for wire compatibility with the ORM CLI's + * first-`init` flow (where the config file does not exist on disk at + * send time). When unset the child's c12 load determines the value — + * there is no third state, so the field's type is `string | undefined`, + * not `string | null | undefined`. + * + * Both sides version-couple on this shape because the IPC carrier is + * structured-cloned by Node and there's no on-wire compat to maintain. + */ +export interface ParentToSenderPayload { + readonly installationId: string; + readonly version: string; + readonly command: string; + readonly flags: readonly string[]; + /** + * Absolute path of the user's project. The child reads + * `/package.json` for `tsVersion` and loads + * `/prisma-next.config.*` via c12 for `databaseTarget` + * + `extensions`. + */ + readonly projectRoot: string; + /** Resolved endpoint URL (already includes the `/events` path). */ + readonly endpoint: string; + /** + * Optional parent-side override for the c12-derived database target. + * The wire-format `TelemetryEvent.databaseTarget: string | null` + * keeps `null` as the on-the-wire "no target known" marker, but the + * IPC override channel only needs two states so it's + * `string | undefined`. + */ + readonly databaseTarget?: string; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isStringArray(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === "string") + ); +} + +/** + * Runtime validation for {@link ParentToSenderPayload}. The child sender + * uses this before `postEvent` so a payload missing a required field + * cannot silently produce a degraded telemetry event downstream. + * + * Same semantics as the ORM CLI's arktype schema (this repo carries no + * arktype dependency, so the checks are spelled out): required scalars + * must be non-empty strings; the optional `databaseTarget` override is + * a `string` when present (no `null` — see the type's doc-block); the + * string array is validated element-by-element. Size caps are enforced + * by the backend, not here — IPC is structured-cloned and the + * parent/child agree on the schema by version-coupling. + */ +export function isParentToSenderPayload( + value: unknown, +): value is ParentToSenderPayload { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + if (!isNonEmptyString(record["installationId"])) return false; + if (!isNonEmptyString(record["version"])) return false; + if (!isNonEmptyString(record["command"])) return false; + if (!isStringArray(record["flags"])) return false; + if (!isNonEmptyString(record["projectRoot"])) return false; + if (!isNonEmptyString(record["endpoint"])) return false; + if ("databaseTarget" in record && typeof record["databaseTarget"] !== "string") { + return false; + } + return true; +} + +/** + * The full event the child POSTs to the backend. Shape matches the + * telemetry backend's schema — identical to the ORM CLI's wire format. + */ +export interface TelemetryEvent { + readonly installationId: string; + readonly version: string; + readonly command: string; + readonly flags: readonly string[]; + readonly runtimeName: string; + readonly runtimeVersion: string; + readonly os: string; + readonly arch: string; + readonly packageManager: string | null; + readonly databaseTarget: string | null; + readonly tsVersion: string | null; + readonly agent: string | null; + readonly extensions: readonly string[]; +} diff --git a/packages/cli-telemetry/src/sanitize.ts b/packages/cli-telemetry/src/sanitize.ts new file mode 100644 index 00000000..e0b4e788 --- /dev/null +++ b/packages/cli-telemetry/src/sanitize.ts @@ -0,0 +1,69 @@ +/** + * Input shape: the engine's parse-time command snapshot. Declared + * structurally here (not imported from `@prisma/cli-engine`) so this + * package carries no engine dependency — the engine declares the + * identical shape and the two stay compatible structurally, the same + * way the ORM CLI's sanitiser declared a thin projection of commander + * instead of importing it. + * + * The snapshot carries NO VALUES, ever: flag names with their value + * source, the command path, and a bare count of positionals. The + * engine builds it from what it knows at parse time; raw argv, flag + * values, and positional values never reach this module. + */ +export interface EngineCommandSnapshot { + /** Mount-path segments of the executed command, e.g. `['telemetry', + * 'status']`. Never includes the binary name. */ + readonly commandPath: readonly string[]; + /** + * One entry per flag the engine knows for the command, in + * declaration order. `source` is where the value came from: only + * `'cli'` (explicitly passed by the user) survives sanitisation. + */ + readonly flags: ReadonlyArray<{ + readonly name: string; + readonly source: "cli" | "env" | "default"; + }>; + /** + * How many positional arguments the run supplied. A count only — + * positional VALUES never leave the engine. Intentionally never + * read by the sanitiser; the field exists so the call site makes it + * obvious positionals were deliberately reduced to a number. + */ + readonly positionalCount: number; +} + +/** + * Output shape: the sanitised projection that flows into the telemetry + * payload. Two fields only — command name (space-delimited subcommand + * path) and flag names (in the snapshot's declaration order). + */ +export interface SanitisedCommand { + readonly command: string; + readonly flags: readonly string[]; +} + +/** + * Project the engine snapshot into the wire-shape command and + * flag-name list. Pure; the only allowed inputs are the fields of + * `EngineCommandSnapshot`. + * + * Sanitiser contract — no flag values, no positionals, no raw argv: + * - The wire ships `telemetry status`, never the binary name (the + * engine's commandPath already excludes it). + * - Emit only flags whose source is `cli` — defaulted and + * env-sourced flags say nothing about what the user typed. + * - Emit the user-facing kebab-case flag spelling the engine + * recorded; nothing is renamed here. + * - `positionalCount` is accepted but never consumed. + */ +export function sanitizeEngineSnapshot( + snapshot: EngineCommandSnapshot, +): SanitisedCommand { + return { + command: snapshot.commandPath.join(" "), + flags: snapshot.flags.flatMap((flag) => + flag.source === "cli" ? [flag.name] : [], + ), + }; +} diff --git a/packages/cli-telemetry/src/sender.ts b/packages/cli-telemetry/src/sender.ts new file mode 100644 index 00000000..126f73e5 --- /dev/null +++ b/packages/cli-telemetry/src/sender.ts @@ -0,0 +1,80 @@ +/** + * Sender script entry — forked into a detached child by the parent CLI via + * `child_process.fork(senderPath, [], { detached: true, ... })`. + * + * Lifecycle: + * 1. Wait for the parent's IPC `message` event carrying a + * `ParentToSenderPayload`. + * 2. Enrich with the local-process probes (runtime, os, arch, agent, + * package manager, tsVersion). + * 3. POST the event to the endpoint URL with a hard 1.5 s timeout. + * 4. Exit 0 unconditionally — successful POST, network failure, server + * error, parse error of the response, anything else: same outcome. + * + * Every error is swallowed; the only escape valve for visibility is + * `PRISMA_NEXT_DEBUG=1`, which routes diagnostics to stderr. In normal + * operation no telemetry-originating output ever reaches the user — the + * parent's stdio map ignores our streams anyway, but stderr writes are + * also held behind the debug flag so the same binary is safe to invoke + * directly outside the spawn flow. + */ +import { buildTelemetryEventFromProcess } from "./enrich"; +import { isParentToSenderPayload, type ParentToSenderPayload } from "./payload"; + +const REQUEST_TIMEOUT_MS = 1500; + +function debugLog(message: string, error?: unknown): void { + if (process.env["PRISMA_NEXT_DEBUG"] !== "1") return; + if (error !== undefined) { + process.stderr.write(`[cli-telemetry] ${message}: ${String(error)}\n`); + } else { + process.stderr.write(`[cli-telemetry] ${message}\n`); + } +} + +async function postEvent(payload: ParentToSenderPayload): Promise { + const event = await buildTelemetryEventFromProcess(payload); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(payload.endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(event), + signal: controller.signal, + }); + debugLog(`sent event: status=${response.status}`); + } catch (err) { + debugLog("send failed", err); + } finally { + clearTimeout(timer); + } +} + +function exitClean(): void { + // `process.disconnect()` lets the parent's `.disconnect()` complete + // without lingering IPC handles when the parent is fast. + try { + process.disconnect?.(); + } catch { + // ignore + } + process.exit(0); +} + +process.once("message", (message: unknown) => { + if (!isParentToSenderPayload(message)) { + debugLog("received malformed payload; exiting"); + exitClean(); + return; + } + postEvent(message) + .catch((err) => debugLog("post threw", err)) + .finally(exitClean); +}); + +// Defensive: if the parent never sends a payload (or the IPC channel +// closes before `message` arrives), exit after a generous grace period +// so the child process is not stuck holding a handle. +const SENDER_IDLE_EXIT_MS = REQUEST_TIMEOUT_MS * 2; +setTimeout(exitClean, SENDER_IDLE_EXIT_MS).unref(); diff --git a/packages/cli-telemetry/src/spawn.ts b/packages/cli-telemetry/src/spawn.ts new file mode 100644 index 00000000..e542d1e8 --- /dev/null +++ b/packages/cli-telemetry/src/spawn.ts @@ -0,0 +1,147 @@ +import { fork } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { resolveTelemetryEndpoint } from "./endpoint"; +import { resolveGating } from "./gating"; +import type { ParentToSenderPayload } from "./payload"; +import { type EngineCommandSnapshot, sanitizeEngineSnapshot } from "./sanitize"; +import { readUserConfig, type UserConfig } from "./user-config"; + +/** + * Inputs the CLI entry point hands the telemetry layer when a run + * settles. The CLI is responsible for stitching the engine's command + * snapshot and the project root together; the telemetry module does no + * I/O of its own except for the user-config read (skipped when + * `userConfig` is provided). `extensions` is deliberately absent: the + * detached child loads `prisma-next.config.*` via c12 itself and + * derives the extension-pack ids from the loaded config — see the + * rationale on `ParentToSenderPayload` for why c12 lives in the child + * rather than on the parent's hot path. + * + * `databaseTarget` is an optional parent-side override forwarded to + * the child, kept for wire compatibility with the ORM CLI's + * init-consent flow; normal invocations leave it unset so the child's + * c12 load supplies the value. + */ +export interface RunTelemetryInputs { + /** The engine's value-free command snapshot — see `EngineCommandSnapshot`. */ + readonly command: EngineCommandSnapshot; + /** This CLI's own version (from its `package.json`). */ + readonly version: string; + /** Absolute path of the project root (typically `process.cwd()`). */ + readonly projectRoot: string; + /** + * Optional parent-side override for the c12-derived database target, + * forwarded verbatim to the child sender. Wins over the child's + * c12-derived value when present; `undefined` means "no override". + */ + readonly databaseTarget?: string; + /** + * Path to the sender entry compiled into this package's `dist/`. + * Resolved by the caller because the compiled sender lives at + * `/dist/sender.js` and only the consumer knows its own + * `import.meta.url`. + */ + readonly senderPath: string; + /** + * `isCI()` result from the consumer. Telemetry is suppressed when + * `true` regardless of the stored consent answer — CI environments + * never emit. + */ + readonly isCI: boolean; + /** Process env to read for opt-out signals. Defaults to `process.env`. */ + readonly env?: Readonly>; + /** Cached user config when the caller already read it to resolve gating before other work. */ + readonly userConfig?: UserConfig; +} + +/** + * Best-effort telemetry spawn at command settlement. Returns + * synchronously — the fork runs in the background and never blocks the + * parent. Every failure mode is swallowed; the parent's stdout/stderr + * is untouched in normal operation, the only escape valve being + * `PRISMA_NEXT_DEBUG=1` which routes diagnostics to stderr. + * + * Returns the spawn outcome so debug-mode logging and the test-harness + * probe (which verifies test runs short-circuit the fork) can inspect + * the decision without scraping stderr. + */ +export type TelemetryRunOutcome = + | { readonly spawned: true } + | { + readonly spawned: false; + readonly reason: "gated-off" | "ci" | "fork-failed"; + }; + +export function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome { + const env = inputs.env ?? process.env; + + if (inputs.isCI) { + return { spawned: false, reason: "ci" }; + } + + const config = inputs.userConfig ?? readUserConfig(); + const gating = resolveGating({ env, config }); + if (!gating.enabled) { + return { spawned: false, reason: "gated-off" }; + } + + const sanitised = sanitizeEngineSnapshot(inputs.command); + // Gating resolved enabled, so installationId should be set: the parent + // fire path mints it before calling runTelemetry on the default-on + // first run, and the consent flow mints it on explicit opt-in. + // Defence-in-depth: a missing id here means a stale/corrupt config, so + // skip rather than send a junk event. + if ( + typeof config.installationId !== "string" || + config.installationId.length === 0 + ) { + return { spawned: false, reason: "gated-off" }; + } + + const payload: ParentToSenderPayload = { + installationId: config.installationId, + version: inputs.version, + command: sanitised.command, + flags: sanitised.flags, + projectRoot: inputs.projectRoot, + endpoint: resolveTelemetryEndpoint(env), + ...(inputs.databaseTarget === undefined + ? {} + : { databaseTarget: inputs.databaseTarget }), + }; + + try { + const child = fork(inputs.senderPath, [], { + detached: true, + stdio: ["pipe", "ignore", "ignore", "ipc"], + }); + child.send(payload, (err) => { + if (err !== null && process.env["PRISMA_NEXT_DEBUG"] === "1") { + process.stderr.write( + `[cli-telemetry] parent send error: ${String(err)}\n`, + ); + } + }); + child.disconnect(); + child.unref(); + return { spawned: true }; + } catch (err) { + if (process.env["PRISMA_NEXT_DEBUG"] === "1") { + process.stderr.write( + `[cli-telemetry] parent fork failed: ${String(err)}\n`, + ); + } + return { spawned: false, reason: "fork-failed" }; + } +} + +/** + * Resolve the path to the compiled sender entry relative to a consumer + * that has captured its own `import.meta.url`. The `tsdown`-emitted + * entry sits at `/sender.js` next to the consumer's own entry; + * the consumer asks `senderModuleUrl()` and forwards the result to + * `runTelemetry({ senderPath })`. + */ +export function senderModuleUrl(importMetaUrl: string): string { + return fileURLToPath(new URL("./sender.js", importMetaUrl)); +} diff --git a/packages/cli-telemetry/src/user-config.ts b/packages/cli-telemetry/src/user-config.ts new file mode 100644 index 00000000..80b9c96b --- /dev/null +++ b/packages/cli-telemetry/src/user-config.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +/** + * The user-level config file. Persists the telemetry flag and the + * installation UUID. Under the opt-out model the flag stays `undefined` + * until the user makes an explicit choice (default-on first run mints + * only the id via {@link ensureInstallationId}), and an env-var opt-out + * never mutates disk. Once the id exists it survives any + * on → off → on cycle, keeping the same UUID (correct for MAU continuity). + * + * Readers tolerate unknown fields for forward compat; writers merge + * partials into the existing object so unknown fields are preserved. + */ +export interface UserConfig { + readonly enableTelemetry?: boolean; + readonly installationId?: string; + readonly [key: string]: unknown; +} + +const APP_DIR = "prisma-next"; +const FILE_NAME = "config.json"; + +/** + * Resolves the user-level config directory: + * - Windows: `%APPDATA%\prisma-next\` (fallback: `%USERPROFILE%\AppData\Roaming\prisma-next\`). + * - Unix (incl. macOS): `$XDG_CONFIG_HOME/prisma-next/` if set, else + * `$HOME/.config/prisma-next/` per the XDG Base Directory Specification. + * + * XDG is chosen over the macOS-native `~/Library/Preferences/` + * convention so the path resolution is test-overridable via + * `XDG_CONFIG_HOME` and matches the documented behaviour on all *nix + * platforms. The `prisma-next` directory name is deliberate: this file + * is SHARED with the ORM CLI (same path, same format), so both CLIs + * read one consent answer and one installation id. + */ +function configDir(): string { + if (process.platform === "win32") { + const appData = process.env["APPDATA"]; + if (appData !== undefined && appData.length > 0) { + return join(appData, APP_DIR); + } + return join(homedir(), "AppData", "Roaming", APP_DIR); + } + const xdg = process.env["XDG_CONFIG_HOME"]; + if (xdg !== undefined && xdg.length > 0) { + return join(xdg, APP_DIR); + } + return join(homedir(), ".config", APP_DIR); +} + +/** + * Path to the user-level config file. Resolved per call so test + * harnesses can mutate `$XDG_CONFIG_HOME` between cases. + */ +export function userConfigPath(): string { + return join(configDir(), FILE_NAME); +} + +/** + * Reads the user-level config. File-missing, unreadable, or malformed → + * `{}` (the absence of consent is the same answer in every error mode). + * Unknown fields from a future client are passed through verbatim. + */ +export function readUserConfig(): UserConfig { + const path = userConfigPath(); + if (!existsSync(path)) return {}; + try { + const raw = readFileSync(path, "utf-8"); + const parsed: unknown = JSON.parse(raw); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as UserConfig; + } + return {}; + } catch { + return {}; + } +} + +/** + * Merges `partial` into the current config and writes the result + * atomically (temp file + rename) so a crash mid-write never leaves a + * half-baked file readable on disk. Unknown fields already on disk are + * preserved. + * + * When `partial.enableTelemetry === true` and no `installationId` is + * stored yet, generates a v4 random UUID and persists both fields in + * the same write. An existing `installationId` is never rotated. This is + * the *explicit-consent* mint path: a `false` answer + * (`writeUserConfig({ enableTelemetry: false })`) writes no id, and a bare + * `writeUserConfig({ installationId })` mints nothing extra. The default-on + * first-send path mints its id separately via {@link ensureInstallationId}, + * which records no consent answer. + */ +export function writeUserConfig(partial: Partial): void { + const current = readUserConfig(); + const merged: Record = { ...current, ...partial }; + if ( + partial.enableTelemetry === true && + merged["installationId"] === undefined + ) { + merged["installationId"] = randomUUID(); + } + const path = userConfigPath(); + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const tmpPath = `${path}.${process.pid}.tmp`; + writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8"); + renameSync(tmpPath, path); +} + +/** + * Returns the stored `installationId`, minting and persisting a fresh v4 + * UUID when none exists yet. Crucially, this persists *only* the id — + * `enableTelemetry` is left untouched (stays `undefined` on a default-on + * first run), so no explicit consent the user never gave is recorded. + * + * Used by the default-on first-run fire path: the gating resolution has + * already come back enabled, so this only ever runs when telemetry is on. + */ +export function ensureInstallationId(): string { + const existing = readUserConfig().installationId; + if (typeof existing === "string" && existing.length > 0) { + return existing; + } + const installationId = randomUUID(); + writeUserConfig({ installationId }); + return installationId; +} diff --git a/packages/cli-telemetry/tests/endpoint.test.ts b/packages/cli-telemetry/tests/endpoint.test.ts new file mode 100644 index 00000000..bb3b9aef --- /dev/null +++ b/packages/cli-telemetry/tests/endpoint.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + resolveTelemetryEndpoint, + TELEMETRY_BACKEND_URL, + TELEMETRY_ENDPOINT_PATH, +} from "../src/endpoint"; + +describe("resolveTelemetryEndpoint", () => { + it("defaults to the production backend + events path when no override is set", () => { + expect(resolveTelemetryEndpoint({})).toBe( + `${TELEMETRY_BACKEND_URL}${TELEMETRY_ENDPOINT_PATH}`, + ); + }); + + it("honours PRISMA_NEXT_TELEMETRY_ENDPOINT when set", () => { + expect( + resolveTelemetryEndpoint({ + PRISMA_NEXT_TELEMETRY_ENDPOINT: "http://127.0.0.1:54321", + }), + ).toBe(`http://127.0.0.1:54321${TELEMETRY_ENDPOINT_PATH}`); + }); + + it("treats an empty PRISMA_NEXT_TELEMETRY_ENDPOINT as unset (falls back to production)", () => { + expect(resolveTelemetryEndpoint({ PRISMA_NEXT_TELEMETRY_ENDPOINT: "" })).toBe( + `${TELEMETRY_BACKEND_URL}${TELEMETRY_ENDPOINT_PATH}`, + ); + }); + + it("preserves a trailing path in the override base (e.g. mock servers using a sub-path)", () => { + expect( + resolveTelemetryEndpoint({ + PRISMA_NEXT_TELEMETRY_ENDPOINT: "http://127.0.0.1:54321/", + }), + ).toBe(`http://127.0.0.1:54321${TELEMETRY_ENDPOINT_PATH}`); + }); + + it("falls back to the production backend without throwing when the override is malformed", () => { + expect( + resolveTelemetryEndpoint({ PRISMA_NEXT_TELEMETRY_ENDPOINT: "invalid-url" }), + ).toBe(`${TELEMETRY_BACKEND_URL}${TELEMETRY_ENDPOINT_PATH}`); + }); +}); diff --git a/packages/cli-telemetry/tests/enrich.test.ts b/packages/cli-telemetry/tests/enrich.test.ts new file mode 100644 index 00000000..8b22f8a6 --- /dev/null +++ b/packages/cli-telemetry/tests/enrich.test.ts @@ -0,0 +1,362 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + buildTelemetryEvent, + buildTelemetryEventFromProcess, + type EnrichEnvironment, + loadProjectConfig, + type ProjectConfigFields, + parsePackageManager, + readTsVersionFromPackageJson, +} from "../src/enrich"; +import type { ParentToSenderPayload } from "../src/payload"; + +const basePayload: ParentToSenderPayload = { + installationId: "install-1", + version: "0.9.0", + command: "migration new", + flags: ["name", "dry-run"], + projectRoot: "/project", + endpoint: "http://localhost/events", +}; + +const baseProjectConfig: ProjectConfigFields = { + databaseTarget: "postgres", + extensions: ["pgvector"], +}; + +const EMPTY_PROJECT_CONFIG: ProjectConfigFields = { + databaseTarget: null, + extensions: [], +}; + +const baseEnv: EnrichEnvironment = { + platform: "darwin", + arch: "arm64", + versions: { node: "24.13.0" }, + env: {}, + agent: null, + readProjectPackageJson: () => null, +}; + +describe("parsePackageManager", () => { + it("extracts the leading / token from npm_config_user_agent", () => { + expect( + parsePackageManager("pnpm/10.27.0 npm/? node/v24.13.0 darwin arm64"), + ).toBe("pnpm/10.27.0"); + }); + + it("handles npm, yarn, and bun ua strings", () => { + expect(parsePackageManager("npm/10.5.0 node/v24.13.0 darwin arm64")).toBe( + "npm/10.5.0", + ); + expect( + parsePackageManager("yarn/4.6.0 npm/? node/v24.13.0 darwin arm64"), + ).toBe("yarn/4.6.0"); + expect(parsePackageManager("bun/1.3.0 node/v24.13.0 darwin arm64")).toBe( + "bun/1.3.0", + ); + }); + + it("returns null for undefined, empty, or malformed values", () => { + expect(parsePackageManager(undefined)).toBeNull(); + expect(parsePackageManager("")).toBeNull(); + expect(parsePackageManager("nopepenope")).toBeNull(); + }); +}); + +describe("readTsVersionFromPackageJson", () => { + it("reads typescript from devDependencies and strips a leading ^", () => { + expect( + readTsVersionFromPackageJson( + JSON.stringify({ devDependencies: { typescript: "^5.9.3" } }), + ), + ).toBe("5.9.3"); + }); + + it("falls back to dependencies when devDependencies is absent", () => { + expect( + readTsVersionFromPackageJson( + JSON.stringify({ dependencies: { typescript: "5.9.3" } }), + ), + ).toBe("5.9.3"); + }); + + it("strips a leading ~ in addition to ^", () => { + expect( + readTsVersionFromPackageJson( + JSON.stringify({ devDependencies: { typescript: "~5.9.0" } }), + ), + ).toBe("5.9.0"); + }); + + it("prefers devDependencies over dependencies when both are present", () => { + expect( + readTsVersionFromPackageJson( + JSON.stringify({ + devDependencies: { typescript: "5.9.0" }, + dependencies: { typescript: "5.0.0" }, + }), + ), + ).toBe("5.9.0"); + }); + + it("returns null on null input (file missing)", () => { + expect(readTsVersionFromPackageJson(null)).toBeNull(); + }); + + it("returns null on malformed JSON", () => { + expect(readTsVersionFromPackageJson("{not-json")).toBeNull(); + }); + + it("returns null when typescript key is absent", () => { + expect( + readTsVersionFromPackageJson(JSON.stringify({ dependencies: { foo: "1.0" } })), + ).toBeNull(); + }); + + it("returns null when typescript is not a string", () => { + expect( + readTsVersionFromPackageJson( + JSON.stringify({ devDependencies: { typescript: 5 } }), + ), + ).toBeNull(); + }); +}); + +describe("buildTelemetryEvent", () => { + it("round-trips the parent payload and overlays child-side probes", () => { + const event = buildTelemetryEvent(basePayload, baseProjectConfig, { + ...baseEnv, + env: { npm_config_user_agent: "pnpm/10.27.0 node/v24.13.0" }, + readProjectPackageJson: () => + JSON.stringify({ devDependencies: { typescript: "^5.9.3" } }), + }); + + expect(event).toEqual({ + installationId: "install-1", + version: "0.9.0", + command: "migration new", + flags: ["name", "dry-run"], + runtimeName: "node", + runtimeVersion: "24.13.0", + os: "darwin", + arch: "arm64", + packageManager: "pnpm/10.27.0", + databaseTarget: "postgres", + tsVersion: "5.9.3", + agent: null, + extensions: ["pgvector"], + }); + }); + + it("detects bun as the runtime when versions.bun is present", () => { + const event = buildTelemetryEvent(basePayload, baseProjectConfig, { + ...baseEnv, + versions: { node: "24.13.0", bun: "1.3.0" }, + }); + expect(event.runtimeName).toBe("bun"); + expect(event.runtimeVersion).toBe("1.3.0"); + }); + + it("detects deno as the runtime when versions.deno is present", () => { + const event = buildTelemetryEvent(basePayload, baseProjectConfig, { + ...baseEnv, + versions: { node: "24.13.0", deno: "2.5.0" }, + }); + expect(event.runtimeName).toBe("deno"); + expect(event.runtimeVersion).toBe("2.5.0"); + }); + + it("passes the pre-resolved agent label through to the event", () => { + const event = buildTelemetryEvent(basePayload, baseProjectConfig, { + ...baseEnv, + agent: "claude", + }); + expect(event.agent).toBe("claude"); + }); + + it("passes null tsVersion when the project package.json read fails", () => { + const event = buildTelemetryEvent(basePayload, baseProjectConfig, { + ...baseEnv, + readProjectPackageJson: () => null, + }); + expect(event.tsVersion).toBeNull(); + }); + + it("passes null packageManager when npm_config_user_agent is absent", () => { + expect( + buildTelemetryEvent(basePayload, baseProjectConfig, baseEnv).packageManager, + ).toBeNull(); + }); + + it("passes the project-config slice straight through (databaseTarget + extensions)", () => { + const event = buildTelemetryEvent( + basePayload, + { databaseTarget: "mongodb", extensions: ["pgvector", "paradedb"] }, + baseEnv, + ); + expect(event.databaseTarget).toBe("mongodb"); + expect(event.extensions).toEqual(["pgvector", "paradedb"]); + }); +}); + +/** + * Build a `prisma-next.config.mjs` source string in the descriptor + * shape the ORM CLI's canonical validator accepts. The structural + * extraction in this repo reads only `target.targetId` and + * `extensions[].id`; the fixture keeps the full descriptor shape so + * the cases stay faithful to real on-disk configs. + */ +function validConfigSource( + options: { + readonly targetId?: string; + readonly extensionsLiteral?: string; + readonly omitExtensions?: boolean; + } = {}, +): string { + const targetId = options.targetId ?? "postgres"; + const descriptor = (kind: string) => + `{ kind: '${kind}', id: '${targetId}', familyId: 'sql', targetId: '${targetId}', version: '0.0.1', create: () => ({}) }`; + const lines = [ + "export default {", + ` family: { kind: 'family', id: 'sql', familyId: 'sql', version: '0.0.1', emission: {}, create: () => ({}) },`, + ` target: ${descriptor("target")},`, + ` adapter: ${descriptor("adapter")},`, + ]; + if (options.omitExtensions !== true) { + const extensionsLiteral = options.extensionsLiteral ?? "[]"; + lines.push(` extensions: ${extensionsLiteral},`); + } + lines.push("};\n"); + return lines.join("\n"); +} + +describe("loadProjectConfig", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "cli-telemetry-loadcfg-")); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + it("returns empty config when no prisma-next.config.* exists in projectRoot", async () => { + expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); + }); + + it("extracts target.targetId and extensions[].id from a valid .mjs config", async () => { + writeFileSync( + join(projectDir, "prisma-next.config.mjs"), + validConfigSource({ + extensionsLiteral: + "[{ kind: 'extension', id: 'pgvector', familyId: 'sql', targetId: 'postgres', version: '0.0.1', create: () => ({}) }, { kind: 'extension', id: 'paradedb', familyId: 'sql', targetId: 'postgres', version: '0.0.1', create: () => ({}) }]", + }), + ); + expect(await loadProjectConfig(projectDir)).toEqual({ + databaseTarget: "postgres", + extensions: ["pgvector", "paradedb"], + }); + }); + + it("returns empty extensions when extensions is truly omitted from an otherwise valid config", async () => { + writeFileSync( + join(projectDir, "prisma-next.config.mjs"), + validConfigSource({ omitExtensions: true }), + ); + expect(await loadProjectConfig(projectDir)).toEqual({ + databaseTarget: "postgres", + extensions: [], + }); + }); + + it("returns empty config when the target descriptor is missing", async () => { + writeFileSync( + join(projectDir, "prisma-next.config.mjs"), + `export default { family: { kind: 'family', id: 'sql', familyId: 'sql', version: '0.0.1', emission: {}, create: () => ({}) } };\n`, + ); + expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); + }); + + it("returns empty config when an extensions entry has no usable id", async () => { + writeFileSync( + join(projectDir, "prisma-next.config.mjs"), + validConfigSource({ + extensionsLiteral: + "[{ kind: 'extension', familyId: 'sql', targetId: 'postgres', version: '0.0.1', create: () => ({}) }]", + }), + ); + expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); + }); + + it("swallows errors from a config file that throws during load", async () => { + writeFileSync( + join(projectDir, "prisma-next.config.mjs"), + `throw new Error('boom — user config crashed');\n`, + ); + expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); + }); +}); + +describe("buildTelemetryEventFromProcess — parent databaseTarget override", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "cli-telemetry-override-")); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + it("overrides the c12-derived databaseTarget when payload.databaseTarget is a string", async () => { + writeFileSync( + join(projectDir, "prisma-next.config.mjs"), + validConfigSource({ targetId: "postgres" }), + ); + const event = await buildTelemetryEventFromProcess({ + installationId: "install-1", + version: "0.9.0", + command: "init", + flags: [], + projectRoot: projectDir, + endpoint: "http://localhost/events", + databaseTarget: "mongodb", + }); + expect(event.databaseTarget).toBe("mongodb"); + }); + + it("falls back to the c12-derived databaseTarget when payload.databaseTarget is omitted", async () => { + writeFileSync( + join(projectDir, "prisma-next.config.mjs"), + validConfigSource({ targetId: "postgres" }), + ); + const event = await buildTelemetryEventFromProcess({ + installationId: "install-1", + version: "0.9.0", + command: "migration new", + flags: [], + projectRoot: projectDir, + endpoint: "http://localhost/events", + }); + expect(event.databaseTarget).toBe("postgres"); + }); + + it("uses the override even when no prisma-next.config.* exists on disk (first-init shape)", async () => { + const event = await buildTelemetryEventFromProcess({ + installationId: "install-1", + version: "0.9.0", + command: "init", + flags: [], + projectRoot: projectDir, + endpoint: "http://localhost/events", + databaseTarget: "postgres", + }); + expect(event.databaseTarget).toBe("postgres"); + expect(event.extensions).toEqual([]); + }); +}); diff --git a/packages/cli-telemetry/tests/gating.test.ts b/packages/cli-telemetry/tests/gating.test.ts new file mode 100644 index 00000000..56a3451e --- /dev/null +++ b/packages/cli-telemetry/tests/gating.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { resolveGating } from "../src/gating"; + +describe("resolveGating", () => { + it("returns enabled=true when no env override and stored enableTelemetry is true", () => { + expect(resolveGating({ env: {}, config: { enableTelemetry: true } })).toEqual( + { enabled: true }, + ); + }); + + it("returns enabled=false when stored enableTelemetry is false", () => { + expect( + resolveGating({ env: {}, config: { enableTelemetry: false } }), + ).toEqual({ enabled: false, reason: "stored-opt-out" }); + }); + + it("returns enabled=true when enableTelemetry is undefined (opt-out default: file missing or field absent)", () => { + expect(resolveGating({ env: {}, config: {} })).toEqual({ enabled: true }); + }); + + it("returns enabled=false when PRISMA_NEXT_DISABLE_TELEMETRY=1 overrides a true stored preference", () => { + expect( + resolveGating({ + env: { PRISMA_NEXT_DISABLE_TELEMETRY: "1" }, + config: { enableTelemetry: true }, + }), + ).toEqual({ enabled: false, reason: "env-override" }); + }); + + it("treats any truthy value of PRISMA_NEXT_DISABLE_TELEMETRY as opt-out", () => { + for (const value of ["1", "true", "yes", "on", "truthy-anything"]) { + expect( + resolveGating({ + env: { PRISMA_NEXT_DISABLE_TELEMETRY: value }, + config: { enableTelemetry: true }, + }).enabled, + ).toBe(false); + } + }); + + it('treats PRISMA_NEXT_DISABLE_TELEMETRY=0 / empty / "false" as NOT an opt-out (set-but-falsy = unset)', () => { + for (const value of ["", "0", "false", "FALSE"]) { + expect( + resolveGating({ + env: { PRISMA_NEXT_DISABLE_TELEMETRY: value }, + config: { enableTelemetry: true }, + }).enabled, + ).toBe(true); + } + }); + + it("returns enabled=false when DO_NOT_TRACK=1 overrides a true stored preference", () => { + expect( + resolveGating({ + env: { DO_NOT_TRACK: "1" }, + config: { enableTelemetry: true }, + }), + ).toEqual({ enabled: false, reason: "env-override" }); + }); + + it('treats DO_NOT_TRACK=0 as NOT an opt-out (community convention pins the trigger to "=1")', () => { + expect( + resolveGating({ + env: { DO_NOT_TRACK: "0" }, + config: { enableTelemetry: true }, + }).enabled, + ).toBe(true); + }); + + it("env override takes precedence over both stored false and stored true (returns the same env-override reason)", () => { + const result = resolveGating({ + env: { DO_NOT_TRACK: "1" }, + config: { enableTelemetry: false }, + }); + expect(result).toEqual({ enabled: false, reason: "env-override" }); + }); +}); diff --git a/packages/cli-telemetry/tests/no-spawn-in-tests.test.ts b/packages/cli-telemetry/tests/no-spawn-in-tests.test.ts new file mode 100644 index 00000000..f201a15f --- /dev/null +++ b/packages/cli-telemetry/tests/no-spawn-in-tests.test.ts @@ -0,0 +1,70 @@ +import * as childProcess from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EngineCommandSnapshot } from "../src/sanitize"; +import { runTelemetry } from "../src/spawn"; +import { userConfigPath, writeUserConfig } from "../src/user-config"; + +vi.mock("node:child_process", async () => { + const actual = + await vi.importActual("node:child_process"); + return { ...actual, fork: vi.fn() }; +}); + +const commandInput: EngineCommandSnapshot = { + commandPath: ["init"], + positionalCount: 0, + flags: [], +}; + +describe("test runs short-circuit the telemetry path", () => { + let xdgRoot: string; + let originalXdg: string | undefined; + + beforeEach(() => { + xdgRoot = mkdtempSync(join(tmpdir(), "cli-telemetry-probe-")); + originalXdg = process.env["XDG_CONFIG_HOME"]; + process.env["XDG_CONFIG_HOME"] = xdgRoot; + mkdirSync(dirname(userConfigPath()), { recursive: true }); + vi.mocked(childProcess.fork).mockReset(); + }); + + afterEach(() => { + if (originalXdg === undefined) { + delete process.env["XDG_CONFIG_HOME"]; + } else { + process.env["XDG_CONFIG_HOME"] = originalXdg; + } + rmSync(xdgRoot, { recursive: true, force: true }); + }); + + it("the test harness sets PRISMA_NEXT_DISABLE_TELEMETRY=1", () => { + expect(process.env["PRISMA_NEXT_DISABLE_TELEMETRY"]).toBe("1"); + }); + + it("runTelemetry returns gated-off under the harness env even with a stored opt-in", () => { + writeUserConfig({ enableTelemetry: true }); + const result = runTelemetry({ + command: commandInput, + version: "0.9.0", + projectRoot: process.cwd(), + senderPath: "/never/used", + isCI: false, + }); + expect(result).toEqual({ spawned: false, reason: "gated-off" }); + }); + + it("child_process.fork is never called from runTelemetry under the harness env", () => { + writeUserConfig({ enableTelemetry: true }); + runTelemetry({ + command: commandInput, + version: "0.9.0", + projectRoot: process.cwd(), + senderPath: "/never/used", + isCI: false, + }); + expect(childProcess.fork).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-telemetry/tests/payload.test.ts b/packages/cli-telemetry/tests/payload.test.ts new file mode 100644 index 00000000..0b5d327c --- /dev/null +++ b/packages/cli-telemetry/tests/payload.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + isParentToSenderPayload, + type ParentToSenderPayload, +} from "../src/payload"; + +const validPayload: ParentToSenderPayload = { + installationId: "install-uuid", + version: "0.9.0", + command: "auth whoami", + flags: ["json"], + projectRoot: "/abs/project", + endpoint: "https://example.test/events", +}; + +describe("isParentToSenderPayload", () => { + it("accepts a full valid payload", () => { + expect(isParentToSenderPayload(validPayload)).toBe(true); + }); + + it("accepts an empty flags array", () => { + expect(isParentToSenderPayload({ ...validPayload, flags: [] })).toBe(true); + }); + + it("accepts a payload with the optional databaseTarget override set to a string", () => { + expect( + isParentToSenderPayload({ ...validPayload, databaseTarget: "postgres" }), + ).toBe(true); + }); + + it("accepts a payload with the optional databaseTarget override omitted entirely (no override)", () => { + expect("databaseTarget" in validPayload).toBe(false); + expect(isParentToSenderPayload(validPayload)).toBe(true); + }); + + it("rejects a payload whose databaseTarget override is the wrong type", () => { + expect( + isParentToSenderPayload({ ...validPayload, databaseTarget: 42 }), + ).toBe(false); + expect( + isParentToSenderPayload({ ...validPayload, databaseTarget: ["postgres"] }), + ).toBe(false); + // `null` is not a valid override value — the IPC channel uses + // `undefined` (field omitted) for the "no override" state. The wire- + // format `TelemetryEvent.databaseTarget` is still `string | null`, + // but that's a separate downstream shape. + expect( + isParentToSenderPayload({ ...validPayload, databaseTarget: null }), + ).toBe(false); + }); + + it("rejects non-objects", () => { + expect(isParentToSenderPayload(null)).toBe(false); + expect(isParentToSenderPayload(undefined)).toBe(false); + expect(isParentToSenderPayload("payload")).toBe(false); + expect(isParentToSenderPayload(42)).toBe(false); + }); + + for (const key of [ + "installationId", + "version", + "command", + "flags", + "projectRoot", + "endpoint", + ] as const) { + it(`rejects payloads missing required field ${key}`, () => { + const partial: Record = { ...validPayload }; + delete partial[key]; + expect(isParentToSenderPayload(partial)).toBe(false); + }); + } + + it("rejects an empty installationId", () => { + expect(isParentToSenderPayload({ ...validPayload, installationId: "" })).toBe( + false, + ); + }); + + it("rejects an empty endpoint", () => { + expect(isParentToSenderPayload({ ...validPayload, endpoint: "" })).toBe( + false, + ); + }); + + it("rejects flags when it is not a string array", () => { + expect( + isParentToSenderPayload({ ...validPayload, flags: "not-an-array" }), + ).toBe(false); + expect(isParentToSenderPayload({ ...validPayload, flags: [42] })).toBe( + false, + ); + }); + + it("rejects a number where a string is expected", () => { + expect(isParentToSenderPayload({ ...validPayload, version: 123 })).toBe( + false, + ); + expect(isParentToSenderPayload({ ...validPayload, projectRoot: 0 })).toBe( + false, + ); + }); +}); diff --git a/packages/cli-telemetry/tests/sanitize.test.ts b/packages/cli-telemetry/tests/sanitize.test.ts new file mode 100644 index 00000000..6b1d8e67 --- /dev/null +++ b/packages/cli-telemetry/tests/sanitize.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + type EngineCommandSnapshot, + sanitizeEngineSnapshot, +} from "../src/sanitize"; + +describe("sanitizeEngineSnapshot", () => { + it("extracts the command name and user-supplied flag names, dropping everything else", () => { + expect( + sanitizeEngineSnapshot({ + commandPath: ["migration", "new"], + positionalCount: 2, + flags: [ + { name: "name", source: "cli" }, + { name: "dry-run", source: "cli" }, + { name: "target", source: "cli" }, + { name: "connection-string", source: "cli" }, + ], + }), + ).toEqual({ + command: "migration new", + flags: ["name", "dry-run", "target", "connection-string"], + }); + }); + + it("returns the empty flag list when no flags were supplied by the user", () => { + expect( + sanitizeEngineSnapshot({ + commandPath: ["init"], + positionalCount: 0, + flags: [ + { name: "no-install", source: "default" }, + { name: "json", source: "default" }, + ], + }), + ).toEqual({ command: "init", flags: [] }); + }); + + it("joins multi-segment command paths into a single space-delimited command field", () => { + expect( + sanitizeEngineSnapshot({ + commandPath: ["contract", "emit"], + positionalCount: 0, + flags: [{ name: "config", source: "cli" }], + }).command, + ).toBe("contract emit"); + }); + + it("preserves flag declaration order while filtering non-cli sources", () => { + expect( + sanitizeEngineSnapshot({ + commandPath: ["migrate"], + positionalCount: 0, + flags: [ + { name: "to", source: "cli" }, + { name: "yes", source: "cli" }, + { name: "json", source: "default" }, + { name: "verbose", source: "env" }, + ], + }).flags, + ).toEqual(["to", "yes"]); + }); + + it("never leaks positional information — only names survive, the count is dropped", () => { + const out = sanitizeEngineSnapshot({ + commandPath: ["init"], + positionalCount: 7, + flags: [{ name: "target", source: "cli" }], + }); + expect(out).toEqual({ command: "init", flags: ["target"] }); + expect(JSON.stringify(out)).not.toContain("7"); + }); + + it("never includes flag values in its output (there is no value channel in the snapshot)", () => { + const out = sanitizeEngineSnapshot({ + commandPath: ["migration", "new"], + positionalCount: 0, + flags: [{ name: "name", source: "cli" }], + }); + expect(out.flags).toEqual(["name"]); + expect(JSON.stringify(out)).not.toContain("customer-acme-payments"); + }); + + it("does not pass through extra properties smuggled onto a hostile snapshot object", () => { + const hostile = { + commandPath: ["deploy"], + positionalCount: 1, + flags: [ + { + name: "token", + source: "cli", + value: "sk_live_SHOULD-NEVER-LEAK", + } as unknown as EngineCommandSnapshot["flags"][number], + ], + argv: ["--token", "sk_live_SHOULD-NEVER-LEAK"], + positionals: ["/Users/alice/secret.toml"], + } as unknown as EngineCommandSnapshot; + const serialised = JSON.stringify(sanitizeEngineSnapshot(hostile)); + expect(serialised).not.toContain("SHOULD-NEVER-LEAK"); + expect(serialised).not.toContain("secret.toml"); + expect(serialised).not.toContain("argv"); + }); + + it("drops env- and default-sourced flags even when their names look sensitive", () => { + expect( + sanitizeEngineSnapshot({ + commandPath: ["auth", "login"], + positionalCount: 0, + flags: [ + { name: "service-token", source: "env" }, + { name: "password-stdin", source: "default" }, + ], + }).flags, + ).toEqual([]); + }); + + it("handles an empty commandPath by returning an empty command string", () => { + expect( + sanitizeEngineSnapshot({ commandPath: [], positionalCount: 0, flags: [] }) + .command, + ).toBe(""); + }); +}); diff --git a/packages/cli-telemetry/tests/sender-integration.test.ts b/packages/cli-telemetry/tests/sender-integration.test.ts new file mode 100644 index 00000000..85e22c3e --- /dev/null +++ b/packages/cli-telemetry/tests/sender-integration.test.ts @@ -0,0 +1,271 @@ +import { fork } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import type { ParentToSenderPayload, TelemetryEvent } from "../src/payload"; + +/** + * End-to-end sender coverage against a local mock HTTP backend — the + * same faking approach the ORM CLI's integration suite uses (endpoint + * override pointed at an ephemeral local server; the production + * endpoint is never contacted). Forks the compiled `dist/sender.js`, + * drives it over IPC exactly like `runTelemetry` does, and asserts the + * wire shape the backend receives plus the silence invariants. + */ + +const SENDER_PATH = fileURLToPath( + new URL("../dist/sender.js", import.meta.url), +); + +interface CapturedRequest { + readonly url: string; + readonly contentType: string | undefined; + readonly body: TelemetryEvent; +} + +let server: Server; +let endpointBase: string; +const captured: CapturedRequest[] = []; +let projectDir: string; + +beforeAll(async () => { + server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + captured.push({ + url: req.url ?? "", + contentType: req.headers["content-type"], + body: JSON.parse(Buffer.concat(chunks).toString("utf-8")), + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), + ); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("mock server did not bind a port"); + } + endpointBase = `http://127.0.0.1:${address.port}`; + projectDir = mkdtempSync(join(tmpdir(), "cli-telemetry-sender-int-")); + writeFileSync( + join(projectDir, "package.json"), + JSON.stringify({ name: "fixture", devDependencies: { typescript: "^5.9.3" } }), + ); +}); + +afterEach(() => { + captured.length = 0; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(projectDir, { recursive: true, force: true }); +}); + +function buildPayload( + overrides: Partial = {}, +): ParentToSenderPayload { + return { + installationId: "00000000-0000-4000-8000-000000000001", + version: "0.9.0", + command: "auth whoami", + flags: ["json", "dry-run"], + projectRoot: projectDir, + endpoint: `${endpointBase}/events`, + ...overrides, + }; +} + +interface SenderResult { + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; +} + +/** + * Fork the sender with stdout + stderr piped into in-memory buffers so + * a test can assert on what the child wrote. Resolves on `exit` + both + * stdio streams reporting `end` (composing the three signals directly + * avoids the parent-side IPC handle lingering that keeps `close` from + * firing after the child's disconnect-driven exit). + */ +function spawnSender(options: { + readonly payload?: ParentToSenderPayload; + readonly env: NodeJS.ProcessEnv; +}): Promise { + return new Promise((resolveSender, reject) => { + const child = fork(SENDER_PATH, [], { + stdio: ["pipe", "pipe", "pipe", "ipc"], + env: options.env, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdoutEnded = child.stdout === null; + let stderrEnded = child.stderr === null; + let exited = false; + let exitCode: number | null = null; + let settled = false; + + const maybeResolve = (): void => { + if (settled || !exited || !stdoutEnded || !stderrEnded) return; + settled = true; + resolveSender({ + exitCode, + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + }); + }; + + child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + child.stdout?.on("end", () => { + stdoutEnded = true; + maybeResolve(); + }); + child.stderr?.on("end", () => { + stderrEnded = true; + maybeResolve(); + }); + child.on("error", reject); + child.on("exit", (code) => { + exited = true; + exitCode = code; + maybeResolve(); + }); + + if (options.payload !== undefined) { + child.send(options.payload); + } + }); +} + +/** + * A hermetic child env: no inherited debug/opt-out signals, no agent + * markers, so assertions hold no matter what the developer's (or CI's) + * session exports. `extra` layers on top for explicit opt-ins. + */ +function childEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + PATH: process.env["PATH"], + ...extra, + }; +} + +describe("sender end-to-end via a local mock backend", () => { + it("POSTs the enriched wire shape and stays silent on success", async () => { + const result = await spawnSender({ + payload: buildPayload(), + env: childEnv(), + }); + + expect(captured).toHaveLength(1); + const request = captured[0]; + expect(request?.url).toBe("/events"); + expect(request?.contentType).toBe("application/json"); + const event = request?.body; + expect(event?.installationId).toBe("00000000-0000-4000-8000-000000000001"); + expect(event?.version).toBe("0.9.0"); + expect(event?.command).toBe("auth whoami"); + expect(event?.flags).toEqual(["json", "dry-run"]); + expect(event?.databaseTarget).toBeNull(); + expect(event?.extensions).toEqual([]); + expect(event?.tsVersion).toBe("5.9.3"); + expect(typeof event?.runtimeName).toBe("string"); + expect(typeof event?.runtimeVersion).toBe("string"); + expect(typeof event?.os).toBe("string"); + expect(typeof event?.arch).toBe("string"); + expect(event?.agent).toBeNull(); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); + + it("transmits only flag names, never values or positionals", async () => { + const sensitiveFlags = ["connection-string", "name", "config"]; + await spawnSender({ + payload: buildPayload({ flags: sensitiveFlags }), + env: childEnv(), + }); + expect(captured).toHaveLength(1); + expect(captured[0]?.body.flags).toEqual(sensitiveFlags); + const serialised = JSON.stringify(captured[0]?.body); + expect(serialised).not.toMatch(/postgres:\/\/u:p@h\/d/); + expect(serialised).not.toMatch(/customer-acme-payments/); + expect(serialised).not.toMatch(/\/Users\/alice\/secrets/); + }); + + it("derives databaseTarget and extensions from a prisma-next.config.* in projectRoot", async () => { + const configuredDir = mkdtempSync(join(tmpdir(), "cli-telemetry-sender-cfg-")); + try { + writeFileSync( + join(configuredDir, "prisma-next.config.mjs"), + [ + "export default {", + " target: { kind: 'target', id: 'postgres', targetId: 'postgres', version: '0.0.1', create: () => ({}) },", + " extensions: [{ kind: 'extension', id: 'pgvector', version: '0.0.1', create: () => ({}) }],", + "};", + "", + ].join("\n"), + ); + await spawnSender({ + payload: buildPayload({ projectRoot: configuredDir }), + env: childEnv(), + }); + expect(captured).toHaveLength(1); + expect(captured[0]?.body.databaseTarget).toBe("postgres"); + expect(captured[0]?.body.extensions).toEqual(["pgvector"]); + } finally { + rmSync(configuredDir, { recursive: true, force: true }); + } + }); + + it("populates the agent field from the child env", async () => { + await spawnSender({ + payload: buildPayload(), + env: childEnv({ CLAUDECODE: "1" }), + }); + expect(captured).toHaveLength(1); + expect(captured[0]?.body.agent).toBe("claude"); + }); + + it("swallows a network failure (exit 0, silent) when the endpoint is unreachable", async () => { + const result = await spawnSender({ + payload: buildPayload({ endpoint: "http://127.0.0.1:1/events" }), + env: childEnv(), + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + expect(captured).toHaveLength(0); + }); + + it("emits diagnostics to stderr under PRISMA_NEXT_DEBUG=1", async () => { + const result = await spawnSender({ + payload: buildPayload({ endpoint: "http://127.0.0.1:1/events" }), + env: childEnv({ PRISMA_NEXT_DEBUG: "1" }), + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("[cli-telemetry]"); + expect(result.stderr).toContain("send failed"); + }); + + it( + "exits 0 when no payload arrives within the idle timeout, and stays silent", + { timeout: 10_000 }, + async () => { + const result = await spawnSender({ env: childEnv() }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/packages/cli-telemetry/tests/spawn.test.ts b/packages/cli-telemetry/tests/spawn.test.ts new file mode 100644 index 00000000..2d021898 --- /dev/null +++ b/packages/cli-telemetry/tests/spawn.test.ts @@ -0,0 +1,110 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { EngineCommandSnapshot } from "../src/sanitize"; +import { runTelemetry, senderModuleUrl, type RunTelemetryInputs } from "../src/spawn"; +import { userConfigPath, writeUserConfig } from "../src/user-config"; + +const commandInput: EngineCommandSnapshot = { + commandPath: ["init"], + positionalCount: 0, + flags: [{ name: "target", source: "cli" }], +}; + +function makeInputs( + overrides: Partial = {}, +): RunTelemetryInputs { + return { + command: commandInput, + version: "0.9.0", + projectRoot: process.cwd(), + senderPath: "/non/existent/path/never-forked.js", + isCI: false, + env: {}, + ...overrides, + }; +} + +describe("runTelemetry — gating decisions short-circuit before fork", () => { + let xdgRoot: string; + let originalXdg: string | undefined; + + beforeEach(() => { + xdgRoot = mkdtempSync(join(tmpdir(), "cli-telemetry-spawn-")); + originalXdg = process.env["XDG_CONFIG_HOME"]; + process.env["XDG_CONFIG_HOME"] = xdgRoot; + mkdirSync(dirname(userConfigPath()), { recursive: true }); + }); + + afterEach(() => { + if (originalXdg === undefined) { + delete process.env["XDG_CONFIG_HOME"]; + } else { + process.env["XDG_CONFIG_HOME"] = originalXdg; + } + rmSync(xdgRoot, { recursive: true, force: true }); + }); + + it("returns gated-off when no config file exists (no installation id yet)", () => { + expect(runTelemetry(makeInputs())).toEqual({ + spawned: false, + reason: "gated-off", + }); + }); + + it("returns gated-off when enableTelemetry is false", () => { + writeUserConfig({ enableTelemetry: false }); + expect(runTelemetry(makeInputs())).toEqual({ + spawned: false, + reason: "gated-off", + }); + }); + + it("returns ci when isCI is true, even with stored opt-in", () => { + writeUserConfig({ enableTelemetry: true }); + expect(runTelemetry(makeInputs({ isCI: true }))).toEqual({ + spawned: false, + reason: "ci", + }); + }); + + it("returns gated-off when PRISMA_NEXT_DISABLE_TELEMETRY overrides a stored opt-in", () => { + writeUserConfig({ enableTelemetry: true }); + expect( + runTelemetry(makeInputs({ env: { PRISMA_NEXT_DISABLE_TELEMETRY: "1" } })), + ).toEqual({ spawned: false, reason: "gated-off" }); + }); + + it("returns gated-off when DO_NOT_TRACK=1 overrides a stored opt-in", () => { + writeUserConfig({ enableTelemetry: true }); + expect(runTelemetry(makeInputs({ env: { DO_NOT_TRACK: "1" } }))).toEqual({ + spawned: false, + reason: "gated-off", + }); + }); + + it("returns gated-off when installationId is missing despite enableTelemetry=true (defence-in-depth)", () => { + writeFileSync(userConfigPath(), JSON.stringify({ enableTelemetry: true })); + expect(runTelemetry(makeInputs())).toEqual({ + spawned: false, + reason: "gated-off", + }); + }); + + it("returns spawned:true synchronously even when the sender path does not exist (child fails out-of-band, never throws)", () => { + writeUserConfig({ enableTelemetry: true }); + // fork() of a nonexistent path does not throw — it spawns a child + // that errors asynchronously on its own exit, so runTelemetry + // returns `spawned: true` synchronously and the parent is never + // perturbed by the downstream failure. + expect(runTelemetry(makeInputs())).toEqual({ spawned: true }); + }); +}); + +describe("senderModuleUrl", () => { + it("resolves the sender entry relative to the consumer's import.meta.url", () => { + const consumer = "file:///some/consumer/dist/cli.js"; + expect(senderModuleUrl(consumer)).toBe("/some/consumer/dist/sender.js"); + }); +}); diff --git a/packages/cli-telemetry/tests/user-config.test.ts b/packages/cli-telemetry/tests/user-config.test.ts new file mode 100644 index 00000000..33a8af37 --- /dev/null +++ b/packages/cli-telemetry/tests/user-config.test.ts @@ -0,0 +1,178 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ensureInstallationId, + readUserConfig, + userConfigPath, + writeUserConfig, +} from "../src/user-config"; + +const V4_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe("readUserConfig / writeUserConfig", () => { + let xdgRoot: string; + let originalXdg: string | undefined; + + beforeEach(() => { + xdgRoot = mkdtempSync(join(tmpdir(), "prisma-cli-telemetry-")); + originalXdg = process.env["XDG_CONFIG_HOME"]; + process.env["XDG_CONFIG_HOME"] = xdgRoot; + mkdirSync(dirname(userConfigPath()), { recursive: true }); + }); + + afterEach(() => { + if (originalXdg === undefined) { + delete process.env["XDG_CONFIG_HOME"]; + } else { + process.env["XDG_CONFIG_HOME"] = originalXdg; + } + rmSync(xdgRoot, { recursive: true, force: true }); + }); + + it("resolves the shared prisma-next config path (same file as the ORM CLI)", () => { + expect(userConfigPath()).toBe(join(xdgRoot, "prisma-next", "config.json")); + }); + + it("returns an empty object when the config file does not exist", () => { + expect(readUserConfig()).toEqual({}); + expect(existsSync(userConfigPath())).toBe(false); + }); + + it("parses a well-formed config and exposes both known fields", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ enableTelemetry: true, installationId: "pre-existing-uuid" }), + ); + const cfg = readUserConfig(); + expect(cfg.enableTelemetry).toBe(true); + expect(cfg.installationId).toBe("pre-existing-uuid"); + }); + + it("preserves unknown fields on read (forward compat)", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ + enableTelemetry: true, + installationId: "id-1", + someFutureField: "opaque", + nested: { foo: "bar" }, + }), + ); + const cfg = readUserConfig() as Record; + expect(cfg["someFutureField"]).toBe("opaque"); + expect(cfg["nested"]).toEqual({ foo: "bar" }); + }); + + it("tolerates a malformed (unparseable) file by returning an empty object", () => { + writeFileSync(userConfigPath(), "{not valid json"); + expect(readUserConfig()).toEqual({}); + }); + + it("writeUserConfig({enableTelemetry: true}) generates a v4 installationId and persists both", () => { + writeUserConfig({ enableTelemetry: true }); + const cfg = readUserConfig(); + expect(cfg.enableTelemetry).toBe(true); + expect(cfg.installationId).toMatch(V4_UUID); + }); + + it("writeUserConfig({enableTelemetry: true}) preserves an existing installationId rather than rotating it", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ installationId: "sticky-id-do-not-rotate" }), + ); + writeUserConfig({ enableTelemetry: true }); + expect(readUserConfig().installationId).toBe("sticky-id-do-not-rotate"); + }); + + it("writeUserConfig({enableTelemetry: false}) does NOT generate an installationId", () => { + writeUserConfig({ enableTelemetry: false }); + const cfg = readUserConfig(); + expect(cfg.enableTelemetry).toBe(false); + expect(cfg.installationId).toBeUndefined(); + }); + + it("writeUserConfig merges with existing fields and preserves unknown ones", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ + installationId: "kept", + unknown: "preserve-me", + nested: { foo: 1 }, + }), + ); + writeUserConfig({ enableTelemetry: true }); + const cfg = readUserConfig() as Record; + expect(cfg["enableTelemetry"]).toBe(true); + expect(cfg["installationId"]).toBe("kept"); + expect(cfg["unknown"]).toBe("preserve-me"); + expect(cfg["nested"]).toEqual({ foo: 1 }); + }); + + it("writes via temp-file-and-rename so a half-written file is never observable", () => { + writeUserConfig({ enableTelemetry: true }); + const raw = readFileSync(userConfigPath(), "utf-8"); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it("creates the parent directory if missing", () => { + rmSync(xdgRoot, { recursive: true, force: true }); + writeUserConfig({ enableTelemetry: false }); + expect(existsSync(userConfigPath())).toBe(true); + }); +}); + +describe("ensureInstallationId", () => { + let xdgRoot: string; + let originalXdg: string | undefined; + + beforeEach(() => { + xdgRoot = mkdtempSync(join(tmpdir(), "prisma-cli-telemetry-id-")); + originalXdg = process.env["XDG_CONFIG_HOME"]; + process.env["XDG_CONFIG_HOME"] = xdgRoot; + mkdirSync(dirname(userConfigPath()), { recursive: true }); + }); + + afterEach(() => { + if (originalXdg === undefined) { + delete process.env["XDG_CONFIG_HOME"]; + } else { + process.env["XDG_CONFIG_HOME"] = originalXdg; + } + rmSync(xdgRoot, { recursive: true, force: true }); + }); + + it("mints and persists a v4 UUID when none is stored", () => { + const id = ensureInstallationId(); + expect(id).toMatch(V4_UUID); + expect(readUserConfig().installationId).toBe(id); + }); + + it("does NOT set enableTelemetry when minting", () => { + ensureInstallationId(); + expect(readUserConfig().enableTelemetry).toBeUndefined(); + }); + + it("returns the existing id and does not rotate it", () => { + writeFileSync(userConfigPath(), JSON.stringify({ installationId: "sticky-id" })); + expect(ensureInstallationId()).toBe("sticky-id"); + expect(readUserConfig().installationId).toBe("sticky-id"); + }); + + it("preserves an existing enableTelemetry: false while minting an id", () => { + writeFileSync(userConfigPath(), JSON.stringify({ enableTelemetry: false })); + ensureInstallationId(); + const cfg = readUserConfig(); + expect(cfg.installationId).toBeDefined(); + expect(cfg.enableTelemetry).toBe(false); + }); +}); diff --git a/packages/cli-telemetry/tsconfig.json b/packages/cli-telemetry/tsconfig.json new file mode 100644 index 00000000..e7152148 --- /dev/null +++ b/packages/cli-telemetry/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@repo/tsconfig/base.json", + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/cli-telemetry/tsdown.config.ts b/packages/cli-telemetry/tsdown.config.ts new file mode 100644 index 00000000..afbe6e30 --- /dev/null +++ b/packages/cli-telemetry/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/exports/index.ts", + sender: "src/sender.ts", + }, + format: ["esm"], + dts: true, + clean: true, + fixedExtension: false, + outDir: "dist", +}); diff --git a/packages/cli-telemetry/vitest.config.ts b/packages/cli-telemetry/vitest.config.ts new file mode 100644 index 00000000..c34365c0 --- /dev/null +++ b/packages/cli-telemetry/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + // Harness short-circuit: the probe test in + // `tests/no-spawn-in-tests.test.ts` verifies test runs never fork + // the detached sender. Set here so it holds under + // `pnpm --filter @repo/cli-telemetry test` and under turbo alike. + env: { + PRISMA_NEXT_DISABLE_TELEMETRY: "1", + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a85cb465..19cdf5a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,31 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/cli-telemetry: + dependencies: + '@vercel/detect-agent': + specifier: ^1.2.3 + version: 1.2.4 + c12: + specifier: ^3.3.4 + version: 3.3.4(magicast@0.5.3) + devDependencies: + '@repo/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^22.19.19 + version: 22.19.19 + tsdown: + specifier: ^0.21.10 + version: 0.21.10(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/compute: devDependencies: '@repo/tsconfig': @@ -914,6 +939,10 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@vercel/detect-agent@1.2.4': + resolution: {integrity: sha512-euARTdvCVoi2k7/mqRUGyareO98jiVjwUyfJ8VFpN53aUv3R6ZgwSpU3ErOwFQKT6Fq8dOplEsy+DSmEoJPQdQ==} + engines: {node: '>=14'} + '@vercel/nft@1.10.2': resolution: {integrity: sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw==} engines: {node: '>=20'} @@ -1059,6 +1088,14 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -1067,6 +1104,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1078,6 +1119,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1113,6 +1157,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1164,6 +1211,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -1208,6 +1258,10 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -1327,6 +1381,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -1360,6 +1417,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1371,6 +1431,9 @@ 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} @@ -1386,6 +1449,13 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2225,6 +2295,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@vercel/detect-agent@1.2.4': {} + '@vercel/nft@1.10.2(rollup@4.62.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3 @@ -2364,16 +2436,39 @@ snapshots: dependencies: run-applescript: 7.1.0 + c12@3.3.4(magicast@0.5.3): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.3 + cac@7.0.0: {} chai@6.2.2: {} + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + chownr@3.0.0: {} colorette@2.0.20: {} commander@14.0.3: {} + confbox@0.2.4: {} + consola@3.4.2: {} convert-source-map@2.0.0: {} @@ -2399,6 +2494,8 @@ snapshots: defu@6.1.7: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} dotenv@17.4.2: {} @@ -2496,6 +2593,8 @@ snapshots: expect-type@1.3.0: {} + exsolve@1.1.1: {} + fast-fifo@1.3.2: {} fast-string-truncated-width@3.0.3: {} @@ -2532,6 +2631,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.1: {} + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -2620,6 +2721,8 @@ snapshots: obug@2.1.1: {} + ohash@2.0.11: {} + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -2652,12 +2755,20 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} pkg-pr-new@0.0.75: {} + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + postcss@8.5.15: dependencies: nanoid: 3.3.15 @@ -2672,6 +2783,13 @@ snapshots: quansync@1.0.0: {} + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@5.1.1: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} From 2001fec95b4a58e88bb034db8307a09ced05e286 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 02:21:37 +0200 Subject: [PATCH 08/67] feat(cli-engine,cli): onSettled run hook, telemetry bin wiring, consent commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine amendment (draft §10): RunHooks gains onSettled(summary) with RunSummary { commandId, exitCode, durationMs, snapshot } and the value-free EngineCommandSnapshot { commandPath, flags (name + source), positionalCount }. The snapshot is captured at parse time when a command mounts: flags explicitly present on argv are source 'cli' (long, =-form, kebab or camel spelling, --no- negation, and aliases); the engine reads no flags from the environment today, so everything else is 'default'. The hook fires exactly once per run after settlement, never for --help/--version or pre-mount usage errors, with durationMs from the injectable clock; a throwing hook is swallowed. RunHooks stays internal — the public growth is the minimal hooks parameter on Cli.run (CliRunHooks, onSettled only), and the test harness gains a matching onSettled tap. Bin wiring (v8 main): the CI/env/consent decision resolves before the run; when enabled an onSettled hook fires runTelemetry (detached fork + IPC send + disconnect + unref, reference spawn semantics), performing the first-run stderr disclosure + shared-id mint when no installation id is stored; the telemetry command family is exempt; when disabled no hook is attached. CI detection ports the ORM CLI's ci-info wrapper. Commands telemetry status|enable|disable port the ORM consent surface as engine result commands, mounted shell-owned under the new telemetry group, with the reference copy, cards per the S1 whoami pattern, and json serializers. status is a pure read. The cli bundles @repo/cli-telemetry (workspace devDependency, never a published dep): a second tsdown config emits dist/v8/cli.js with the telemetry source inlined plus the forkable dist/v8/sender.js; the sender's third-party deps (c12, @vercel/detect-agent) move onto the cli's own dependencies. Smoke-verified against a local mock backend: a force-enabled run POSTs exactly one wire-shape event with the stored installation id; CI=1 spawns nothing. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/engine-interface-draft.ts | 56 +++- packages/cli-engine/src/cli.ts | 23 +- .../src/execution/command-snapshot.ts | 114 +++++++++ packages/cli-engine/src/execution/engine.ts | 56 +++- packages/cli-engine/src/exports/index.ts | 3 +- packages/cli-engine/src/run-summary.ts | 42 +++ packages/cli-engine/src/testing.ts | 5 + packages/cli-engine/tests/run-hooks.test.ts | 176 +++++++++++++ packages/cli-telemetry/package.json | 5 +- packages/cli-telemetry/src/enrich.ts | 4 +- packages/cli-telemetry/src/payload.ts | 5 +- packages/cli-telemetry/src/user-config.ts | 6 +- packages/cli-telemetry/tests/endpoint.test.ts | 10 +- packages/cli-telemetry/tests/enrich.test.ts | 7 +- packages/cli-telemetry/tests/gating.test.ts | 6 +- packages/cli-telemetry/tests/payload.test.ts | 11 +- .../tests/sender-integration.test.ts | 27 +- packages/cli-telemetry/tests/spawn.test.ts | 6 +- .../cli-telemetry/tests/user-config.test.ts | 10 +- packages/cli/package.json | 4 + packages/cli/src/v8/cli.ts | 16 ++ packages/cli/src/v8/main.ts | 16 +- packages/cli/src/v8/telemetry/commands.ts | 109 ++++++++ packages/cli/src/v8/telemetry/is-ci.ts | 12 + packages/cli/src/v8/telemetry/sender.ts | 7 + packages/cli/src/v8/telemetry/status.ts | 78 ++++++ packages/cli/src/v8/telemetry/wiring.ts | 142 ++++++++++ .../cli/tests/v8-telemetry-wiring.test.ts | 196 ++++++++++++++ packages/cli/tests/v8-telemetry.test.ts | 242 ++++++++++++++++++ packages/cli/tsdown.config.ts | 39 ++- pnpm-lock.yaml | 18 ++ 31 files changed, 1398 insertions(+), 53 deletions(-) create mode 100644 packages/cli-engine/src/execution/command-snapshot.ts create mode 100644 packages/cli-engine/src/run-summary.ts create mode 100644 packages/cli-engine/tests/run-hooks.test.ts create mode 100644 packages/cli/src/v8/telemetry/commands.ts create mode 100644 packages/cli/src/v8/telemetry/is-ci.ts create mode 100644 packages/cli/src/v8/telemetry/sender.ts create mode 100644 packages/cli/src/v8/telemetry/status.ts create mode 100644 packages/cli/src/v8/telemetry/wiring.ts create mode 100644 packages/cli/tests/v8-telemetry-wiring.test.ts create mode 100644 packages/cli/tests/v8-telemetry.test.ts diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index 787ffc96..c5964b84 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -948,8 +948,56 @@ export interface Cli { /** Parse, execute, render, return the exit code. Never touches * process globals — it exits only through the runtime's exit proxy * (second-signal force exit) and writes only to the provided - * streams. */ - run(argv: readonly string[], runtime: Runtime): Promise + * streams. `hooks` is the bin's observation seam (S2a telemetry + * amendment): the engine's internal RunHooks stayed internal, and + * the minimal public surface growth is this optional parameter + * carrying only the settlement observer. */ + run(argv: readonly string[], runtime: Runtime, hooks?: CliRunHooks): Promise +} + +/** + * S2a telemetry amendment. The observation hooks a bin may attach to + * a run — deliberately narrower than the engine's internal hook set + * (whose other members are test seams reachable only through the + * ./testing harness, which also accepts an `onSettled` tap). + */ +export interface CliRunHooks { + /** Fired exactly once per run, after settlement (exit code final, + * terminal output written). Never fired for --help/--version, and + * never for a run that failed before reaching a mounted command + * (nothing executed, so there is no snapshot). Errors thrown by + * the hook are swallowed — a telemetry bug must not break a + * command. */ + readonly onSettled?: (summary: RunSummary) => void +} + +/** What onSettled receives. `durationMs` comes from the engine's + * injectable clock (§11), never from wall time directly. */ +export interface RunSummary { + readonly commandId: string + readonly exitCode: number + readonly durationMs: number + readonly snapshot: EngineCommandSnapshot +} + +/** + * The value-free command snapshot recorded at parse time. NO VALUES, + * EVER: command-path segments, flag names with their value source, + * and a bare count of positionals. Flag `source` derives from what + * the engine knows at parse time: flags explicitly present on argv + * are 'cli'; the engine reads no flags from the environment today, + * so everything else is 'default' ('env' is reserved for a future + * env-sourced flag mechanism). + */ +export interface EngineCommandSnapshot { + /** Mount-path segments ('telemetry status' → ['telemetry', + * 'status']). Never includes the binary name. */ + readonly commandPath: readonly string[] + /** One entry per flag the command accepts (the engine-injected + * shared family first, then the command's own declarations), in + * the user-facing kebab-case spelling. */ + readonly flags: ReadonlyArray<{ readonly name: string; readonly source: 'cli' | 'env' | 'default' }> + readonly positionalCount: number } /** Everything environmental, injected once by the bin (or by a test). */ @@ -1049,6 +1097,10 @@ export interface TestCli { readonly abort?: AbortSignal /** Live event tap, for asserting mid-session behavior. */ readonly onEvent?: (event: EngineEvent) => void + /** Settlement tap (S2a telemetry amendment): receives the + * RunSummary the engine fires after settlement (once, mounted + * runs only). */ + readonly onSettled?: (summary: RunSummary) => void readonly cwd?: string readonly isTty?: { stdin?: boolean; stdout?: boolean; stderr?: boolean } readonly env?: Readonly> diff --git a/packages/cli-engine/src/cli.ts b/packages/cli-engine/src/cli.ts index 4ad193be..7446d674 100644 --- a/packages/cli-engine/src/cli.ts +++ b/packages/cli-engine/src/cli.ts @@ -1,14 +1,32 @@ import type { CommandFamily, MountedTree } from "./command-family"; import { buildEngine } from "./execution/engine"; +import type { RunSummary } from "./run-summary"; import type { Runtime } from "./runtime"; +/** + * The observation hooks a bin may attach to a run. Deliberately + * narrower than the engine's internal hook set (whose other members + * are test seams reachable only through the ./testing harness). + */ +export interface CliRunHooks { + /** Fired exactly once per run, after settlement, for runs that + * reached a mounted command. Never fired for --help/--version. + * Errors thrown by the hook are swallowed. */ + readonly onSettled?: (summary: RunSummary) => void; +} + export interface Cli { /** * Parse, execute, render, return the exit code. Never touches * process globals — it exits only through the runtime's exit proxy * (second-signal force exit) and writes only to the provided streams. + * `hooks` is the bin's observation seam (telemetry). */ - run(argv: readonly string[], runtime: Runtime): Promise; + run( + argv: readonly string[], + runtime: Runtime, + hooks?: CliRunHooks, + ): Promise; } /** @@ -28,6 +46,7 @@ export function createCli(spec: { }): Cli { const engine = buildEngine(spec); return { - run: (argv, runtime) => engine.execute(argv, runtime, {}), + run: (argv, runtime, hooks) => + engine.execute(argv, runtime, { onSettled: hooks?.onSettled }), }; } diff --git a/packages/cli-engine/src/execution/command-snapshot.ts b/packages/cli-engine/src/execution/command-snapshot.ts new file mode 100644 index 00000000..ac2564c1 --- /dev/null +++ b/packages/cli-engine/src/execution/command-snapshot.ts @@ -0,0 +1,114 @@ +/** + * Builds the value-free `EngineCommandSnapshot` for a mounted run — + * everything `RunHooks.onSettled` may reveal about the invocation. + * The projection happens at parse time from what the engine already + * knows: the command's declared flags, the raw argv tokens (consulted + * only for WHICH flag names appear, never for values), and the parsed + * positional slots (reduced to a count on the spot). + */ +import { flagRuntime } from "../args"; +import type { AnyCommand } from "../commands"; +import type { EngineCommandSnapshot } from "../run-summary"; +import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; + +function kebabCase(key: string): string { + return key.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`); +} + +function camelCase(raw: string): string { + return raw.replace(/-([a-zA-Z0-9])/g, (_, char: string) => + char.toUpperCase(), + ); +} + +function declaredFlagKeys(def: AnyCommand): readonly string[] { + const own = Object.keys(def.args.flags); + if (def.kind === "server-command") { + return own; + } + return [...Object.keys(SHARED_FLAG_PARAMETERS), ...own]; +} + +function aliasMap(def: AnyCommand): ReadonlyMap { + const aliases = new Map(); + if (def.kind !== "server-command") { + for (const [alias, key] of Object.entries(SHARED_ALIASES)) { + aliases.set(alias, key); + } + } + for (const [key, spec] of Object.entries(def.args.flags)) { + const alias = flagRuntime(spec).alias; + if (alias !== undefined) { + aliases.set(alias, key); + } + } + return aliases; +} + +/** + * The flag keys explicitly present on argv, resolved against the + * command's declared keys and aliases. Long tokens match in both the + * kebab and camel spellings (the scanner allows kebab-for-camel), a + * `--no-` token marks its base flag, and single-dash tokens are + * resolved character-by-character through the alias map. Everything + * after a bare `--` is positional and never consulted. Values are + * irrelevant here: `--name=value` is cut at the `=` before matching. + */ +function explicitFlagKeys( + def: AnyCommand, + declared: readonly string[], + argv: readonly string[], +): ReadonlySet { + const declaredSet = new Set(declared); + const aliases = aliasMap(def); + const explicit = new Set(); + for (const token of argv) { + if (token === "--") { + break; + } + if (token.startsWith("--")) { + const equals = token.indexOf("="); + const raw = token.slice(2, equals === -1 ? undefined : equals); + const exact = camelCase(raw); + if (declaredSet.has(exact)) { + explicit.add(exact); + continue; + } + if (raw.startsWith("no-")) { + const negated = camelCase(raw.slice(3)); + if (declaredSet.has(negated)) { + explicit.add(negated); + } + } + continue; + } + if (token.startsWith("-") && token.length > 1) { + for (const char of token.slice(1)) { + const key = aliases.get(char); + if (key !== undefined && declaredSet.has(key)) { + explicit.add(key); + } + } + } + } + return explicit; +} + +export function buildCommandSnapshot( + entryId: string, + def: AnyCommand, + argv: readonly string[], + positionalValues: readonly (string | undefined)[], +): EngineCommandSnapshot { + const declared = declaredFlagKeys(def); + const explicit = explicitFlagKeys(def, declared, argv); + return { + commandPath: entryId.split("."), + flags: declared.map((key) => ({ + name: kebabCase(key), + source: explicit.has(key) ? "cli" : "default", + })), + positionalCount: positionalValues.filter((value) => value !== undefined) + .length, + }; +} diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 227dac9a..47d66f2e 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -11,9 +11,11 @@ import type { EngineEvent, Severity, StreamEvent } from "../events"; import type { ManagementApiClient } from "../management-api"; import type { Format, PresentedResult } from "../presentation"; import type { CliStructuredError, Result } from "../protocol"; +import type { EngineCommandSnapshot, RunSummary } from "../run-summary"; import type { InputStream, Runtime } from "../runtime"; import type { CreateManagementApiSdk } from "./api-client"; import { makeContext } from "./command-context"; +import { buildCommandSnapshot } from "./command-snapshot"; import { buildCommandTree, type CommandTreeEntry } from "./command-tree"; import { checkNeeds, type NeedsOutcome } from "./needs"; import { @@ -51,6 +53,11 @@ export interface RunHooks { readonly onEvent?: (event: EngineEvent) => void; readonly onPresented?: (presented: PresentedResult) => void; readonly onStreamEvent?: (frame: StreamEvent) => void; + /** Fired exactly once per run, after settlement, for runs that + * reached a mounted command. Never fired for --help/--version. + * Errors thrown by the hook are swallowed — an observer bug must + * not break a command. */ + readonly onSettled?: (summary: RunSummary) => void; readonly answers?: ReadonlyArray; /** Test seams: an injected `client` becomes ctx.api verbatim; an * injected `createSdk` replaces the SDK factory. */ @@ -85,6 +92,12 @@ export interface RunState { /** The stdin iterator a prompt opened, closed when the run settles so * a real process's stdin never keeps the event loop alive. */ stdinIterator: AsyncIterator | undefined; + /** The run's raw argv — consulted only to derive which flag NAMES + * were explicitly passed for the settlement snapshot. */ + argv: readonly string[]; + /** The value-free snapshot captured when a command mounted; + * undefined for runs that never reached one (help, usage errors). */ + snapshot: EngineCommandSnapshot | undefined; } export interface Invocation { @@ -174,7 +187,10 @@ export class EngineImpl implements Engine { internalErrorText: undefined, stricliStderr: "", stdinIterator: undefined, + argv, + snapshot: undefined, }; + const startedAtMs = this.now().getTime(); const controller = new AbortController(); let signalDelivered = false; const unsubscribe = runtime.onSignal((signal) => { @@ -233,10 +249,38 @@ export class EngineImpl implements Engine { unsubscribe(); await state.stdinIterator?.return?.(); } - if (state.settledExitCode !== undefined) { - return state.settledExitCode; + const exitCode = + state.settledExitCode !== undefined + ? state.settledExitCode + : settleUnhandled(this.spec, invocation, stricliProcess.exitCode); + this.fireOnSettled(invocation, exitCode, startedAtMs); + return exitCode; + } + + /** The onSettled delivery: once per run, after the exit code is + * final, only for runs that mounted a command (--help, --version, + * and pre-mount usage errors leave no snapshot and fire nothing). + * A throwing hook is swallowed — observation must not break runs. */ + private fireOnSettled( + invocation: Invocation, + exitCode: number, + startedAtMs: number, + ): void { + const { state, hooks } = invocation; + if (state.snapshot === undefined || hooks.onSettled === undefined) { + return; + } + const summary: RunSummary = { + commandId: state.commandId, + exitCode, + durationMs: this.now().getTime() - startedAtMs, + snapshot: state.snapshot, + }; + try { + hooks.onSettled(summary); + } catch { + // Swallowed by contract: a telemetry bug must not break a command. } - return settleUnhandled(this.spec, invocation, stricliProcess.exitCode); } private async executeMounted( @@ -248,6 +292,12 @@ export class EngineImpl implements Engine { const state = invocation.state; state.commandId = entry.id; state.docsBaseUrl = entry.docsBaseUrl; + state.snapshot = buildCommandSnapshot( + entry.id, + entry.def, + state.argv, + values, + ); if (entry.def.kind === "server-command") { await this.executeServer(invocation, entry, rawFlags); return; diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 078454e3..79d2d029 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -18,7 +18,7 @@ export { type PositionalSpec, positional, } from "../args"; -export { type Cli, createCli } from "../cli"; +export { type Cli, type CliRunHooks, createCli } from "../cli"; export { type CommandFamily, defineCommandFamily, @@ -69,6 +69,7 @@ export { type TreeNode, type Ui, } from "../presentation"; +export type { EngineCommandSnapshot, RunSummary } from "../run-summary"; export { type HostProcess, type InputStream, diff --git a/packages/cli-engine/src/run-summary.ts b/packages/cli-engine/src/run-summary.ts new file mode 100644 index 00000000..db66bd39 --- /dev/null +++ b/packages/cli-engine/src/run-summary.ts @@ -0,0 +1,42 @@ +/** + * The value-free command snapshot the engine records at parse time and + * hands to `RunHooks.onSettled`. Consumed by the shell's telemetry + * wiring; carries NO user data — command-path segments, flag names + * with their value source, and a bare count of positionals. Flag + * values, positional values, and raw argv never appear here. + */ +export interface EngineCommandSnapshot { + /** Mount-path segments of the executed command ('telemetry status' + * → ['telemetry', 'status']). Never includes the binary name. */ + readonly commandPath: readonly string[]; + /** + * One entry per flag the command accepts (the engine-injected shared + * family first, then the command's own declarations), named in the + * user-facing kebab-case spelling. `source` is what the engine knows + * at parse time: flags explicitly present on argv are 'cli'; the + * engine reads no flags from the environment today, so everything + * else is 'default'. 'env' is reserved for a future env-sourced + * flag mechanism. + */ + readonly flags: ReadonlyArray<{ + readonly name: string; + readonly source: "cli" | "env" | "default"; + }>; + /** How many positional arguments the run supplied — a count only. */ + readonly positionalCount: number; +} + +/** + * What `RunHooks.onSettled` receives, exactly once per run, after the + * run has settled (exit code determined, terminal output written). + * Never fired for `--help` or `--version`, and never for a run that + * failed before a mounted command was reached (nothing executed, so + * there is no snapshot to report). `durationMs` comes from the + * engine's injectable clock. + */ +export interface RunSummary { + readonly commandId: string; + readonly exitCode: number; + readonly durationMs: number; + readonly snapshot: EngineCommandSnapshot; +} diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index cc5b2b75..4878032f 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -4,6 +4,7 @@ import type { EngineEvent, StreamEvent } from "./events"; import { buildEngine } from "./execution/engine"; import type { ManagementApiClient } from "./management-api"; import type { PresentedResult } from "./presentation"; +import type { RunSummary } from "./run-summary"; import type { Runtime } from "./runtime"; export interface TestCli { @@ -24,6 +25,9 @@ export interface TestCli { readonly abort?: AbortSignal; /** Live event tap, for asserting mid-session behavior. */ readonly onEvent?: (event: EngineEvent) => void; + /** Settlement tap: receives the RunSummary the engine fires + * after settlement (once, mounted runs only). */ + readonly onSettled?: (summary: RunSummary) => void; readonly cwd?: string; readonly isTty?: { stdin?: boolean; stdout?: boolean; stderr?: boolean }; readonly env?: Readonly>; @@ -150,6 +154,7 @@ export function createTestCli(spec: { onStreamEvent: (frame) => { frames.push(frame); }, + onSettled: opts?.onSettled, answers: opts?.answers, managementApi: spec.managementApi?.client === undefined diff --git a/packages/cli-engine/tests/run-hooks.test.ts b/packages/cli-engine/tests/run-hooks.test.ts new file mode 100644 index 00000000..954c7ed5 --- /dev/null +++ b/packages/cli-engine/tests/run-hooks.test.ts @@ -0,0 +1,176 @@ +import { + defineCommand, + flag, + positional, + type RunSummary, +} from "@prisma/cli-engine"; +import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, it } from "vitest"; + +const deploy = defineCommand({ + help: { summary: "Deploy something" }, + args: { + flags: { + dryRun: flag.boolean({ brief: "no writes" }), + name: flag.string({ brief: "deployment name", placeholder: "name" }), + count: flag.number({ brief: "how many", alias: "c", placeholder: "n" }), + }, + positionals: { + target: positional.string({ brief: "where", placeholder: "target" }), + extra: positional.optionalString({ + brief: "spare", + placeholder: "extra", + }), + }, + }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: null }, + { human: () => [{ kind: "summary", tone: "ok", text: "deployed" }] }, + ), + ), +}); + +const failing = defineCommand({ + help: { summary: "Always errors" }, + handler: async () => notOk(new CliStructuredError("APP.BROKEN", "It broke")), +}); + +/** Fixed-step injectable clock: every now() call advances exactly one + * second, so durationMs is a positive multiple of 1000 iff it came + * from this clock and not from wall time. */ +function steppingClock(): () => Date { + let tick = 0; + return () => { + tick += 1_000; + return new Date(tick); + }; +} + +function makeCli(options?: { now?: () => Date }) { + return createTestCli({ + commands: { "app deploy": deploy, "app fail": failing }, + groups: { app: { brief: "app commands" } }, + now: options?.now ?? (() => new Date(0)), + }); +} + +describe("RunHooks.onSettled", () => { + it("fires exactly once with the command identity, exit code, and value-free snapshot", async () => { + const summaries: RunSummary[] = []; + const result = await makeCli().run( + ["app", "deploy", "--dry-run", "--name", "secret-value", "prod-target"], + { onSettled: (summary) => summaries.push(summary) }, + ); + + expect(result.exitCode).toBe(0); + expect(summaries).toHaveLength(1); + const summary = summaries[0]; + expect(summary?.commandId).toBe("app.deploy"); + expect(summary?.exitCode).toBe(0); + expect(summary?.snapshot.commandPath).toEqual(["app", "deploy"]); + expect(summary?.snapshot.positionalCount).toBe(1); + expect(summary?.snapshot.flags).toEqual([ + { name: "format", source: "default" }, + { name: "json", source: "default" }, + { name: "log-level", source: "default" }, + { name: "verbose", source: "default" }, + { name: "quiet", source: "default" }, + { name: "yes", source: "default" }, + { name: "interactive", source: "default" }, + { name: "color", source: "default" }, + { name: "dry-run", source: "cli" }, + { name: "name", source: "cli" }, + { name: "count", source: "default" }, + ]); + // Value-free: flag values and positional values never appear. + const serialised = JSON.stringify(summary); + expect(serialised).not.toContain("secret-value"); + expect(serialised).not.toContain("prod-target"); + }); + + it("marks alias-passed and =-form flags as cli, and shared flags when explicitly set", async () => { + const summaries: RunSummary[] = []; + await makeCli().run( + ["app", "deploy", "-c", "3", "--name=x", "--yes", "t"], + { + onSettled: (summary) => summaries.push(summary), + }, + ); + + const bySource = new Map( + summaries[0]?.snapshot.flags.map((entry) => [entry.name, entry.source]), + ); + expect(bySource.get("count")).toBe("cli"); + expect(bySource.get("name")).toBe("cli"); + expect(bySource.get("yes")).toBe("cli"); + expect(bySource.get("dry-run")).toBe("default"); + }); + + it("fires for errored runs with the errored exit code", async () => { + const summaries: RunSummary[] = []; + const result = await makeCli().run(["app", "fail"], { + onSettled: (summary) => summaries.push(summary), + }); + + expect(result.exitCode).toBe(2); + expect(summaries).toHaveLength(1); + expect(summaries[0]?.commandId).toBe("app.fail"); + expect(summaries[0]?.exitCode).toBe(2); + expect(summaries[0]?.snapshot.positionalCount).toBe(0); + }); + + it("derives durationMs from the injectable clock", async () => { + const summaries: RunSummary[] = []; + await makeCli({ now: steppingClock() }).run(["app", "fail"], { + onSettled: (summary) => summaries.push(summary), + }); + + const durationMs = summaries[0]?.durationMs; + expect(durationMs).toBeGreaterThan(0); + expect((durationMs ?? 0) % 1_000).toBe(0); + }); + + it("swallows a throwing hook without changing the run's outcome", async () => { + const result = await makeCli().run(["app", "deploy", "t"], { + onSettled: () => { + throw new Error("telemetry bug"); + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("telemetry bug"); + }); + + it("does not fire for --help", async () => { + const summaries: RunSummary[] = []; + const result = await makeCli().run(["app", "deploy", "--help"], { + onSettled: (summary) => summaries.push(summary), + }); + + expect(result.exitCode).toBe(0); + expect(summaries).toHaveLength(0); + }); + + it("does not fire for --version", async () => { + const summaries: RunSummary[] = []; + const result = await makeCli().run(["--version"], { + onSettled: (summary) => summaries.push(summary), + }); + + expect(result.exitCode).toBe(0); + expect(summaries).toHaveLength(0); + }); + + it("does not fire for a usage error that never reaches a mounted command", async () => { + const summaries: RunSummary[] = []; + const result = await makeCli().run(["app", "no-such-command"], { + onSettled: (summary) => summaries.push(summary), + }); + + expect(result.exitCode).not.toBe(0); + expect(summaries).toHaveLength(0); + }); +}); diff --git a/packages/cli-telemetry/package.json b/packages/cli-telemetry/package.json index 0786213f..a86960f1 100644 --- a/packages/cli-telemetry/package.json +++ b/packages/cli-telemetry/package.json @@ -4,7 +4,10 @@ "version": "0.0.0", "description": "CLI telemetry client: detached subprocess sender, gating resolution, user-config store, and the value-free command sanitizer", "type": "module", - "sideEffects": false, + "sideEffects": [ + "./dist/sender.js", + "./src/sender.ts" + ], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/cli-telemetry/src/enrich.ts b/packages/cli-telemetry/src/enrich.ts index 570e0f44..cc55d6ca 100644 --- a/packages/cli-telemetry/src/enrich.ts +++ b/packages/cli-telemetry/src/enrich.ts @@ -183,7 +183,9 @@ export function parsePackageManager( * prefix. Returns `null` on any failure mode — file missing, * unreadable, malformed JSON, key absent, not a string. */ -export function readTsVersionFromPackageJson(raw: string | null): string | null { +export function readTsVersionFromPackageJson( + raw: string | null, +): string | null { if (raw === null) return null; let parsed: Record; try { diff --git a/packages/cli-telemetry/src/payload.ts b/packages/cli-telemetry/src/payload.ts index 68640ce6..57e42289 100644 --- a/packages/cli-telemetry/src/payload.ts +++ b/packages/cli-telemetry/src/payload.ts @@ -85,7 +85,10 @@ export function isParentToSenderPayload( if (!isStringArray(record["flags"])) return false; if (!isNonEmptyString(record["projectRoot"])) return false; if (!isNonEmptyString(record["endpoint"])) return false; - if ("databaseTarget" in record && typeof record["databaseTarget"] !== "string") { + if ( + "databaseTarget" in record && + typeof record["databaseTarget"] !== "string" + ) { return false; } return true; diff --git a/packages/cli-telemetry/src/user-config.ts b/packages/cli-telemetry/src/user-config.ts index 80b9c96b..7b2c4987 100644 --- a/packages/cli-telemetry/src/user-config.ts +++ b/packages/cli-telemetry/src/user-config.ts @@ -76,7 +76,11 @@ export function readUserConfig(): UserConfig { try { const raw = readFileSync(path, "utf-8"); const parsed: unknown = JSON.parse(raw); - if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + if ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ) { return parsed as UserConfig; } return {}; diff --git a/packages/cli-telemetry/tests/endpoint.test.ts b/packages/cli-telemetry/tests/endpoint.test.ts index bb3b9aef..48d28e1c 100644 --- a/packages/cli-telemetry/tests/endpoint.test.ts +++ b/packages/cli-telemetry/tests/endpoint.test.ts @@ -21,9 +21,9 @@ describe("resolveTelemetryEndpoint", () => { }); it("treats an empty PRISMA_NEXT_TELEMETRY_ENDPOINT as unset (falls back to production)", () => { - expect(resolveTelemetryEndpoint({ PRISMA_NEXT_TELEMETRY_ENDPOINT: "" })).toBe( - `${TELEMETRY_BACKEND_URL}${TELEMETRY_ENDPOINT_PATH}`, - ); + expect( + resolveTelemetryEndpoint({ PRISMA_NEXT_TELEMETRY_ENDPOINT: "" }), + ).toBe(`${TELEMETRY_BACKEND_URL}${TELEMETRY_ENDPOINT_PATH}`); }); it("preserves a trailing path in the override base (e.g. mock servers using a sub-path)", () => { @@ -36,7 +36,9 @@ describe("resolveTelemetryEndpoint", () => { it("falls back to the production backend without throwing when the override is malformed", () => { expect( - resolveTelemetryEndpoint({ PRISMA_NEXT_TELEMETRY_ENDPOINT: "invalid-url" }), + resolveTelemetryEndpoint({ + PRISMA_NEXT_TELEMETRY_ENDPOINT: "invalid-url", + }), ).toBe(`${TELEMETRY_BACKEND_URL}${TELEMETRY_ENDPOINT_PATH}`); }); }); diff --git a/packages/cli-telemetry/tests/enrich.test.ts b/packages/cli-telemetry/tests/enrich.test.ts index 8b22f8a6..d8424594 100644 --- a/packages/cli-telemetry/tests/enrich.test.ts +++ b/packages/cli-telemetry/tests/enrich.test.ts @@ -113,7 +113,9 @@ describe("readTsVersionFromPackageJson", () => { it("returns null when typescript key is absent", () => { expect( - readTsVersionFromPackageJson(JSON.stringify({ dependencies: { foo: "1.0" } })), + readTsVersionFromPackageJson( + JSON.stringify({ dependencies: { foo: "1.0" } }), + ), ).toBeNull(); }); @@ -188,7 +190,8 @@ describe("buildTelemetryEvent", () => { it("passes null packageManager when npm_config_user_agent is absent", () => { expect( - buildTelemetryEvent(basePayload, baseProjectConfig, baseEnv).packageManager, + buildTelemetryEvent(basePayload, baseProjectConfig, baseEnv) + .packageManager, ).toBeNull(); }); diff --git a/packages/cli-telemetry/tests/gating.test.ts b/packages/cli-telemetry/tests/gating.test.ts index 56a3451e..2c0c34c6 100644 --- a/packages/cli-telemetry/tests/gating.test.ts +++ b/packages/cli-telemetry/tests/gating.test.ts @@ -3,9 +3,9 @@ import { resolveGating } from "../src/gating"; describe("resolveGating", () => { it("returns enabled=true when no env override and stored enableTelemetry is true", () => { - expect(resolveGating({ env: {}, config: { enableTelemetry: true } })).toEqual( - { enabled: true }, - ); + expect( + resolveGating({ env: {}, config: { enableTelemetry: true } }), + ).toEqual({ enabled: true }); }); it("returns enabled=false when stored enableTelemetry is false", () => { diff --git a/packages/cli-telemetry/tests/payload.test.ts b/packages/cli-telemetry/tests/payload.test.ts index 0b5d327c..2257830b 100644 --- a/packages/cli-telemetry/tests/payload.test.ts +++ b/packages/cli-telemetry/tests/payload.test.ts @@ -38,7 +38,10 @@ describe("isParentToSenderPayload", () => { isParentToSenderPayload({ ...validPayload, databaseTarget: 42 }), ).toBe(false); expect( - isParentToSenderPayload({ ...validPayload, databaseTarget: ["postgres"] }), + isParentToSenderPayload({ + ...validPayload, + databaseTarget: ["postgres"], + }), ).toBe(false); // `null` is not a valid override value — the IPC channel uses // `undefined` (field omitted) for the "no override" state. The wire- @@ -72,9 +75,9 @@ describe("isParentToSenderPayload", () => { } it("rejects an empty installationId", () => { - expect(isParentToSenderPayload({ ...validPayload, installationId: "" })).toBe( - false, - ); + expect( + isParentToSenderPayload({ ...validPayload, installationId: "" }), + ).toBe(false); }); it("rejects an empty endpoint", () => { diff --git a/packages/cli-telemetry/tests/sender-integration.test.ts b/packages/cli-telemetry/tests/sender-integration.test.ts index 85e22c3e..1c699276 100644 --- a/packages/cli-telemetry/tests/sender-integration.test.ts +++ b/packages/cli-telemetry/tests/sender-integration.test.ts @@ -56,7 +56,10 @@ beforeAll(async () => { projectDir = mkdtempSync(join(tmpdir(), "cli-telemetry-sender-int-")); writeFileSync( join(projectDir, "package.json"), - JSON.stringify({ name: "fixture", devDependencies: { typescript: "^5.9.3" } }), + JSON.stringify({ + name: "fixture", + devDependencies: { typescript: "^5.9.3" }, + }), ); }); @@ -203,7 +206,9 @@ describe("sender end-to-end via a local mock backend", () => { }); it("derives databaseTarget and extensions from a prisma-next.config.* in projectRoot", async () => { - const configuredDir = mkdtempSync(join(tmpdir(), "cli-telemetry-sender-cfg-")); + const configuredDir = mkdtempSync( + join(tmpdir(), "cli-telemetry-sender-cfg-"), + ); try { writeFileSync( join(configuredDir, "prisma-next.config.mjs"), @@ -258,14 +263,12 @@ describe("sender end-to-end via a local mock backend", () => { expect(result.stderr).toContain("send failed"); }); - it( - "exits 0 when no payload arrives within the idle timeout, and stays silent", - { timeout: 10_000 }, - async () => { - const result = await spawnSender({ env: childEnv() }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }, - ); + it("exits 0 when no payload arrives within the idle timeout, and stays silent", { + timeout: 10_000, + }, async () => { + const result = await spawnSender({ env: childEnv() }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); }); diff --git a/packages/cli-telemetry/tests/spawn.test.ts b/packages/cli-telemetry/tests/spawn.test.ts index 2d021898..c164eba4 100644 --- a/packages/cli-telemetry/tests/spawn.test.ts +++ b/packages/cli-telemetry/tests/spawn.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { EngineCommandSnapshot } from "../src/sanitize"; -import { runTelemetry, senderModuleUrl, type RunTelemetryInputs } from "../src/spawn"; +import { + type RunTelemetryInputs, + runTelemetry, + senderModuleUrl, +} from "../src/spawn"; import { userConfigPath, writeUserConfig } from "../src/user-config"; const commandInput: EngineCommandSnapshot = { diff --git a/packages/cli-telemetry/tests/user-config.test.ts b/packages/cli-telemetry/tests/user-config.test.ts index 33a8af37..5e5b83a6 100644 --- a/packages/cli-telemetry/tests/user-config.test.ts +++ b/packages/cli-telemetry/tests/user-config.test.ts @@ -51,7 +51,10 @@ describe("readUserConfig / writeUserConfig", () => { it("parses a well-formed config and exposes both known fields", () => { writeFileSync( userConfigPath(), - JSON.stringify({ enableTelemetry: true, installationId: "pre-existing-uuid" }), + JSON.stringify({ + enableTelemetry: true, + installationId: "pre-existing-uuid", + }), ); const cfg = readUserConfig(); expect(cfg.enableTelemetry).toBe(true); @@ -163,7 +166,10 @@ describe("ensureInstallationId", () => { }); it("returns the existing id and does not rotate it", () => { - writeFileSync(userConfigPath(), JSON.stringify({ installationId: "sticky-id" })); + writeFileSync( + userConfigPath(), + JSON.stringify({ installationId: "sticky-id" }), + ); expect(ensureInstallationId()).toBe("sticky-id"); expect(readUserConfig().installationId).toBe("sticky-id"); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 1bd91a85..3bb23c9d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,10 @@ "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "1.55.0", + "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", + "c12": "^3.3.4", + "ci-info": "^4.3.1", "colorette": "^2.0.20", "commander": "^14.0.3", "dotenv": "^17.4.2", @@ -61,6 +64,7 @@ "wrap-ansi": "^10.0.0" }, "devDependencies": { + "@repo/cli-telemetry": "workspace:*", "@repo/tsconfig": "workspace:*", "@types/node": "^22.19.19", "tsdown": "^0.21.10", diff --git a/packages/cli/src/v8/cli.ts b/packages/cli/src/v8/cli.ts index 37c82a15..28797715 100644 --- a/packages/cli/src/v8/cli.ts +++ b/packages/cli/src/v8/cli.ts @@ -1,6 +1,11 @@ import { type Cli, createCli, defineCommandFamily } from "@prisma/cli-engine"; import { getCliVersion } from "../lib/version"; import { authWhoamiCommand } from "./auth/whoami"; +import { + telemetryDisableCommand, + telemetryEnableCommand, + telemetryStatusCommand, +} from "./telemetry/commands"; export function buildCli(): Cli { return createCli({ @@ -11,9 +16,20 @@ export function buildCli(): Cli { ], groups: { auth: { brief: "Manage local authentication for the CLI" }, + telemetry: { + brief: "Inspect and change anonymous CLI telemetry", + description: + "Show telemetry status, or enable / disable anonymous CLI usage data.\n" + + "Telemetry is on by default (opt-out); see https://prisma-next.dev/docs/cli/telemetry\n" + + "for what is collected and why.", + }, }, commands: { "auth whoami": authWhoamiCommand, + // Shell-owned consent surface (no command family). + "telemetry status": telemetryStatusCommand, + "telemetry enable": telemetryEnableCommand, + "telemetry disable": telemetryDisableCommand, }, }); } diff --git a/packages/cli/src/v8/main.ts b/packages/cli/src/v8/main.ts index 49558a6e..832609c7 100644 --- a/packages/cli/src/v8/main.ts +++ b/packages/cli/src/v8/main.ts @@ -1,11 +1,15 @@ -import type { Cli } from "@prisma/cli-engine"; +import type { Cli, CliRunHooks } from "@prisma/cli-engine"; import { buildCli } from "./cli"; import { assembleRuntime, type HostProcess } from "./runtime"; +import { resolveTelemetryHooks } from "./telemetry/wiring"; /** The bin body: build, run, return the exit code. Signal policy lives * in the engine; the bin only forwards signals and provides * process.exit. A construction error prints one line to stderr and - * exits 1. */ + * exits 1. Telemetry: the CI/env/consent decision resolves before the + * run; when enabled an onSettled hook spawns the detached sender, + * when disabled no hook is attached — and a failure inside the + * telemetry layer never blocks the command. */ export async function main( proc: HostProcess, buildCliForRun: () => Cli = buildCli, @@ -20,5 +24,11 @@ export async function main( return 1; } const runtime = await assembleRuntime(proc); - return cli.run(proc.argv.slice(2), runtime); + let hooks: CliRunHooks | undefined; + try { + hooks = resolveTelemetryHooks(proc); + } catch { + hooks = undefined; + } + return cli.run(proc.argv.slice(2), runtime, hooks); } diff --git a/packages/cli/src/v8/telemetry/commands.ts b/packages/cli/src/v8/telemetry/commands.ts new file mode 100644 index 00000000..36249dc5 --- /dev/null +++ b/packages/cli/src/v8/telemetry/commands.ts @@ -0,0 +1,109 @@ +/** + * The consent surface: `telemetry status|enable|disable`, ported from + * the ORM CLI's commander implementation as engine result commands. + * Semantics and copy match the reference: `status` is a pure read + * (never sends, never mints, never writes); `enable` stores the + * opt-in and mints an installation id when none exists; `disable` + * stores the opt-out and mints nothing. Mounted shell-owned (no + * command family), group `telemetry`. + */ +import { defineCommand, type Presentations } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { userConfigPath, writeUserConfig } from "@repo/cli-telemetry"; +import { isCI } from "./is-ci"; +import { + formatTelemetryStatusLines, + resolveTelemetryStatus, + statusSummaryLine, + type TelemetryStatus, +} from "./status"; + +function statusPresentations(status: TelemetryStatus): Presentations { + return { + human: () => [ + { kind: "summary", tone: "info", text: statusSummaryLine(status) }, + { + kind: "fields", + rows: [ + { label: "Config file", value: status.configPath }, + { + label: "Installation ID", + value: status.installationIdStored ? "stored" : "not stored", + }, + ], + }, + ], + stdout: () => formatTelemetryStatusLines(status), + json: () => status, + }; +} + +export const telemetryStatusCommand = defineCommand({ + help: { + summary: "Show whether anonymous CLI telemetry is enabled and why", + description: + "Reports whether telemetry is currently enabled or disabled and the reason\n" + + "(default-on, stored opt-out, environment opt-out, or CI), the path to your\n" + + "user-level config file, and whether an installation ID has been stored.\n" + + "Read-only: never sends an event, never mints an ID, never writes anything.", + examples: ["telemetry status", "telemetry status --json"], + }, + handler: async (_args, ctx) => { + const status = resolveTelemetryStatus({ env: ctx.env, inCI: isCI() }); + return ok(ctx.present({ data: status }, statusPresentations(status))); + }, +}); + +function consentPresentations(line: string, json: unknown): Presentations { + return { + human: () => [{ kind: "summary", tone: "ok", text: line }], + stdout: () => [line], + json: () => json, + }; +} + +export const telemetryEnableCommand = defineCommand({ + help: { + summary: "Enable anonymous CLI telemetry", + description: + 'Stores "enableTelemetry": true in your user-level config and mints an\n' + + "installation ID if one is not already stored.", + examples: ["telemetry enable"], + }, + handler: async (_args, ctx) => { + writeUserConfig({ enableTelemetry: true }); + const configPath = userConfigPath(); + return ok( + ctx.present( + { data: { enableTelemetry: true, configPath } }, + consentPresentations( + `Telemetry enabled. Preference stored in ${configPath}.`, + { enableTelemetry: true, configPath }, + ), + ), + ); + }, +}); + +export const telemetryDisableCommand = defineCommand({ + help: { + summary: "Disable anonymous CLI telemetry", + description: + 'Stores "enableTelemetry": false in your user-level config. No installation\n' + + "ID is minted and no event is sent.", + examples: ["telemetry disable"], + }, + handler: async (_args, ctx) => { + writeUserConfig({ enableTelemetry: false }); + const configPath = userConfigPath(); + return ok( + ctx.present( + { data: { enableTelemetry: false, configPath } }, + consentPresentations( + `Telemetry disabled. Preference stored in ${configPath}.`, + { enableTelemetry: false, configPath }, + ), + ), + ); + }, +}); diff --git a/packages/cli/src/v8/telemetry/is-ci.ts b/packages/cli/src/v8/telemetry/is-ci.ts new file mode 100644 index 00000000..6ae0d493 --- /dev/null +++ b/packages/cli/src/v8/telemetry/is-ci.ts @@ -0,0 +1,12 @@ +import { isCI as ciInfoIsCI } from "ci-info"; + +/** + * Returns true when the process is running in any CI environment + * recognised by the `ci-info` package — the standard `CI=true` marker + * plus dozens of provider-specific environment variables a raw + * `process.env.CI` read misses. The single source of truth for CI + * detection in the v8 shell so callers cannot drift from each other. + */ +export function isCI(): boolean { + return ciInfoIsCI; +} diff --git a/packages/cli/src/v8/telemetry/sender.ts b/packages/cli/src/v8/telemetry/sender.ts new file mode 100644 index 00000000..f26fd17f --- /dev/null +++ b/packages/cli/src/v8/telemetry/sender.ts @@ -0,0 +1,7 @@ +/** + * Build entry that carries `@repo/cli-telemetry`'s detached sender + * into the cli's own dist (`dist/v8/sender.js`) — the telemetry + * package is private and bundled, so the published cli must ship the + * forkable sender script itself. + */ +import "@repo/cli-telemetry/sender"; diff --git a/packages/cli/src/v8/telemetry/status.ts b/packages/cli/src/v8/telemetry/status.ts new file mode 100644 index 00000000..1c82e8fa --- /dev/null +++ b/packages/cli/src/v8/telemetry/status.ts @@ -0,0 +1,78 @@ +import { + readUserConfig, + resolveGating, + userConfigPath, +} from "@repo/cli-telemetry"; + +/** + * Why telemetry resolves the way it does, in the order the shell's + * hook wiring evaluates: CI hard-disables first, then the env + * opt-outs, then the stored `enableTelemetry`, then the opt-out default. + */ +export type TelemetryStatusReason = + | "ci" + | "env-opt-out" + | "stored-opt-out" + | "stored-opt-in" + | "default-on"; + +export interface TelemetryStatus { + readonly enabled: boolean; + readonly reason: TelemetryStatusReason; + readonly configPath: string; + readonly installationIdStored: boolean; +} + +/** + * Resolves the same decision the runtime wiring uses (CI check + + * `resolveGating`) and projects it into a user-facing status. Pure + * read: never mints, never writes. The `installationId` value itself + * is never surfaced — only its presence — so `status` discloses + * nothing identifying. + */ +export function resolveTelemetryStatus(inputs: { + readonly env: Readonly>; + readonly inCI: boolean; +}): TelemetryStatus { + const config = readUserConfig(); + const configPath = userConfigPath(); + const installationIdStored = + typeof config.installationId === "string" && + config.installationId.length > 0; + + if (inputs.inCI) { + return { enabled: false, reason: "ci", configPath, installationIdStored }; + } + + const gating = resolveGating({ env: inputs.env, config }); + if (!gating.enabled) { + const reason: TelemetryStatusReason = + gating.reason === "env-override" ? "env-opt-out" : "stored-opt-out"; + return { enabled: false, reason, configPath, installationIdStored }; + } + + const reason: TelemetryStatusReason = + config.enableTelemetry === true ? "stored-opt-in" : "default-on"; + return { enabled: true, reason, configPath, installationIdStored }; +} + +const REASON_EXPLANATION: Record = { + ci: "CI environment detected — telemetry is hard-disabled.", + "env-opt-out": + "an environment opt-out is set (DO_NOT_TRACK / PRISMA_NEXT_DISABLE_TELEMETRY).", + "stored-opt-out": '"enableTelemetry": false is stored in your config.', + "stored-opt-in": '"enableTelemetry": true is stored in your config.', + "default-on": "no explicit choice is stored, so the opt-out default applies.", +}; + +export function statusSummaryLine(status: TelemetryStatus): string { + return `Telemetry is ${status.enabled ? "enabled" : "disabled"}: ${REASON_EXPLANATION[status.reason]}`; +} + +export function formatTelemetryStatusLines(status: TelemetryStatus): string[] { + return [ + statusSummaryLine(status), + `Config file: ${status.configPath}`, + `Installation ID: ${status.installationIdStored ? "stored" : "not stored"}`, + ]; +} diff --git a/packages/cli/src/v8/telemetry/wiring.ts b/packages/cli/src/v8/telemetry/wiring.ts new file mode 100644 index 00000000..78de7e80 --- /dev/null +++ b/packages/cli/src/v8/telemetry/wiring.ts @@ -0,0 +1,142 @@ +/** + * Bin-side telemetry wiring, sequenced like the ORM CLI's preAction + * wiring: resolve the CI/env/consent decision up front (cheap, all + * in-memory plus one tiny user-config read); only when enabled, + * attach an `onSettled` hook that spawns the detached sender via + * `runTelemetry` (fork + IPC send + disconnect + unref, every failure + * swallowed). When the decision comes back disabled, no hook is + * attached at all. + */ +import { fileURLToPath } from "node:url"; +import type { CliRunHooks, HostProcess } from "@prisma/cli-engine"; +import { + ensureInstallationId, + type RunTelemetryInputs, + readUserConfig, + resolveGating, + runTelemetry, + type TelemetryRunOutcome, + type UserConfig, + userConfigPath, +} from "@repo/cli-telemetry"; +import { getCliVersion } from "../../lib/version"; +import { isCI } from "./is-ci"; + +/** + * Path to the compiled sender entry. In the workspace (dev runs and + * the monorepo dist) the package specifier resolves to + * `packages/cli-telemetry/dist/sender.js`; in the published cli the + * telemetry package is bundled away, so the fallback resolves the + * copy tsdown emits next to the v8 entry (`dist/v8/sender.js`). + */ +function resolveSenderPath(): string { + try { + return fileURLToPath(import.meta.resolve("@repo/cli-telemetry/sender")); + } catch { + return fileURLToPath(new URL("./sender.js", import.meta.url)); + } +} + +/** + * The one-time first-run disclosure. The resolved absolute path to + * the user-level config file is substituted in so the user can see + * exactly which file to edit. `telemetry disable` is named as the + * primary, friendliest opt-out, alongside the env vars and the + * config edit. + */ +function firstRunNotice(configPath: string): string { + return [ + "Prisma collects anonymous CLI usage data, enabled by default.", + "What's collected and why: https://prisma-next.dev/docs/cli/telemetry.", + 'Opt out: run "prisma telemetry disable", set DO_NOT_TRACK=1 or', + `PRISMA_NEXT_DISABLE_TELEMETRY=1, or set "enableTelemetry": false in ${configPath}.`, + ].join(" "); +} + +/** + * Best-effort first-run disclosure + installationId mint. Runs only on + * the enabled path. Prints the notice to stderr (never stdout) and + * mints a persistent id without touching `enableTelemetry`, so the + * opt-out default stays intact and no unasked-for consent is recorded. + * + * Every step is wrapped so an un-writable config dir (or any other + * failure) never throws and never blocks the command. On mint failure + * it returns `undefined`: the notice may reprint next run, and + * `runTelemetry` no-ops on the missing id. + */ +function discloseAndMintOnFirstRun( + stderr: HostProcess["stderr"], +): string | undefined { + try { + stderr.write(`${firstRunNotice(userConfigPath())}\n`); + } catch {} + try { + return ensureInstallationId(); + } catch {} + return undefined; +} + +export interface TelemetryWiringOptions { + /** CI decision override; defaults to `isCI()` (ci-info). */ + readonly inCI?: boolean; + /** Spawn seam for tests; defaults to `runTelemetry`. */ + readonly fire?: (inputs: RunTelemetryInputs) => TelemetryRunOutcome; + /** Sender path override; defaults to {@link resolveSenderPath}. */ + readonly senderPath?: string; +} + +/** + * Resolve the telemetry decision for this process and return the hook + * to attach — or `undefined` when telemetry is off (CI, env opt-out, + * or stored opt-out), so a disabled run carries no hook at all. + * + * The attached hook fires after settlement with the engine's + * value-free snapshot. The `telemetry` command and its subcommands are + * exempt — `telemetry disable` must not send a usage event before + * disabling, and `telemetry status` must not mint an id while merely + * reporting state. On the enabled path with no stored id yet, the hook + * performs the first-run disclosure and mints the shared installation + * id before spawning the sender. + */ +export function resolveTelemetryHooks( + proc: Pick, + options?: TelemetryWiringOptions, +): CliRunHooks | undefined { + const inCI = options?.inCI ?? isCI(); + if (inCI) { + return undefined; + } + const userConfig = readUserConfig(); + if (!resolveGating({ env: proc.env, config: userConfig }).enabled) { + return undefined; + } + const fire = options?.fire ?? runTelemetry; + return { + onSettled: (summary) => { + try { + if (summary.snapshot.commandPath[0] === "telemetry") { + return; + } + let config: UserConfig = userConfig; + const storedId = config.installationId; + if (typeof storedId !== "string" || storedId.length === 0) { + const installationId = discloseAndMintOnFirstRun(proc.stderr); + if (installationId !== undefined) { + config = { ...config, installationId }; + } + } + fire({ + command: summary.snapshot, + version: getCliVersion(), + projectRoot: proc.cwd(), + senderPath: options?.senderPath ?? resolveSenderPath(), + isCI: inCI, + env: proc.env, + userConfig: config, + }); + } catch { + // Telemetry must never break a command. + } + }, + }; +} diff --git a/packages/cli/tests/v8-telemetry-wiring.test.ts b/packages/cli/tests/v8-telemetry-wiring.test.ts new file mode 100644 index 00000000..4ae22743 --- /dev/null +++ b/packages/cli/tests/v8-telemetry-wiring.test.ts @@ -0,0 +1,196 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { RunSummary } from "@prisma/cli-engine"; +import { + type RunTelemetryInputs, + readUserConfig, + userConfigPath, +} from "@repo/cli-telemetry"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveTelemetryHooks } from "../src/v8/telemetry/wiring"; + +const V4_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function makeSummary(overrides: Partial = {}): RunSummary { + return { + commandId: "auth.whoami", + exitCode: 0, + durationMs: 5, + snapshot: { + commandPath: ["auth", "whoami"], + flags: [{ name: "json", source: "cli" }], + positionalCount: 0, + }, + ...overrides, + }; +} + +function makeProc(env: Record = {}) { + const proc = { + env, + cwd: () => "/project/root", + stderrText: "", + stderr: { + write(text: string) { + proc.stderrText += text; + }, + }, + }; + return proc; +} + +let xdgRoot: string; +let originalXdg: string | undefined; + +beforeEach(() => { + xdgRoot = mkdtempSync(join(tmpdir(), "v8-telemetry-wiring-")); + originalXdg = process.env["XDG_CONFIG_HOME"]; + process.env["XDG_CONFIG_HOME"] = xdgRoot; + mkdirSync(dirname(userConfigPath()), { recursive: true }); +}); + +afterEach(() => { + if (originalXdg === undefined) { + delete process.env["XDG_CONFIG_HOME"]; + } else { + process.env["XDG_CONFIG_HOME"] = originalXdg; + } + rmSync(xdgRoot, { recursive: true, force: true }); +}); + +describe("resolveTelemetryHooks — decision resolution", () => { + it("attaches no hook in CI, even with a stored opt-in", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ enableTelemetry: true, installationId: "id-1" }), + ); + expect( + resolveTelemetryHooks(makeProc(), { inCI: true, fire: vi.fn() }), + ).toBeUndefined(); + }); + + it("attaches no hook under an env opt-out", () => { + expect( + resolveTelemetryHooks(makeProc({ PRISMA_NEXT_DISABLE_TELEMETRY: "1" }), { + inCI: false, + fire: vi.fn(), + }), + ).toBeUndefined(); + expect( + resolveTelemetryHooks(makeProc({ DO_NOT_TRACK: "1" }), { + inCI: false, + fire: vi.fn(), + }), + ).toBeUndefined(); + }); + + it("attaches no hook under a stored opt-out", () => { + writeFileSync(userConfigPath(), JSON.stringify({ enableTelemetry: false })); + expect( + resolveTelemetryHooks(makeProc(), { inCI: false, fire: vi.fn() }), + ).toBeUndefined(); + }); + + it("attaches a hook on the opt-out default (no stored choice)", () => { + expect( + resolveTelemetryHooks(makeProc(), { inCI: false, fire: vi.fn() }), + ).toBeDefined(); + }); +}); + +describe("resolveTelemetryHooks — the attached hook", () => { + it("fires the sender spawn with the engine snapshot, project root, and stored id", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ enableTelemetry: true, installationId: "stored-id" }), + ); + const fire = vi.fn().mockReturnValue({ spawned: true }); + const proc = makeProc(); + const hooks = resolveTelemetryHooks(proc, { + inCI: false, + fire, + senderPath: "/sender/path.js", + }); + + hooks?.onSettled?.(makeSummary()); + + expect(fire).toHaveBeenCalledTimes(1); + const inputs = fire.mock.calls[0]?.[0] as RunTelemetryInputs; + expect(inputs.command).toEqual({ + commandPath: ["auth", "whoami"], + flags: [{ name: "json", source: "cli" }], + positionalCount: 0, + }); + expect(inputs.projectRoot).toBe("/project/root"); + expect(inputs.senderPath).toBe("/sender/path.js"); + expect(inputs.isCI).toBe(false); + expect(inputs.userConfig?.installationId).toBe("stored-id"); + expect(typeof inputs.version).toBe("string"); + // No first-run notice for a returning installation. + expect(proc.stderrText).toBe(""); + }); + + it("discloses on stderr and mints the shared installation id on the first enabled run", () => { + const fire = vi.fn().mockReturnValue({ spawned: true }); + const proc = makeProc(); + const hooks = resolveTelemetryHooks(proc, { + inCI: false, + fire, + senderPath: "/sender/path.js", + }); + + hooks?.onSettled?.(makeSummary()); + + expect(proc.stderrText).toContain( + "Prisma collects anonymous CLI usage data, enabled by default.", + ); + expect(proc.stderrText).toContain(userConfigPath()); + const stored = readUserConfig(); + expect(stored.installationId).toMatch(V4_UUID); + // The mint records no consent the user never gave. + expect(stored.enableTelemetry).toBeUndefined(); + const inputs = fire.mock.calls[0]?.[0] as RunTelemetryInputs; + expect(inputs.userConfig?.installationId).toBe(stored.installationId); + }); + + it("never fires for the telemetry command family itself", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ enableTelemetry: true, installationId: "stored-id" }), + ); + const fire = vi.fn(); + const hooks = resolveTelemetryHooks(makeProc(), { inCI: false, fire }); + + for (const leaf of ["status", "enable", "disable"]) { + hooks?.onSettled?.( + makeSummary({ + commandId: `telemetry.${leaf}`, + snapshot: { + commandPath: ["telemetry", leaf], + flags: [], + positionalCount: 0, + }, + }), + ); + } + + expect(fire).not.toHaveBeenCalled(); + }); + + it("swallows a throwing spawn — the hook never propagates", () => { + writeFileSync( + userConfigPath(), + JSON.stringify({ enableTelemetry: true, installationId: "stored-id" }), + ); + const fire = vi.fn(() => { + throw new Error("spawn exploded"); + }); + const hooks = resolveTelemetryHooks(makeProc(), { inCI: false, fire }); + + expect(() => hooks?.onSettled?.(makeSummary())).not.toThrow(); + expect(fire).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/tests/v8-telemetry.test.ts b/packages/cli/tests/v8-telemetry.test.ts new file mode 100644 index 00000000..4bd3ef0d --- /dev/null +++ b/packages/cli/tests/v8-telemetry.test.ts @@ -0,0 +1,242 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { readUserConfig, userConfigPath } from "@repo/cli-telemetry"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + telemetryDisableCommand, + telemetryEnableCommand, + telemetryStatusCommand, +} from "../src/v8/telemetry/commands"; +import { isCI } from "../src/v8/telemetry/is-ci"; + +vi.mock("../src/v8/telemetry/is-ci", () => ({ isCI: vi.fn(() => false) })); + +const V4_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function makeCli() { + return createTestCli({ + commands: { + "telemetry status": telemetryStatusCommand, + "telemetry enable": telemetryEnableCommand, + "telemetry disable": telemetryDisableCommand, + }, + groups: { + telemetry: { brief: "Inspect and change anonymous CLI telemetry" }, + }, + now: () => new Date(0), + }); +} + +let xdgRoot: string; +let originalXdg: string | undefined; +let configPath: string; + +beforeEach(() => { + xdgRoot = mkdtempSync(join(tmpdir(), "v8-telemetry-cmd-")); + originalXdg = process.env["XDG_CONFIG_HOME"]; + process.env["XDG_CONFIG_HOME"] = xdgRoot; + configPath = userConfigPath(); + mkdirSync(dirname(configPath), { recursive: true }); + vi.mocked(isCI).mockReset(); + vi.mocked(isCI).mockReturnValue(false); +}); + +afterEach(() => { + if (originalXdg === undefined) { + delete process.env["XDG_CONFIG_HOME"]; + } else { + process.env["XDG_CONFIG_HOME"] = originalXdg; + } + rmSync(xdgRoot, { recursive: true, force: true }); +}); + +function seedConfig(config: Record): void { + writeFileSync(configPath, JSON.stringify(config)); +} + +describe("prisma-v8 telemetry status", () => { + it("reports the opt-out default when no choice is stored, without writing anything", async () => { + const result = await makeCli().run(["telemetry", "status"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe( + "Telemetry is enabled: no explicit choice is stored, so the opt-out default applies.\n" + + `Config file: ${configPath}\n` + + "Installation ID: not stored\n", + ); + expect(result.stderr).toContain("ℹ Telemetry is enabled"); + // Read-only: no config file appears, no id is minted. + expect(existsSync(configPath)).toBe(false); + }); + + it("reports a stored opt-in and the presence (never the value) of the installation id", async () => { + seedConfig({ enableTelemetry: true, installationId: "id-secret-123" }); + + const result = await makeCli().run(["telemetry", "status"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + 'Telemetry is enabled: "enableTelemetry": true is stored in your config.', + ); + expect(result.stdout).toContain("Installation ID: stored"); + expect(result.stdout).not.toContain("id-secret-123"); + }); + + it("reports a stored opt-out", async () => { + seedConfig({ enableTelemetry: false }); + + const result = await makeCli().run(["telemetry", "status"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + 'Telemetry is disabled: "enableTelemetry": false is stored in your config.', + ); + }); + + it("reports an environment opt-out over a stored opt-in", async () => { + seedConfig({ enableTelemetry: true, installationId: "id-1" }); + + const result = await makeCli().run(["telemetry", "status"], { + env: { DO_NOT_TRACK: "1" }, + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Telemetry is disabled: an environment opt-out is set (DO_NOT_TRACK / PRISMA_NEXT_DISABLE_TELEMETRY).", + ); + }); + + it("reports CI as hard-disabled ahead of every other signal", async () => { + vi.mocked(isCI).mockReturnValue(true); + seedConfig({ enableTelemetry: true, installationId: "id-1" }); + + const result = await makeCli().run(["telemetry", "status"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Telemetry is disabled: CI environment detected — telemetry is hard-disabled.", + ); + }); + + it("serializes the full status as the json envelope result", async () => { + seedConfig({ enableTelemetry: true, installationId: "id-1" }); + + const result = await makeCli().run(["telemetry", "status", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.json).toHaveLength(1); + const frame = result.json[0]; + if (frame?.kind !== "result" || frame.envelope.ok !== true) { + throw new Error("expected a completed result frame"); + } + expect(frame.envelope.commandId).toBe("telemetry.status"); + expect(frame.envelope.result).toEqual({ + enabled: true, + reason: "stored-opt-in", + configPath, + installationIdStored: true, + }); + }); +}); + +describe("prisma-v8 telemetry enable", () => { + it("stores the opt-in, mints an installation id, and names the config file", async () => { + const result = await makeCli().run(["telemetry", "enable"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe( + `Telemetry enabled. Preference stored in ${configPath}.\n`, + ); + const config = readUserConfig(); + expect(config.enableTelemetry).toBe(true); + expect(config.installationId).toMatch(V4_UUID); + }); + + it("preserves an existing installation id rather than rotating it", async () => { + seedConfig({ installationId: "sticky-id" }); + + await makeCli().run(["telemetry", "enable"], { + isTty: { stdout: true }, + }); + + const config = readUserConfig(); + expect(config.enableTelemetry).toBe(true); + expect(config.installationId).toBe("sticky-id"); + }); + + it("serializes the consent decision as the json envelope result", async () => { + const result = await makeCli().run(["telemetry", "enable", "--json"]); + + expect(result.exitCode).toBe(0); + const frame = result.json[0]; + if (frame?.kind !== "result" || frame.envelope.ok !== true) { + throw new Error("expected a completed result frame"); + } + expect(frame.envelope.result).toEqual({ + enableTelemetry: true, + configPath, + }); + }); +}); + +describe("prisma-v8 telemetry disable", () => { + it("stores the opt-out without minting an installation id", async () => { + const result = await makeCli().run(["telemetry", "disable"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe( + `Telemetry disabled. Preference stored in ${configPath}.\n`, + ); + const config = readUserConfig(); + expect(config.enableTelemetry).toBe(false); + expect(config.installationId).toBeUndefined(); + }); + + it("keeps an existing installation id while disabling (MAU continuity on re-enable)", async () => { + seedConfig({ enableTelemetry: true, installationId: "sticky-id" }); + + await makeCli().run(["telemetry", "disable"], { + isTty: { stdout: true }, + }); + + const config = readUserConfig(); + expect(config.enableTelemetry).toBe(false); + expect(config.installationId).toBe("sticky-id"); + }); + + it("serializes the consent decision as the json envelope result", async () => { + const result = await makeCli().run(["telemetry", "disable", "--json"]); + + expect(result.exitCode).toBe(0); + const frame = result.json[0]; + if (frame?.kind !== "result" || frame.envelope.ok !== true) { + throw new Error("expected a completed result frame"); + } + expect(frame.envelope.result).toEqual({ + enableTelemetry: false, + configPath, + }); + }); +}); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 93225549..721407d5 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,13 +1,32 @@ import { defineConfig } from "tsdown"; -export default defineConfig({ - entry: { - cli: "src/bin.ts", +export default defineConfig([ + { + entry: { + cli: "src/bin.ts", + }, + format: ["esm"], + clean: true, + shims: true, + unbundle: true, + fixedExtension: false, + outDir: "dist", }, - format: ["esm"], - clean: true, - shims: true, - unbundle: true, - fixedExtension: false, - outDir: "dist", -}); + // The v8 shell. Bundled (not unbundle) so the private + // @repo/cli-telemetry workspace package lands inside this package's + // own dist instead of being a published dependency; the sender entry + // ships the forkable script at dist/v8/sender.js. Published deps + // (engine, c12, ci-info, @vercel/detect-agent, …) stay external. + { + entry: { + "v8/cli": "src/v8/bin.ts", + "v8/sender": "src/v8/telemetry/sender.ts", + }, + format: ["esm"], + clean: false, + shims: true, + fixedExtension: false, + noExternal: ["@repo/cli-telemetry"], + outDir: "dist", + }, +]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19cdf5a0..2151b676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,18 @@ importers: '@prisma/management-api-sdk': specifier: 1.55.0 version: 1.55.0 + '@vercel/detect-agent': + specifier: ^1.2.3 + version: 1.2.4 better-result: specifier: ^2.9.2 version: 2.9.2 + c12: + specifier: ^3.3.4 + version: 3.3.4(magicast@0.5.3) + ci-info: + specifier: ^4.3.1 + version: 4.4.0 colorette: specifier: ^2.0.20 version: 2.0.20 @@ -69,6 +78,9 @@ importers: specifier: ^10.0.0 version: 10.0.0 devDependencies: + '@repo/cli-telemetry': + specifier: workspace:* + version: link:../cli-telemetry '@repo/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -1112,6 +1124,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -2463,6 +2479,8 @@ snapshots: chownr@3.0.0: {} + ci-info@4.4.0: {} + colorette@2.0.20: {} commander@14.0.3: {} From 83d95c54f73363e0c40ab3ddd1c0709c1b965d58 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 02:46:35 +0200 Subject: [PATCH 09/67] feat(cli): port the auth command family to the v8 engine bin Mounts all six auth commands (login, logout [--workspace], whoami, workspace list/use/logout) as engine result commands in the platform command family, backed by the extracted auth module. - login runs the real OAuth flow via performLogin, emitting step-started/finished events around it and an 'endpoint' event for the verification URL (new optional onVerificationUrl hook on performLogin; legacy callers unaffected), then reads the auth state and appends the ported agent-setup tip (CI-suppressed; tip line is human-mode only). Fixture-only --provider/--user/--workspace flags do not port. - logout --workspace calls the shared workspace-logout operation directly instead of the legacy argv re-dispatch. - workspace use resolves by id/case-insensitive name, auto-selects a single workspace, prompts via ctx.prompt.select otherwise, and lets the engine's structural prompt failure cover non-interactive runs. - Legacy flat error codes map mechanically to dotted AUTH.* codes (fix prose -> one user-choice nextAction, meta preserved), matching the S1 whoami precedent. - The workspace operations' context parameter narrows to a structural type both shells satisfy; whoami now shares the state-card and config-invalid helpers. - Semantic tests stub the auth module at the src/auth/index.ts seam; fixture-mode auth.test.ts cases covering ported commands are deleted (real-mode and shell-presentation cases stay until S2d). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/auth/operations.ts | 2 + packages/cli/src/auth/workspaces.ts | 22 +- packages/cli/src/lib/auth/login.ts | 6 + packages/cli/src/shell/runtime.ts | 4 +- packages/cli/src/v8/auth/agent-setup-tip.ts | 69 ++ packages/cli/src/v8/auth/errors.ts | 58 ++ packages/cli/src/v8/auth/login.ts | 100 +++ packages/cli/src/v8/auth/logout.ts | 78 ++ packages/cli/src/v8/auth/state-card.ts | 52 ++ packages/cli/src/v8/auth/whoami.ts | 78 +- .../cli/src/v8/auth/workspace-commands.ts | 393 +++++++++ packages/cli/src/v8/cli.ts | 24 +- packages/cli/tests/auth.test.ts | 474 ----------- packages/cli/tests/v8-auth.test.ts | 798 ++++++++++++++++++ 14 files changed, 1603 insertions(+), 555 deletions(-) create mode 100644 packages/cli/src/v8/auth/agent-setup-tip.ts create mode 100644 packages/cli/src/v8/auth/errors.ts create mode 100644 packages/cli/src/v8/auth/login.ts create mode 100644 packages/cli/src/v8/auth/logout.ts create mode 100644 packages/cli/src/v8/auth/state-card.ts create mode 100644 packages/cli/src/v8/auth/workspace-commands.ts create mode 100644 packages/cli/tests/v8-auth.test.ts diff --git a/packages/cli/src/auth/operations.ts b/packages/cli/src/auth/operations.ts index b8a4651a..a101bb8a 100644 --- a/packages/cli/src/auth/operations.ts +++ b/packages/cli/src/auth/operations.ts @@ -61,6 +61,7 @@ function workspaceIdFromClaims(claims: Record): string | null { export async function performLogin( env: NodeJS.ProcessEnv, signal?: AbortSignal, + options?: { onVerificationUrl?: (url: string) => void }, ): Promise { await login({ tokenStorage: new FileTokenStorage(env, signal, { @@ -68,6 +69,7 @@ export async function performLogin( }), env, signal, + onVerificationUrl: options?.onVerificationUrl, }); } diff --git a/packages/cli/src/auth/workspaces.ts b/packages/cli/src/auth/workspaces.ts index 3e08cb45..761e08ae 100644 --- a/packages/cli/src/auth/workspaces.ts +++ b/packages/cli/src/auth/workspaces.ts @@ -8,7 +8,6 @@ import { workspaceNotAuthenticatedError, workspaceSwitchUnavailableError, } from "../shell/errors"; -import type { CommandContext } from "../shell/runtime"; import type { AuthWorkspace, AuthWorkspaceListResult, @@ -23,8 +22,17 @@ import { WorkspaceSelectionError, } from "./token-storage"; +/** The exact context surface the workspace operations read; both the + * legacy shell's CommandContext and a v8 handler's ctx satisfy it. */ +export interface WorkspaceOperationContext { + readonly runtime: { + readonly env: NodeJS.ProcessEnv; + readonly signal?: AbortSignal; + }; +} + export async function listRealAuthWorkspaces( - context: CommandContext, + context: WorkspaceOperationContext, ): Promise { const rawServiceToken = context.runtime.env[SERVICE_TOKEN_ENV_VAR]; const storage = new FileTokenStorage( @@ -86,7 +94,7 @@ export async function listRealAuthWorkspaces( } export async function useRealAuthWorkspace( - context: CommandContext, + context: WorkspaceOperationContext, workspaceRef: string, ): Promise { if (context.runtime.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { @@ -132,7 +140,7 @@ export async function useRealAuthWorkspace( } export async function logoutRealAuthWorkspace( - context: CommandContext, + context: WorkspaceOperationContext, workspaceRef: string, ): Promise { const storage = new FileTokenStorage( @@ -175,7 +183,7 @@ export async function logoutRealAuthWorkspace( } async function hydrateLocalAuthWorkspaces( - context: CommandContext, + context: WorkspaceOperationContext, storage: FileTokenStorage, workspaces: StoredAuthWorkspace[], ): Promise { @@ -216,7 +224,7 @@ async function hydrateLocalAuthWorkspaces( } async function rememberResolvedWorkspaceMetadata( - context: CommandContext, + context: WorkspaceOperationContext, storage: FileTokenStorage, tokens: Tokens, resolved: { id: string; name: string }, @@ -237,7 +245,7 @@ function needsWorkspaceMetadataHydration(workspace: StoredAuthWorkspace) { } async function resolveOAuthWorkspaceMetadata( - context: CommandContext, + context: WorkspaceOperationContext, tokens: Tokens, ): Promise<{ id: string; name: string } | null> { const refreshStorage = new FileTokenStorage( diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/lib/auth/login.ts index 99c5800e..c17ec495 100644 --- a/packages/cli/src/lib/auth/login.ts +++ b/packages/cli/src/lib/auth/login.ts @@ -29,6 +29,9 @@ export interface LoginOptions { hostname?: string; port?: number; openUrl?: (url: string) => Promise | unknown; + /** Observes the verification URL as soon as it is known, before the + * browser opens. */ + onVerificationUrl?: (url: string) => void; env?: NodeJS.ProcessEnv; signal?: AbortSignal; input?: Readable; @@ -59,6 +62,7 @@ export async function login(options: LoginOptions = {}): Promise { apiBaseUrl: options.apiBaseUrl, authBaseUrl: options.authBaseUrl, openUrl: options.openUrl, + onVerificationUrl: options.onVerificationUrl, env: options.env, signal: options.signal, output, @@ -246,6 +250,7 @@ class LoginState { apiBaseUrl?: string; authBaseUrl?: string; openUrl?: (url: string) => Promise | unknown; + onVerificationUrl?: (url: string) => void; env?: NodeJS.ProcessEnv; signal?: AbortSignal; output?: Writable; @@ -280,6 +285,7 @@ class LoginState { this.latestState = state; this.latestVerifier = verifier; + this.options.onVerificationUrl?.(url); this.options.signal?.throwIfAborted(); // Browser launch cannot consume AbortSignal; check immediately before and after the boundary. diff --git a/packages/cli/src/shell/runtime.ts b/packages/cli/src/shell/runtime.ts index 89d223c3..67f2fb98 100644 --- a/packages/cli/src/shell/runtime.ts +++ b/packages/cli/src/shell/runtime.ts @@ -89,7 +89,9 @@ export async function createCommandContext( }; } -export async function resolveStateDir(runtime: CliRuntime): Promise { +export async function resolveStateDir( + runtime: Pick, +): Promise { const explicitStateDir = runtime.stateDir ?? runtime.env.PRISMA_CLI_STATE_DIR; if (explicitStateDir) { return explicitStateDir; diff --git a/packages/cli/src/v8/auth/agent-setup-tip.ts b/packages/cli/src/v8/auth/agent-setup-tip.ts new file mode 100644 index 00000000..dc5a8ab6 --- /dev/null +++ b/packages/cli/src/v8/auth/agent-setup-tip.ts @@ -0,0 +1,69 @@ +/** + * Port of the legacy shell's post-login agent-setup tip (the real-mode + * path of `resolveAgentSetupTipCommand` in controllers/auth.ts). The + * legacy --json / --quiet / stderr-TTY suppressions do not translate: + * the engine's format selection already keeps the tip line out of json + * output, and handlers cannot read TTY-ness or the interactive flag — + * both recorded in the S2 parity divergence list. CI suppression is + * kept via ctx.env. + */ +import { LocalStateStore } from "../../adapters/local-state"; +import { resolvePrismaCliPackageCommand } from "../../lib/agent/cli-command"; +import { PRISMA_AGENT_INSTALL_ARGS } from "../../lib/agent/constants"; +import { + isLikelyProjectDirectory, + readPrismaAgentSetupStatus, + resolvePrismaAgentSetupCwd, + shouldOfferPrismaAgentSetup, +} from "../../lib/agent/setup-status"; +import { resolveStateDir } from "../../shell/runtime"; + +export interface AgentSetupTipContext { + readonly cwd: string; + readonly env: Readonly>; + readonly signal: AbortSignal; +} + +export async function resolveAgentSetupTipCommand( + ctx: AgentSetupTipContext, +): Promise { + if (ctx.env.CI) { + return null; + } + + const setupCwd = await resolvePrismaAgentSetupCwd({ + cwd: ctx.cwd, + signal: ctx.signal, + }); + + if ( + !(await isLikelyProjectDirectory({ cwd: setupCwd, signal: ctx.signal })) + ) { + return null; + } + + const stateDir = await resolveStateDir({ + stateDir: undefined, + env: ctx.env, + cwd: ctx.cwd, + signal: ctx.signal, + }); + const stateStore = new LocalStateStore(stateDir, ctx.signal); + + const shouldOffer = shouldOfferPrismaAgentSetup( + await readPrismaAgentSetupStatus({ + cwd: setupCwd, + stateStore, + signal: ctx.signal, + }), + ); + if (!shouldOffer) { + return null; + } + + return await resolvePrismaCliPackageCommand({ + cwd: setupCwd, + signal: ctx.signal, + args: PRISMA_AGENT_INSTALL_ARGS, + }); +} diff --git a/packages/cli/src/v8/auth/errors.ts b/packages/cli/src/v8/auth/errors.ts new file mode 100644 index 00000000..da36489d --- /dev/null +++ b/packages/cli/src/v8/auth/errors.ts @@ -0,0 +1,58 @@ +/** + * Mapping from the auth operations layer's legacy CliError shapes to + * the v8 protocol's dotted AUTH.* structured errors. The mapping is + * mechanical (flat code -> AUTH., fix prose -> + * one user-choice nextAction, meta preserved); every mapped code is + * enumerated in the S2 parity divergence list. + */ +import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import { CliError } from "../../shell/errors"; + +const AUTH_CODE_MAP: Readonly> = { + AUTH_CONFIG_INVALID: "AUTH.CONFIG_INVALID", + WORKSPACE_SWITCH_UNAVAILABLE: "AUTH.WORKSPACE_SWITCH_UNAVAILABLE", + WORKSPACE_NOT_AUTHENTICATED: "AUTH.WORKSPACE_NOT_AUTHENTICATED", + WORKSPACE_AMBIGUOUS: "AUTH.WORKSPACE_AMBIGUOUS", + USAGE_ERROR: "AUTH.USAGE_ERROR", +}; + +/** The structured error `auth whoami` established for an empty + * PRISMA_SERVICE_TOKEN; shared verbatim by login and logout. */ +export function authConfigInvalidError(why: string): CliStructuredError { + return new CliStructuredError( + "AUTH.CONFIG_INVALID", + "Authentication configuration is invalid", + { + why, + nextActions: [ + { + kind: "user-choice", + label: + "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", + }, + ], + }, + ); +} + +/** + * Maps a legacy CliError thrown by the auth operations to its dotted + * AUTH.* structured form; returns null for anything else so the caller + * rethrows and the engine settles it as a bug. + */ +export function mapAuthOperationError( + error: unknown, +): CliStructuredError | null { + if (!(error instanceof CliError)) { + return null; + } + const code = AUTH_CODE_MAP[error.code]; + if (code === undefined) { + return null; + } + return new CliStructuredError(code, error.summary, { + why: error.why ?? undefined, + meta: Object.keys(error.meta).length > 0 ? error.meta : undefined, + nextActions: error.fix ? [{ kind: "user-choice", label: error.fix }] : [], + }); +} diff --git a/packages/cli/src/v8/auth/login.ts b/packages/cli/src/v8/auth/login.ts new file mode 100644 index 00000000..092bf877 --- /dev/null +++ b/packages/cli/src/v8/auth/login.ts @@ -0,0 +1,100 @@ +import { defineCommand, type Presentations } from "@prisma/cli-engine"; +import { type NextAction, notOk, ok } from "@prisma/cli-engine/protocol"; +import { + isEmptyServiceTokenError, + performLogin, + readAuthState, +} from "../../auth"; +import type { AuthStateResult } from "../../types/auth"; +import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; +import { authConfigInvalidError } from "./errors"; +import { authStateFieldRows } from "./state-card"; + +const TITLE = "Starting an authenticated CLI session."; +const LOGIN_STEP = "Sign in via your browser"; + +function nextActionsFor(state: AuthStateResult): readonly NextAction[] { + return [ + { + kind: "run-command", + label: "Show the signed-in identity", + command: "prisma-cli auth whoami", + }, + { + kind: "run-command", + label: "List projects", + command: "prisma-cli project list", + }, + ...(state.agentSetupTip + ? [ + { + kind: "run-command", + label: "Install Prisma skills for this project", + command: state.agentSetupTip.command, + } as const, + ] + : []), + ]; +} + +function presentationsFor(state: AuthStateResult): Presentations { + const rows = authStateFieldRows(state); + return { + human: () => [ + { kind: "summary", tone: "info", text: TITLE }, + { kind: "fields", rows }, + ...(state.agentSetupTip + ? [ + { + kind: "summary", + tone: "info", + text: `Install Prisma skills for this project with ${state.agentSetupTip.command}.`, + } as const, + ] + : []), + ], + stdout: () => rows.map((row) => `${row.label}: ${row.value}`), + next: () => nextActionsFor(state), + }; +} + +export const authLoginCommand = defineCommand({ + help: { + summary: "Log in to your Prisma platform account", + examples: ["auth login"], + }, + handler: async (_args, ctx) => { + ctx.report({ kind: "step-started", step: LOGIN_STEP }); + try { + await performLogin(ctx.env, ctx.signal, { + onVerificationUrl: (url) => + ctx.report({ kind: "endpoint", name: "verification", url }), + }); + } catch (error) { + ctx.report({ + kind: "step-finished", + step: LOGIN_STEP, + outcome: "failed", + }); + throw error; + } + ctx.report({ kind: "step-finished", step: LOGIN_STEP, outcome: "ok" }); + + let state: AuthStateResult; + try { + state = await readAuthState(ctx.env, ctx.signal); + } catch (error) { + if (isEmptyServiceTokenError(error)) { + return notOk(authConfigInvalidError(error.message)); + } + throw error; + } + + const agentSetupTipCommand = await resolveAgentSetupTipCommand(ctx); + if (agentSetupTipCommand) { + state = { ...state, agentSetupTip: { command: agentSetupTipCommand } }; + } + + return ok(ctx.present({ data: state }, presentationsFor(state))); + }, +}); diff --git a/packages/cli/src/v8/auth/logout.ts b/packages/cli/src/v8/auth/logout.ts new file mode 100644 index 00000000..9d5aa8f1 --- /dev/null +++ b/packages/cli/src/v8/auth/logout.ts @@ -0,0 +1,78 @@ +import { defineCommand, flag, type Presentations } from "@prisma/cli-engine"; +import { type NextAction, notOk, ok } from "@prisma/cli-engine/protocol"; +import { + isEmptyServiceTokenError, + performLogout, + readAuthState, +} from "../../auth"; +import type { AuthStateResult } from "../../types/auth"; +import { authConfigInvalidError } from "./errors"; +import { authStateFieldRows } from "./state-card"; +import { runWorkspaceLogout } from "./workspace-commands"; + +const SIGN_IN: NextAction = { + kind: "run-command", + label: "Sign in", + command: "prisma-cli auth login", +}; + +function presentationsFor(state: AuthStateResult): Presentations { + const rows = authStateFieldRows(state); + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Clearing the current CLI session.", + }, + { + kind: "fields", + rows: [{ label: "session", value: "local CLI state" }], + }, + { + kind: "summary", + tone: "ok", + text: "Session removed from local CLI state.", + }, + ], + stdout: () => rows.map((row) => `${row.label}: ${row.value}`), + next: () => [SIGN_IN], + }; +} + +export const authLogoutCommand = defineCommand({ + args: { + flags: { + workspace: flag.string({ + brief: "Remove one stored OAuth workspace session", + placeholder: "id-or-name", + }), + }, + }, + help: { + summary: "Clear stored authentication credentials", + examples: ["auth logout", "auth logout --workspace my-workspace"], + }, + handler: async (args, ctx) => { + const workspaceRef = args.flags.workspace?.trim(); + if (workspaceRef) { + // Same semantics and presentation as `auth workspace logout `, + // by calling the shared operation directly (the legacy shell's + // argv-level re-dispatch does not port). + return runWorkspaceLogout(ctx, workspaceRef); + } + + await performLogout(ctx.env, ctx.signal); + let state: AuthStateResult; + try { + state = await readAuthState(ctx.env, ctx.signal); + } catch (error) { + if (isEmptyServiceTokenError(error)) { + return notOk(authConfigInvalidError(error.message)); + } + throw error; + } + + return ok(ctx.present({ data: state }, presentationsFor(state))); + }, +}); diff --git a/packages/cli/src/v8/auth/state-card.ts b/packages/cli/src/v8/auth/state-card.ts new file mode 100644 index 00000000..628aaae8 --- /dev/null +++ b/packages/cli/src/v8/auth/state-card.ts @@ -0,0 +1,52 @@ +import type { AuthProviderId, AuthStateResult } from "../../types/auth"; + +export interface FieldRow { + readonly label: string; + readonly value: string; +} + +export function providerLabel(provider: AuthProviderId): string { + return provider === "github" ? "GitHub" : "Google"; +} + +export function userLabel(state: AuthStateResult): string | null { + if (state.user?.email) { + return state.user.email; + } + + if (state.credential?.type === "service_token") { + return state.credential.name + ? `` + : ""; + } + + if (state.credential?.type === "management_token") { + return state.credential.name + ? `` + : ""; + } + + return null; +} + +/** The whoami-style card rows for an auth state. */ +export function authStateFieldRows( + state: AuthStateResult, +): readonly FieldRow[] { + if (!state.authenticated) { + return [{ label: "status", value: "signed out" }]; + } + + const rows: FieldRow[] = [{ label: "status", value: "signed in" }]; + const user = userLabel(state); + if (user) { + rows.push({ label: "user", value: user }); + } + if (state.provider) { + rows.push({ label: "provider", value: providerLabel(state.provider) }); + } + if (state.workspace?.name) { + rows.push({ label: "workspace", value: state.workspace.name }); + } + return rows; +} diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index c63d9b1a..60f0b7af 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -1,12 +1,9 @@ import { defineCommand, type Presentations } from "@prisma/cli-engine"; -import { - CliStructuredError, - type NextAction, - notOk, - ok, -} from "@prisma/cli-engine/protocol"; +import { type NextAction, notOk, ok } from "@prisma/cli-engine/protocol"; import { isEmptyServiceTokenError, readAuthState } from "../../auth"; -import type { AuthProviderId, AuthStateResult } from "../../types/auth"; +import type { AuthStateResult } from "../../types/auth"; +import { authConfigInvalidError } from "./errors"; +import { authStateFieldRows } from "./state-card"; const TITLE = "Showing the current authenticated identity."; @@ -16,56 +13,8 @@ const SIGN_IN: NextAction = { command: "prisma-cli auth login", }; -interface FieldRow { - readonly label: string; - readonly value: string; -} - -function providerLabel(provider: AuthProviderId): string { - return provider === "github" ? "GitHub" : "Google"; -} - -function userLabel(state: AuthStateResult): string | null { - if (state.user?.email) { - return state.user.email; - } - - if (state.credential?.type === "service_token") { - return state.credential.name - ? `` - : ""; - } - - if (state.credential?.type === "management_token") { - return state.credential.name - ? `` - : ""; - } - - return null; -} - -function fieldRows(state: AuthStateResult): readonly FieldRow[] { - if (!state.authenticated) { - return [{ label: "status", value: "signed out" }]; - } - - const rows: FieldRow[] = [{ label: "status", value: "signed in" }]; - const user = userLabel(state); - if (user) { - rows.push({ label: "user", value: user }); - } - if (state.provider) { - rows.push({ label: "provider", value: providerLabel(state.provider) }); - } - if (state.workspace?.name) { - rows.push({ label: "workspace", value: state.workspace.name }); - } - return rows; -} - function presentationsFor(state: AuthStateResult): Presentations { - const rows = fieldRows(state); + const rows = authStateFieldRows(state); return { human: () => [ { kind: "summary", tone: "info", text: TITLE }, @@ -87,22 +36,7 @@ export const authWhoamiCommand = defineCommand({ state = await readAuthState(ctx.env, ctx.signal); } catch (error) { if (isEmptyServiceTokenError(error)) { - return notOk( - new CliStructuredError( - "AUTH.CONFIG_INVALID", - "Authentication configuration is invalid", - { - why: error.message, - nextActions: [ - { - kind: "user-choice", - label: - "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", - }, - ], - }, - ), - ); + return notOk(authConfigInvalidError(error.message)); } throw error; } diff --git a/packages/cli/src/v8/auth/workspace-commands.ts b/packages/cli/src/v8/auth/workspace-commands.ts new file mode 100644 index 00000000..51c34ecc --- /dev/null +++ b/packages/cli/src/v8/auth/workspace-commands.ts @@ -0,0 +1,393 @@ +/** + * The `auth workspace *` family, ported from the legacy controller's + * real-mode paths. Operations come from the auth module + * (`src/auth/index.ts`); legacy CliError shapes map to dotted AUTH.* + * structured errors via `mapAuthOperationError`. + */ +import { + type CommandContext, + defineCommand, + type Presentations, + positional, +} from "@prisma/cli-engine"; +import { + CliStructuredError, + type NextAction, + notOk, + ok, +} from "@prisma/cli-engine/protocol"; +import { + listRealAuthWorkspaces, + logoutRealAuthWorkspace, + SERVICE_TOKEN_ENV_VAR, + useRealAuthWorkspace, +} from "../../auth"; +import type { + AuthWorkspaceListResult, + AuthWorkspaceLogoutResult, + AuthWorkspaceUseResult, +} from "../../types/auth"; +import { mapAuthOperationError } from "./errors"; + +const LIST_NEXT_ACTION: NextAction = { + kind: "run-command", + label: "List authenticated workspaces", + command: "prisma-cli auth workspace list", +}; + +const LOGIN_NEXT_ACTION: NextAction = { + kind: "run-command", + label: "Sign in", + command: "prisma-cli auth login", +}; + +function operationContext(ctx: CommandContext): { + runtime: { env: NodeJS.ProcessEnv; signal: AbortSignal }; +} { + return { runtime: { env: ctx.env, signal: ctx.signal } }; +} + +function rethrowMapped(error: unknown): never { + const mapped = mapAuthOperationError(error); + if (mapped) { + throw mapped; + } + throw error; +} + +// --- auth workspace list --------------------------------------------- + +function authSourceLabel( + source: AuthWorkspaceListResult["authSource"], +): string { + if (source === "oauth") { + return "local OAuth"; + } + if (source === "service_token") { + return "PRISMA_SERVICE_TOKEN"; + } + return "none"; +} + +function workspaceSourceLabel(source: "oauth" | "service_token"): string { + return source === "service_token" ? "service token" : "OAuth"; +} + +export function serializeAuthWorkspaceList(result: AuthWorkspaceListResult) { + return { + context: { + authSource: result.authSource, + activeWorkspaceId: result.activeWorkspace?.id ?? null, + activeWorkspaceName: result.activeWorkspace?.name ?? null, + }, + items: result.workspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + status: workspace.active ? "active" : null, + source: workspace.source, + switchable: workspace.switchable, + credentialWorkspaceId: workspace.credentialWorkspaceId, + lastSeenAt: workspace.lastSeenAt, + })), + count: result.workspaces.length, + }; +} + +/** The legacy table's column rule: the source column appears only when + * the listed workspaces mix sources. */ +function workspaceTableRows(result: AuthWorkspaceListResult): { + columns: readonly string[]; + rows: ReadonlyArray; +} { + const hasMixedSources = + new Set(result.workspaces.map((workspace) => workspace.source)).size > 1; + const columns = hasMixedSources + ? ["name", "id", "source", "status"] + : ["name", "id", "status"]; + const rows = result.workspaces.map((workspace) => { + const status = workspace.active ? "active" : ""; + return hasMixedSources + ? [ + workspace.name, + workspace.id, + workspaceSourceLabel(workspace.source), + status, + ] + : [workspace.name, workspace.id, status]; + }); + return { columns, rows }; +} + +function listPresentations(result: AuthWorkspaceListResult): Presentations { + const table = workspaceTableRows(result); + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Listing authenticated workspaces on this machine.", + }, + { + kind: "fields", + rows: [ + { label: "auth source", value: authSourceLabel(result.authSource) }, + ], + }, + ...(result.workspaces.length === 0 + ? [ + { + kind: "summary", + tone: "info", + text: "No local OAuth workspaces found.", + } as const, + ] + : [{ kind: "table", ...table } as const]), + ], + stdout: () => table.rows.map((row) => row.join(" ").trimEnd()), + json: () => serializeAuthWorkspaceList(result), + next: () => (result.workspaces.length === 0 ? [LOGIN_NEXT_ACTION] : []), + }; +} + +export const authWorkspaceListCommand = defineCommand({ + help: { + summary: "List locally authenticated workspaces", + examples: ["auth workspace list", "auth workspace list --json"], + }, + handler: async (_args, ctx) => { + let result: AuthWorkspaceListResult; + try { + result = await listRealAuthWorkspaces(operationContext(ctx)); + } catch (error) { + rethrowMapped(error); + } + return ok(ctx.present({ data: result }, listPresentations(result))); + }, +}); + +// --- auth workspace use ---------------------------------------------- + +function usePresentations(result: AuthWorkspaceUseResult): Presentations { + const rows = [ + ...(result.previousWorkspace + ? [{ label: "previous", value: result.previousWorkspace.name }] + : []), + { label: "workspace", value: result.workspace.name }, + ]; + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Switching the local CLI workspace.", + }, + { kind: "fields", rows }, + { + kind: "summary", + tone: "ok", + text: "Local OAuth workspace selection updated.", + }, + ], + stdout: () => rows.map((row) => `${row.label}: ${row.value}`), + next: () => [ + { + kind: "run-command", + label: "Show the signed-in identity", + command: "prisma-cli auth whoami", + }, + { + kind: "run-command", + label: "List projects", + command: "prisma-cli project list", + }, + ], + }; +} + +function noWorkspacesError(): CliStructuredError { + return new CliStructuredError( + "AUTH.USAGE_ERROR", + "No authenticated workspaces", + { + why: "There are no local OAuth workspace sessions to select.", + nextActions: [ + { + kind: "user-choice", + label: "Run prisma-cli auth login and authorize a workspace.", + }, + ], + }, + ); +} + +function serviceTokenSwitchError(): CliStructuredError { + return new CliStructuredError( + "AUTH.WORKSPACE_SWITCH_UNAVAILABLE", + "Workspace switching is unavailable", + { + why: "PRISMA_SERVICE_TOKEN is set, so authenticated commands use that token instead of local OAuth workspaces.", + nextActions: [ + { + kind: "user-choice", + label: + "Unset PRISMA_SERVICE_TOKEN to switch between local OAuth workspaces, or use a token for the workspace you want.", + }, + ], + }, + ); +} + +async function selectWorkspaceRef( + ctx: CommandContext, +): Promise { + if (ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { + throw serviceTokenSwitchError(); + } + + let listed: AuthWorkspaceListResult; + try { + listed = await listRealAuthWorkspaces(operationContext(ctx)); + } catch (error) { + rethrowMapped(error); + } + const workspaces = listed.workspaces.filter( + (workspace) => workspace.switchable, + ); + + if (workspaces.length === 0) { + throw noWorkspacesError(); + } + + if (workspaces.length === 1) { + return workspaces[0].id; + } + + return await ctx.prompt.select( + "Select a workspace", + workspaces.map((workspace) => ({ + value: workspace.id, + label: `${workspace.name} (${workspace.id})${workspace.active ? " active" : ""}`, + })), + ); +} + +export const authWorkspaceUseCommand = defineCommand({ + args: { + positionals: { + workspace: positional.optionalString({ + brief: "Workspace id or exact name", + placeholder: "id-or-name", + }), + }, + }, + help: { + summary: "Switch the local CLI workspace", + examples: ["auth workspace use", "auth workspace use my-workspace"], + }, + handler: async (args, ctx) => { + const trimmed = args.positionals.workspace?.trim(); + const workspaceRef = trimmed ? trimmed : await selectWorkspaceRef(ctx); + + let result: AuthWorkspaceUseResult; + try { + result = await useRealAuthWorkspace(operationContext(ctx), workspaceRef); + } catch (error) { + const mapped = mapAuthOperationError(error); + if (mapped) { + return notOk(mapped); + } + throw error; + } + return ok(ctx.present({ data: result }, usePresentations(result))); + }, +}); + +// --- auth workspace logout ------------------------------------------- + +function logoutPresentations(result: AuthWorkspaceLogoutResult): Presentations { + const rows = [ + { label: "workspace", value: result.workspace.name }, + { label: "active", value: result.activeWorkspace?.name ?? "none" }, + ]; + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Removing a local OAuth workspace session.", + }, + { kind: "fields", rows }, + { + kind: "summary", + tone: "ok", + text: result.wasActive + ? "Removed active workspace session; no replacement workspace was selected." + : "Removed workspace session.", + }, + ], + stdout: () => rows.map((row) => `${row.label}: ${row.value}`), + next: () => + result.activeWorkspace + ? [LIST_NEXT_ACTION] + : [ + LIST_NEXT_ACTION, + { + kind: "run-command", + label: "Select a replacement workspace", + command: "prisma-cli auth workspace use ", + }, + ], + }; +} + +function workspaceRequiredError(): CliStructuredError { + return new CliStructuredError("AUTH.USAGE_ERROR", "Workspace required", { + why: "auth workspace logout needs a workspace id or cached workspace name.", + nextActions: [ + { + kind: "user-choice", + label: "Pass a workspace from prisma-cli auth workspace list.", + }, + ], + }); +} + +/** Shared by `auth workspace logout ` and `auth logout + * --workspace ` — the same operation, the same presentation. */ +export async function runWorkspaceLogout( + ctx: CommandContext, + workspaceRef: string, +) { + if (!workspaceRef.trim()) { + return notOk(workspaceRequiredError()); + } + + let result: AuthWorkspaceLogoutResult; + try { + result = await logoutRealAuthWorkspace(operationContext(ctx), workspaceRef); + } catch (error) { + const mapped = mapAuthOperationError(error); + if (mapped) { + return notOk(mapped); + } + throw error; + } + return ok(ctx.present({ data: result }, logoutPresentations(result))); +} + +export const authWorkspaceLogoutCommand = defineCommand({ + args: { + positionals: { + workspace: positional.string({ + brief: "Workspace id or exact name", + placeholder: "id-or-name", + }), + }, + }, + help: { + summary: "Remove one local OAuth workspace session", + examples: ["auth workspace logout my-workspace"], + }, + handler: async (args, ctx) => + runWorkspaceLogout(ctx, args.positionals.workspace), +}); diff --git a/packages/cli/src/v8/cli.ts b/packages/cli/src/v8/cli.ts index 28797715..44c81263 100644 --- a/packages/cli/src/v8/cli.ts +++ b/packages/cli/src/v8/cli.ts @@ -1,6 +1,13 @@ import { type Cli, createCli, defineCommandFamily } from "@prisma/cli-engine"; import { getCliVersion } from "../lib/version"; +import { authLoginCommand } from "./auth/login"; +import { authLogoutCommand } from "./auth/logout"; import { authWhoamiCommand } from "./auth/whoami"; +import { + authWorkspaceListCommand, + authWorkspaceLogoutCommand, + authWorkspaceUseCommand, +} from "./auth/workspace-commands"; import { telemetryDisableCommand, telemetryEnableCommand, @@ -12,10 +19,20 @@ export function buildCli(): Cli { name: "prisma-v8", version: getCliVersion(), commandFamilies: [ - defineCommandFamily({ commands: { whoami: authWhoamiCommand } }), + defineCommandFamily({ + commands: { + login: authLoginCommand, + logout: authLogoutCommand, + whoami: authWhoamiCommand, + workspaceList: authWorkspaceListCommand, + workspaceUse: authWorkspaceUseCommand, + workspaceLogout: authWorkspaceLogoutCommand, + }, + }), ], groups: { auth: { brief: "Manage local authentication for the CLI" }, + "auth workspace": { brief: "Manage local workspace sessions" }, telemetry: { brief: "Inspect and change anonymous CLI telemetry", description: @@ -25,7 +42,12 @@ export function buildCli(): Cli { }, }, commands: { + "auth login": authLoginCommand, + "auth logout": authLogoutCommand, "auth whoami": authWhoamiCommand, + "auth workspace list": authWorkspaceListCommand, + "auth workspace use": authWorkspaceUseCommand, + "auth workspace logout": authWorkspaceLogoutCommand, // Shell-owned consent surface (no command family). "telemetry status": telemetryStatusCommand, "telemetry enable": telemetryEnableCommand, diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 3bd0087d..bfd3d2ec 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -17,447 +17,6 @@ async function writeAuthFile( } describe("auth commands", () => { - it("shows the signed-out empty state for whoami", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["auth", "whoami"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "auth whoami → Showing the current authenticated identity.\n\n│ status: signed out\n", - ); - }); - - it("logs in with mock selectors and returns the documented human output", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "auth login → Starting an authenticated CLI session.\n\n│ provider: GitHub\n│ user: bob@example.com\n│ workspace: Acme Inc\n\n◇ Applying authentication session changes...\n✔ Applied 1 operation(s)\n Session stored in local CLI state.\n", - ); - }); - - it("suggests agent setup after login from a project directory", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await writeFile( - path.join(cwd, "package.json"), - JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), - "utf8", - ); - - const result = await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - isTTY: true, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain( - "Install Prisma skills for this project with pnpm dlx @prisma/cli@latest agent install.", - ); - }); - - it("suggests agent setup from a subdirectory under a compute config root", async () => { - const cwd = await createTempCwd(); - const appDir = path.join(cwd, "apps", "web"); - const stateDir = path.join(cwd, ".state"); - await mkdir(appDir, { recursive: true }); - await mkdir(path.join(cwd, ".git"), { recursive: true }); - await writeFile( - path.join(cwd, "prisma.compute.ts"), - 'export default { apps: { web: { root: "apps/web" } } };\n', - "utf8", - ); - await writeFile( - path.join(cwd, "package.json"), - JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), - "utf8", - ); - - const result = await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd: appDir, - stateDir, - fixturePath, - isTTY: true, - }); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "Install Prisma skills for this project with pnpm dlx @prisma/cli@latest agent install.", - ); - }); - - it("does not suggest agent setup after login when Prisma skills are installed", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); - await writeFile( - path.join(cwd, "skills-lock.json"), - JSON.stringify({ sources: ["prisma/skills"] }), - "utf8", - ); - - const result = await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - isTTY: true, - }); - - expect(result.exitCode).toBe(0); - expect(result.stderr).not.toContain("@prisma/cli@latest agent install"); - }); - - it("returns the stable signed-in JSON shape for whoami", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); - - const result = await executeCli({ - argv: ["auth", "whoami", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: true, - command: "auth.whoami", - result: { - authenticated: true, - provider: "github", - user: { - id: "usr_456", - email: "bob@example.com", - name: "Bob Example", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - credential: { - type: "oauth", - id: null, - name: null, - }, - }, - warnings: [], - nextSteps: [], - nextActions: [], - }); - }); - - it("lists and switches mock workspaces for the current session", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: [ - "auth", - "login", - "--provider", - "github", - "--user", - "usr_123", - "--workspace", - "ws_123", - ], - cwd, - stateDir, - fixturePath, - }); - - const list = await executeCli({ - argv: ["auth", "workspace", "list", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(list.exitCode).toBe(0); - expect(JSON.parse(list.stdout)).toMatchObject({ - ok: true, - command: "auth.workspace.list", - result: { - context: { - authSource: "oauth", - activeWorkspaceId: "ws_123", - activeWorkspaceName: "Acme Inc", - }, - items: [ - { - id: "ws_123", - name: "Acme Inc", - status: "active", - switchable: true, - }, - { - id: "ws_456", - name: "Prisma Labs", - status: null, - switchable: true, - }, - ], - count: 2, - }, - }); - - const humanList = await executeCli({ - argv: ["auth", "workspace", "list"], - cwd, - stateDir, - fixturePath, - }); - const humanListOutput = stripAnsi(humanList.stderr); - - expect(humanList.exitCode).toBe(0); - expect(humanList.stdout).toBe(""); - expect(humanListOutput).toBe( - "auth workspace list → Listing authenticated workspaces on this machine.\n\n│ auth source: local OAuth\n│\n│ name id status\n│ Acme Inc ws_123 active\n│ Prisma Labs ws_456 \n", - ); - - const use = await executeCli({ - argv: ["auth", "workspace", "use", "ws_456", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(use.exitCode).toBe(0); - expect(JSON.parse(use.stdout)).toMatchObject({ - ok: true, - command: "auth.workspace.use", - result: { - previousWorkspace: { - id: "ws_123", - name: "Acme Inc", - }, - workspace: { - id: "ws_456", - name: "Prisma Labs", - }, - }, - }); - - const whoami = await executeCli({ - argv: ["auth", "whoami", "--json"], - cwd, - stateDir, - fixturePath, - }); - expect(JSON.parse(whoami.stdout).result.workspace).toEqual({ - id: "ws_456", - name: "Prisma Labs", - }); - }); - - it("trims an explicit workspace ref before switching", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: [ - "auth", - "login", - "--provider", - "github", - "--user", - "usr_123", - "--workspace", - "ws_123", - ], - cwd, - stateDir, - fixturePath, - }); - - const result = await executeCli({ - argv: ["auth", "workspace", "use", " ws_456 ", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - ok: true, - command: "auth.workspace.use", - result: { - previousWorkspace: { - id: "ws_123", - name: "Acme Inc", - }, - workspace: { - id: "ws_456", - name: "Prisma Labs", - }, - }, - }); - }); - - it("interactively selects a mock workspace with no workspace argument", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: [ - "auth", - "login", - "--provider", - "github", - "--user", - "usr_123", - "--workspace", - "ws_123", - ], - cwd, - stateDir, - fixturePath, - }); - - const result = await executeCli({ - argv: ["auth", "workspace", "use"], - cwd, - stateDir, - fixturePath, - isTTY: true, - stdinText: "\u001B[B\r", - }); - const stderr = stripAnsi(result.stderr); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(stderr).toContain("Select a workspace"); - expect(stderr).toContain("Prisma Labs (ws_456)"); - - const whoami = await executeCli({ - argv: ["auth", "whoami", "--json"], - cwd, - stateDir, - fixturePath, - }); - expect(JSON.parse(whoami.stdout).result.workspace).toEqual({ - id: "ws_456", - name: "Prisma Labs", - }); - }); - - it("returns a usage error for non-interactive workspace use without an argument when multiple workspaces exist", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: [ - "auth", - "login", - "--provider", - "github", - "--user", - "usr_123", - "--workspace", - "ws_123", - ], - cwd, - stateDir, - fixturePath, - }); - - const result = await executeCli({ - argv: ["auth", "workspace", "use", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toMatchObject({ - ok: false, - command: "auth.workspace.use", - error: { - code: "USAGE_ERROR", - domain: "auth", - summary: "Interactive workspace selection unavailable", - }, - nextSteps: ["prisma-cli auth workspace list"], - }); - }); - - it("selects the only mock workspace without prompting when no workspace argument is provided", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: [ - "auth", - "login", - "--provider", - "github", - "--user", - "usr_456", - "--workspace", - "ws_123", - ], - cwd, - stateDir, - fixturePath, - }); - - const result = await executeCli({ - argv: ["auth", "workspace", "use", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toMatchObject({ - ok: true, - command: "auth.workspace.use", - result: { - previousWorkspace: { - id: "ws_123", - name: "Acme Inc", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - }); - }); - it("switches real OAuth storage by canonical workspace id", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -990,39 +549,6 @@ describe("auth commands", () => { }); }); - it("returns a structured usage error for non-interactive login without selectors", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["auth", "login", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "auth.login", - error: { - code: "USAGE_ERROR", - domain: "auth", - severity: "error", - summary: "Login requires explicit selectors in non-interactive mode", - why: "The fixture mode cannot prompt in the current mode.", - fix: "Re-run prisma-cli auth login in a TTY, or provide --provider and --user, and --workspace when required.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli auth login"], - nextActions: [], - }); - }); - it("shows the documented help text for auth login", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts new file mode 100644 index 00000000..b25ed9f0 --- /dev/null +++ b/packages/cli/tests/v8-auth.test.ts @@ -0,0 +1,798 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { StreamEvent } from "@prisma/cli-engine"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + listRealAuthWorkspaces, + logoutRealAuthWorkspace, + performLogin, + performLogout, + readAuthState, + useRealAuthWorkspace, +} from "../src/auth"; +import { + workspaceAmbiguousError, + workspaceNotAuthenticatedError, +} from "../src/shell/errors"; +import type { + AuthStateResult, + AuthWorkspaceListResult, +} from "../src/types/auth"; +import { authLoginCommand } from "../src/v8/auth/login"; +import { authLogoutCommand } from "../src/v8/auth/logout"; +import { authWhoamiCommand } from "../src/v8/auth/whoami"; +import { + authWorkspaceListCommand, + authWorkspaceLogoutCommand, + authWorkspaceUseCommand, +} from "../src/v8/auth/workspace-commands"; + +vi.mock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), + performLogin: vi.fn(), + performLogout: vi.fn(), + readAuthState: vi.fn(), + listRealAuthWorkspaces: vi.fn(), + useRealAuthWorkspace: vi.fn(), + logoutRealAuthWorkspace: vi.fn(), +})); + +const SIGNED_OUT: AuthStateResult = { + authenticated: false, + provider: null, + user: null, + workspace: null, + credential: null, +}; + +const SIGNED_IN: AuthStateResult = { + authenticated: true, + provider: null, + user: { id: "usr_456", email: "bob@example.com", name: "Bob Example" }, + workspace: { id: "ws_123", name: "Acme Inc" }, + credential: { type: "oauth", id: null, name: null }, +}; + +const TWO_OAUTH_WORKSPACES: AuthWorkspaceListResult = { + authSource: "oauth", + activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + workspaces: [ + { + id: "ws_1", + name: "Acme Inc", + credentialWorkspaceId: "cred_1", + active: true, + source: "oauth", + switchable: true, + lastSeenAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "ws_2", + name: "Globex", + credentialWorkspaceId: "cred_2", + active: false, + source: "oauth", + switchable: true, + lastSeenAt: null, + }, + ], +}; + +const MIXED_SOURCES: AuthWorkspaceListResult = { + authSource: "service_token", + activeWorkspace: { id: "ws_tok", name: "Token WS" }, + workspaces: [ + { + id: "ws_tok", + name: "Token WS", + credentialWorkspaceId: null, + active: true, + source: "service_token", + switchable: false, + lastSeenAt: null, + }, + { + id: "ws_1", + name: "Acme Inc", + credentialWorkspaceId: "cred_1", + active: false, + source: "oauth", + switchable: false, + lastSeenAt: null, + }, + ], +}; + +const EMPTY_LIST: AuthWorkspaceListResult = { + authSource: "none", + activeWorkspace: null, + workspaces: [], +}; + +function makeCli() { + return createTestCli({ + commands: { + "auth login": authLoginCommand, + "auth logout": authLogoutCommand, + "auth whoami": authWhoamiCommand, + "auth workspace list": authWorkspaceListCommand, + "auth workspace use": authWorkspaceUseCommand, + "auth workspace logout": authWorkspaceLogoutCommand, + }, + groups: { + auth: { brief: "Manage local authentication for the CLI" }, + "auth workspace": { brief: "Manage local workspace sessions" }, + }, + now: () => new Date(0), + }); +} + +async function emptyTempCwd(): Promise { + return await mkdtemp(path.join(os.tmpdir(), "v8-auth-test-")); +} + +function resultFrame(frames: ReadonlyArray<{ kind: string }>) { + const frame = frames.at(-1); + if (frame === undefined || frame.kind !== "result") { + throw new Error("expected a terminal result frame"); + } + return frame as Extract; +} + +beforeEach(() => { + vi.mocked(performLogin).mockReset(); + vi.mocked(performLogout).mockReset(); + vi.mocked(readAuthState).mockReset(); + vi.mocked(listRealAuthWorkspaces).mockReset(); + vi.mocked(useRealAuthWorkspace).mockReset(); + vi.mocked(logoutRealAuthWorkspace).mockReset(); +}); + +describe("prisma-v8 auth login", () => { + it("runs the browser flow, emits step and endpoint events, and renders the signed-in card", async () => { + vi.mocked(performLogin).mockImplementation( + async (_env, _signal, options) => { + options?.onVerificationUrl?.( + "https://auth.prisma.io/activate?code=XYZ", + ); + }, + ); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); + + const result = await makeCli().run(["auth", "login"], { + isTty: { stdout: true }, + cwd: await emptyTempCwd(), + }); + + expect(result.exitCode).toBe(0); + expect(result.events).toEqual([ + { kind: "step-started", step: "Sign in via your browser" }, + { + kind: "endpoint", + name: "verification", + url: "https://auth.prisma.io/activate?code=XYZ", + }, + { + kind: "step-finished", + step: "Sign in via your browser", + outcome: "ok", + }, + ]); + expect(result.stdout).toBe( + "status: signed in\nuser: bob@example.com\nworkspace: Acme Inc\n", + ); + expect(result.stderr).toContain( + "ℹ Starting an authenticated CLI session.\n", + ); + expect(result.stderr).toContain("verification: https://auth.prisma.io/"); + expect(result.stderr).toContain( + "→ Show the signed-in identity: prisma-cli auth whoami\n", + ); + expect(result.stderr).toContain( + "→ List projects: prisma-cli project list\n", + ); + expect(result.stderr).not.toContain("Install Prisma skills"); + }); + + it("appends the agent-setup tip line and next action from a project directory", async () => { + vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); + const cwd = await emptyTempCwd(); + await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); + const stateDir = path.join(cwd, ".state"); + + const result = await makeCli().run(["auth", "login"], { + isTty: { stdout: true }, + cwd, + env: { PRISMA_CLI_STATE_DIR: stateDir }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "Install Prisma skills for this project with ", + ); + expect(result.stderr).toContain("agent install"); + const presented = result.presented; + expect(presented?.presentation.next.at(-1)).toMatchObject({ + kind: "run-command", + label: "Install Prisma skills for this project", + command: expect.stringContaining("agent install"), + }); + }); + + it("suppresses the agent-setup tip in CI", async () => { + vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); + const cwd = await emptyTempCwd(); + await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); + + const result = await makeCli().run(["auth", "login"], { + isTty: { stdout: true }, + cwd, + env: { CI: "1", PRISMA_CLI_STATE_DIR: path.join(cwd, ".state") }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("Install Prisma skills"); + }); + + it("streams the flow events and the raw auth state envelope in json mode", async () => { + vi.mocked(performLogin).mockImplementation( + async (_env, _signal, options) => { + options?.onVerificationUrl?.( + "https://auth.prisma.io/activate?code=XYZ", + ); + }, + ); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); + + const result = await makeCli().run(["auth", "login", "--json"], { + cwd: await emptyTempCwd(), + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.json.map((frame) => frame.kind)).toEqual([ + "step-started", + "endpoint", + "step-finished", + "result", + ]); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: true, + commandId: "auth.login", + result: SIGNED_IN, + exitCode: 0, + }); + if (!frame.envelope.ok) { + throw new Error("expected a completed envelope"); + } + expect(frame.envelope.nextActions.map((action) => action.command)).toEqual([ + "prisma-cli auth whoami", + "prisma-cli project list", + ]); + }); + + it("settles a failed login as a bug with the failed step event", async () => { + vi.mocked(performLogin).mockRejectedValue(new Error("browser failed")); + + const result = await makeCli().run(["auth", "login"], { + isTty: { stdout: true }, + cwd: await emptyTempCwd(), + }); + + expect(result.exitCode).toBe(1); + expect(result.events).toEqual([ + { kind: "step-started", step: "Sign in via your browser" }, + { + kind: "step-finished", + step: "Sign in via your browser", + outcome: "failed", + }, + ]); + expect(result.stderr).toContain("✖ [CLI.INTERNAL_ERROR] browser failed"); + expect(vi.mocked(readAuthState)).not.toHaveBeenCalled(); + }); + + it("never requires credentials (it creates the session)", () => { + expect(authLoginCommand.needs.credentials).toBe(false); + }); +}); + +describe("prisma-v8 auth logout", () => { + it("clears the session and renders the logout card with the sign-in follow-up", async () => { + vi.mocked(performLogout).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); + + const result = await makeCli().run(["auth", "logout"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(vi.mocked(performLogout)).toHaveBeenCalledTimes(1); + expect(result.stderr).toBe( + "ℹ Clearing the current CLI session.\n" + + "session: local CLI state\n" + + "✔ Session removed from local CLI state.\n" + + "→ Sign in: prisma-cli auth login\n", + ); + expect(result.stdout).toBe("status: signed out\n"); + }); + + it("carries the post-logout auth state as the json envelope result", async () => { + vi.mocked(performLogout).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); + + const result = await makeCli().run(["auth", "logout", "--json"]); + + expect(result.exitCode).toBe(0); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: true, + commandId: "auth.logout", + result: SIGNED_OUT, + }); + }); + + it("--workspace runs the shared workspace-logout operation with its presentation", async () => { + vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + workspace: { id: "ws_2", name: "Globex" }, + wasActive: false, + activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + }); + + const result = await makeCli().run( + ["auth", "logout", "--workspace", "Globex"], + { isTty: { stdout: true } }, + ); + + expect(result.exitCode).toBe(0); + expect(vi.mocked(performLogout)).not.toHaveBeenCalled(); + expect(vi.mocked(logoutRealAuthWorkspace)).toHaveBeenCalledWith( + expect.anything(), + "Globex", + ); + expect(result.stderr).toBe( + "ℹ Removing a local OAuth workspace session.\n" + + "workspace: Globex\n" + + "active: Acme Inc\n" + + "✔ Removed workspace session.\n" + + "→ List authenticated workspaces: prisma-cli auth workspace list\n", + ); + }); + + it("--workspace reports the mounted command id auth.logout in json mode", async () => { + vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + workspace: { id: "ws_2", name: "Globex" }, + wasActive: false, + activeWorkspace: null, + }); + + const result = await makeCli().run([ + "auth", + "logout", + "--workspace", + "ws_2", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: true, + commandId: "auth.logout", + }); + }); + + it("--workspace maps a missing workspace to AUTH.WORKSPACE_NOT_AUTHENTICATED, exit 2", async () => { + vi.mocked(logoutRealAuthWorkspace).mockRejectedValue( + workspaceNotAuthenticatedError("nope"), + ); + + const result = await makeCli().run([ + "auth", + "logout", + "--workspace", + "nope", + "--json", + ]); + + expect(result.exitCode).toBe(2); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: false, + commandId: "auth.logout", + error: { + code: "AUTH.WORKSPACE_NOT_AUTHENTICATED", + summary: "Workspace is not authenticated", + why: 'No stored OAuth session matched "nope".', + meta: { workspaceRef: "nope" }, + }, + }); + }); +}); + +describe("prisma-v8 auth workspace list", () => { + it("renders the workspace table without the source column for a single source", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + + const result = await makeCli().run(["auth", "workspace", "list"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe( + "ℹ Listing authenticated workspaces on this machine.\n" + + "auth source: local OAuth\n" + + "name id status\n" + + "Acme Inc ws_1 active\n" + + "Globex ws_2 \n", + ); + expect(result.stdout).toBe("Acme Inc ws_1 active\nGlobex ws_2\n"); + }); + + it("adds the source column only when sources are mixed", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(MIXED_SOURCES); + + const result = await makeCli().run(["auth", "workspace", "list"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("name id source status\n"); + expect(result.stderr).toContain( + "Token WS ws_tok service token active\n", + ); + expect(result.stderr).toContain("Acme Inc ws_1 OAuth \n"); + }); + + it("serializes the ported list shape in json mode", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + + const result = await makeCli().run(["auth", "workspace", "list", "--json"]); + + expect(result.exitCode).toBe(0); + const frame = resultFrame(result.json); + if (!frame.envelope.ok) { + throw new Error("expected a completed envelope"); + } + expect(frame.envelope.result).toEqual({ + context: { + authSource: "oauth", + activeWorkspaceId: "ws_1", + activeWorkspaceName: "Acme Inc", + }, + items: [ + { + id: "ws_1", + name: "Acme Inc", + status: "active", + source: "oauth", + switchable: true, + credentialWorkspaceId: "cred_1", + lastSeenAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "ws_2", + name: "Globex", + status: null, + source: "oauth", + switchable: true, + credentialWorkspaceId: "cred_2", + lastSeenAt: null, + }, + ], + count: 2, + }); + }); + + it("shows the empty state with the sign-in follow-up while signed out", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(EMPTY_LIST); + + const result = await makeCli().run(["auth", "workspace", "list"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("No local OAuth workspaces found.\n"); + expect(result.stderr).toContain("→ Sign in: prisma-cli auth login\n"); + expect(result.stdout).toBe(""); + }); +}); + +describe("prisma-v8 auth workspace use", () => { + it("switches by explicit ref and renders the mutation card", async () => { + vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + previousWorkspace: { id: "ws_1", name: "Acme Inc" }, + workspace: { id: "ws_2", name: "Globex" }, + }); + + const result = await makeCli().run(["auth", "workspace", "use", " ws_2 "], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(vi.mocked(useRealAuthWorkspace)).toHaveBeenCalledWith( + expect.anything(), + "ws_2", + ); + expect(result.stderr).toBe( + "ℹ Switching the local CLI workspace.\n" + + "previous: Acme Inc\n" + + "workspace: Globex\n" + + "✔ Local OAuth workspace selection updated.\n" + + "→ Show the signed-in identity: prisma-cli auth whoami\n" + + "→ List projects: prisma-cli project list\n", + ); + expect(result.stdout).toBe("previous: Acme Inc\nworkspace: Globex\n"); + }); + + it("carries the raw use result in the json envelope", async () => { + vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + previousWorkspace: null, + workspace: { id: "ws_2", name: "Globex" }, + }); + + const result = await makeCli().run([ + "auth", + "workspace", + "use", + "ws_2", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: true, + commandId: "auth.workspace.use", + result: { + previousWorkspace: null, + workspace: { id: "ws_2", name: "Globex" }, + }, + }); + }); + + it("maps an ambiguous name to AUTH.WORKSPACE_AMBIGUOUS with the match list, exit 2", async () => { + vi.mocked(useRealAuthWorkspace).mockRejectedValue( + workspaceAmbiguousError("Acme Inc", [ + { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, + { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, + ]), + ); + + const result = await makeCli().run([ + "auth", + "workspace", + "use", + "Acme Inc", + "--json", + ]); + + expect(result.exitCode).toBe(2); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: false, + commandId: "auth.workspace.use", + error: { + code: "AUTH.WORKSPACE_AMBIGUOUS", + summary: "Workspace name is ambiguous", + meta: { + workspaceRef: "Acme Inc", + matches: [ + { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, + { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, + ], + }, + nextActions: [ + { + kind: "user-choice", + label: + "Run prisma-cli auth workspace list and switch by workspace id.", + }, + ], + }, + }); + }); + + it("fails selection with AUTH.WORKSPACE_SWITCH_UNAVAILABLE when PRISMA_SERVICE_TOKEN is set", async () => { + const result = await makeCli().run(["auth", "workspace", "use"], { + isTty: { stdout: true }, + env: { PRISMA_SERVICE_TOKEN: "svc_token" }, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "✖ [AUTH.WORKSPACE_SWITCH_UNAVAILABLE] Workspace switching is unavailable\n", + ); + expect(vi.mocked(listRealAuthWorkspaces)).not.toHaveBeenCalled(); + }); + + it("fails with AUTH.USAGE_ERROR when no switchable workspaces exist", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(EMPTY_LIST); + + const result = await makeCli().run(["auth", "workspace", "use"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "✖ [AUTH.USAGE_ERROR] No authenticated workspaces\n", + ); + }); + + it("auto-selects the only switchable workspace without prompting", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue({ + ...TWO_OAUTH_WORKSPACES, + workspaces: [TWO_OAUTH_WORKSPACES.workspaces[0]], + }); + vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + previousWorkspace: null, + workspace: { id: "ws_1", name: "Acme Inc" }, + }); + + const result = await makeCli().run(["auth", "workspace", "use"], { + isTty: { stdout: true, stdin: true }, + }); + + expect(result.exitCode).toBe(0); + expect(vi.mocked(useRealAuthWorkspace)).toHaveBeenCalledWith( + expect.anything(), + "ws_1", + ); + }); + + it("prompts a select over the workspaces and switches to the answer", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + previousWorkspace: { id: "ws_1", name: "Acme Inc" }, + workspace: { id: "ws_2", name: "Globex" }, + }); + + const result = await makeCli().run(["auth", "workspace", "use"], { + isTty: { stdout: true, stdin: true }, + answers: ["ws_2"], + }); + + expect(result.exitCode).toBe(0); + expect(vi.mocked(useRealAuthWorkspace)).toHaveBeenCalledWith( + expect.anything(), + "ws_2", + ); + }); + + it("fails an invalid select answer with CLI.PROMPT_INVALID, exit 2", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + + const result = await makeCli().run(["auth", "workspace", "use"], { + isTty: { stdout: true, stdin: true }, + answers: ["not-a-workspace"], + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("[CLI.PROMPT_INVALID]"); + expect(vi.mocked(useRealAuthWorkspace)).not.toHaveBeenCalled(); + }); + + it("fails non-interactively with the engine's structural prompt error, exit 2", async () => { + vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + + const result = await makeCli().run(["auth", "workspace", "use"], { + isTty: { stdout: true, stdin: false }, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("[CLI.PROMPT_REQUIRED]"); + expect(vi.mocked(useRealAuthWorkspace)).not.toHaveBeenCalled(); + }); +}); + +describe("prisma-v8 auth workspace logout", () => { + it("removes a non-active session and keeps the active workspace", async () => { + vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + workspace: { id: "ws_2", name: "Globex" }, + wasActive: false, + activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + }); + + const result = await makeCli().run( + ["auth", "workspace", "logout", "ws_2"], + { isTty: { stdout: true } }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe( + "ℹ Removing a local OAuth workspace session.\n" + + "workspace: Globex\n" + + "active: Acme Inc\n" + + "✔ Removed workspace session.\n" + + "→ List authenticated workspaces: prisma-cli auth workspace list\n", + ); + expect(result.stdout).toBe("workspace: Globex\nactive: Acme Inc\n"); + }); + + it("reports the was-active removal with no auto-fallthrough and the use follow-up", async () => { + vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + workspace: { id: "ws_1", name: "Acme Inc" }, + wasActive: true, + activeWorkspace: null, + }); + + const result = await makeCli().run( + ["auth", "workspace", "logout", "ws_1"], + { isTty: { stdout: true } }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "✔ Removed active workspace session; no replacement workspace was selected.\n", + ); + expect(result.stderr).toContain("active: none\n"); + expect(result.stderr).toContain( + "→ Select a replacement workspace: prisma-cli auth workspace use \n", + ); + }); + + it("carries the raw logout result in the json envelope", async () => { + vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + workspace: { id: "ws_2", name: "Globex" }, + wasActive: false, + activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + }); + + const result = await makeCli().run([ + "auth", + "workspace", + "logout", + "ws_2", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: true, + commandId: "auth.workspace.logout", + result: { + workspace: { id: "ws_2", name: "Globex" }, + wasActive: false, + activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + }, + }); + }); + + it("maps an ambiguous name to AUTH.WORKSPACE_AMBIGUOUS, exit 2", async () => { + vi.mocked(logoutRealAuthWorkspace).mockRejectedValue( + workspaceAmbiguousError("Acme Inc", [ + { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, + { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, + ]), + ); + + const result = await makeCli().run( + ["auth", "workspace", "logout", "Acme Inc"], + { isTty: { stdout: true } }, + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "✖ [AUTH.WORKSPACE_AMBIGUOUS] Workspace name is ambiguous\n", + ); + }); + + it("fails a blank workspace ref with AUTH.USAGE_ERROR, exit 2", async () => { + const result = await makeCli().run(["auth", "workspace", "logout", " "], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "✖ [AUTH.USAGE_ERROR] Workspace required\n", + ); + expect(vi.mocked(logoutRealAuthWorkspace)).not.toHaveBeenCalled(); + }); +}); From 5ac848733ed79a1f7f397ee21b595736e7a6e9af Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 02:46:46 +0200 Subject: [PATCH 10/67] feat(cli): move the update check out of the shell and wire the v8 bin packages/cli/src/shell/update-check.ts moves to packages/cli/src/update-check.ts; its CliRuntime parameter narrows to the structural UpdateCheckRuntime (env/argv/stderr) both shells satisfy. The legacy shell keeps its call sites; the v8 bin copies their sequencing exactly: main() awaits the cached notification (and detached refresh spawn) before dispatch, and the v8 bin entry gains the PRISMA_CLI_RUN_UPDATE_CHECK_WORKER branch. Suppression rules are copied as-is, including silence when argv contains --json/--quiet/-q, recorded in the S2 divergence list. Tests cover the v8 wiring: cached-newer notify, interval silence, json-mode silence, non-TTY silence, and the detached refresh spawn's arguments and worker env contract. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/bin.ts | 2 +- packages/cli/src/cli.ts | 2 +- packages/cli/src/{shell => }/update-check.ts | 19 +- packages/cli/src/v8/bin.ts | 8 +- packages/cli/src/v8/main.ts | 9 + packages/cli/tests/update-check.test.ts | 2 +- packages/cli/tests/v8-update-check.test.ts | 190 +++++++++++++++++++ 7 files changed, 222 insertions(+), 10 deletions(-) rename packages/cli/src/{shell => }/update-check.ts (95%) create mode 100644 packages/cli/tests/v8-update-check.test.ts diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index a1774a64..ce5daeb7 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -2,7 +2,7 @@ import process from "node:process"; import { runCli } from "./cli"; -import { runUpdateDiscoveryWorker } from "./shell/update-check"; +import { runUpdateDiscoveryWorker } from "./update-check"; if (process.env.PRISMA_CLI_RUN_UPDATE_CHECK_WORKER === "1") { await runUpdateDiscoveryWorker(); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d1c1a6b2..f5673908 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -34,7 +34,7 @@ import { createCommandContext, } from "./shell/runtime"; import { createShellUi } from "./shell/ui"; -import { maybeWriteCachedUpdateNotification } from "./shell/update-check"; +import { maybeWriteCachedUpdateNotification } from "./update-check"; export interface RunCliOptions extends Partial { argv?: string[]; diff --git a/packages/cli/src/shell/update-check.ts b/packages/cli/src/update-check.ts similarity index 95% rename from packages/cli/src/shell/update-check.ts rename to packages/cli/src/update-check.ts index 84e19146..f5f8cf84 100644 --- a/packages/cli/src/shell/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -5,8 +5,15 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { getCliName, getCliVersion } from "../lib/version"; -import type { CliRuntime } from "./runtime"; +import { getCliName, getCliVersion } from "./lib/version"; + +/** The exact runtime surface the update check reads; both the legacy + * shell's CliRuntime and the v8 bin's host process satisfy it. */ +export interface UpdateCheckRuntime { + readonly env: NodeJS.ProcessEnv; + readonly argv: readonly string[]; + readonly stderr: { readonly isTTY?: boolean; write(text: string): unknown }; +} const UPDATE_CHECK_FILE_NAME = "update-check.json"; const FALLBACK_INSTALL_DOCS_URL = @@ -62,7 +69,7 @@ export class UpdateCheckStore { } export async function maybeWriteCachedUpdateNotification( - runtime: CliRuntime, + runtime: UpdateCheckRuntime, ): Promise { if (!canRunUpdateCheck(runtime)) { return; @@ -156,7 +163,7 @@ export async function runUpdateDiscoveryWorker( }); } -function canRunUpdateCheck(runtime: CliRuntime): boolean { +function canRunUpdateCheck(runtime: UpdateCheckRuntime): boolean { if (runtime.env.NO_UPDATE_NOTIFIER !== undefined) { return false; } @@ -196,7 +203,7 @@ function shouldNotify(state: UpdateCheckState): boolean { } async function scheduleRemoteDiscovery( - runtime: CliRuntime, + runtime: UpdateCheckRuntime, store: UpdateCheckStore, state: UpdateCheckState | null, cacheDir: string, @@ -316,7 +323,7 @@ function docsInstruction(): UpdateInstruction { return { type: "docs", value: FALLBACK_INSTALL_DOCS_URL }; } -function resolveUpdateCheckCacheDir(runtime: CliRuntime): string { +function resolveUpdateCheckCacheDir(runtime: UpdateCheckRuntime): string { const configured = runtime.env.PRISMA_CLI_UPDATE_CHECK_DIR; if (configured?.trim()) { return path.resolve(configured); diff --git a/packages/cli/src/v8/bin.ts b/packages/cli/src/v8/bin.ts index 73a60ad4..09ad240c 100644 --- a/packages/cli/src/v8/bin.ts +++ b/packages/cli/src/v8/bin.ts @@ -1,5 +1,11 @@ #!/usr/bin/env node import process from "node:process"; +import { runUpdateDiscoveryWorker } from "../update-check"; import { main } from "./main"; -process.exitCode = await main(process); +if (process.env.PRISMA_CLI_RUN_UPDATE_CHECK_WORKER === "1") { + await runUpdateDiscoveryWorker(); + process.exitCode = 0; +} else { + process.exitCode = await main(process); +} diff --git a/packages/cli/src/v8/main.ts b/packages/cli/src/v8/main.ts index 832609c7..b607a418 100644 --- a/packages/cli/src/v8/main.ts +++ b/packages/cli/src/v8/main.ts @@ -1,4 +1,5 @@ import type { Cli, CliRunHooks } from "@prisma/cli-engine"; +import { maybeWriteCachedUpdateNotification } from "../update-check"; import { buildCli } from "./cli"; import { assembleRuntime, type HostProcess } from "./runtime"; import { resolveTelemetryHooks } from "./telemetry/wiring"; @@ -23,6 +24,14 @@ export async function main( ); return 1; } + // Legacy sequencing: the cached update notification (and its detached + // refresh spawn) runs before the command dispatches, so the notice + // precedes all command output on stderr. + await maybeWriteCachedUpdateNotification({ + env: proc.env, + argv: proc.argv.slice(2), + stderr: proc.stderr, + }); const runtime = await assembleRuntime(proc); let hooks: CliRunHooks | undefined; try { diff --git a/packages/cli/tests/update-check.test.ts b/packages/cli/tests/update-check.test.ts index ff87ddb3..2b9a43e9 100644 --- a/packages/cli/tests/update-check.test.ts +++ b/packages/cli/tests/update-check.test.ts @@ -7,7 +7,7 @@ import { runUpdateDiscovery, selectUpdateInstruction, UpdateCheckStore, -} from "../src/shell/update-check"; +} from "../src/update-check"; import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); diff --git a/packages/cli/tests/v8-update-check.test.ts b/packages/cli/tests/v8-update-check.test.ts new file mode 100644 index 00000000..37f1af5d --- /dev/null +++ b/packages/cli/tests/v8-update-check.test.ts @@ -0,0 +1,190 @@ +/** + * v8 bin wiring of the update check: the cached notification (and the + * detached refresh spawn) runs inside main() before the command + * dispatches, with the legacy shell's sequencing and suppression rules. + */ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getCliVersion } from "../src/lib/version"; +import { UpdateCheckStore } from "../src/update-check"; +import { main } from "../src/v8/main"; +import type { HostProcess } from "../src/v8/runtime"; + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => ({ unref: vi.fn() })), +})); + +function makeProcess(overrides: { + argv?: string[]; + env?: NodeJS.ProcessEnv; + stderrTty?: boolean; +}): HostProcess & { stdoutText: string; stderrText: string } { + const proc = { + argv: overrides.argv ?? ["node", "bin.js", "auth", "whoami"], + env: overrides.env ?? {}, + cwd: () => "/tmp/v8-update-check-cwd", + stdoutText: "", + stderrText: "", + stdout: { + isTTY: true, + write(text: string) { + proc.stdoutText += text; + }, + }, + stderr: { + isTTY: overrides.stderrTty ?? true, + write(text: string) { + proc.stderrText += text; + }, + }, + stdin: { + isTTY: false, + async *[Symbol.asyncIterator]() {}, + } as unknown as HostProcess["stdin"], + on: () => proc, + off: () => proc, + exit(code: number): never { + throw new Error(`process.exit(${code})`); + }, + }; + return proc; +} + +const stubCli = () => ({ run: async () => 0 }); + +async function makeUpdateCheckDir(): Promise { + return await mkdtemp(path.join(os.tmpdir(), "v8-update-check-")); +} + +function nextMajorVersion(): string { + const [major] = getCliVersion().split("."); + return `${Number(major) + 1}.0.0`; +} + +async function seedStaleUpdate(updateCheckDir: string): Promise { + await new UpdateCheckStore(updateCheckDir).write({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + latestVersion: nextMajorVersion(), + checkedAt: new Date().toISOString(), + }); +} + +function updateCheckEnv(updateCheckDir: string): NodeJS.ProcessEnv { + return { PRISMA_CLI_UPDATE_CHECK_DIR: updateCheckDir }; +} + +beforeEach(() => { + vi.mocked(spawn).mockClear(); +}); + +describe("v8 main update-check wiring", () => { + it("prints the cached update notice to stderr before dispatching", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + await seedStaleUpdate(updateCheckDir); + const proc = makeProcess({ env: updateCheckEnv(updateCheckDir) }); + + const exitCode = await main(proc, stubCli); + + expect(exitCode).toBe(0); + expect(proc.stderrText).toContain( + `Update available: prisma-cli ${getCliVersion()} -> ${nextMajorVersion()}`, + ); + expect(proc.stdoutText).toBe(""); + }); + + it("stays silent inside the notification interval", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + await seedStaleUpdate(updateCheckDir); + const env = updateCheckEnv(updateCheckDir); + + const first = makeProcess({ env }); + await main(first, stubCli); + const second = makeProcess({ env }); + await main(second, stubCli); + + expect(first.stderrText).toContain("Update available"); + expect(second.stderrText).not.toContain("Update available"); + }); + + it("stays silent in json mode (legacy behavior, copied)", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + await seedStaleUpdate(updateCheckDir); + const proc = makeProcess({ + argv: ["node", "bin.js", "auth", "whoami", "--json"], + env: updateCheckEnv(updateCheckDir), + }); + + await main(proc, stubCli); + + expect(proc.stderrText).not.toContain("Update available"); + }); + + it("stays silent without a stderr TTY", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + await seedStaleUpdate(updateCheckDir); + const proc = makeProcess({ + env: updateCheckEnv(updateCheckDir), + stderrTty: false, + }); + + await main(proc, stubCli); + + expect(proc.stderrText).not.toContain("Update available"); + }); + + it("spawns the detached refresh worker with the worker env contract", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + const proc = makeProcess({ + env: { + ...updateCheckEnv(updateCheckDir), + PRISMA_CLI_UPDATE_CHECK_REGISTRY_URL: "https://registry.test/pkg", + }, + }); + + await main(proc, stubCli); + + expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1); + const [execPath, args, options] = vi.mocked(spawn).mock.calls[0] as [ + string, + string[], + { + detached: boolean; + stdio: string; + env: NodeJS.ProcessEnv; + }, + ]; + expect(execPath).toBe(process.execPath); + expect(args).toEqual([process.argv[1]]); + expect(options.detached).toBe(true); + expect(options.stdio).toBe("ignore"); + expect(options.env).toMatchObject({ + PRISMA_CLI_RUN_UPDATE_CHECK_WORKER: "1", + PRISMA_CLI_UPDATE_CHECK_DIR: updateCheckDir, + PRISMA_CLI_UPDATE_CHECK_INSTALLED_VERSION: getCliVersion(), + PRISMA_CLI_UPDATE_CHECK_REGISTRY_URL: "https://registry.test/pkg", + }); + const state = JSON.parse( + await readFile(path.join(updateCheckDir, "update-check.json"), "utf8"), + ) as Record; + expect(state.checkedAt).toEqual(expect.any(String)); + }); + + it("skips the refresh spawn inside the 24-hour discovery interval", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + await new UpdateCheckStore(updateCheckDir).write({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + checkedAt: new Date().toISOString(), + }); + const proc = makeProcess({ env: updateCheckEnv(updateCheckDir) }); + + await main(proc, stubCli); + + expect(vi.mocked(spawn)).not.toHaveBeenCalled(); + }); +}); From 3f2508ce9bffa936f76a096b46525342171b0d46 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 02:46:55 +0200 Subject: [PATCH 11/67] docs(drive): record S2a parity divergences and check the acceptance boxes Creates the S2 cumulative parity divergence list (seeded with a pointer to the S1 whoami-scoped record) covering the auth family's AUTH.* error-code mapping, login's fixture-flag removal and event surface, the logout --workspace commandId change, prompt-path behavior for workspace use, and the update-check json-mode finding. Marks the whoami record as whoami-scoped and updates its stale update-notification note. Checks every S2a acceptance box except the operator publish and the operator's divergence-list review. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/whoami-parity-divergences.md | 12 +- .../assets/s2/parity-divergences.md | 138 ++++++++++++++++++ .../prisma-cli-v8/specs/s2a-foundations.md | 14 +- 3 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 .drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md diff --git a/.drive/projects/prisma-cli-v8/assets/engine/whoami-parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/engine/whoami-parity-divergences.md index 14d2e8ee..444b165b 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/whoami-parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/whoami-parity-divergences.md @@ -1,5 +1,11 @@ # `prisma-v8 auth whoami` — parity divergences from `prisma-cli auth whoami` +> SCOPE: this document is whoami-scoped (S1). Its engine-global +> sections (json framing, format auto-selection, channel discipline, +> rendering style, `--quiet`, exit codes, flag family) are the +> baseline for every later port. S2 onward, per-command divergences +> accumulate in [`../s2/parity-divergences.md`](../s2/parity-divergences.md). + Written for D6 of slice s1-engine-vertical (2026-08-09). Every known place where the v8 port's output or behavior differs from the shipped `prisma-cli auth whoami`, and why. The v8 side is pinned by @@ -183,8 +189,10 @@ operations residue resolves. ## 12. Update notification / agent tips The current CLI shell may prepend a cached update notification to any -command's output, and login (not whoami) appends agent-setup tips. The -v8 bin has none of that shell behavior. +command's output, and login (not whoami) appends agent-setup tips. +UPDATED in S2a: the v8 bin now wires the same cached update +notification with the legacy sequencing, and `auth login` ports the +agent-setup tip — see the S2 cumulative divergence list. --- diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md new file mode 100644 index 00000000..10f88e33 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -0,0 +1,138 @@ +# S2 cumulative parity divergences + +Every known place where the v8 ports differ from the shipping +`prisma-cli`, enumerated per PR for operator review (S2 standing ruling +10: divergences are enumerated, not discovered; maintainability +outranks byte parity). Later S2 PRs append their own sections here. + +The S1 whoami-scoped record — +[`../engine/whoami-parity-divergences.md`](../engine/whoami-parity-divergences.md) +— remains the baseline for everything the engine changes globally +(json framing, format auto-selection, stderr/stdout channel discipline, +rendering style, `--quiet` as a log-level alias, exit-code semantics, +the dropped `--trace`, the shared flag family). Those apply to every +ported command and are not repeated per command below. + +## S2a — auth family + update check (this PR) + +### Error-code mapping (flat → dotted `AUTH.*`) + +Pattern set by S1 (`AUTH_CONFIG_INVALID` → `AUTH.CONFIG_INVALID`). +Every errored settlement exits 2 in v8, regardless of the legacy +per-error exit code; `fix` prose maps to one `user-choice` nextAction +and the legacy `nextSteps` string list is folded into it (the S1 +whoami precedent), while `meta` is preserved verbatim. + +| Legacy flat code (exit) | v8 dotted code (exit) | Commands | +| --- | --- | --- | +| `AUTH_CONFIG_INVALID` (1) | `AUTH.CONFIG_INVALID` (2) | whoami, login, logout | +| `WORKSPACE_SWITCH_UNAVAILABLE` (1) | `AUTH.WORKSPACE_SWITCH_UNAVAILABLE` (2) | workspace use | +| `WORKSPACE_NOT_AUTHENTICATED` (1) | `AUTH.WORKSPACE_NOT_AUTHENTICATED` (2) | workspace use, workspace logout, logout --workspace | +| `WORKSPACE_AMBIGUOUS` (2) | `AUTH.WORKSPACE_AMBIGUOUS` (2) | workspace use, workspace logout, logout --workspace | +| `USAGE_ERROR` (2) — "No authenticated workspaces" | `AUTH.USAGE_ERROR` (2) | workspace use | +| `USAGE_ERROR` (2) — "Workspace required" (blank ref) | `AUTH.USAGE_ERROR` (2) | workspace logout | + +No documented 4–99 codes exist in this family. + +### `auth login` + +- The fixture-only flags `--provider`, `--user`, `--workspace` do NOT + port (hidden mock-selection surface; fixture machinery dies in S2d). + The `--workspace` name is reused by `auth logout` only. +- The flow now speaks engine events: `step-started`/`step-finished` + around the browser flow, and an `endpoint` event named `verification` + carrying the OAuth authorize URL (surfaced via a new optional + `onVerificationUrl` hook on `performLogin`; legacy callers are + unaffected). Legacy printed the URL only inside the interactive + instruction prose. +- The interactive paste-fallback prompt and instruction prose inside + `performLogin` still write to the process's own stdin/stderr (the + reference implementation owns that flow); unchanged from legacy. +- Presentation is the whoami-style card (summary + `label: value` + rows) titled with the legacy copy "Starting an authenticated CLI + session.", not the legacy mutate-card layout. +- Agent-setup tip: the legacy helper suppressed the tip under + `--json`, `--quiet`, CI (unless `--interactive`), and non-TTY + stderr. In v8: CI suppression is kept (`ctx.env.CI`; the engine does + not expose the `--interactive` flag to handlers, so its override is + dropped); the tip LINE renders only in the human presentation, so + json output never shows it; but the tip nextAction and the + `agentSetupTip` result field DO appear in json envelopes (the + contract's nextActions row), where legacy omitted the tip from + `--json` entirely. `--quiet` no longer suppresses it (log-level + alias ruling). There is no stderr-TTY check; format auto-selection + covers the piped case. +- A failed login (browser launch, callback, token exchange) was an + unstructured crash (exit 1) in legacy; in v8 it settles as + `CLI.INTERNAL_ERROR`, exit 1 — same class, structured envelope. +- nextActions: `prisma-cli auth whoami`, `prisma-cli project list`, + plus the tip command when present (legacy: same strings as + `nextSteps`). + +### `auth logout` + +- `--workspace ` no longer re-dispatches at the argv level: the + handler calls the shared workspace-logout operation directly. The + envelope therefore reports commandId `auth.logout` where legacy + reported `auth.workspace.logout` for the same invocation. Semantics + and presentation are those of `auth workspace logout`. +- Plain logout keeps the legacy copy ("Clearing the current CLI + session." / "Session removed from local CLI state.") in the block + vocabulary; the json result stays the raw post-logout + `AuthStateResult` (legacy had no serializer either). + +### `auth workspace list` + +- Human table ports the exact legacy column rule: name, id, status, + with a source column only when sources are mixed; the `auth source` + line is kept. Rail/padding/color styling goes the way of all v8 + rendering (S1 doc §3). +- The json serializer is `serializeAuthWorkspaceList`, ported + verbatim (context/items/count shape). +- Human mode now also writes the table's data rows to stdout (the + machine payload surface); legacy wrote nothing to stdout. + +### `auth workspace use` + +- Absent positional + multiple workspaces + non-interactive: legacy + threw its own `USAGE_ERROR` ("Interactive workspace selection + unavailable", exit 2); v8 lets the engine's structural prompt + failure speak — `CLI.PROMPT_REQUIRED`, exit 2. An invalid scripted + answer is `CLI.PROMPT_INVALID` (exit 2); cancellation is + `CLI.PROMPT_CANCELLED` (exit 3). +- Single-workspace auto-select and the zero-workspace usage error + port unchanged; the picker itself is `ctx.prompt.select` (clack on + real TTYs) with the legacy label shape + `name (id)[ active]`. + +### `auth workspace logout` + +- Ported unchanged, including was-active handling (never + auto-falls-through; suggests `auth workspace use ` when the + active workspace was removed). Raw result shape in json (legacy + serializer was the identity). + +### Update check (§5) + +- The module moves to `packages/cli/src/update-check.ts` with a + structural `UpdateCheckRuntime` (env/argv/stderr); both shells + consume it. Sequencing copied from the legacy call sites: cached + notify + detached refresh spawn awaited before dispatch + (`src/cli.ts` for the legacy shell, `src/v8/main.ts` for the v8 + bin), worker branch in both bins + (`PRISMA_CLI_RUN_UPDATE_CHECK_WORKER=1`). +- json mode: the legacy shell prints NOTHING when argv contains + `--json`/`--quiet`/`-q` — copied as-is (the contract's + decide-by-current-behavior rule). Note the check is literal argv + matching: the v8 spelling `--format json` is NOT suppressed (and + `-q` still suppresses although v8's quiet is only a log-level + alias). Same for `--version`, CI, non-TTY stderr, and + `NO_UPDATE_NOTIFIER`. + +### Test surface + +- `tests/auth.test.ts` fixture-mode cases covering the six ported + commands are deleted; the file keeps its real-mode storage cases and + the legacy-shell presentation cases (help text, TTY header) until + S2d. The v8 side is pinned semantically in `tests/v8-auth.test.ts` + and `tests/v8-update-check.test.ts`. diff --git a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md index 1551c465..8d5405e2 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md +++ b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md @@ -215,16 +215,16 @@ cherry-pick): - [ ] Operator has published `@prisma/cli-engine@0.1.0` (metadata PR landed first; publish is the operator's single action). -- [ ] `ctx.api` on the context with the harness `client` override; +- [x] `ctx.api` on the context with the harness `client` override; draft amended; refresh-pickup test green. -- [ ] Auth module extracted; legacy shell green against it; v8 runtime +- [x] Auth module extracted; legacy shell green against it; v8 runtime consumes `makeGetCredentials` from it. -- [ ] All six `auth *` commands on the engine with semantic tests; +- [x] All six `auth *` commands on the engine with semantic tests; fixture-only flags gone; divergence list updated. -- [ ] Update check ported to both shells; sequencing matches legacy. -- [ ] Telemetry: package ported, hook amendment landed, bin wired, +- [x] Update check ported to both shells; sequencing matches legacy. +- [x] Telemetry: package ported, hook amendment landed, bin wired, consent commands mounted, sanitizer value-free by test. -- [ ] Clack renderer landed per spike; all prompt tests green +- [x] Clack renderer landed per spike; all prompt tests green including the clack-path fixture suite. -- [ ] Root verification: engine + cli suites, typecheck, lint exit 0. +- [x] Root verification: engine + cli suites, typecheck, lint exit 0. - [ ] PR ≥1k LOC (expected: well above), divergence list reviewed. From f47619086c83f197a7cd4dab9929e7433cd7ada3 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 03:30:48 +0200 Subject: [PATCH 12/67] fix(cli-telemetry): swallow async fork errors and drop the c12 config load Review-round fixes for slice s2a-foundations (B1, C4, A3, A7, C7): - Attach a swallowing 'error' listener on the forked sender child so an async fork failure can neither crash the parent CLI nor flip its exit code (B1), with a regression test driving a fake child's error event. - Drop the detached child's prisma-next.config.* c12 load entirely: the config does not exist in this product and the load evaluated arbitrary user TS in a detached process. databaseTarget now ships null (payload override kept for wire compatibility) and extensions ships []; the c12 and magicast dependencies are removed (C4). Recorded as an S2a parity divergence. - resolveGating now takes { env, config, inCI } and returns a total reason union (ci | env-opt-out | stored-opt-out | stored-opt-in | default-on); CI is part of the resolution itself (A3). - Public-surface tightening: the duplicated EngineCommandSnapshot type is no longer re-exported (the structural declaration stays internal) and SanitisedCommand is normalized to SanitizedCommand (A7). - Pin @clack/prompts to exact 1.5.0 in packages/cli (C7). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-telemetry/package.json | 3 +- packages/cli-telemetry/src/enrich.ts | 111 +---- packages/cli-telemetry/src/exports/index.ts | 5 +- packages/cli-telemetry/src/gating.ts | 49 ++- packages/cli-telemetry/src/payload.ts | 40 +- packages/cli-telemetry/src/sanitize.ts | 4 +- packages/cli-telemetry/src/spawn.ts | 32 +- packages/cli-telemetry/tests/enrich.test.ts | 140 +------ packages/cli-telemetry/tests/gating.test.ts | 59 ++- .../tests/sender-integration.test.ts | 15 +- .../tests/spawn-fork-error.test.ts | 52 +++ packages/cli/package.json | 4 +- packages/cli/src/{lib => }/auth/guard.ts | 0 packages/cli/src/{lib => }/auth/login.ts | 0 packages/cli/src/{lib => }/auth/recipient.ts | 0 .../cli/src/v8/auth/workspace-commands.ts | 393 ------------------ packages/cli/src/v8/telemetry/commands.ts | 109 ----- .../v8/telemetry/{wiring.ts => reporting.ts} | 0 ...test.ts => v8-telemetry-reporting.test.ts} | 0 packages/cli/tsdown.config.ts | 2 +- pnpm-lock.yaml | 98 +---- 21 files changed, 212 insertions(+), 904 deletions(-) create mode 100644 packages/cli-telemetry/tests/spawn-fork-error.test.ts rename packages/cli/src/{lib => }/auth/guard.ts (100%) rename packages/cli/src/{lib => }/auth/login.ts (100%) rename packages/cli/src/{lib => }/auth/recipient.ts (100%) delete mode 100644 packages/cli/src/v8/auth/workspace-commands.ts delete mode 100644 packages/cli/src/v8/telemetry/commands.ts rename packages/cli/src/v8/telemetry/{wiring.ts => reporting.ts} (100%) rename packages/cli/tests/{v8-telemetry-wiring.test.ts => v8-telemetry-reporting.test.ts} (100%) diff --git a/packages/cli-telemetry/package.json b/packages/cli-telemetry/package.json index a86960f1..d39cf591 100644 --- a/packages/cli-telemetry/package.json +++ b/packages/cli-telemetry/package.json @@ -32,8 +32,7 @@ "test": "pnpm run build && pnpm run typecheck && vitest run" }, "dependencies": { - "@vercel/detect-agent": "^1.2.3", - "c12": "^3.3.4" + "@vercel/detect-agent": "^1.2.3" }, "devDependencies": { "@repo/tsconfig": "workspace:*", diff --git a/packages/cli-telemetry/src/enrich.ts b/packages/cli-telemetry/src/enrich.ts index cc55d6ca..2a7140f4 100644 --- a/packages/cli-telemetry/src/enrich.ts +++ b/packages/cli-telemetry/src/enrich.ts @@ -4,97 +4,17 @@ import { determineAgent } from "@vercel/detect-agent"; import type { ParentToSenderPayload, TelemetryEvent } from "./payload"; /** - * Subset of the user's `prisma-next.config.*` the telemetry event - * surfaces. Loaded inside the detached child via {@link loadProjectConfig} - * — see the design rationale on {@link ParentToSenderPayload} for why - * this side runs c12 instead of the parent CLI. + * The project-derived slice of the telemetry event. The ORM CLI filled + * these by loading `prisma-next.config.*` (via c12) inside the detached + * child; that config file does not exist in this product, so the load + * was dropped: `databaseTarget` is the parent-side override or `null`, + * and `extensions` is always empty. Recorded as an S2a divergence. */ export interface ProjectConfigFields { readonly databaseTarget: string | null; readonly extensions: readonly string[]; } -const EMPTY_PROJECT_CONFIG: ProjectConfigFields = { - databaseTarget: null, - extensions: [], -}; - -/** - * Structural extraction of the two telemetry fields from a raw - * `prisma-next.config.*` object: `target.targetId` and the - * `extensions[].id` list. The ORM CLI ran the full canonical - * `@internal/config` validator here; that package lives in - * prisma/prisma and is not consumable from this repo, so the same - * outcome is reproduced structurally — a config whose read fields are - * not the expected shapes yields the empty result, exactly like a - * validator rejection did. - */ -function extractProjectConfig( - config: Record, -): ProjectConfigFields { - const target = config["target"]; - if (target === null || typeof target !== "object") { - return EMPTY_PROJECT_CONFIG; - } - const targetId = (target as Record)["targetId"]; - if (typeof targetId !== "string" || targetId.length === 0) { - return EMPTY_PROJECT_CONFIG; - } - const rawExtensions = config["extensions"] ?? []; - if (!Array.isArray(rawExtensions)) { - return EMPTY_PROJECT_CONFIG; - } - const extensions: string[] = []; - for (const pack of rawExtensions) { - if (pack === null || typeof pack !== "object") { - return EMPTY_PROJECT_CONFIG; - } - const id = (pack as Record)["id"]; - if (typeof id !== "string" || id.length === 0) { - return EMPTY_PROJECT_CONFIG; - } - extensions.push(id); - } - return { databaseTarget: targetId, extensions }; -} - -/** - * Best-effort load of `prisma-next.config.*` from `projectRoot`. - * Returns `{ databaseTarget: null, extensions: [] }` on any failure - * mode — missing config file, c12 throws while evaluating user TS, a - * malformed shape, etc. Telemetry is non-blocking and best-effort; an - * empty result is the only downside of an unloadable or invalid config. - * - * `c12` is imported lazily so the detached sender's cold-start cost is - * paid only when telemetry actually fires, not on every fork even when - * the checks short-circuit before reaching this code path. - */ -export async function loadProjectConfig( - projectRoot: string, -): Promise { - try { - const { loadConfig } = await import("c12"); - const result = await loadConfig>({ - name: "prisma-next", - cwd: projectRoot, - dotenv: false, - rcFile: false, - globalRc: false, - }); - const config = result.config ?? null; - // c12 returns an empty object when no config file exists in the - // search path — distinct from "file existed but parsed to an empty - // object". Either way the extraction below would come back empty, - // so short-circuit. - if (config === null || Object.keys(config).length === 0) { - return EMPTY_PROJECT_CONFIG; - } - return extractProjectConfig(config); - } catch { - return EMPTY_PROJECT_CONFIG; - } -} - /** * Versions surface the enrichment cares about. Modelled as a structural * record with a required `node` field so tests can pass a literal object @@ -210,8 +130,8 @@ function pickStringDep(deps: unknown): string | null { /** * Build the full backend event from the parent's payload, the - * c12-loaded project-config slice, and the child's per-process - * snapshot. Pure given a `projectConfig` + `EnrichEnvironment`. + * project-config slice, and the child's per-process snapshot. Pure + * given a `projectConfig` + `EnrichEnvironment`. */ export function buildTelemetryEvent( payload: ParentToSenderPayload, @@ -253,21 +173,20 @@ async function resolveAgentLabel(): Promise { /** * Convenience for the sender entry: build the event from the live - * `process` plus a c12 load of `prisma-next.config.*` from - * `payload.projectRoot` plus a real project-package.json reader, - * swallowing any I/O errors in the file read. + * `process` plus a real project-package.json reader, swallowing any + * I/O errors in the file read. * - * The parent's `payload.databaseTarget` (when present) wins over the - * c12-derived value; every other invocation leaves it unset and the - * c12 load supplies the value. + * The project-config slice is payload-only: `databaseTarget` is the + * parent's override when present (else `null`), and `extensions` is + * always empty — there is no `prisma-next.config.*` in this product to + * derive them from. */ export async function buildTelemetryEventFromProcess( payload: ParentToSenderPayload, ): Promise { - const loadedConfig = await loadProjectConfig(payload.projectRoot); const projectConfig: ProjectConfigFields = { - databaseTarget: payload.databaseTarget ?? loadedConfig.databaseTarget, - extensions: loadedConfig.extensions, + databaseTarget: payload.databaseTarget ?? null, + extensions: [], }; return buildTelemetryEvent(payload, projectConfig, { platform: process.platform, diff --git a/packages/cli-telemetry/src/exports/index.ts b/packages/cli-telemetry/src/exports/index.ts index e20c0a40..4fee07b6 100644 --- a/packages/cli-telemetry/src/exports/index.ts +++ b/packages/cli-telemetry/src/exports/index.ts @@ -3,16 +3,15 @@ export { TELEMETRY_BACKEND_URL, TELEMETRY_ENDPOINT_PATH, } from "../endpoint"; -export type { ProjectConfigFields } from "../enrich"; -export { loadProjectConfig } from "../enrich"; export type { GatingDisabledReason, + GatingEnabledReason, GatingInputs, GatingResolution, } from "../gating"; export { resolveGating } from "../gating"; export type { ParentToSenderPayload, TelemetryEvent } from "../payload"; -export type { EngineCommandSnapshot, SanitisedCommand } from "../sanitize"; +export type { SanitizedCommand } from "../sanitize"; export { sanitizeEngineSnapshot } from "../sanitize"; export type { RunTelemetryInputs, TelemetryRunOutcome } from "../spawn"; export { runTelemetry, senderModuleUrl } from "../spawn"; diff --git a/packages/cli-telemetry/src/gating.ts b/packages/cli-telemetry/src/gating.ts index 903578d9..7eb4140c 100644 --- a/packages/cli-telemetry/src/gating.ts +++ b/packages/cli-telemetry/src/gating.ts @@ -1,13 +1,15 @@ import type { UserConfig } from "./user-config"; /** - * Why telemetry was disabled. Useful for debug-mode logging in the - * parent; never surfaces to users. + * Why telemetry resolved the way it did. Total: every resolution + * carries a reason, enabled or not, so status surfaces can project + * copy from it without re-deriving the decision. */ -export type GatingDisabledReason = "env-override" | "stored-opt-out"; +export type GatingDisabledReason = "ci" | "env-opt-out" | "stored-opt-out"; +export type GatingEnabledReason = "stored-opt-in" | "default-on"; export type GatingResolution = - | { readonly enabled: true } + | { readonly enabled: true; readonly reason: GatingEnabledReason } | { readonly enabled: false; readonly reason: GatingDisabledReason }; export interface GatingInputs { @@ -20,6 +22,8 @@ export interface GatingInputs { readonly env: Readonly>; /** Result of `readUserConfig()` — file-missing tolerated as `{}`. */ readonly config: UserConfig; + /** CI detection result from the consumer. CI hard-disables. */ + readonly inCI: boolean; } /** @@ -40,32 +44,37 @@ function isTruthyOptOut(raw: string | undefined): boolean { /** * Pure-function resolution of the gating decision. Same input → same - * output; no I/O. The caller is responsible for reading the env and the - * user config. + * output; no I/O. The caller is responsible for reading the env, the + * user config, and the CI signal. * * Decision order: - * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or - * `DO_NOT_TRACK=1`) → disabled. The env check runs first, so an - * opt-out env var wins over any stored or unset preference. - * 2. Stored `enableTelemetry === false` → disabled (`stored-opt-out`). - * 3. Stored `enableTelemetry === true` → enabled. - * 4. Stored `enableTelemetry === undefined` (file missing, or field - * not set) → ENABLED. This is the opt-out default: absence of an - * explicit choice means telemetry is on. This branch carries the - * whole opt-out model — do not "fix" it to default-off. - * - * Telemetry is disabled only when an env override is active or - * `enableTelemetry` is explicitly `false`. + * 1. CI (`inCI`) → disabled (`ci`). CI environments never emit, + * regardless of any stored consent. + * 2. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or + * `DO_NOT_TRACK=1`) → disabled (`env-opt-out`), winning over any + * stored or unset preference. + * 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`). + * 4. Stored `enableTelemetry === true` → enabled (`stored-opt-in`). + * 5. Stored `enableTelemetry === undefined` (file missing, or field + * not set) → ENABLED (`default-on`). This is the opt-out default: + * absence of an explicit choice means telemetry is on. This branch + * carries the whole opt-out model — do not "fix" it to default-off. */ export function resolveGating(inputs: GatingInputs): GatingResolution { + if (inputs.inCI) { + return { enabled: false, reason: "ci" }; + } if ( isTruthyOptOut(inputs.env["PRISMA_NEXT_DISABLE_TELEMETRY"]) || inputs.env["DO_NOT_TRACK"] === "1" ) { - return { enabled: false, reason: "env-override" }; + return { enabled: false, reason: "env-opt-out" }; } if (inputs.config.enableTelemetry === false) { return { enabled: false, reason: "stored-opt-out" }; } - return { enabled: true }; + if (inputs.config.enableTelemetry === true) { + return { enabled: true, reason: "stored-opt-in" }; + } + return { enabled: true, reason: "default-on" }; } diff --git a/packages/cli-telemetry/src/payload.ts b/packages/cli-telemetry/src/payload.ts index 57e42289..1bbf404e 100644 --- a/packages/cli-telemetry/src/payload.ts +++ b/packages/cli-telemetry/src/payload.ts @@ -1,26 +1,19 @@ /** * Wire-shape payload the parent IPC-sends to the forked child sender. * Mirrors only the fields the parent has naturally in hand at command - * settlement: installation id, sanitised command + flags, CLI version, + * settlement: installation id, sanitized command + flags, CLI version, * and the project root the child uses to discover everything else. The * child probes its own process (runtime/os/arch, package manager, ts - * version, agent) and reads the user's `prisma-next.config.*` via - * c12 to derive `databaseTarget` and `extensions`. + * version, agent). The ORM CLI's child additionally loaded + * `prisma-next.config.*` via c12 for `databaseTarget` + `extensions`; + * that config does not exist in this product, so the load was dropped + * (recorded as an S2a divergence) and those event fields come from the + * payload alone. * - * Loading c12 on the parent side would put a `loadConfig()` await on - * the command's hot path between gating resolution and `fork()`, - * opening a race against a parent that exits before the await resolves - * (the telemetry event is lost). Moving the load into the detached - * child eliminates that race; the trade is that the child now - * evaluates user config code, so it runs behind the same privacy - * checks the parent already resolved before forking. - * - * `databaseTarget` is an optional parent-side override for the - * c12-derived value, kept for wire compatibility with the ORM CLI's - * first-`init` flow (where the config file does not exist on disk at - * send time). When unset the child's c12 load determines the value — - * there is no third state, so the field's type is `string | undefined`, - * not `string | null | undefined`. + * `databaseTarget` is an optional parent-side value kept for wire + * compatibility with the ORM CLI's first-`init` flow. When unset the + * event ships `null` — there is no third state, so the field's type is + * `string | undefined`, not `string | null | undefined`. * * Both sides version-couple on this shape because the IPC carrier is * structured-cloned by Node and there's no on-wire compat to maintain. @@ -32,19 +25,16 @@ export interface ParentToSenderPayload { readonly flags: readonly string[]; /** * Absolute path of the user's project. The child reads - * `/package.json` for `tsVersion` and loads - * `/prisma-next.config.*` via c12 for `databaseTarget` - * + `extensions`. + * `/package.json` for `tsVersion`. */ readonly projectRoot: string; /** Resolved endpoint URL (already includes the `/events` path). */ readonly endpoint: string; /** - * Optional parent-side override for the c12-derived database target. - * The wire-format `TelemetryEvent.databaseTarget: string | null` - * keeps `null` as the on-the-wire "no target known" marker, but the - * IPC override channel only needs two states so it's - * `string | undefined`. + * Optional parent-side database target. The wire-format + * `TelemetryEvent.databaseTarget: string | null` keeps `null` as the + * on-the-wire "no target known" marker, but the IPC channel only + * needs two states so it's `string | undefined`. */ readonly databaseTarget?: string; } diff --git a/packages/cli-telemetry/src/sanitize.ts b/packages/cli-telemetry/src/sanitize.ts index e0b4e788..5745c44b 100644 --- a/packages/cli-telemetry/src/sanitize.ts +++ b/packages/cli-telemetry/src/sanitize.ts @@ -38,7 +38,7 @@ export interface EngineCommandSnapshot { * payload. Two fields only — command name (space-delimited subcommand * path) and flag names (in the snapshot's declaration order). */ -export interface SanitisedCommand { +export interface SanitizedCommand { readonly command: string; readonly flags: readonly string[]; } @@ -59,7 +59,7 @@ export interface SanitisedCommand { */ export function sanitizeEngineSnapshot( snapshot: EngineCommandSnapshot, -): SanitisedCommand { +): SanitizedCommand { return { command: snapshot.commandPath.join(" "), flags: snapshot.flags.flatMap((flag) => diff --git a/packages/cli-telemetry/src/spawn.ts b/packages/cli-telemetry/src/spawn.ts index e542d1e8..d069263d 100644 --- a/packages/cli-telemetry/src/spawn.ts +++ b/packages/cli-telemetry/src/spawn.ts @@ -11,16 +11,12 @@ import { readUserConfig, type UserConfig } from "./user-config"; * settles. The CLI is responsible for stitching the engine's command * snapshot and the project root together; the telemetry module does no * I/O of its own except for the user-config read (skipped when - * `userConfig` is provided). `extensions` is deliberately absent: the - * detached child loads `prisma-next.config.*` via c12 itself and - * derives the extension-pack ids from the loaded config — see the - * rationale on `ParentToSenderPayload` for why c12 lives in the child - * rather than on the parent's hot path. + * `userConfig` is provided). * * `databaseTarget` is an optional parent-side override forwarded to * the child, kept for wire compatibility with the ORM CLI's - * init-consent flow; normal invocations leave it unset so the child's - * c12 load supplies the value. + * init-consent flow; normal invocations leave it unset and the event + * ships `null` (no config-derived value exists in this product). */ export interface RunTelemetryInputs { /** The engine's value-free command snapshot — see `EngineCommandSnapshot`. */ @@ -30,9 +26,8 @@ export interface RunTelemetryInputs { /** Absolute path of the project root (typically `process.cwd()`). */ readonly projectRoot: string; /** - * Optional parent-side override for the c12-derived database target, - * forwarded verbatim to the child sender. Wins over the child's - * c12-derived value when present; `undefined` means "no override". + * Optional parent-side database target forwarded verbatim to the + * child sender; `undefined` means "none" and the event ships `null`. */ readonly databaseTarget?: string; /** @@ -75,14 +70,13 @@ export type TelemetryRunOutcome = export function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome { const env = inputs.env ?? process.env; - if (inputs.isCI) { - return { spawned: false, reason: "ci" }; - } - const config = inputs.userConfig ?? readUserConfig(); - const gating = resolveGating({ env, config }); + const gating = resolveGating({ env, config, inCI: inputs.isCI }); if (!gating.enabled) { - return { spawned: false, reason: "gated-off" }; + return { + spawned: false, + reason: gating.reason === "ci" ? "ci" : "gated-off", + }; } const sanitised = sanitizeEngineSnapshot(inputs.command); @@ -115,6 +109,12 @@ export function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome { detached: true, stdio: ["pipe", "ignore", "ignore", "ipc"], }); + // fork() reports failures that surface after the synchronous call + // (missing sender path, spawn EMFILE, ...) as an async "error" + // event. An unhandled "error" would crash the parent and, even + // when the parent is already exiting, flip its exit code — so it + // is swallowed unconditionally. + child.on("error", () => {}); child.send(payload, (err) => { if (err !== null && process.env["PRISMA_NEXT_DEBUG"] === "1") { process.stderr.write( diff --git a/packages/cli-telemetry/tests/enrich.test.ts b/packages/cli-telemetry/tests/enrich.test.ts index d8424594..33d246bc 100644 --- a/packages/cli-telemetry/tests/enrich.test.ts +++ b/packages/cli-telemetry/tests/enrich.test.ts @@ -6,7 +6,6 @@ import { buildTelemetryEvent, buildTelemetryEventFromProcess, type EnrichEnvironment, - loadProjectConfig, type ProjectConfigFields, parsePackageManager, readTsVersionFromPackageJson, @@ -27,11 +26,6 @@ const baseProjectConfig: ProjectConfigFields = { extensions: ["pgvector"], }; -const EMPTY_PROJECT_CONFIG: ProjectConfigFields = { - databaseTarget: null, - extensions: [], -}; - const baseEnv: EnrichEnvironment = { platform: "darwin", arch: "arm64", @@ -206,121 +200,18 @@ describe("buildTelemetryEvent", () => { }); }); -/** - * Build a `prisma-next.config.mjs` source string in the descriptor - * shape the ORM CLI's canonical validator accepts. The structural - * extraction in this repo reads only `target.targetId` and - * `extensions[].id`; the fixture keeps the full descriptor shape so - * the cases stay faithful to real on-disk configs. - */ -function validConfigSource( - options: { - readonly targetId?: string; - readonly extensionsLiteral?: string; - readonly omitExtensions?: boolean; - } = {}, -): string { - const targetId = options.targetId ?? "postgres"; - const descriptor = (kind: string) => - `{ kind: '${kind}', id: '${targetId}', familyId: 'sql', targetId: '${targetId}', version: '0.0.1', create: () => ({}) }`; - const lines = [ - "export default {", - ` family: { kind: 'family', id: 'sql', familyId: 'sql', version: '0.0.1', emission: {}, create: () => ({}) },`, - ` target: ${descriptor("target")},`, - ` adapter: ${descriptor("adapter")},`, - ]; - if (options.omitExtensions !== true) { - const extensionsLiteral = options.extensionsLiteral ?? "[]"; - lines.push(` extensions: ${extensionsLiteral},`); - } - lines.push("};\n"); - return lines.join("\n"); -} - -describe("loadProjectConfig", () => { - let projectDir: string; - - beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), "cli-telemetry-loadcfg-")); - }); - - afterEach(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); - - it("returns empty config when no prisma-next.config.* exists in projectRoot", async () => { - expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); - }); - - it("extracts target.targetId and extensions[].id from a valid .mjs config", async () => { - writeFileSync( - join(projectDir, "prisma-next.config.mjs"), - validConfigSource({ - extensionsLiteral: - "[{ kind: 'extension', id: 'pgvector', familyId: 'sql', targetId: 'postgres', version: '0.0.1', create: () => ({}) }, { kind: 'extension', id: 'paradedb', familyId: 'sql', targetId: 'postgres', version: '0.0.1', create: () => ({}) }]", - }), - ); - expect(await loadProjectConfig(projectDir)).toEqual({ - databaseTarget: "postgres", - extensions: ["pgvector", "paradedb"], - }); - }); - - it("returns empty extensions when extensions is truly omitted from an otherwise valid config", async () => { - writeFileSync( - join(projectDir, "prisma-next.config.mjs"), - validConfigSource({ omitExtensions: true }), - ); - expect(await loadProjectConfig(projectDir)).toEqual({ - databaseTarget: "postgres", - extensions: [], - }); - }); - - it("returns empty config when the target descriptor is missing", async () => { - writeFileSync( - join(projectDir, "prisma-next.config.mjs"), - `export default { family: { kind: 'family', id: 'sql', familyId: 'sql', version: '0.0.1', emission: {}, create: () => ({}) } };\n`, - ); - expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); - }); - - it("returns empty config when an extensions entry has no usable id", async () => { - writeFileSync( - join(projectDir, "prisma-next.config.mjs"), - validConfigSource({ - extensionsLiteral: - "[{ kind: 'extension', familyId: 'sql', targetId: 'postgres', version: '0.0.1', create: () => ({}) }]", - }), - ); - expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); - }); - - it("swallows errors from a config file that throws during load", async () => { - writeFileSync( - join(projectDir, "prisma-next.config.mjs"), - `throw new Error('boom — user config crashed');\n`, - ); - expect(await loadProjectConfig(projectDir)).toEqual(EMPTY_PROJECT_CONFIG); - }); -}); - -describe("buildTelemetryEventFromProcess — parent databaseTarget override", () => { +describe("buildTelemetryEventFromProcess — payload-only project fields", () => { let projectDir: string; beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), "cli-telemetry-override-")); + projectDir = mkdtempSync(join(tmpdir(), "cli-telemetry-payload-")); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); }); - it("overrides the c12-derived databaseTarget when payload.databaseTarget is a string", async () => { - writeFileSync( - join(projectDir, "prisma-next.config.mjs"), - validConfigSource({ targetId: "postgres" }), - ); + it("uses the payload databaseTarget when present", async () => { const event = await buildTelemetryEventFromProcess({ installationId: "install-1", version: "0.9.0", @@ -328,15 +219,19 @@ describe("buildTelemetryEventFromProcess — parent databaseTarget override", () flags: [], projectRoot: projectDir, endpoint: "http://localhost/events", - databaseTarget: "mongodb", + databaseTarget: "postgres", }); - expect(event.databaseTarget).toBe("mongodb"); + expect(event.databaseTarget).toBe("postgres"); + expect(event.extensions).toEqual([]); }); - it("falls back to the c12-derived databaseTarget when payload.databaseTarget is omitted", async () => { + it("ships null databaseTarget and empty extensions when the payload has no override (no config load exists)", async () => { + // A prisma-next.config.* on disk must NOT be read: this product + // dropped the ORM CLI's c12 load (arbitrary user TS evaluated in a + // detached child). Divergence recorded in the S2a parity list. writeFileSync( join(projectDir, "prisma-next.config.mjs"), - validConfigSource({ targetId: "postgres" }), + "export default { target: { targetId: 'postgres' }, extensions: [{ id: 'pgvector' }] };\n", ); const event = await buildTelemetryEventFromProcess({ installationId: "install-1", @@ -346,10 +241,15 @@ describe("buildTelemetryEventFromProcess — parent databaseTarget override", () projectRoot: projectDir, endpoint: "http://localhost/events", }); - expect(event.databaseTarget).toBe("postgres"); + expect(event.databaseTarget).toBeNull(); + expect(event.extensions).toEqual([]); }); - it("uses the override even when no prisma-next.config.* exists on disk (first-init shape)", async () => { + it("still derives tsVersion from the project package.json", async () => { + writeFileSync( + join(projectDir, "package.json"), + JSON.stringify({ devDependencies: { typescript: "^5.9.3" } }), + ); const event = await buildTelemetryEventFromProcess({ installationId: "install-1", version: "0.9.0", @@ -357,9 +257,7 @@ describe("buildTelemetryEventFromProcess — parent databaseTarget override", () flags: [], projectRoot: projectDir, endpoint: "http://localhost/events", - databaseTarget: "postgres", }); - expect(event.databaseTarget).toBe("postgres"); - expect(event.extensions).toEqual([]); + expect(event.tsVersion).toBe("5.9.3"); }); }); diff --git a/packages/cli-telemetry/tests/gating.test.ts b/packages/cli-telemetry/tests/gating.test.ts index 2c0c34c6..5cf0d724 100644 --- a/packages/cli-telemetry/tests/gating.test.ts +++ b/packages/cli-telemetry/tests/gating.test.ts @@ -2,29 +2,57 @@ import { describe, expect, it } from "vitest"; import { resolveGating } from "../src/gating"; describe("resolveGating", () => { - it("returns enabled=true when no env override and stored enableTelemetry is true", () => { + it("returns stored-opt-in when no CI/env override and stored enableTelemetry is true", () => { expect( - resolveGating({ env: {}, config: { enableTelemetry: true } }), - ).toEqual({ enabled: true }); + resolveGating({ + env: {}, + config: { enableTelemetry: true }, + inCI: false, + }), + ).toEqual({ enabled: true, reason: "stored-opt-in" }); }); - it("returns enabled=false when stored enableTelemetry is false", () => { + it("returns stored-opt-out when stored enableTelemetry is false", () => { expect( - resolveGating({ env: {}, config: { enableTelemetry: false } }), + resolveGating({ + env: {}, + config: { enableTelemetry: false }, + inCI: false, + }), ).toEqual({ enabled: false, reason: "stored-opt-out" }); }); - it("returns enabled=true when enableTelemetry is undefined (opt-out default: file missing or field absent)", () => { - expect(resolveGating({ env: {}, config: {} })).toEqual({ enabled: true }); + it("returns default-on when enableTelemetry is undefined (opt-out default: file missing or field absent)", () => { + expect(resolveGating({ env: {}, config: {}, inCI: false })).toEqual({ + enabled: true, + reason: "default-on", + }); + }); + + it("returns ci ahead of every other signal, even a stored opt-in", () => { + expect( + resolveGating({ env: {}, config: { enableTelemetry: true }, inCI: true }), + ).toEqual({ enabled: false, reason: "ci" }); + }); + + it("returns ci over an env opt-out (CI is checked first)", () => { + expect( + resolveGating({ + env: { DO_NOT_TRACK: "1" }, + config: {}, + inCI: true, + }), + ).toEqual({ enabled: false, reason: "ci" }); }); - it("returns enabled=false when PRISMA_NEXT_DISABLE_TELEMETRY=1 overrides a true stored preference", () => { + it("returns env-opt-out when PRISMA_NEXT_DISABLE_TELEMETRY=1 overrides a true stored preference", () => { expect( resolveGating({ env: { PRISMA_NEXT_DISABLE_TELEMETRY: "1" }, config: { enableTelemetry: true }, + inCI: false, }), - ).toEqual({ enabled: false, reason: "env-override" }); + ).toEqual({ enabled: false, reason: "env-opt-out" }); }); it("treats any truthy value of PRISMA_NEXT_DISABLE_TELEMETRY as opt-out", () => { @@ -33,6 +61,7 @@ describe("resolveGating", () => { resolveGating({ env: { PRISMA_NEXT_DISABLE_TELEMETRY: value }, config: { enableTelemetry: true }, + inCI: false, }).enabled, ).toBe(false); } @@ -44,18 +73,20 @@ describe("resolveGating", () => { resolveGating({ env: { PRISMA_NEXT_DISABLE_TELEMETRY: value }, config: { enableTelemetry: true }, + inCI: false, }).enabled, ).toBe(true); } }); - it("returns enabled=false when DO_NOT_TRACK=1 overrides a true stored preference", () => { + it("returns env-opt-out when DO_NOT_TRACK=1 overrides a true stored preference", () => { expect( resolveGating({ env: { DO_NOT_TRACK: "1" }, config: { enableTelemetry: true }, + inCI: false, }), - ).toEqual({ enabled: false, reason: "env-override" }); + ).toEqual({ enabled: false, reason: "env-opt-out" }); }); it('treats DO_NOT_TRACK=0 as NOT an opt-out (community convention pins the trigger to "=1")', () => { @@ -63,15 +94,17 @@ describe("resolveGating", () => { resolveGating({ env: { DO_NOT_TRACK: "0" }, config: { enableTelemetry: true }, + inCI: false, }).enabled, ).toBe(true); }); - it("env override takes precedence over both stored false and stored true (returns the same env-override reason)", () => { + it("env override takes precedence over both stored false and stored true (returns the same env-opt-out reason)", () => { const result = resolveGating({ env: { DO_NOT_TRACK: "1" }, config: { enableTelemetry: false }, + inCI: false, }); - expect(result).toEqual({ enabled: false, reason: "env-override" }); + expect(result).toEqual({ enabled: false, reason: "env-opt-out" }); }); }); diff --git a/packages/cli-telemetry/tests/sender-integration.test.ts b/packages/cli-telemetry/tests/sender-integration.test.ts index 1c699276..b8a2a40a 100644 --- a/packages/cli-telemetry/tests/sender-integration.test.ts +++ b/packages/cli-telemetry/tests/sender-integration.test.ts @@ -205,7 +205,7 @@ describe("sender end-to-end via a local mock backend", () => { expect(serialised).not.toMatch(/\/Users\/alice\/secrets/); }); - it("derives databaseTarget and extensions from a prisma-next.config.* in projectRoot", async () => { + it("never reads a prisma-next.config.* from projectRoot (config load dropped; payload-only fields)", async () => { const configuredDir = mkdtempSync( join(tmpdir(), "cli-telemetry-sender-cfg-"), ); @@ -225,13 +225,22 @@ describe("sender end-to-end via a local mock backend", () => { env: childEnv(), }); expect(captured).toHaveLength(1); - expect(captured[0]?.body.databaseTarget).toBe("postgres"); - expect(captured[0]?.body.extensions).toEqual(["pgvector"]); + expect(captured[0]?.body.databaseTarget).toBeNull(); + expect(captured[0]?.body.extensions).toEqual([]); } finally { rmSync(configuredDir, { recursive: true, force: true }); } }); + it("passes the payload databaseTarget through to the event", async () => { + await spawnSender({ + payload: buildPayload({ databaseTarget: "postgres" }), + env: childEnv(), + }); + expect(captured).toHaveLength(1); + expect(captured[0]?.body.databaseTarget).toBe("postgres"); + }); + it("populates the agent field from the child env", async () => { await spawnSender({ payload: buildPayload(), diff --git a/packages/cli-telemetry/tests/spawn-fork-error.test.ts b/packages/cli-telemetry/tests/spawn-fork-error.test.ts new file mode 100644 index 00000000..45d7f9b1 --- /dev/null +++ b/packages/cli-telemetry/tests/spawn-fork-error.test.ts @@ -0,0 +1,52 @@ +/** + * The async fork-failure path: `child_process.fork` can report failure + * AFTER the synchronous call returns, as an "error" event on the child. + * Without a listener, Node turns that event into an uncaught exception — + * crashing the parent CLI (or flipping its exit code mid-exit) over a + * telemetry fork. `runTelemetry` must attach a swallowing listener. + */ +import { fork } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { runTelemetry } from "../src/spawn"; + +vi.mock("node:child_process", () => ({ fork: vi.fn() })); + +class FakeChild extends EventEmitter { + send = vi.fn( + (_payload: unknown, callback?: (error: Error | null) => void): boolean => { + callback?.(null); + return true; + }, + ); + disconnect = vi.fn(); + unref = vi.fn(); +} + +describe("runTelemetry — async fork error", () => { + it("neither throws nor alters the process exit code when the child emits 'error' after spawn", async () => { + const child = new FakeChild(); + vi.mocked(fork).mockReturnValue( + child as unknown as ReturnType, + ); + const exitCodeBefore = process.exitCode; + + const outcome = runTelemetry({ + command: { commandPath: ["init"], flags: [], positionalCount: 0 }, + version: "0.9.0", + projectRoot: process.cwd(), + senderPath: "/sender/path.js", + isCI: false, + env: {}, + userConfig: { enableTelemetry: true, installationId: "id-1" }, + }); + expect(outcome).toEqual({ spawned: true }); + + // An EventEmitter throws synchronously from emit('error') when no + // listener is attached — this line IS the assertion that + // runTelemetry registered one. + expect(() => child.emit("error", new Error("spawn failed"))).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(process.exitCode).toBe(exitCodeBefore); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 3bb23c9d..2297a5bc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,20 +44,18 @@ "test": "vitest run" }, "dependencies": { - "@clack/prompts": "^1.5.0", + "@clack/prompts": "1.5.0", "@prisma/cli-engine": "workspace:*", "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "1.55.0", "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", - "c12": "^3.3.4", "ci-info": "^4.3.1", "colorette": "^2.0.20", "commander": "^14.0.3", "dotenv": "^17.4.2", "execa": "^9.6.1", - "magicast": "^0.5.3", "open": "^11.0.0", "string-width": "^8.2.1", "strip-ansi": "^7.2.0", diff --git a/packages/cli/src/lib/auth/guard.ts b/packages/cli/src/auth/guard.ts similarity index 100% rename from packages/cli/src/lib/auth/guard.ts rename to packages/cli/src/auth/guard.ts diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/auth/login.ts similarity index 100% rename from packages/cli/src/lib/auth/login.ts rename to packages/cli/src/auth/login.ts diff --git a/packages/cli/src/lib/auth/recipient.ts b/packages/cli/src/auth/recipient.ts similarity index 100% rename from packages/cli/src/lib/auth/recipient.ts rename to packages/cli/src/auth/recipient.ts diff --git a/packages/cli/src/v8/auth/workspace-commands.ts b/packages/cli/src/v8/auth/workspace-commands.ts deleted file mode 100644 index 51c34ecc..00000000 --- a/packages/cli/src/v8/auth/workspace-commands.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * The `auth workspace *` family, ported from the legacy controller's - * real-mode paths. Operations come from the auth module - * (`src/auth/index.ts`); legacy CliError shapes map to dotted AUTH.* - * structured errors via `mapAuthOperationError`. - */ -import { - type CommandContext, - defineCommand, - type Presentations, - positional, -} from "@prisma/cli-engine"; -import { - CliStructuredError, - type NextAction, - notOk, - ok, -} from "@prisma/cli-engine/protocol"; -import { - listRealAuthWorkspaces, - logoutRealAuthWorkspace, - SERVICE_TOKEN_ENV_VAR, - useRealAuthWorkspace, -} from "../../auth"; -import type { - AuthWorkspaceListResult, - AuthWorkspaceLogoutResult, - AuthWorkspaceUseResult, -} from "../../types/auth"; -import { mapAuthOperationError } from "./errors"; - -const LIST_NEXT_ACTION: NextAction = { - kind: "run-command", - label: "List authenticated workspaces", - command: "prisma-cli auth workspace list", -}; - -const LOGIN_NEXT_ACTION: NextAction = { - kind: "run-command", - label: "Sign in", - command: "prisma-cli auth login", -}; - -function operationContext(ctx: CommandContext): { - runtime: { env: NodeJS.ProcessEnv; signal: AbortSignal }; -} { - return { runtime: { env: ctx.env, signal: ctx.signal } }; -} - -function rethrowMapped(error: unknown): never { - const mapped = mapAuthOperationError(error); - if (mapped) { - throw mapped; - } - throw error; -} - -// --- auth workspace list --------------------------------------------- - -function authSourceLabel( - source: AuthWorkspaceListResult["authSource"], -): string { - if (source === "oauth") { - return "local OAuth"; - } - if (source === "service_token") { - return "PRISMA_SERVICE_TOKEN"; - } - return "none"; -} - -function workspaceSourceLabel(source: "oauth" | "service_token"): string { - return source === "service_token" ? "service token" : "OAuth"; -} - -export function serializeAuthWorkspaceList(result: AuthWorkspaceListResult) { - return { - context: { - authSource: result.authSource, - activeWorkspaceId: result.activeWorkspace?.id ?? null, - activeWorkspaceName: result.activeWorkspace?.name ?? null, - }, - items: result.workspaces.map((workspace) => ({ - id: workspace.id, - name: workspace.name, - status: workspace.active ? "active" : null, - source: workspace.source, - switchable: workspace.switchable, - credentialWorkspaceId: workspace.credentialWorkspaceId, - lastSeenAt: workspace.lastSeenAt, - })), - count: result.workspaces.length, - }; -} - -/** The legacy table's column rule: the source column appears only when - * the listed workspaces mix sources. */ -function workspaceTableRows(result: AuthWorkspaceListResult): { - columns: readonly string[]; - rows: ReadonlyArray; -} { - const hasMixedSources = - new Set(result.workspaces.map((workspace) => workspace.source)).size > 1; - const columns = hasMixedSources - ? ["name", "id", "source", "status"] - : ["name", "id", "status"]; - const rows = result.workspaces.map((workspace) => { - const status = workspace.active ? "active" : ""; - return hasMixedSources - ? [ - workspace.name, - workspace.id, - workspaceSourceLabel(workspace.source), - status, - ] - : [workspace.name, workspace.id, status]; - }); - return { columns, rows }; -} - -function listPresentations(result: AuthWorkspaceListResult): Presentations { - const table = workspaceTableRows(result); - return { - human: () => [ - { - kind: "summary", - tone: "info", - text: "Listing authenticated workspaces on this machine.", - }, - { - kind: "fields", - rows: [ - { label: "auth source", value: authSourceLabel(result.authSource) }, - ], - }, - ...(result.workspaces.length === 0 - ? [ - { - kind: "summary", - tone: "info", - text: "No local OAuth workspaces found.", - } as const, - ] - : [{ kind: "table", ...table } as const]), - ], - stdout: () => table.rows.map((row) => row.join(" ").trimEnd()), - json: () => serializeAuthWorkspaceList(result), - next: () => (result.workspaces.length === 0 ? [LOGIN_NEXT_ACTION] : []), - }; -} - -export const authWorkspaceListCommand = defineCommand({ - help: { - summary: "List locally authenticated workspaces", - examples: ["auth workspace list", "auth workspace list --json"], - }, - handler: async (_args, ctx) => { - let result: AuthWorkspaceListResult; - try { - result = await listRealAuthWorkspaces(operationContext(ctx)); - } catch (error) { - rethrowMapped(error); - } - return ok(ctx.present({ data: result }, listPresentations(result))); - }, -}); - -// --- auth workspace use ---------------------------------------------- - -function usePresentations(result: AuthWorkspaceUseResult): Presentations { - const rows = [ - ...(result.previousWorkspace - ? [{ label: "previous", value: result.previousWorkspace.name }] - : []), - { label: "workspace", value: result.workspace.name }, - ]; - return { - human: () => [ - { - kind: "summary", - tone: "info", - text: "Switching the local CLI workspace.", - }, - { kind: "fields", rows }, - { - kind: "summary", - tone: "ok", - text: "Local OAuth workspace selection updated.", - }, - ], - stdout: () => rows.map((row) => `${row.label}: ${row.value}`), - next: () => [ - { - kind: "run-command", - label: "Show the signed-in identity", - command: "prisma-cli auth whoami", - }, - { - kind: "run-command", - label: "List projects", - command: "prisma-cli project list", - }, - ], - }; -} - -function noWorkspacesError(): CliStructuredError { - return new CliStructuredError( - "AUTH.USAGE_ERROR", - "No authenticated workspaces", - { - why: "There are no local OAuth workspace sessions to select.", - nextActions: [ - { - kind: "user-choice", - label: "Run prisma-cli auth login and authorize a workspace.", - }, - ], - }, - ); -} - -function serviceTokenSwitchError(): CliStructuredError { - return new CliStructuredError( - "AUTH.WORKSPACE_SWITCH_UNAVAILABLE", - "Workspace switching is unavailable", - { - why: "PRISMA_SERVICE_TOKEN is set, so authenticated commands use that token instead of local OAuth workspaces.", - nextActions: [ - { - kind: "user-choice", - label: - "Unset PRISMA_SERVICE_TOKEN to switch between local OAuth workspaces, or use a token for the workspace you want.", - }, - ], - }, - ); -} - -async function selectWorkspaceRef( - ctx: CommandContext, -): Promise { - if (ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { - throw serviceTokenSwitchError(); - } - - let listed: AuthWorkspaceListResult; - try { - listed = await listRealAuthWorkspaces(operationContext(ctx)); - } catch (error) { - rethrowMapped(error); - } - const workspaces = listed.workspaces.filter( - (workspace) => workspace.switchable, - ); - - if (workspaces.length === 0) { - throw noWorkspacesError(); - } - - if (workspaces.length === 1) { - return workspaces[0].id; - } - - return await ctx.prompt.select( - "Select a workspace", - workspaces.map((workspace) => ({ - value: workspace.id, - label: `${workspace.name} (${workspace.id})${workspace.active ? " active" : ""}`, - })), - ); -} - -export const authWorkspaceUseCommand = defineCommand({ - args: { - positionals: { - workspace: positional.optionalString({ - brief: "Workspace id or exact name", - placeholder: "id-or-name", - }), - }, - }, - help: { - summary: "Switch the local CLI workspace", - examples: ["auth workspace use", "auth workspace use my-workspace"], - }, - handler: async (args, ctx) => { - const trimmed = args.positionals.workspace?.trim(); - const workspaceRef = trimmed ? trimmed : await selectWorkspaceRef(ctx); - - let result: AuthWorkspaceUseResult; - try { - result = await useRealAuthWorkspace(operationContext(ctx), workspaceRef); - } catch (error) { - const mapped = mapAuthOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } - return ok(ctx.present({ data: result }, usePresentations(result))); - }, -}); - -// --- auth workspace logout ------------------------------------------- - -function logoutPresentations(result: AuthWorkspaceLogoutResult): Presentations { - const rows = [ - { label: "workspace", value: result.workspace.name }, - { label: "active", value: result.activeWorkspace?.name ?? "none" }, - ]; - return { - human: () => [ - { - kind: "summary", - tone: "info", - text: "Removing a local OAuth workspace session.", - }, - { kind: "fields", rows }, - { - kind: "summary", - tone: "ok", - text: result.wasActive - ? "Removed active workspace session; no replacement workspace was selected." - : "Removed workspace session.", - }, - ], - stdout: () => rows.map((row) => `${row.label}: ${row.value}`), - next: () => - result.activeWorkspace - ? [LIST_NEXT_ACTION] - : [ - LIST_NEXT_ACTION, - { - kind: "run-command", - label: "Select a replacement workspace", - command: "prisma-cli auth workspace use ", - }, - ], - }; -} - -function workspaceRequiredError(): CliStructuredError { - return new CliStructuredError("AUTH.USAGE_ERROR", "Workspace required", { - why: "auth workspace logout needs a workspace id or cached workspace name.", - nextActions: [ - { - kind: "user-choice", - label: "Pass a workspace from prisma-cli auth workspace list.", - }, - ], - }); -} - -/** Shared by `auth workspace logout ` and `auth logout - * --workspace ` — the same operation, the same presentation. */ -export async function runWorkspaceLogout( - ctx: CommandContext, - workspaceRef: string, -) { - if (!workspaceRef.trim()) { - return notOk(workspaceRequiredError()); - } - - let result: AuthWorkspaceLogoutResult; - try { - result = await logoutRealAuthWorkspace(operationContext(ctx), workspaceRef); - } catch (error) { - const mapped = mapAuthOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } - return ok(ctx.present({ data: result }, logoutPresentations(result))); -} - -export const authWorkspaceLogoutCommand = defineCommand({ - args: { - positionals: { - workspace: positional.string({ - brief: "Workspace id or exact name", - placeholder: "id-or-name", - }), - }, - }, - help: { - summary: "Remove one local OAuth workspace session", - examples: ["auth workspace logout my-workspace"], - }, - handler: async (args, ctx) => - runWorkspaceLogout(ctx, args.positionals.workspace), -}); diff --git a/packages/cli/src/v8/telemetry/commands.ts b/packages/cli/src/v8/telemetry/commands.ts deleted file mode 100644 index 36249dc5..00000000 --- a/packages/cli/src/v8/telemetry/commands.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * The consent surface: `telemetry status|enable|disable`, ported from - * the ORM CLI's commander implementation as engine result commands. - * Semantics and copy match the reference: `status` is a pure read - * (never sends, never mints, never writes); `enable` stores the - * opt-in and mints an installation id when none exists; `disable` - * stores the opt-out and mints nothing. Mounted shell-owned (no - * command family), group `telemetry`. - */ -import { defineCommand, type Presentations } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; -import { userConfigPath, writeUserConfig } from "@repo/cli-telemetry"; -import { isCI } from "./is-ci"; -import { - formatTelemetryStatusLines, - resolveTelemetryStatus, - statusSummaryLine, - type TelemetryStatus, -} from "./status"; - -function statusPresentations(status: TelemetryStatus): Presentations { - return { - human: () => [ - { kind: "summary", tone: "info", text: statusSummaryLine(status) }, - { - kind: "fields", - rows: [ - { label: "Config file", value: status.configPath }, - { - label: "Installation ID", - value: status.installationIdStored ? "stored" : "not stored", - }, - ], - }, - ], - stdout: () => formatTelemetryStatusLines(status), - json: () => status, - }; -} - -export const telemetryStatusCommand = defineCommand({ - help: { - summary: "Show whether anonymous CLI telemetry is enabled and why", - description: - "Reports whether telemetry is currently enabled or disabled and the reason\n" + - "(default-on, stored opt-out, environment opt-out, or CI), the path to your\n" + - "user-level config file, and whether an installation ID has been stored.\n" + - "Read-only: never sends an event, never mints an ID, never writes anything.", - examples: ["telemetry status", "telemetry status --json"], - }, - handler: async (_args, ctx) => { - const status = resolveTelemetryStatus({ env: ctx.env, inCI: isCI() }); - return ok(ctx.present({ data: status }, statusPresentations(status))); - }, -}); - -function consentPresentations(line: string, json: unknown): Presentations { - return { - human: () => [{ kind: "summary", tone: "ok", text: line }], - stdout: () => [line], - json: () => json, - }; -} - -export const telemetryEnableCommand = defineCommand({ - help: { - summary: "Enable anonymous CLI telemetry", - description: - 'Stores "enableTelemetry": true in your user-level config and mints an\n' + - "installation ID if one is not already stored.", - examples: ["telemetry enable"], - }, - handler: async (_args, ctx) => { - writeUserConfig({ enableTelemetry: true }); - const configPath = userConfigPath(); - return ok( - ctx.present( - { data: { enableTelemetry: true, configPath } }, - consentPresentations( - `Telemetry enabled. Preference stored in ${configPath}.`, - { enableTelemetry: true, configPath }, - ), - ), - ); - }, -}); - -export const telemetryDisableCommand = defineCommand({ - help: { - summary: "Disable anonymous CLI telemetry", - description: - 'Stores "enableTelemetry": false in your user-level config. No installation\n' + - "ID is minted and no event is sent.", - examples: ["telemetry disable"], - }, - handler: async (_args, ctx) => { - writeUserConfig({ enableTelemetry: false }); - const configPath = userConfigPath(); - return ok( - ctx.present( - { data: { enableTelemetry: false, configPath } }, - consentPresentations( - `Telemetry disabled. Preference stored in ${configPath}.`, - { enableTelemetry: false, configPath }, - ), - ), - ); - }, -}); diff --git a/packages/cli/src/v8/telemetry/wiring.ts b/packages/cli/src/v8/telemetry/reporting.ts similarity index 100% rename from packages/cli/src/v8/telemetry/wiring.ts rename to packages/cli/src/v8/telemetry/reporting.ts diff --git a/packages/cli/tests/v8-telemetry-wiring.test.ts b/packages/cli/tests/v8-telemetry-reporting.test.ts similarity index 100% rename from packages/cli/tests/v8-telemetry-wiring.test.ts rename to packages/cli/tests/v8-telemetry-reporting.test.ts diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 721407d5..456f5276 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -16,7 +16,7 @@ export default defineConfig([ // @repo/cli-telemetry workspace package lands inside this package's // own dist instead of being a published dependency; the sender entry // ships the forkable script at dist/v8/sender.js. Published deps - // (engine, c12, ci-info, @vercel/detect-agent, …) stay external. + // (engine, ci-info, @vercel/detect-agent, …) stay external. { entry: { "v8/cli": "src/v8/bin.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2151b676..9719b73c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: packages/cli: dependencies: '@clack/prompts': - specifier: ^1.5.0 + specifier: 1.5.0 version: 1.5.0 '@prisma/cli-engine': specifier: workspace:* @@ -44,9 +44,6 @@ importers: better-result: specifier: ^2.9.2 version: 2.9.2 - c12: - specifier: ^3.3.4 - version: 3.3.4(magicast@0.5.3) ci-info: specifier: ^4.3.1 version: 4.4.0 @@ -62,9 +59,6 @@ importers: execa: specifier: ^9.6.1 version: 9.6.1 - magicast: - specifier: ^0.5.3 - version: 0.5.3 open: specifier: ^11.0.0 version: 11.0.0 @@ -133,9 +127,6 @@ importers: '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.4 - c12: - specifier: ^3.3.4 - version: 3.3.4(magicast@0.5.3) devDependencies: '@repo/tsconfig': specifier: workspace:* @@ -1100,14 +1091,6 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} - c12@3.3.4: - resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} - peerDependencies: - magicast: '*' - peerDependenciesMeta: - magicast: - optional: true - cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -1116,10 +1099,6 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1135,9 +1114,6 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1173,9 +1149,6 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1227,9 +1200,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - exsolve@1.1.1: - resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} - fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -1274,10 +1244,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - giget@3.3.1: - resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -1397,9 +1363,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -1433,9 +1396,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - perfect-debounce@2.1.0: - resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1447,9 +1407,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} @@ -1465,13 +1422,6 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} - rc9@3.0.1: - resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - - readdirp@5.1.1: - resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} - engines: {node: '>= 20.19.0'} - resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2452,31 +2402,10 @@ snapshots: dependencies: run-applescript: 7.1.0 - c12@3.3.4(magicast@0.5.3): - dependencies: - chokidar: 5.0.0 - confbox: 0.2.4 - defu: 6.1.7 - dotenv: 17.4.2 - exsolve: 1.1.1 - giget: 3.3.1 - jiti: 2.7.0 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - rc9: 3.0.1 - optionalDependencies: - magicast: 0.5.3 - cac@7.0.0: {} chai@6.2.2: {} - chokidar@5.0.0: - dependencies: - readdirp: 5.1.1 - chownr@3.0.0: {} ci-info@4.4.0: {} @@ -2485,8 +2414,6 @@ snapshots: commander@14.0.3: {} - confbox@0.2.4: {} - consola@3.4.2: {} convert-source-map@2.0.0: {} @@ -2512,8 +2439,6 @@ snapshots: defu@6.1.7: {} - destr@2.0.5: {} - detect-libc@2.1.2: {} dotenv@17.4.2: {} @@ -2611,8 +2536,6 @@ snapshots: expect-type@1.3.0: {} - exsolve@1.1.1: {} - fast-fifo@1.3.2: {} fast-string-truncated-width@3.0.3: {} @@ -2649,8 +2572,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - giget@3.3.1: {} - glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -2739,8 +2660,6 @@ snapshots: obug@2.1.1: {} - ohash@2.0.11: {} - open@11.0.0: dependencies: default-browser: 5.5.0 @@ -2773,20 +2692,12 @@ snapshots: pathe@2.0.3: {} - perfect-debounce@2.1.0: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} pkg-pr-new@0.0.75: {} - pkg-types@2.3.1: - dependencies: - confbox: 0.2.4 - exsolve: 1.1.1 - pathe: 2.0.3 - postcss@8.5.15: dependencies: nanoid: 3.3.15 @@ -2801,13 +2712,6 @@ snapshots: quansync@1.0.0: {} - rc9@3.0.1: - dependencies: - defu: 6.1.7 - destr: 2.0.5 - - readdirp@5.1.1: {} - resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} From 54ae8a53d1ff2ae09c4e6b631e794c199e6dc2d2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 03:30:59 +0200 Subject: [PATCH 13/67] fix(cli-engine): harden ctx.api error mapping and lazy-load the SDK module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-round fixes for slice s2a-foundations (C1, A10, A11, A8): - restoreStructuredThrows maps the SDK's AuthError (the 401 / refresh-unavailable path) to the shared CLI.CREDENTIALS_REQUIRED structured error, matched structurally by name so a duplicate module instance cannot defeat it (C1). - The cause-chain walk tracks visited errors with a depth cap so a cyclic cause chain terminates (C1), with a settlement test. - The @prisma/management-api-sdk module now loads via dynamic import on the first actual request: ctx.api is a Proxy whose async method wrappers await the lazy construction, mirroring the clack renderer's lazy-import pattern; the structured-throw restoration merged into the same wrapper (A10). - The engine's OAuth client id / redirect URI are inert empty-string placeholders — no refresh token is ever supplied, so the SDK's OAuth flow is unreachable (A11). - RunSummary.commandId documents its derivation from snapshot.commandPath (always equals commandPath.join('.')); draft §10 carries the same note (A8). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/engine-interface-draft.ts | 6 +- .../cli-engine/src/execution/api-client.ts | 126 +++++++++++++----- packages/cli-engine/src/run-summary.ts | 5 + .../cli-engine/tests/management-api.test.ts | 96 ++++++++++++- 4 files changed, 197 insertions(+), 36 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index c5964b84..df8906d3 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -972,7 +972,11 @@ export interface CliRunHooks { } /** What onSettled receives. `durationMs` comes from the engine's - * injectable clock (§11), never from wall time directly. */ + * injectable clock (§11), never from wall time directly. + * `commandId` is derived from the same mount entry as + * `snapshot.commandPath` and always equals + * `snapshot.commandPath.join('.')`; both fields are kept on purpose + * (id for addressing, snapshot as the value-free wire projection). */ export interface RunSummary { readonly commandId: string readonly exitCode: number diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 0e71ed91..562d54cd 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -1,27 +1,69 @@ -import { createManagementApiSdk } from "@prisma/management-api-sdk"; +import type { createManagementApiSdk } from "@prisma/management-api-sdk"; import type { ManagementApiClient } from "../management-api"; import { CliStructuredError } from "../protocol"; import type { Invocation } from "./engine"; import { credentialsRequiredError } from "./needs"; -/* Copied from the shell's createManagementApiSdk call site - * (packages/cli/src/auth/workspaces.ts). The OAuth fields are inert - * here: the token source is ctx.getCredentials and never carries a - * refresh token, so the SDK's own OAuth flow can never run. */ -const CLIENT_ID = "cmm3lndn701oo0uefvxzo0ivw"; -const REDIRECT_URI = "http://localhost:0/auth/callback"; +/* Inert placeholders: the token source is ctx.getCredentials and never + * supplies a refresh token, so the SDK's own OAuth flow is unreachable + * and these values are never used in a request. */ +const CLIENT_ID = ""; +const REDIRECT_URI = ""; export type CreateManagementApiSdk = typeof createManagementApiSdk; /** ctx.api construction: the SDK reads tokens per request, so a token * refreshed mid-run by the credentials source is picked up on the next * request. A request while getCredentials() resolves undefined throws - * CLI.CREDENTIALS_REQUIRED. */ + * CLI.CREDENTIALS_REQUIRED. + * + * The SDK module itself is loaded lazily (dynamic import, mirroring + * the clack renderer pattern) and only when no `createSdk` test seam + * is injected, so a run that never issues a request never pays for — + * or depends on — the SDK module load. The returned client is a Proxy + * whose method wrappers await the lazy construction before applying + * the call; every client method is async, so the deferral is + * invisible to callers. */ export function buildManagementApiClient( invocation: Invocation, ): ManagementApiClient { + let clientPromise: Promise | undefined; + const resolveClient = (): Promise => { + clientPromise ??= constructClient(invocation); + return clientPromise; + }; + + return new Proxy({} as ManagementApiClient, { + get(_target, property) { + // Symbol probes and thenable checks (`await client`, promise + // adoption) must not look like callable API methods. + if (typeof property === "symbol" || property === "then") { + return undefined; + } + return (...args: unknown[]): Promise => + resolveClient().then((client) => { + const value: unknown = Reflect.get(client, property); + if (typeof value !== "function") { + throw new TypeError( + `@prisma/cli-engine: ctx.api.${property} is not a function`, + ); + } + return Promise.resolve(Reflect.apply(value, client, args)).catch( + (cause: unknown) => { + throw restoreStructuredThrow(cause); + }, + ); + }); + }, + }); +} + +async function constructClient( + invocation: Invocation, +): Promise { const createSdk = - invocation.hooks.managementApi?.createSdk ?? createManagementApiSdk; + invocation.hooks.managementApi?.createSdk ?? + (await import("@prisma/management-api-sdk")).createManagementApiSdk; const sdk = createSdk({ clientId: CLIENT_ID, redirectUri: REDIRECT_URI, @@ -38,44 +80,62 @@ export function buildManagementApiClient( }, apiBaseUrl: invocation.runtime.managementApi.baseUrl, }); - return restoreStructuredThrows(sdk.client); + return sdk.client; } /** The SDK wraps every request failure in its FetchError; a structured * error raised inside the request pipeline (the unauthenticated * CLI.CREDENTIALS_REQUIRED throw) is rethrown unwrapped so it settles - * as itself. */ -function restoreStructuredThrows( - client: ManagementApiClient, -): ManagementApiClient { - return new Proxy(client, { - get(target, property, receiver) { - const value: unknown = Reflect.get(target, property, receiver); - if (typeof value !== "function") { - return value; - } - return (...args: unknown[]): unknown => { - const result: unknown = Reflect.apply(value, target, args); - if (result instanceof Promise) { - return result.catch((cause: unknown) => { - throw structuredCause(cause) ?? cause; - }); - } - return result; - }; - }, - }); + * as itself. The SDK's own AuthError (a 401 the SDK cannot recover + * from — no refresh token is ever supplied here) maps to the same + * shared CLI.CREDENTIALS_REQUIRED error the needs check raises. */ +function restoreStructuredThrow(cause: unknown): unknown { + const structured = structuredCause(cause); + if (structured !== undefined) { + return structured; + } + if (causeChainHasSdkAuthError(cause)) { + return credentialsRequiredError(); + } + return cause; } -function structuredCause(error: unknown): CliStructuredError | undefined { +/** Cause chains can be cyclic (an Error whose `cause` eventually points + * back at itself); track visited nodes with a depth cap as a backstop + * so the walk always terminates. */ +const CAUSE_CHAIN_DEPTH_CAP = 32; + +function* causeChain(error: unknown): Generator { + const visited = new Set(); for ( let current: unknown = error; - current instanceof Error; + current instanceof Error && + !visited.has(current) && + visited.size < CAUSE_CHAIN_DEPTH_CAP; current = current.cause ) { + visited.add(current); + yield current; + } +} + +function structuredCause(error: unknown): CliStructuredError | undefined { + for (const current of causeChain(error)) { if (CliStructuredError.is(current)) { return current; } } return undefined; } + +/** Structural match (name discriminator) rather than instanceof, so a + * duplicate SDK module instance cannot defeat the mapping — and so + * this check itself never forces the SDK module to load. */ +function causeChainHasSdkAuthError(error: unknown): boolean { + for (const current of causeChain(error)) { + if (current.name === "AuthError") { + return true; + } + } + return false; +} diff --git a/packages/cli-engine/src/run-summary.ts b/packages/cli-engine/src/run-summary.ts index db66bd39..9c14b35b 100644 --- a/packages/cli-engine/src/run-summary.ts +++ b/packages/cli-engine/src/run-summary.ts @@ -35,6 +35,11 @@ export interface EngineCommandSnapshot { * engine's injectable clock. */ export interface RunSummary { + /** The mounted command's dotted id ('telemetry.status'). Derived + * from the same mount entry as `snapshot.commandPath` — it always + * equals `snapshot.commandPath.join('.')`. Both are kept: consumers + * addressing the command use the id; the snapshot is the value-free + * wire projection. */ readonly commandId: string; readonly exitCode: number; readonly durationMs: number; diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 2f4063ca..7a0fbd9d 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -2,6 +2,7 @@ * ctx.api: injected fakes, lazy construction, the unauthenticated * throw path, and per-request credential pickup. */ + import { defineCommand, type ManagementApiClient, @@ -9,16 +10,25 @@ import { } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli } from "@prisma/cli-engine/testing"; +import { AuthError } from "@prisma/management-api-sdk"; import { describe, expect, test } from "vitest"; import type { AnyCommand } from "../src/commands"; import { buildEngine, type RunHooks } from "../src/execution/engine"; function makeRuntime(overrides?: { readonly getCredentials?: Runtime["getCredentials"]; -}): Runtime & { readonly stderrText: () => string } { +}): Runtime & { + readonly stderrText: () => string; + readonly stdoutText: () => string; +} { let stderrText = ""; + let stdoutText = ""; return { - stdout: { write: () => {} }, + stdout: { + write: (text) => { + stdoutText += text; + }, + }, stderr: { write: (text) => { stderrText += text; @@ -39,6 +49,7 @@ function makeRuntime(overrides?: { managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", stderrText: () => stderrText, + stdoutText: () => stdoutText, }; } @@ -123,6 +134,87 @@ describe("ctx.api", () => { }); }); + test("construction happens on first method call, not on ctx.api property access", async () => { + const exitCode = await runEngine( + succeed(async (ctx) => { + // Touch the property without invoking any request method. + void ctx.api; + }), + makeRuntime(), + { + managementApi: { + createSdk: () => { + throw new Error( + "the SDK factory ran for a run that never issued a request", + ); + }, + }, + }, + ); + expect(exitCode).toBe(0); + }); + + test("an SDK AuthError (401 / refresh unavailable) maps to CLI.CREDENTIALS_REQUIRED, exit 2", async () => { + const runtime = makeRuntime({ + getCredentials: async () => ({ token: "stale-token" }), + }); + const exitCode = await runEngine( + succeed(async (ctx) => { + const client = ctx.api as unknown as { call: () => Promise }; + await client.call(); + }), + runtime, + { + managementApi: { + createSdk: () => ({ + client: { + call: async () => { + throw new AuthError("401 Unauthorized", true); + }, + } as unknown as ManagementApiClient, + getLoginUrl: () => Promise.reject(new Error("unused")), + handleCallback: () => Promise.reject(new Error("unused")), + logout: () => Promise.reject(new Error("unused")), + }), + }, + }, + ); + expect(exitCode).toBe(2); + // Non-TTY runtime auto-selects json format: the errored envelope + // streams to stdout. + expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); + }); + + test("a cyclic cause chain on a request failure terminates and settles as a bug", async () => { + const cyclic = new Error("outer"); + const inner = new Error("inner", { cause: cyclic }); + cyclic.cause = inner; + const runtime = makeRuntime(); + const exitCode = await runEngine( + succeed(async (ctx) => { + const client = ctx.api as unknown as { call: () => Promise }; + await client.call(); + }), + runtime, + { + managementApi: { + createSdk: () => ({ + client: { + call: async () => { + throw cyclic; + }, + } as unknown as ManagementApiClient, + getLoginUrl: () => Promise.reject(new Error("unused")), + handleCallback: () => Promise.reject(new Error("unused")), + logout: () => Promise.reject(new Error("unused")), + }), + }, + }, + ); + expect(exitCode).toBe(1); + expect(runtime.stdoutText()).toContain('"code":"CLI.INTERNAL_ERROR"'); + }); + test("constructed once per run; credential refresh is picked up per request", async () => { const tokens = ["token-1", "token-2"]; const observed: string[] = []; From b9123461cee5857b12e10196bd0661ccdd49ee49 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 03:31:13 +0200 Subject: [PATCH 14/67] refactor(cli): close the auth module and give shared constants durable homes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-round fixes for slice s2a-foundations (A1, A2, A5, A6, C8, C6): - src/lib/auth/{login,guard,recipient}.ts move into src/auth/; the index gains requireComputeAuth and the recipient exports, and every controller imports via src/auth/index.ts only (A1). src/auth is now a leaf cluster: its one remaining legacy import is the CliError base class (named as an S2d survivor). - The auth-specific error constructors move from shell/errors into src/auth/errors.ts (shell re-exports for legacy imports), and resolveStateDir moves to src/state-dir.ts with shell/runtime re-exporting (A2). - WorkspaceOperationContext flattens to { env, signal }; the v8 adapter shape is gone and legacy controller call sites build the flat context (A5). - listRealAuthWorkspaces/useRealAuthWorkspace/logoutRealAuthWorkspace rename to listAuthWorkspaces/useAuthWorkspace/logoutAuthWorkspace — there is no fixture-mode counterpart to distinguish from (A6). - The onVerificationUrl observer hook is invoked inside try/catch so an observer bug cannot break the login flow (C8). - New src/cli-name.ts owns the user-facing binary name and docs URL; getCliName and the update-check fallback URL consume it (C6). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/auth/errors.ts | 55 ++++++ packages/cli/src/auth/guard.ts | 8 +- packages/cli/src/auth/index.ts | 18 +- packages/cli/src/auth/login.ts | 8 +- packages/cli/src/auth/operations.ts | 4 +- packages/cli/src/auth/recipient.ts | 8 +- packages/cli/src/auth/workspaces.ts | 65 +++---- packages/cli/src/cli-name.ts | 10 + packages/cli/src/controllers/app-env.ts | 3 +- packages/cli/src/controllers/app.ts | 2 +- packages/cli/src/controllers/auth.ts | 27 ++- packages/cli/src/controllers/branch.ts | 2 +- packages/cli/src/controllers/bucket.ts | 2 +- packages/cli/src/controllers/build.ts | 2 +- packages/cli/src/controllers/database.ts | 3 +- packages/cli/src/controllers/project.ts | 8 +- packages/cli/src/lib/version.ts | 7 +- packages/cli/src/shell/errors.ts | 52 +---- packages/cli/src/shell/runtime.ts | 22 +-- packages/cli/src/state-dir.ts | 24 +++ packages/cli/src/update-check.ts | 4 +- .../cli/tests/app-branch-database.test.ts | 30 +-- packages/cli/tests/app-controller.test.ts | 184 +++++++++--------- packages/cli/tests/app-env-vars.test.ts | 8 +- packages/cli/tests/app-env.test.ts | 4 +- packages/cli/tests/auth-login.test.ts | 8 +- packages/cli/tests/auth-ops.test.ts | 30 +-- packages/cli/tests/branch-controller.test.ts | 4 +- packages/cli/tests/project-controller.test.ts | 10 +- packages/cli/tests/project-real-mode.test.ts | 24 +-- 30 files changed, 337 insertions(+), 299 deletions(-) create mode 100644 packages/cli/src/auth/errors.ts create mode 100644 packages/cli/src/cli-name.ts create mode 100644 packages/cli/src/state-dir.ts diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts new file mode 100644 index 00000000..2b3a02e4 --- /dev/null +++ b/packages/cli/src/auth/errors.ts @@ -0,0 +1,55 @@ +/** + * Auth-specific error constructors, owned by the auth module. The + * CliError base class still lives in the legacy shell (`shell/errors`); + * it is the one remaining legacy dependency of this module and is + * named as an S2d survivor until the operations layer throws structured + * errors directly. The shell re-exports these constructors so legacy + * imports keep working. + */ +import { CliError } from "../shell/errors"; + +export function workspaceSwitchUnavailableError(): CliError { + return new CliError({ + code: "WORKSPACE_SWITCH_UNAVAILABLE", + domain: "auth", + summary: "Workspace switching is unavailable", + why: "PRISMA_SERVICE_TOKEN is set, so authenticated commands use that token instead of local OAuth workspaces.", + fix: "Unset PRISMA_SERVICE_TOKEN to switch between local OAuth workspaces, or use a token for the workspace you want.", + exitCode: 1, + nextSteps: ["unset PRISMA_SERVICE_TOKEN", "prisma-cli auth workspace list"], + }); +} + +export function workspaceNotAuthenticatedError(workspaceRef: string): CliError { + return new CliError({ + code: "WORKSPACE_NOT_AUTHENTICATED", + domain: "auth", + summary: "Workspace is not authenticated", + why: `No stored OAuth session matched "${workspaceRef}".`, + fix: "Run prisma-cli auth login and authorize that workspace, then switch to it.", + meta: { + workspaceRef, + }, + exitCode: 1, + nextSteps: ["prisma-cli auth workspace list", "prisma-cli auth login"], + }); +} + +export function workspaceAmbiguousError( + workspaceRef: string, + matches: Array<{ id: string; name: string; credentialWorkspaceId: string }>, +): CliError { + return new CliError({ + code: "WORKSPACE_AMBIGUOUS", + domain: "auth", + summary: "Workspace name is ambiguous", + why: `Multiple authenticated workspaces matched "${workspaceRef}".`, + fix: "Run prisma-cli auth workspace list and switch by workspace id.", + meta: { + workspaceRef, + matches, + }, + exitCode: 2, + nextSteps: ["prisma-cli auth workspace list"], + }); +} diff --git a/packages/cli/src/auth/guard.ts b/packages/cli/src/auth/guard.ts index 8f35a7bd..48659159 100644 --- a/packages/cli/src/auth/guard.ts +++ b/packages/cli/src/auth/guard.ts @@ -4,12 +4,8 @@ import { type ManagementApiClient, } from "@prisma/management-api-sdk"; -import { - CLIENT_ID, - FileTokenStorage, - getApiBaseUrl, - SERVICE_TOKEN_ENV_VAR, -} from "../../auth"; +import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +import { FileTokenStorage } from "./token-storage"; /** * Resolve authentication and return a ManagementApiClient. diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index b1cad878..4d7998f2 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -1,5 +1,11 @@ export { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; export { makeGetCredentials } from "./credentials"; +export { + workspaceAmbiguousError, + workspaceNotAuthenticatedError, + workspaceSwitchUnavailableError, +} from "./errors"; +export { requireComputeAuth } from "./guard"; export { EmptyServiceTokenError, isEmptyServiceTokenError, @@ -7,13 +13,19 @@ export { performLogout, readAuthState, } from "./operations"; +export { + RecipientSessionInvalidError, + type RecipientWorkspaceSession, + resolveRecipientWorkspaceSession, +} from "./recipient"; export { FileTokenStorage, type StoredAuthWorkspace, WorkspaceSelectionError, } from "./token-storage"; export { - listRealAuthWorkspaces, - logoutRealAuthWorkspace, - useRealAuthWorkspace, + listAuthWorkspaces, + logoutAuthWorkspace, + useAuthWorkspace, + type WorkspaceOperationContext, } from "./workspaces"; diff --git a/packages/cli/src/auth/login.ts b/packages/cli/src/auth/login.ts index c17ec495..0c90339e 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -12,7 +12,8 @@ import { type TokenStorage, } from "@prisma/management-api-sdk"; import open from "open"; -import { CLIENT_ID, FileTokenStorage, getApiBaseUrl } from "../../auth"; +import { CLIENT_ID, getApiBaseUrl } from "./client"; +import { FileTokenStorage } from "./token-storage"; export class AuthError extends Error { constructor(message: string) { @@ -285,7 +286,10 @@ class LoginState { this.latestState = state; this.latestVerifier = verifier; - this.options.onVerificationUrl?.(url); + try { + // An observer must never break the login flow itself. + this.options.onVerificationUrl?.(url); + } catch {} this.options.signal?.throwIfAborted(); // Browser launch cannot consume AbortSignal; check immediately before and after the boundary. diff --git a/packages/cli/src/auth/operations.ts b/packages/cli/src/auth/operations.ts index a101bb8a..a3db2e9e 100644 --- a/packages/cli/src/auth/operations.ts +++ b/packages/cli/src/auth/operations.ts @@ -1,8 +1,8 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { requireComputeAuth } from "../lib/auth/guard"; -import { login } from "../lib/auth/login"; import type { AuthStateResult } from "../types/auth"; import { SERVICE_TOKEN_ENV_VAR } from "./client"; +import { requireComputeAuth } from "./guard"; +import { login } from "./login"; import { FileTokenStorage } from "./token-storage"; const WORKSPACE_SUB_PREFIX = "workspace:"; diff --git a/packages/cli/src/auth/recipient.ts b/packages/cli/src/auth/recipient.ts index db9e7900..c22e1747 100644 --- a/packages/cli/src/auth/recipient.ts +++ b/packages/cli/src/auth/recipient.ts @@ -1,11 +1,7 @@ import { createManagementApiSdk } from "@prisma/management-api-sdk"; -import { - CLIENT_ID, - FileTokenStorage, - getApiBaseUrl, - type StoredAuthWorkspace, -} from "../../auth"; +import { CLIENT_ID, getApiBaseUrl } from "./client"; +import { FileTokenStorage, type StoredAuthWorkspace } from "./token-storage"; export interface RecipientWorkspaceSession { workspace: StoredAuthWorkspace; diff --git a/packages/cli/src/auth/workspaces.ts b/packages/cli/src/auth/workspaces.ts index 761e08ae..4adf7d88 100644 --- a/packages/cli/src/auth/workspaces.ts +++ b/packages/cli/src/auth/workspaces.ts @@ -3,11 +3,6 @@ import { type TokenStorage, type Tokens, } from "@prisma/management-api-sdk"; -import { - workspaceAmbiguousError, - workspaceNotAuthenticatedError, - workspaceSwitchUnavailableError, -} from "../shell/errors"; import type { AuthWorkspace, AuthWorkspaceListResult, @@ -15,6 +10,11 @@ import type { AuthWorkspaceUseResult, } from "../types/auth"; import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +import { + workspaceAmbiguousError, + workspaceNotAuthenticatedError, + workspaceSwitchUnavailableError, +} from "./errors"; import { readAuthState } from "./operations"; import { FileTokenStorage, @@ -22,23 +22,17 @@ import { WorkspaceSelectionError, } from "./token-storage"; -/** The exact context surface the workspace operations read; both the - * legacy shell's CommandContext and a v8 handler's ctx satisfy it. */ +/** The exact context surface the workspace operations read. */ export interface WorkspaceOperationContext { - readonly runtime: { - readonly env: NodeJS.ProcessEnv; - readonly signal?: AbortSignal; - }; + readonly env: NodeJS.ProcessEnv; + readonly signal?: AbortSignal; } -export async function listRealAuthWorkspaces( +export async function listAuthWorkspaces( context: WorkspaceOperationContext, ): Promise { - const rawServiceToken = context.runtime.env[SERVICE_TOKEN_ENV_VAR]; - const storage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - ); + const rawServiceToken = context.env[SERVICE_TOKEN_ENV_VAR]; + const storage = new FileTokenStorage(context.env, context.signal); const localWorkspaces = await hydrateLocalAuthWorkspaces( context, storage, @@ -46,10 +40,7 @@ export async function listRealAuthWorkspaces( ); if (rawServiceToken !== undefined) { - const authState = await readAuthState( - context.runtime.env, - context.runtime.signal, - ); + const authState = await readAuthState(context.env, context.signal); return { authSource: authState.authenticated ? "service_token" : "none", activeWorkspace: authState.workspace, @@ -93,18 +84,15 @@ export async function listRealAuthWorkspaces( }; } -export async function useRealAuthWorkspace( +export async function useAuthWorkspace( context: WorkspaceOperationContext, workspaceRef: string, ): Promise { - if (context.runtime.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { + if (context.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { throw workspaceSwitchUnavailableError(); } - const storage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - ); + const storage = new FileTokenStorage(context.env, context.signal); await hydrateLocalAuthWorkspaces( context, storage, @@ -139,14 +127,11 @@ export async function useRealAuthWorkspace( } } -export async function logoutRealAuthWorkspace( +export async function logoutAuthWorkspace( context: WorkspaceOperationContext, workspaceRef: string, ): Promise { - const storage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - ); + const storage = new FileTokenStorage(context.env, context.signal); await hydrateLocalAuthWorkspaces( context, storage, @@ -232,7 +217,7 @@ async function rememberResolvedWorkspaceMetadata( try { await storage.rememberWorkspace(tokens.workspaceId, resolved); } catch { - context.runtime.signal?.throwIfAborted(); + context.signal?.throwIfAborted(); } } @@ -248,11 +233,9 @@ async function resolveOAuthWorkspaceMetadata( context: WorkspaceOperationContext, tokens: Tokens, ): Promise<{ id: string; name: string } | null> { - const refreshStorage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - { activateOnSetTokens: false }, - ); + const refreshStorage = new FileTokenStorage(context.env, context.signal, { + activateOnSetTokens: false, + }); const tokenStorage = createSingleWorkspaceTokenStorage( refreshStorage, tokens, @@ -261,13 +244,13 @@ async function resolveOAuthWorkspaceMetadata( clientId: CLIENT_ID, redirectUri: "http://localhost:0/auth/callback", tokenStorage, - apiBaseUrl: getApiBaseUrl(context.runtime.env), + apiBaseUrl: getApiBaseUrl(context.env), }); try { const { data } = await sdk.client.GET("/v1/workspaces/{id}", { params: { path: { id: tokens.workspaceId } }, - signal: context.runtime.signal, + signal: context.signal, }); const id = stringOrNull(data?.data?.id) ?? tokens.workspaceId; const name = stringOrNull(data?.data?.name) ?? id; @@ -278,7 +261,7 @@ async function resolveOAuthWorkspaceMetadata( return { id, name }; } catch { - context.runtime.signal?.throwIfAborted(); + context.signal?.throwIfAborted(); return null; } } diff --git a/packages/cli/src/cli-name.ts b/packages/cli/src/cli-name.ts new file mode 100644 index 00000000..6ef3ee2e --- /dev/null +++ b/packages/cli/src/cli-name.ts @@ -0,0 +1,10 @@ +/** + * The CLI's user-facing identity, in one place. The npm package is + * "@prisma/cli" but the binary on PATH is "prisma-cli" (the S1 + * convention) — every user-facing command string and notice consumes + * this constant rather than restating the name. + */ +export const CLI_NAME = "prisma-cli"; + +/** The CLI docs page (also the update-check fallback instruction URL). */ +export const CLI_DOCS_URL = "https://www.prisma.io/docs/orm/tools/prisma-cli"; diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index a1010523..b1e64ca1 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/performance/noAwaitInLoops: API pagination loops are intentionally sequential. import type { ManagementApiClient } from "@prisma/management-api-sdk"; - +import { requireComputeAuth } from "../auth"; import { type EnvScope, type EnvVarRole, @@ -12,7 +12,6 @@ import { type EnvFileAssignment, readEnvFileAssignments, } from "../lib/app/env-file"; -import { requireComputeAuth } from "../lib/auth/guard"; import { readLocalGitBranch } from "../lib/git/local-branch"; import { projectResolutionErrorToCliError, diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 6bf839f3..8b39d369 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -21,6 +21,7 @@ import { FileTokenStorage, getApiBaseUrl, readAuthState, + requireComputeAuth, SERVICE_TOKEN_ENV_VAR, } from "../auth"; import { @@ -91,7 +92,6 @@ import { } from "../lib/app/local-dev"; import { enforceProductionDeployGate } from "../lib/app/production-deploy-gate"; import { resolveReadBranch } from "../lib/app/read-branch"; -import { requireComputeAuth } from "../lib/auth/guard"; import { readLocalGitBranch } from "../lib/git/local-branch"; import { promptForProjectSetupChoice } from "../lib/project/interactive-setup"; import { diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index acac5dee..107d00e4 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -1,11 +1,12 @@ import { - listRealAuthWorkspaces, - logoutRealAuthWorkspace, + listAuthWorkspaces, + logoutAuthWorkspace, performLogin, performLogout, readAuthState, SERVICE_TOKEN_ENV_VAR, - useRealAuthWorkspace, + useAuthWorkspace, + type WorkspaceOperationContext, } from "../auth"; import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; import { PRISMA_AGENT_INSTALL_ARGS } from "../lib/agent/constants"; @@ -43,6 +44,12 @@ export interface AuthLogoutCommandOptions { workspace?: string; } +function workspaceOperationContext( + context: CommandContext, +): WorkspaceOperationContext { + return { env: context.runtime.env, signal: context.runtime.signal }; +} + function isRealMode(context: CommandContext): boolean { return ( !context.runtime.fixturePath && @@ -120,7 +127,7 @@ export async function runAuthWorkspaceList( context: CommandContext, ): Promise> { const result = isRealMode(context) - ? await listRealAuthWorkspaces(context) + ? await listAuthWorkspaces(workspaceOperationContext(context)) : await createAuthUseCases( createCliUseCaseGateways(context), ).listWorkspaces(); @@ -143,7 +150,10 @@ export async function runAuthWorkspaceUse( : await selectWorkspaceSession(context); const result = isRealMode(context) - ? await useRealAuthWorkspace(context, selectedWorkspaceRef) + ? await useAuthWorkspace( + workspaceOperationContext(context), + selectedWorkspaceRef, + ) : await createAuthUseCases(createCliUseCaseGateways(context)).useWorkspace( selectedWorkspaceRef, ); @@ -171,7 +181,10 @@ export async function runAuthWorkspaceLogout( } const result = isRealMode(context) - ? await logoutRealAuthWorkspace(context, workspaceRef) + ? await logoutAuthWorkspace( + workspaceOperationContext(context), + workspaceRef, + ) : await createAuthUseCases( createCliUseCaseGateways(context), ).logoutWorkspace(workspaceRef); @@ -232,7 +245,7 @@ async function selectWorkspaceSession( } const result = realMode - ? await listRealAuthWorkspaces(context) + ? await listAuthWorkspaces(workspaceOperationContext(context)) : await createAuthUseCases( createCliUseCaseGateways(context), ).listWorkspaces(); diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 0e1a7d0a..1a85e905 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Branch pagination requests must run sequentially. import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { requireComputeAuth } from "../lib/auth/guard"; +import { requireComputeAuth } from "../auth"; import { projectResolutionErrorToCliError, resolveProjectTarget, diff --git a/packages/cli/src/controllers/bucket.ts b/packages/cli/src/controllers/bucket.ts index 11609941..1fe6033b 100644 --- a/packages/cli/src/controllers/bucket.ts +++ b/packages/cli/src/controllers/bucket.ts @@ -1,4 +1,4 @@ -import { requireComputeAuth } from "../lib/auth/guard"; +import { requireComputeAuth } from "../auth"; import { type BucketProvider, createManagementBucketProvider, diff --git a/packages/cli/src/controllers/build.ts b/packages/cli/src/controllers/build.ts index 6648891b..7543db58 100644 --- a/packages/cli/src/controllers/build.ts +++ b/packages/cli/src/controllers/build.ts @@ -1,4 +1,4 @@ -import { requireComputeAuth } from "../lib/auth/guard"; +import { requireComputeAuth } from "../auth"; import { authRequiredError, CliError } from "../shell/errors"; import { writeJsonEvent } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts index 93d341d6..a39ae95d 100644 --- a/packages/cli/src/controllers/database.ts +++ b/packages/cli/src/controllers/database.ts @@ -1,10 +1,9 @@ import { randomBytes } from "node:crypto"; - +import { requireComputeAuth } from "../auth"; import { type PrismaCliPackageCommandFormatter, resolvePrismaCliPackageCommandFormatterSync, } from "../lib/agent/cli-command"; -import { requireComputeAuth } from "../lib/auth/guard"; import { createManagementDatabaseProvider, type DatabaseProvider, diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 1d406dc4..421f5617 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -12,6 +12,9 @@ import { } from "../adapters/git"; import { FileTokenStorage, + RecipientSessionInvalidError, + requireComputeAuth, + resolveRecipientWorkspaceSession, SERVICE_TOKEN_ENV_VAR, WorkspaceSelectionError, } from "../auth"; @@ -20,11 +23,6 @@ import { resolvePrismaCliPackageCommandFormatterSync, } from "../lib/agent/cli-command"; import { createAppProvider } from "../lib/app/app-provider"; -import { requireComputeAuth } from "../lib/auth/guard"; -import { - RecipientSessionInvalidError, - resolveRecipientWorkspaceSession, -} from "../lib/auth/recipient"; import { promptForProjectSetupChoice } from "../lib/project/interactive-setup"; import { LOCAL_RESOLUTION_PIN_RELATIVE_PATH, diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index a653225f..b3dc373c 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -2,6 +2,7 @@ import { createRequire } from "node:module"; import process from "node:process"; +import { CLI_NAME } from "../cli-name"; import { CliError } from "../shell/errors"; import type { VersionInvocation, VersionResult } from "../types/version"; @@ -38,10 +39,10 @@ export function getCliVersion(): string { } // Published bin name is the agreed user-facing identifier for the preview. -// We hard-code "prisma-cli" because the bin name and the npm package name differ: -// the npm package is "@prisma/cli", but the binary on PATH is "prisma-cli". +// The bin name and the npm package name differ: the npm package is +// "@prisma/cli", but the binary on PATH is CLI_NAME. export function getCliName(): string { - return "prisma-cli"; + return CLI_NAME; } export function detectInvocation( diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index 87230725..565f9336 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -148,51 +148,13 @@ export function workspaceRequiredError(): CliError { ); } -export function workspaceSwitchUnavailableError(): CliError { - return new CliError({ - code: "WORKSPACE_SWITCH_UNAVAILABLE", - domain: "auth", - summary: "Workspace switching is unavailable", - why: "PRISMA_SERVICE_TOKEN is set, so authenticated commands use that token instead of local OAuth workspaces.", - fix: "Unset PRISMA_SERVICE_TOKEN to switch between local OAuth workspaces, or use a token for the workspace you want.", - exitCode: 1, - nextSteps: ["unset PRISMA_SERVICE_TOKEN", "prisma-cli auth workspace list"], - }); -} - -export function workspaceNotAuthenticatedError(workspaceRef: string): CliError { - return new CliError({ - code: "WORKSPACE_NOT_AUTHENTICATED", - domain: "auth", - summary: "Workspace is not authenticated", - why: `No stored OAuth session matched "${workspaceRef}".`, - fix: "Run prisma-cli auth login and authorize that workspace, then switch to it.", - meta: { - workspaceRef, - }, - exitCode: 1, - nextSteps: ["prisma-cli auth workspace list", "prisma-cli auth login"], - }); -} - -export function workspaceAmbiguousError( - workspaceRef: string, - matches: Array<{ id: string; name: string; credentialWorkspaceId: string }>, -): CliError { - return new CliError({ - code: "WORKSPACE_AMBIGUOUS", - domain: "auth", - summary: "Workspace name is ambiguous", - why: `Multiple authenticated workspaces matched "${workspaceRef}".`, - fix: "Run prisma-cli auth workspace list and switch by workspace id.", - meta: { - workspaceRef, - matches, - }, - exitCode: 2, - nextSteps: ["prisma-cli auth workspace list"], - }); -} +// The auth-specific constructors moved to the auth module +// (src/auth/errors.ts); re-exported here so legacy imports stay valid. +export { + workspaceAmbiguousError, + workspaceNotAuthenticatedError, + workspaceSwitchUnavailableError, +} from "../auth/errors"; export function featureUnavailableError( summary: string, diff --git a/packages/cli/src/shell/runtime.ts b/packages/cli/src/shell/runtime.ts index 67f2fb98..be60a430 100644 --- a/packages/cli/src/shell/runtime.ts +++ b/packages/cli/src/shell/runtime.ts @@ -1,14 +1,15 @@ -import path from "node:path"; -import { findComputeConfigDir } from "@prisma/compute-sdk/config"; import type { Command } from "commander"; import { LocalStateStore } from "../adapters/local-state"; import { MockApi } from "../adapters/mock-api"; +import { DEFAULT_STATE_DIR_NAME, resolveStateDir } from "../state-dir"; import type { GlobalFlags } from "./global-flags"; import { renderHelp } from "./help"; import type { CliOutput } from "./output"; import { createShellUi, type ShellUi } from "./ui"; -export const DEFAULT_STATE_DIR_NAME = path.join(".prisma", "cli"); +// Moved to src/state-dir.ts (durable home); re-exported so legacy +// imports stay valid. +export { DEFAULT_STATE_DIR_NAME, resolveStateDir }; export interface CliRuntime { cwd: string; @@ -89,21 +90,6 @@ export async function createCommandContext( }; } -export async function resolveStateDir( - runtime: Pick, -): 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 { if (context.flags.json) { return false; diff --git a/packages/cli/src/state-dir.ts b/packages/cli/src/state-dir.ts new file mode 100644 index 00000000..e0563b16 --- /dev/null +++ b/packages/cli/src/state-dir.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { findComputeConfigDir } from "@prisma/compute-sdk/config"; + +export const DEFAULT_STATE_DIR_NAME = path.join(".prisma", "cli"); + +export interface StateDirInputs { + readonly stateDir?: string; + readonly env: NodeJS.ProcessEnv; + readonly cwd: string; + readonly signal: AbortSignal; +} + +export async function resolveStateDir(inputs: StateDirInputs): Promise { + const explicitStateDir = inputs.stateDir ?? inputs.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(inputs.cwd, inputs.signal); + return path.join(projectDir ?? inputs.cwd, DEFAULT_STATE_DIR_NAME); +} diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index f5f8cf84..75d92c35 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -5,6 +5,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { CLI_DOCS_URL } from "./cli-name"; import { getCliName, getCliVersion } from "./lib/version"; /** The exact runtime surface the update check reads; both the legacy @@ -16,8 +17,7 @@ export interface UpdateCheckRuntime { } const UPDATE_CHECK_FILE_NAME = "update-check.json"; -const FALLBACK_INSTALL_DOCS_URL = - "https://www.prisma.io/docs/orm/tools/prisma-cli"; +const FALLBACK_INSTALL_DOCS_URL = CLI_DOCS_URL; const NOTIFICATION_INTERVAL_MS = 24 * 60 * 60 * 1000; const REGISTRY_URL = "https://registry.npmjs.org/@prisma%2fcli"; const REGISTRY_TIMEOUT_MS = 3_000; diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 6b1b4a6c..0f0d1853 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -38,7 +38,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; vi.doUnmock("../src/auth"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.doUnmock("../src/lib/app/branch-database"); vi.doUnmock("../src/lib/app/branch-database-deploy"); @@ -119,7 +119,7 @@ describe("app deploy branch database setup", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { @@ -280,7 +280,7 @@ describe("app deploy branch database setup", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { @@ -421,7 +421,7 @@ describe("app deploy branch database setup", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { @@ -547,7 +547,7 @@ describe("app deploy branch database setup", () => { return []; }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -672,7 +672,7 @@ describe("app deploy branch database setup", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -801,7 +801,7 @@ describe("app deploy branch database setup", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -930,7 +930,7 @@ describe("app deploy branch database setup", () => { return []; }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { @@ -1069,7 +1069,7 @@ describe("app deploy branch database setup", () => { return []; }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { @@ -1192,7 +1192,7 @@ describe("app deploy branch database setup", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/shell/prompt", async () => { @@ -1289,7 +1289,7 @@ describe("app deploy branch database setup", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -1366,7 +1366,7 @@ describe("app deploy branch database setup", () => { const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -1452,7 +1452,7 @@ describe("app deploy branch database setup", () => { const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -1541,7 +1541,7 @@ describe("app deploy branch database setup", () => { .mockRejectedValue(new Error("env write failed")); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { @@ -1732,7 +1732,7 @@ describe("app deploy branch database setup", () => { const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 9cc51f51..6a6ee32e 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -41,7 +41,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; vi.doUnmock("../src/auth"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/controllers/agent"); vi.doUnmock("../src/lib/app/app-provider"); vi.doUnmock("../src/lib/app/branch-database"); @@ -205,7 +205,7 @@ async function setupAgentPromptDeployTest(options: { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/controllers/agent", () => ({ @@ -286,7 +286,7 @@ describe("app controller", () => { }), ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -371,7 +371,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -465,7 +465,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -553,7 +553,7 @@ describe("app controller", () => { ]); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -609,7 +609,7 @@ describe("app controller", () => { ]); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -658,7 +658,7 @@ describe("app controller", () => { const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockRejectedValue(new Error("upload exploded")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -791,7 +791,7 @@ describe("app controller", () => { ], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -882,7 +882,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -983,7 +983,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -1050,7 +1050,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -1124,7 +1124,7 @@ describe("app controller", () => { }); const retryDomain = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1220,7 +1220,7 @@ describe("app controller", () => { existing: false, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1277,7 +1277,7 @@ describe("app controller", () => { const listApps = vi.fn(); const addDomain = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1356,7 +1356,7 @@ describe("app controller", () => { existing: false, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1409,7 +1409,7 @@ describe("app controller", () => { }, ]); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1473,7 +1473,7 @@ describe("app controller", () => { }, ]); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1543,7 +1543,7 @@ describe("app controller", () => { }, ]); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1612,7 +1612,7 @@ describe("app controller", () => { }, ]); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1679,7 +1679,7 @@ describe("app controller", () => { ]); const listDomains = vi.fn().mockRejectedValue(new Error("list failed")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1768,7 +1768,7 @@ describe("app controller", () => { .fn() .mockResolvedValue([createDomain({ status: "provisioning_tls" })]); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1836,7 +1836,7 @@ describe("app controller", () => { .mockResolvedValue([createDomain({ status: "verifying" })]); const showDomain = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { @@ -1949,7 +1949,7 @@ describe("app controller", () => { }, })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2089,7 +2089,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2172,7 +2172,7 @@ describe("app controller", () => { .fn() .mockResolvedValue(createProjectClient("proj_my_app")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2235,7 +2235,7 @@ describe("app controller", () => { ]); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2324,7 +2324,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2399,7 +2399,7 @@ describe("app controller", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2471,7 +2471,7 @@ describe("app controller", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2574,7 +2574,7 @@ describe("app controller", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2669,7 +2669,7 @@ describe("app controller", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2803,7 +2803,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2877,7 +2877,7 @@ describe("app controller", () => { }, })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -2944,7 +2944,7 @@ describe("app controller", () => { const listApps = vi.fn(); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3100,7 +3100,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3178,7 +3178,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3272,7 +3272,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/controllers/agent", () => ({ @@ -3359,7 +3359,7 @@ describe("app controller", () => { nextSteps: [], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/controllers/agent", () => ({ @@ -3488,7 +3488,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3560,7 +3560,7 @@ describe("app controller", () => { it("returns FRAMEWORK_NOT_DETECTED before deploy when framework inference fails", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3606,7 +3606,7 @@ describe("app controller", () => { const listApps = vi.fn(); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3669,7 +3669,7 @@ describe("app controller", () => { const listApps = vi.fn(); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3725,7 +3725,7 @@ describe("app controller", () => { const listApps = vi.fn(); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3825,7 +3825,7 @@ describe("app controller", () => { ]); const deployApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -3950,7 +3950,7 @@ describe("app controller", () => { }, })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4023,7 +4023,7 @@ describe("app controller", () => { }, })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4085,7 +4085,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4154,7 +4154,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4256,7 +4256,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4351,7 +4351,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4480,7 +4480,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4544,7 +4544,7 @@ describe("app controller", () => { .fn() .mockRejectedValue(new Error("Authentication failed (HTTP 401)")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4600,7 +4600,7 @@ describe("app controller", () => { .fn() .mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4675,7 +4675,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4758,7 +4758,7 @@ describe("app controller", () => { ], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4800,7 +4800,7 @@ describe("app controller", () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockRejectedValue(new Error("Resource Not Found")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4872,7 +4872,7 @@ describe("app controller", () => { ], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4925,7 +4925,7 @@ describe("app controller", () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -4986,7 +4986,7 @@ describe("app controller", () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5068,7 +5068,7 @@ describe("app controller", () => { ], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5174,7 +5174,7 @@ describe("app controller", () => { ], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5246,7 +5246,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5311,7 +5311,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5370,7 +5370,7 @@ describe("app controller", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5422,7 +5422,7 @@ describe("app controller", () => { .fn() .mockRejectedValue(new Error("Missing or invalid authorization token")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5493,7 +5493,7 @@ describe("app controller", () => { vi.doMock("open", () => ({ default: openUrl, })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5567,7 +5567,7 @@ describe("app controller", () => { vi.doMock("open", () => ({ default: openUrl, })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5640,7 +5640,7 @@ describe("app controller", () => { deployments: [], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5709,7 +5709,7 @@ describe("app controller", () => { ], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5785,7 +5785,7 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5872,7 +5872,7 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -5942,7 +5942,7 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6022,7 +6022,7 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6117,7 +6117,7 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6213,7 +6213,7 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6285,7 +6285,7 @@ describe("app controller", () => { ], }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6348,7 +6348,7 @@ describe("app controller", () => { }, })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6446,7 +6446,7 @@ describe("app controller", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6537,7 +6537,7 @@ describe("app controller", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6607,7 +6607,7 @@ describe("app controller", () => { }); const streamDeploymentLogs = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6695,7 +6695,7 @@ describe("app controller", () => { }, ); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6766,7 +6766,7 @@ describe("app controller", () => { name: "hello-world", }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6850,7 +6850,7 @@ describe("app controller", () => { name: "hello-world", }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6903,7 +6903,7 @@ describe("app controller", () => { const listApps = vi.fn(); const removeApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -6982,7 +6982,7 @@ describe("app controller", () => { name: "hello-world", }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -7054,7 +7054,7 @@ describe("app controller", () => { }); const textPrompt = vi.fn().mockResolvedValue("hello-world"); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/shell/prompt", async () => { @@ -7123,7 +7123,7 @@ describe("app controller", () => { ]); const removeApp = vi.fn(); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -7180,7 +7180,7 @@ describe("app controller", () => { .fn() .mockRejectedValue(new Error("Resource Not Found")); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -7239,7 +7239,7 @@ describe("app controller", () => { name: "hello-world", }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -7320,7 +7320,7 @@ describe("app controller", () => { promoted: false, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -7411,7 +7411,7 @@ describe("app controller", () => { promoted: false, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index 6a931123..e151620c 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -32,7 +32,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; vi.doUnmock("../src/auth"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); vi.restoreAllMocks(); @@ -287,7 +287,7 @@ describe("app env vars", () => { it("project env list requires explicit or durable Project binding", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -359,7 +359,7 @@ describe("app env vars", () => { }; const requireComputeAuth = vi.fn().mockResolvedValue(client); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -498,7 +498,7 @@ describe("app env vars", () => { }, }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index ce46c2fc..cd6c0933 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -16,7 +16,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; vi.doUnmock("../src/auth"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); vi.restoreAllMocks(); @@ -124,7 +124,7 @@ async function loadControllers(client: MockClient, projectId: string) { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi.fn().mockResolvedValue(client), })); diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index 398c5f6a..52ba74c6 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -90,7 +90,7 @@ describe("auth login callback", () => { clearTokens: vi.fn(), }; - const { login } = await import("../src/lib/auth/login"); + const { login } = await import("../src/auth/login"); await expect( login({ @@ -142,7 +142,7 @@ describe("auth login callback", () => { }), })); - const { login } = await import("../src/lib/auth/login"); + const { login } = await import("../src/auth/login"); await expect( login({ @@ -223,7 +223,7 @@ async function requestSuccessPage(options: { }), })); - const { login } = await import("../src/lib/auth/login"); + const { login } = await import("../src/auth/login"); await login({ hostname: "127.0.0.1", @@ -408,7 +408,7 @@ async function runLogin(options: { } }); - const { login } = await import("../src/lib/auth/login"); + const { login } = await import("../src/auth/login"); await login({ hostname: "127.0.0.1", diff --git a/packages/cli/tests/auth-ops.test.ts b/packages/cli/tests/auth-ops.test.ts index 6b0d56e2..89ec3290 100644 --- a/packages/cli/tests/auth-ops.test.ts +++ b/packages/cli/tests/auth-ops.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; afterEach(() => { vi.doUnmock("../src/auth/token-storage"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.resetModules(); vi.restoreAllMocks(); }); @@ -75,7 +75,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -140,7 +140,7 @@ describe("readAuthState", () => { }), }); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -210,7 +210,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -252,7 +252,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -290,7 +290,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -358,7 +358,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -416,7 +416,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -449,7 +449,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -482,7 +482,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -510,7 +510,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -545,7 +545,7 @@ describe("readAuthState", () => { getTokens: vi.fn(), })), })); - vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -578,7 +578,7 @@ describe("readAuthState", () => { getTokens: vi.fn(), })), })); - vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -597,7 +597,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi.fn(), })); @@ -623,7 +623,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi.fn(), })); diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index db3758a0..d94f5ad4 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -7,7 +7,7 @@ import { createTempCwd, createTestCommandContext } from "./helpers"; afterEach(() => { vi.doUnmock("../src/auth"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.resetModules(); vi.restoreAllMocks(); }); @@ -114,7 +114,7 @@ async function loadController(client: ReturnType) { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi.fn().mockResolvedValue(client), })); diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index ab392e0f..6d1c2a3d 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -8,7 +8,7 @@ const fixturePath = path.resolve("fixtures/mock-api.json"); afterEach(() => { vi.doUnmock("../src/auth"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); vi.restoreAllMocks(); @@ -178,7 +178,7 @@ describe("project controller", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -249,7 +249,7 @@ describe("project controller", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -320,7 +320,7 @@ describe("project controller", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ @@ -396,7 +396,7 @@ describe("project controller", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); vi.doMock("../src/lib/app/app-provider", () => ({ diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index 82852f7a..20eb483e 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -12,7 +12,7 @@ type ApiMutationMock = Mock<(pathName: string, request?: unknown) => unknown>; afterEach(() => { vi.doUnmock("../src/auth"); - vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/auth/guard"); vi.doUnmock("open"); vi.resetModules(); vi.restoreAllMocks(); @@ -265,7 +265,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth, })); @@ -332,7 +332,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi.fn().mockResolvedValue(mockClient()), })); @@ -408,7 +408,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -493,7 +493,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -578,7 +578,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -690,7 +690,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -783,7 +783,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -918,7 +918,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -988,7 +988,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -1064,7 +1064,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), @@ -1138,7 +1138,7 @@ describe("real project mode", () => { performLogin: vi.fn(), performLogout: vi.fn(), })); - vi.doMock("../src/lib/auth/guard", () => ({ + vi.doMock("../src/auth/guard", () => ({ requireComputeAuth: vi .fn() .mockResolvedValue(mockClient({ GET: get, DELETE: del })), From 32f10a9b8c728ff056267e508e5927174e0ba2f5 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 03:31:38 +0200 Subject: [PATCH 15/67] refactor(cli): split v8 commands one-per-file and fix telemetry wiring semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-round fixes for slice s2a-foundations (A4, A9, C2, C3, C5, C6, C9, C10, A13): - v8/auth/workspace-commands.ts splits into workspace-list.ts, workspace-use.ts, workspace-logout.ts with a workspace-shared.ts helper module; the shared runWorkspaceLogout operation gets its own module. v8/telemetry/commands.ts splits into status.ts (merged with the status resolution), enable.ts, disable.ts, and a shared consent presentation (A4). - v8/telemetry/wiring.ts renames to reporting.ts; options type renamed to TelemetryReportingOptions (A9). - auth workspace list now maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID (exit 2), matching whoami/login/logout, with a divergence row (C2). - The first-run telemetry disclosure prints at gating time — pre-run, before the command's output — while the event still fires at settlement; the installation-id mint stays at first settlement so 'telemetry status' keeps reporting 'not stored' (C5). Status projects the gating resolver's total reason union directly (A3). - The disclosure and the telemetry group help now use the real docs page (the update-check fallback URL) and the CLI_NAME constant in every user-facing command string (C6). - New packages/cli/vitest.config.ts sets PRISMA_NEXT_DISABLE_TELEMETRY=1 suite-wide, mirroring the cli-telemetry package's own guard, so no test reaches the developer's real user config or the endpoint (C3); the bin/update-check test processes opt out explicitly. - Tests: AUTH.CONFIG_INVALID on login and logout, the json login tip envelope (agentSetupTip + tip nextAction), and the tip-suppression branch when Prisma skills are already installed (C9); update-check notify-before-dispatch ordering via a marker-writing stub CLI and the '--format json is NOT suppressed' literal-argv quirk (C10). - v8-auth.test.ts byte assertions convert to semantic assertions (envelope / presented / events / exit code); the sanctioned golden suite v8-golden-rendering.test.ts pins one representative card, table, and error rendering byte-exactly; the S1 whoami byte pins remain (A13). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/v8/auth/agent-setup-tip.ts | 2 +- packages/cli/src/v8/auth/login.ts | 5 +- packages/cli/src/v8/auth/logout.ts | 5 +- .../cli/src/v8/auth/run-workspace-logout.ts | 81 +++++ packages/cli/src/v8/auth/whoami.ts | 3 +- packages/cli/src/v8/auth/workspace-list.ts | 122 +++++++ packages/cli/src/v8/auth/workspace-logout.ts | 20 ++ packages/cli/src/v8/auth/workspace-shared.ts | 36 ++ packages/cli/src/v8/auth/workspace-use.ts | 155 +++++++++ packages/cli/src/v8/cli.ts | 19 +- packages/cli/src/v8/main.ts | 2 +- packages/cli/src/v8/telemetry/consent.ts | 15 + packages/cli/src/v8/telemetry/disable.ts | 29 ++ packages/cli/src/v8/telemetry/enable.ts | 29 ++ packages/cli/src/v8/telemetry/reporting.ts | 90 +++-- packages/cli/src/v8/telemetry/status.ts | 96 ++++-- packages/cli/tests/v8-auth.test.ts | 317 +++++++++++++----- packages/cli/tests/v8-bin.test.ts | 5 +- .../cli/tests/v8-golden-rendering.test.ts | 141 ++++++++ .../cli/tests/v8-telemetry-reporting.test.ts | 49 ++- packages/cli/tests/v8-telemetry.test.ts | 8 +- packages/cli/tests/v8-update-check.test.ts | 39 ++- packages/cli/vitest.config.ts | 18 + 23 files changed, 1090 insertions(+), 196 deletions(-) create mode 100644 packages/cli/src/v8/auth/run-workspace-logout.ts create mode 100644 packages/cli/src/v8/auth/workspace-list.ts create mode 100644 packages/cli/src/v8/auth/workspace-logout.ts create mode 100644 packages/cli/src/v8/auth/workspace-shared.ts create mode 100644 packages/cli/src/v8/auth/workspace-use.ts create mode 100644 packages/cli/src/v8/telemetry/consent.ts create mode 100644 packages/cli/src/v8/telemetry/disable.ts create mode 100644 packages/cli/src/v8/telemetry/enable.ts create mode 100644 packages/cli/tests/v8-golden-rendering.test.ts create mode 100644 packages/cli/vitest.config.ts diff --git a/packages/cli/src/v8/auth/agent-setup-tip.ts b/packages/cli/src/v8/auth/agent-setup-tip.ts index dc5a8ab6..04bce187 100644 --- a/packages/cli/src/v8/auth/agent-setup-tip.ts +++ b/packages/cli/src/v8/auth/agent-setup-tip.ts @@ -16,7 +16,7 @@ import { resolvePrismaAgentSetupCwd, shouldOfferPrismaAgentSetup, } from "../../lib/agent/setup-status"; -import { resolveStateDir } from "../../shell/runtime"; +import { resolveStateDir } from "../../state-dir"; export interface AgentSetupTipContext { readonly cwd: string; diff --git a/packages/cli/src/v8/auth/login.ts b/packages/cli/src/v8/auth/login.ts index 092bf877..5f0ed16f 100644 --- a/packages/cli/src/v8/auth/login.ts +++ b/packages/cli/src/v8/auth/login.ts @@ -5,6 +5,7 @@ import { performLogin, readAuthState, } from "../../auth"; +import { CLI_NAME } from "../../cli-name"; import type { AuthStateResult } from "../../types/auth"; import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; import { authConfigInvalidError } from "./errors"; @@ -18,12 +19,12 @@ function nextActionsFor(state: AuthStateResult): readonly NextAction[] { { kind: "run-command", label: "Show the signed-in identity", - command: "prisma-cli auth whoami", + command: `${CLI_NAME} auth whoami`, }, { kind: "run-command", label: "List projects", - command: "prisma-cli project list", + command: `${CLI_NAME} project list`, }, ...(state.agentSetupTip ? [ diff --git a/packages/cli/src/v8/auth/logout.ts b/packages/cli/src/v8/auth/logout.ts index 9d5aa8f1..af477dab 100644 --- a/packages/cli/src/v8/auth/logout.ts +++ b/packages/cli/src/v8/auth/logout.ts @@ -5,15 +5,16 @@ import { performLogout, readAuthState, } from "../../auth"; +import { CLI_NAME } from "../../cli-name"; import type { AuthStateResult } from "../../types/auth"; import { authConfigInvalidError } from "./errors"; +import { runWorkspaceLogout } from "./run-workspace-logout"; import { authStateFieldRows } from "./state-card"; -import { runWorkspaceLogout } from "./workspace-commands"; const SIGN_IN: NextAction = { kind: "run-command", label: "Sign in", - command: "prisma-cli auth login", + command: `${CLI_NAME} auth login`, }; function presentationsFor(state: AuthStateResult): Presentations { diff --git a/packages/cli/src/v8/auth/run-workspace-logout.ts b/packages/cli/src/v8/auth/run-workspace-logout.ts new file mode 100644 index 00000000..9cee3a40 --- /dev/null +++ b/packages/cli/src/v8/auth/run-workspace-logout.ts @@ -0,0 +1,81 @@ +/** + * The shared workspace-logout operation + presentation, called by both + * `auth workspace logout ` and `auth logout --workspace ` — + * the same operation, the same presentation. + */ +import type { CommandContext, Presentations } from "@prisma/cli-engine"; +import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; +import { logoutAuthWorkspace } from "../../auth"; +import { CLI_NAME } from "../../cli-name"; +import type { AuthWorkspaceLogoutResult } from "../../types/auth"; +import { mapAuthOperationError } from "./errors"; +import { LIST_NEXT_ACTION, operationContext } from "./workspace-shared"; + +function logoutPresentations(result: AuthWorkspaceLogoutResult): Presentations { + const rows = [ + { label: "workspace", value: result.workspace.name }, + { label: "active", value: result.activeWorkspace?.name ?? "none" }, + ]; + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Removing a local OAuth workspace session.", + }, + { kind: "fields", rows }, + { + kind: "summary", + tone: "ok", + text: result.wasActive + ? "Removed active workspace session; no replacement workspace was selected." + : "Removed workspace session.", + }, + ], + stdout: () => rows.map((row) => `${row.label}: ${row.value}`), + next: () => + result.activeWorkspace + ? [LIST_NEXT_ACTION] + : [ + LIST_NEXT_ACTION, + { + kind: "run-command", + label: "Select a replacement workspace", + command: `${CLI_NAME} auth workspace use `, + }, + ], + }; +} + +function workspaceRequiredError(): CliStructuredError { + return new CliStructuredError("AUTH.USAGE_ERROR", "Workspace required", { + why: "auth workspace logout needs a workspace id or cached workspace name.", + nextActions: [ + { + kind: "user-choice", + label: `Pass a workspace from ${CLI_NAME} auth workspace list.`, + }, + ], + }); +} + +export async function runWorkspaceLogout( + ctx: CommandContext, + workspaceRef: string, +) { + if (!workspaceRef.trim()) { + return notOk(workspaceRequiredError()); + } + + let result: AuthWorkspaceLogoutResult; + try { + result = await logoutAuthWorkspace(operationContext(ctx), workspaceRef); + } catch (error) { + const mapped = mapAuthOperationError(error); + if (mapped) { + return notOk(mapped); + } + throw error; + } + return ok(ctx.present({ data: result }, logoutPresentations(result))); +} diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index 60f0b7af..f4b658c8 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -1,6 +1,7 @@ import { defineCommand, type Presentations } from "@prisma/cli-engine"; import { type NextAction, notOk, ok } from "@prisma/cli-engine/protocol"; import { isEmptyServiceTokenError, readAuthState } from "../../auth"; +import { CLI_NAME } from "../../cli-name"; import type { AuthStateResult } from "../../types/auth"; import { authConfigInvalidError } from "./errors"; import { authStateFieldRows } from "./state-card"; @@ -10,7 +11,7 @@ const TITLE = "Showing the current authenticated identity."; const SIGN_IN: NextAction = { kind: "run-command", label: "Sign in", - command: "prisma-cli auth login", + command: `${CLI_NAME} auth login`, }; function presentationsFor(state: AuthStateResult): Presentations { diff --git a/packages/cli/src/v8/auth/workspace-list.ts b/packages/cli/src/v8/auth/workspace-list.ts new file mode 100644 index 00000000..87b0fc54 --- /dev/null +++ b/packages/cli/src/v8/auth/workspace-list.ts @@ -0,0 +1,122 @@ +/** The `auth workspace list` command. */ +import { defineCommand, type Presentations } from "@prisma/cli-engine"; +import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { isEmptyServiceTokenError, listAuthWorkspaces } from "../../auth"; +import type { AuthWorkspaceListResult } from "../../types/auth"; +import { authConfigInvalidError } from "./errors"; +import { + LOGIN_NEXT_ACTION, + operationContext, + rethrowMapped, +} from "./workspace-shared"; + +function authSourceLabel( + source: AuthWorkspaceListResult["authSource"], +): string { + if (source === "oauth") { + return "local OAuth"; + } + if (source === "service_token") { + return "PRISMA_SERVICE_TOKEN"; + } + return "none"; +} + +function workspaceSourceLabel(source: "oauth" | "service_token"): string { + return source === "service_token" ? "service token" : "OAuth"; +} + +export function serializeAuthWorkspaceList(result: AuthWorkspaceListResult) { + return { + context: { + authSource: result.authSource, + activeWorkspaceId: result.activeWorkspace?.id ?? null, + activeWorkspaceName: result.activeWorkspace?.name ?? null, + }, + items: result.workspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + status: workspace.active ? "active" : null, + source: workspace.source, + switchable: workspace.switchable, + credentialWorkspaceId: workspace.credentialWorkspaceId, + lastSeenAt: workspace.lastSeenAt, + })), + count: result.workspaces.length, + }; +} + +/** The legacy table's column rule: the source column appears only when + * the listed workspaces mix sources. */ +function workspaceTableRows(result: AuthWorkspaceListResult): { + columns: readonly string[]; + rows: ReadonlyArray; +} { + const hasMixedSources = + new Set(result.workspaces.map((workspace) => workspace.source)).size > 1; + const columns = hasMixedSources + ? ["name", "id", "source", "status"] + : ["name", "id", "status"]; + const rows = result.workspaces.map((workspace) => { + const status = workspace.active ? "active" : ""; + return hasMixedSources + ? [ + workspace.name, + workspace.id, + workspaceSourceLabel(workspace.source), + status, + ] + : [workspace.name, workspace.id, status]; + }); + return { columns, rows }; +} + +function listPresentations(result: AuthWorkspaceListResult): Presentations { + const table = workspaceTableRows(result); + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Listing authenticated workspaces on this machine.", + }, + { + kind: "fields", + rows: [ + { label: "auth source", value: authSourceLabel(result.authSource) }, + ], + }, + ...(result.workspaces.length === 0 + ? [ + { + kind: "summary", + tone: "info", + text: "No local OAuth workspaces found.", + } as const, + ] + : [{ kind: "table", ...table } as const]), + ], + stdout: () => table.rows.map((row) => row.join(" ").trimEnd()), + json: () => serializeAuthWorkspaceList(result), + next: () => (result.workspaces.length === 0 ? [LOGIN_NEXT_ACTION] : []), + }; +} + +export const authWorkspaceListCommand = defineCommand({ + help: { + summary: "List locally authenticated workspaces", + examples: ["auth workspace list", "auth workspace list --json"], + }, + handler: async (_args, ctx) => { + let result: AuthWorkspaceListResult; + try { + result = await listAuthWorkspaces(operationContext(ctx)); + } catch (error) { + if (isEmptyServiceTokenError(error)) { + return notOk(authConfigInvalidError(error.message)); + } + rethrowMapped(error); + } + return ok(ctx.present({ data: result }, listPresentations(result))); + }, +}); diff --git a/packages/cli/src/v8/auth/workspace-logout.ts b/packages/cli/src/v8/auth/workspace-logout.ts new file mode 100644 index 00000000..b02a717d --- /dev/null +++ b/packages/cli/src/v8/auth/workspace-logout.ts @@ -0,0 +1,20 @@ +/** The `auth workspace logout` command. */ +import { defineCommand, positional } from "@prisma/cli-engine"; +import { runWorkspaceLogout } from "./run-workspace-logout"; + +export const authWorkspaceLogoutCommand = defineCommand({ + args: { + positionals: { + workspace: positional.string({ + brief: "Workspace id or exact name", + placeholder: "id-or-name", + }), + }, + }, + help: { + summary: "Remove one local OAuth workspace session", + examples: ["auth workspace logout my-workspace"], + }, + handler: async (args, ctx) => + runWorkspaceLogout(ctx, args.positionals.workspace), +}); diff --git a/packages/cli/src/v8/auth/workspace-shared.ts b/packages/cli/src/v8/auth/workspace-shared.ts new file mode 100644 index 00000000..b2659e25 --- /dev/null +++ b/packages/cli/src/v8/auth/workspace-shared.ts @@ -0,0 +1,36 @@ +/** + * Helpers shared by the `auth workspace *` commands. Operations come + * from the auth module (`src/auth/index.ts`); legacy CliError shapes + * map to dotted AUTH.* structured errors via `mapAuthOperationError`. + */ +import type { CommandContext } from "@prisma/cli-engine"; +import type { NextAction } from "@prisma/cli-engine/protocol"; +import type { WorkspaceOperationContext } from "../../auth"; +import { CLI_NAME } from "../../cli-name"; +import { mapAuthOperationError } from "./errors"; + +export const LIST_NEXT_ACTION: NextAction = { + kind: "run-command", + label: "List authenticated workspaces", + command: `${CLI_NAME} auth workspace list`, +}; + +export const LOGIN_NEXT_ACTION: NextAction = { + kind: "run-command", + label: "Sign in", + command: `${CLI_NAME} auth login`, +}; + +export function operationContext( + ctx: CommandContext, +): WorkspaceOperationContext { + return { env: ctx.env, signal: ctx.signal }; +} + +export function rethrowMapped(error: unknown): never { + const mapped = mapAuthOperationError(error); + if (mapped) { + throw mapped; + } + throw error; +} diff --git a/packages/cli/src/v8/auth/workspace-use.ts b/packages/cli/src/v8/auth/workspace-use.ts new file mode 100644 index 00000000..f482dd68 --- /dev/null +++ b/packages/cli/src/v8/auth/workspace-use.ts @@ -0,0 +1,155 @@ +/** The `auth workspace use` command. */ +import { + type CommandContext, + defineCommand, + type Presentations, + positional, +} from "@prisma/cli-engine"; +import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; +import { + listAuthWorkspaces, + SERVICE_TOKEN_ENV_VAR, + useAuthWorkspace, +} from "../../auth"; +import { CLI_NAME } from "../../cli-name"; +import type { + AuthWorkspaceListResult, + AuthWorkspaceUseResult, +} from "../../types/auth"; +import { mapAuthOperationError } from "./errors"; +import { operationContext, rethrowMapped } from "./workspace-shared"; + +function usePresentations(result: AuthWorkspaceUseResult): Presentations { + const rows = [ + ...(result.previousWorkspace + ? [{ label: "previous", value: result.previousWorkspace.name }] + : []), + { label: "workspace", value: result.workspace.name }, + ]; + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Switching the local CLI workspace.", + }, + { kind: "fields", rows }, + { + kind: "summary", + tone: "ok", + text: "Local OAuth workspace selection updated.", + }, + ], + stdout: () => rows.map((row) => `${row.label}: ${row.value}`), + next: () => [ + { + kind: "run-command", + label: "Show the signed-in identity", + command: `${CLI_NAME} auth whoami`, + }, + { + kind: "run-command", + label: "List projects", + command: `${CLI_NAME} project list`, + }, + ], + }; +} + +function noWorkspacesError(): CliStructuredError { + return new CliStructuredError( + "AUTH.USAGE_ERROR", + "No authenticated workspaces", + { + why: "There are no local OAuth workspace sessions to select.", + nextActions: [ + { + kind: "user-choice", + label: `Run ${CLI_NAME} auth login and authorize a workspace.`, + }, + ], + }, + ); +} + +function serviceTokenSwitchError(): CliStructuredError { + return new CliStructuredError( + "AUTH.WORKSPACE_SWITCH_UNAVAILABLE", + "Workspace switching is unavailable", + { + why: "PRISMA_SERVICE_TOKEN is set, so authenticated commands use that token instead of local OAuth workspaces.", + nextActions: [ + { + kind: "user-choice", + label: + "Unset PRISMA_SERVICE_TOKEN to switch between local OAuth workspaces, or use a token for the workspace you want.", + }, + ], + }, + ); +} + +async function selectWorkspaceRef( + ctx: CommandContext, +): Promise { + if (ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { + throw serviceTokenSwitchError(); + } + + let listed: AuthWorkspaceListResult; + try { + listed = await listAuthWorkspaces(operationContext(ctx)); + } catch (error) { + rethrowMapped(error); + } + const workspaces = listed.workspaces.filter( + (workspace) => workspace.switchable, + ); + + if (workspaces.length === 0) { + throw noWorkspacesError(); + } + + if (workspaces.length === 1) { + return workspaces[0].id; + } + + return await ctx.prompt.select( + "Select a workspace", + workspaces.map((workspace) => ({ + value: workspace.id, + label: `${workspace.name} (${workspace.id})${workspace.active ? " active" : ""}`, + })), + ); +} + +export const authWorkspaceUseCommand = defineCommand({ + args: { + positionals: { + workspace: positional.optionalString({ + brief: "Workspace id or exact name", + placeholder: "id-or-name", + }), + }, + }, + help: { + summary: "Switch the local CLI workspace", + examples: ["auth workspace use", "auth workspace use my-workspace"], + }, + handler: async (args, ctx) => { + const trimmed = args.positionals.workspace?.trim(); + const workspaceRef = trimmed ? trimmed : await selectWorkspaceRef(ctx); + + let result: AuthWorkspaceUseResult; + try { + result = await useAuthWorkspace(operationContext(ctx), workspaceRef); + } catch (error) { + const mapped = mapAuthOperationError(error); + if (mapped) { + return notOk(mapped); + } + throw error; + } + return ok(ctx.present({ data: result }, usePresentations(result))); + }, +}); diff --git a/packages/cli/src/v8/cli.ts b/packages/cli/src/v8/cli.ts index 44c81263..e8795699 100644 --- a/packages/cli/src/v8/cli.ts +++ b/packages/cli/src/v8/cli.ts @@ -1,18 +1,15 @@ import { type Cli, createCli, defineCommandFamily } from "@prisma/cli-engine"; +import { CLI_DOCS_URL } from "../cli-name"; import { getCliVersion } from "../lib/version"; import { authLoginCommand } from "./auth/login"; import { authLogoutCommand } from "./auth/logout"; import { authWhoamiCommand } from "./auth/whoami"; -import { - authWorkspaceListCommand, - authWorkspaceLogoutCommand, - authWorkspaceUseCommand, -} from "./auth/workspace-commands"; -import { - telemetryDisableCommand, - telemetryEnableCommand, - telemetryStatusCommand, -} from "./telemetry/commands"; +import { authWorkspaceListCommand } from "./auth/workspace-list"; +import { authWorkspaceLogoutCommand } from "./auth/workspace-logout"; +import { authWorkspaceUseCommand } from "./auth/workspace-use"; +import { telemetryDisableCommand } from "./telemetry/disable"; +import { telemetryEnableCommand } from "./telemetry/enable"; +import { telemetryStatusCommand } from "./telemetry/status"; export function buildCli(): Cli { return createCli({ @@ -37,7 +34,7 @@ export function buildCli(): Cli { brief: "Inspect and change anonymous CLI telemetry", description: "Show telemetry status, or enable / disable anonymous CLI usage data.\n" + - "Telemetry is on by default (opt-out); see https://prisma-next.dev/docs/cli/telemetry\n" + + `Telemetry is on by default (opt-out); see ${CLI_DOCS_URL}\n` + "for what is collected and why.", }, }, diff --git a/packages/cli/src/v8/main.ts b/packages/cli/src/v8/main.ts index b607a418..52124f94 100644 --- a/packages/cli/src/v8/main.ts +++ b/packages/cli/src/v8/main.ts @@ -2,7 +2,7 @@ import type { Cli, CliRunHooks } from "@prisma/cli-engine"; import { maybeWriteCachedUpdateNotification } from "../update-check"; import { buildCli } from "./cli"; import { assembleRuntime, type HostProcess } from "./runtime"; -import { resolveTelemetryHooks } from "./telemetry/wiring"; +import { resolveTelemetryHooks } from "./telemetry/reporting"; /** The bin body: build, run, return the exit code. Signal policy lives * in the engine; the bin only forwards signals and provides diff --git a/packages/cli/src/v8/telemetry/consent.ts b/packages/cli/src/v8/telemetry/consent.ts new file mode 100644 index 00000000..a1cbf419 --- /dev/null +++ b/packages/cli/src/v8/telemetry/consent.ts @@ -0,0 +1,15 @@ +/** Presentation shared by the `telemetry enable|disable` consent + * commands: one confirmation line, echoed on stdout, with the raw + * decision as the json result. */ +import type { Presentations } from "@prisma/cli-engine"; + +export function consentPresentations( + line: string, + json: unknown, +): Presentations { + return { + human: () => [{ kind: "summary", tone: "ok", text: line }], + stdout: () => [line], + json: () => json, + }; +} diff --git a/packages/cli/src/v8/telemetry/disable.ts b/packages/cli/src/v8/telemetry/disable.ts new file mode 100644 index 00000000..58cc70f1 --- /dev/null +++ b/packages/cli/src/v8/telemetry/disable.ts @@ -0,0 +1,29 @@ +/** The `telemetry disable` command: stores the opt-out; mints + * nothing, sends nothing. */ +import { defineCommand } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { userConfigPath, writeUserConfig } from "@repo/cli-telemetry"; +import { consentPresentations } from "./consent"; + +export const telemetryDisableCommand = defineCommand({ + help: { + summary: "Disable anonymous CLI telemetry", + description: + 'Stores "enableTelemetry": false in your user-level config. No installation\n' + + "ID is minted and no event is sent.", + examples: ["telemetry disable"], + }, + handler: async (_args, ctx) => { + writeUserConfig({ enableTelemetry: false }); + const configPath = userConfigPath(); + return ok( + ctx.present( + { data: { enableTelemetry: false, configPath } }, + consentPresentations( + `Telemetry disabled. Preference stored in ${configPath}.`, + { enableTelemetry: false, configPath }, + ), + ), + ); + }, +}); diff --git a/packages/cli/src/v8/telemetry/enable.ts b/packages/cli/src/v8/telemetry/enable.ts new file mode 100644 index 00000000..73cfe505 --- /dev/null +++ b/packages/cli/src/v8/telemetry/enable.ts @@ -0,0 +1,29 @@ +/** The `telemetry enable` command: stores the opt-in and mints an + * installation id when none exists. */ +import { defineCommand } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { userConfigPath, writeUserConfig } from "@repo/cli-telemetry"; +import { consentPresentations } from "./consent"; + +export const telemetryEnableCommand = defineCommand({ + help: { + summary: "Enable anonymous CLI telemetry", + description: + 'Stores "enableTelemetry": true in your user-level config and mints an\n' + + "installation ID if one is not already stored.", + examples: ["telemetry enable"], + }, + handler: async (_args, ctx) => { + writeUserConfig({ enableTelemetry: true }); + const configPath = userConfigPath(); + return ok( + ctx.present( + { data: { enableTelemetry: true, configPath } }, + consentPresentations( + `Telemetry enabled. Preference stored in ${configPath}.`, + { enableTelemetry: true, configPath }, + ), + ), + ); + }, +}); diff --git a/packages/cli/src/v8/telemetry/reporting.ts b/packages/cli/src/v8/telemetry/reporting.ts index 78de7e80..72f4aa77 100644 --- a/packages/cli/src/v8/telemetry/reporting.ts +++ b/packages/cli/src/v8/telemetry/reporting.ts @@ -1,11 +1,18 @@ /** - * Bin-side telemetry wiring, sequenced like the ORM CLI's preAction - * wiring: resolve the CI/env/consent decision up front (cheap, all - * in-memory plus one tiny user-config read); only when enabled, - * attach an `onSettled` hook that spawns the detached sender via - * `runTelemetry` (fork + IPC send + disconnect + unref, every failure - * swallowed). When the decision comes back disabled, no hook is - * attached at all. + * Bin-side telemetry reporting: resolve the gating decision up front + * (cheap, all in-memory plus one tiny user-config read); only when + * enabled, attach an `onSettled` hook that spawns the detached sender + * via `runTelemetry` (fork + IPC send + disconnect + unref, every + * failure swallowed). When the decision comes back disabled, no hook + * is attached at all. + * + * Timing (operator-ratified): the first-run disclosure prints at + * gating time — pre-run, before any command output — while the event + * itself is emitted at settlement (`onSettled`). The ORM CLI emitted + * from a commander `preAction` hook; the consequence of the onSettled + * design is that a run that crashes, is SIGKILLed, or exits through + * `process.exit` before settlement emits no telemetry. Recorded in the + * S2a parity divergence list. */ import { fileURLToPath } from "node:url"; import type { CliRunHooks, HostProcess } from "@prisma/cli-engine"; @@ -19,6 +26,7 @@ import { type UserConfig, userConfigPath, } from "@repo/cli-telemetry"; +import { CLI_DOCS_URL, CLI_NAME } from "../../cli-name"; import { getCliVersion } from "../../lib/version"; import { isCI } from "./is-ci"; @@ -47,36 +55,13 @@ function resolveSenderPath(): string { function firstRunNotice(configPath: string): string { return [ "Prisma collects anonymous CLI usage data, enabled by default.", - "What's collected and why: https://prisma-next.dev/docs/cli/telemetry.", - 'Opt out: run "prisma telemetry disable", set DO_NOT_TRACK=1 or', + `What's collected and why: ${CLI_DOCS_URL}.`, + `Opt out: run "${CLI_NAME} telemetry disable", set DO_NOT_TRACK=1 or`, `PRISMA_NEXT_DISABLE_TELEMETRY=1, or set "enableTelemetry": false in ${configPath}.`, ].join(" "); } -/** - * Best-effort first-run disclosure + installationId mint. Runs only on - * the enabled path. Prints the notice to stderr (never stdout) and - * mints a persistent id without touching `enableTelemetry`, so the - * opt-out default stays intact and no unasked-for consent is recorded. - * - * Every step is wrapped so an un-writable config dir (or any other - * failure) never throws and never blocks the command. On mint failure - * it returns `undefined`: the notice may reprint next run, and - * `runTelemetry` no-ops on the missing id. - */ -function discloseAndMintOnFirstRun( - stderr: HostProcess["stderr"], -): string | undefined { - try { - stderr.write(`${firstRunNotice(userConfigPath())}\n`); - } catch {} - try { - return ensureInstallationId(); - } catch {} - return undefined; -} - -export interface TelemetryWiringOptions { +export interface TelemetryReportingOptions { /** CI decision override; defaults to `isCI()` (ci-info). */ readonly inCI?: boolean; /** Spawn seam for tests; defaults to `runTelemetry`. */ @@ -88,28 +73,34 @@ export interface TelemetryWiringOptions { /** * Resolve the telemetry decision for this process and return the hook * to attach — or `undefined` when telemetry is off (CI, env opt-out, - * or stored opt-out), so a disabled run carries no hook at all. + * or stored opt-out), so a disabled run carries no hook at all. On the + * enabled path with no stored installation id yet, the first-run + * disclosure prints to stderr HERE — before the command runs, so the + * user learns about collection before any output. * * The attached hook fires after settlement with the engine's * value-free snapshot. The `telemetry` command and its subcommands are * exempt — `telemetry disable` must not send a usage event before * disabling, and `telemetry status` must not mint an id while merely - * reporting state. On the enabled path with no stored id yet, the hook - * performs the first-run disclosure and mints the shared installation - * id before spawning the sender. + * reporting state. On the first enabled fire the hook mints the shared + * installation id (best-effort; a failed mint skips the event). */ export function resolveTelemetryHooks( proc: Pick, - options?: TelemetryWiringOptions, + options?: TelemetryReportingOptions, ): CliRunHooks | undefined { const inCI = options?.inCI ?? isCI(); - if (inCI) { - return undefined; - } const userConfig = readUserConfig(); - if (!resolveGating({ env: proc.env, config: userConfig }).enabled) { + if (!resolveGating({ env: proc.env, config: userConfig, inCI }).enabled) { return undefined; } + const storedId = userConfig.installationId; + const hasStoredId = typeof storedId === "string" && storedId.length > 0; + if (!hasStoredId) { + try { + proc.stderr.write(`${firstRunNotice(userConfigPath())}\n`); + } catch {} + } const fire = options?.fire ?? runTelemetry; return { onSettled: (summary) => { @@ -118,12 +109,15 @@ export function resolveTelemetryHooks( return; } let config: UserConfig = userConfig; - const storedId = config.installationId; - if (typeof storedId !== "string" || storedId.length === 0) { - const installationId = discloseAndMintOnFirstRun(proc.stderr); - if (installationId !== undefined) { - config = { ...config, installationId }; - } + if (!hasStoredId) { + // Best-effort mint of the persistent id, without touching + // `enableTelemetry` — the opt-out default stays intact and no + // unasked-for consent is recorded. On failure the notice may + // reprint next run, and `runTelemetry` no-ops on the missing + // id. + try { + config = { ...config, installationId: ensureInstallationId() }; + } catch {} } fire({ command: summary.snapshot, diff --git a/packages/cli/src/v8/telemetry/status.ts b/packages/cli/src/v8/telemetry/status.ts index 1c82e8fa..1ace25aa 100644 --- a/packages/cli/src/v8/telemetry/status.ts +++ b/packages/cli/src/v8/telemetry/status.ts @@ -1,20 +1,21 @@ +/** + * The `telemetry status` command and its status resolution. Ported + * from the ORM CLI's consent surface: a pure read (never sends, never + * mints, never writes). + */ +import { defineCommand, type Presentations } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; import { + type GatingDisabledReason, + type GatingEnabledReason, readUserConfig, resolveGating, userConfigPath, } from "@repo/cli-telemetry"; +import { isCI } from "./is-ci"; -/** - * Why telemetry resolves the way it does, in the order the shell's - * hook wiring evaluates: CI hard-disables first, then the env - * opt-outs, then the stored `enableTelemetry`, then the opt-out default. - */ -export type TelemetryStatusReason = - | "ci" - | "env-opt-out" - | "stored-opt-out" - | "stored-opt-in" - | "default-on"; +/** The gating resolver's total reason union, surfaced verbatim. */ +export type TelemetryStatusReason = GatingDisabledReason | GatingEnabledReason; export interface TelemetryStatus { readonly enabled: boolean; @@ -24,11 +25,11 @@ export interface TelemetryStatus { } /** - * Resolves the same decision the runtime wiring uses (CI check + - * `resolveGating`) and projects it into a user-facing status. Pure - * read: never mints, never writes. The `installationId` value itself - * is never surfaced — only its presence — so `status` discloses - * nothing identifying. + * Resolves the same decision the runtime wiring uses (`resolveGating`, + * CI included) and projects it into a user-facing status. Pure read: + * never mints, never writes. The `installationId` value itself is + * never surfaced — only its presence — so `status` discloses nothing + * identifying. */ export function resolveTelemetryStatus(inputs: { readonly env: Readonly>; @@ -39,23 +40,20 @@ export function resolveTelemetryStatus(inputs: { const installationIdStored = typeof config.installationId === "string" && config.installationId.length > 0; - - if (inputs.inCI) { - return { enabled: false, reason: "ci", configPath, installationIdStored }; - } - - const gating = resolveGating({ env: inputs.env, config }); - if (!gating.enabled) { - const reason: TelemetryStatusReason = - gating.reason === "env-override" ? "env-opt-out" : "stored-opt-out"; - return { enabled: false, reason, configPath, installationIdStored }; - } - - const reason: TelemetryStatusReason = - config.enableTelemetry === true ? "stored-opt-in" : "default-on"; - return { enabled: true, reason, configPath, installationIdStored }; + const gating = resolveGating({ + env: inputs.env, + config, + inCI: inputs.inCI, + }); + return { + enabled: gating.enabled, + reason: gating.reason, + configPath, + installationIdStored, + }; } +/** Projection of the gating reasons to user-facing copy. */ const REASON_EXPLANATION: Record = { ci: "CI environment detected — telemetry is hard-disabled.", "env-opt-out": @@ -76,3 +74,39 @@ export function formatTelemetryStatusLines(status: TelemetryStatus): string[] { `Installation ID: ${status.installationIdStored ? "stored" : "not stored"}`, ]; } + +function statusPresentations(status: TelemetryStatus): Presentations { + return { + human: () => [ + { kind: "summary", tone: "info", text: statusSummaryLine(status) }, + { + kind: "fields", + rows: [ + { label: "Config file", value: status.configPath }, + { + label: "Installation ID", + value: status.installationIdStored ? "stored" : "not stored", + }, + ], + }, + ], + stdout: () => formatTelemetryStatusLines(status), + json: () => status, + }; +} + +export const telemetryStatusCommand = defineCommand({ + help: { + summary: "Show whether anonymous CLI telemetry is enabled and why", + description: + "Reports whether telemetry is currently enabled or disabled and the reason\n" + + "(default-on, stored opt-out, environment opt-out, or CI), the path to your\n" + + "user-level config file, and whether an installation ID has been stored.\n" + + "Read-only: never sends an event, never mints an ID, never writes anything.", + examples: ["telemetry status", "telemetry status --json"], + }, + handler: async (_args, ctx) => { + const status = resolveTelemetryStatus({ env: ctx.env, inCI: isCI() }); + return ok(ctx.present({ data: status }, statusPresentations(status))); + }, +}); diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index b25ed9f0..8e30fba6 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -6,12 +6,13 @@ import { createTestCli } from "@prisma/cli-engine/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { - listRealAuthWorkspaces, - logoutRealAuthWorkspace, + EmptyServiceTokenError, + listAuthWorkspaces, + logoutAuthWorkspace, performLogin, performLogout, readAuthState, - useRealAuthWorkspace, + useAuthWorkspace, } from "../src/auth"; import { workspaceAmbiguousError, @@ -24,20 +25,18 @@ import type { import { authLoginCommand } from "../src/v8/auth/login"; import { authLogoutCommand } from "../src/v8/auth/logout"; import { authWhoamiCommand } from "../src/v8/auth/whoami"; -import { - authWorkspaceListCommand, - authWorkspaceLogoutCommand, - authWorkspaceUseCommand, -} from "../src/v8/auth/workspace-commands"; +import { authWorkspaceListCommand } from "../src/v8/auth/workspace-list"; +import { authWorkspaceLogoutCommand } from "../src/v8/auth/workspace-logout"; +import { authWorkspaceUseCommand } from "../src/v8/auth/workspace-use"; vi.mock("../src/auth", async (importOriginal) => ({ ...(await importOriginal()), performLogin: vi.fn(), performLogout: vi.fn(), readAuthState: vi.fn(), - listRealAuthWorkspaces: vi.fn(), - useRealAuthWorkspace: vi.fn(), - logoutRealAuthWorkspace: vi.fn(), + listAuthWorkspaces: vi.fn(), + useAuthWorkspace: vi.fn(), + logoutAuthWorkspace: vi.fn(), })); const SIGNED_OUT: AuthStateResult = { @@ -146,9 +145,9 @@ beforeEach(() => { vi.mocked(performLogin).mockReset(); vi.mocked(performLogout).mockReset(); vi.mocked(readAuthState).mockReset(); - vi.mocked(listRealAuthWorkspaces).mockReset(); - vi.mocked(useRealAuthWorkspace).mockReset(); - vi.mocked(logoutRealAuthWorkspace).mockReset(); + vi.mocked(listAuthWorkspaces).mockReset(); + vi.mocked(useAuthWorkspace).mockReset(); + vi.mocked(logoutAuthWorkspace).mockReset(); }); describe("prisma-v8 auth login", () => { @@ -181,9 +180,11 @@ describe("prisma-v8 auth login", () => { outcome: "ok", }, ]); - expect(result.stdout).toBe( - "status: signed in\nuser: bob@example.com\nworkspace: Acme Inc\n", - ); + expect(result.presented?.presentation.stdout).toEqual([ + "status: signed in", + "user: bob@example.com", + "workspace: Acme Inc", + ]); expect(result.stderr).toContain( "ℹ Starting an authenticated CLI session.\n", ); @@ -239,6 +240,78 @@ describe("prisma-v8 auth login", () => { expect(result.stderr).not.toContain("Install Prisma skills"); }); + it("suppresses the agent-setup tip when Prisma skills are already installed", async () => { + vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); + const cwd = await emptyTempCwd(); + await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); + await writeFile( + path.join(cwd, "skills-lock.json"), + JSON.stringify({ sources: ["prisma/skills"] }), + "utf8", + ); + + const result = await makeCli().run(["auth", "login"], { + isTty: { stdout: true }, + cwd, + env: { PRISMA_CLI_STATE_DIR: path.join(cwd, ".state") }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("Install Prisma skills"); + expect( + result.presented?.presentation.next.some( + (action) => action.label === "Install Prisma skills for this project", + ), + ).toBe(false); + }); + + it("carries the agent-setup tip in the json envelope (result field + nextAction)", async () => { + vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); + const cwd = await emptyTempCwd(); + await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); + + const result = await makeCli().run(["auth", "login", "--json"], { + cwd, + env: { PRISMA_CLI_STATE_DIR: path.join(cwd, ".state") }, + }); + + expect(result.exitCode).toBe(0); + const frame = resultFrame(result.json); + if (!frame.envelope.ok) { + throw new Error("expected a completed envelope"); + } + expect(frame.envelope.result).toMatchObject({ + agentSetupTip: { command: expect.stringContaining("agent install") }, + }); + expect(frame.envelope.nextActions.at(-1)).toMatchObject({ + kind: "run-command", + label: "Install Prisma skills for this project", + command: expect.stringContaining("agent install"), + }); + }); + + it("maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID, exit 2", async () => { + vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockRejectedValue(new EmptyServiceTokenError()); + + const result = await makeCli().run(["auth", "login", "--json"], { + cwd: await emptyTempCwd(), + }); + + expect(result.exitCode).toBe(2); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: false, + commandId: "auth.login", + error: { + code: "AUTH.CONFIG_INVALID", + summary: "Authentication configuration is invalid", + }, + }); + }); + it("streams the flow events and the raw auth state envelope in json mode", async () => { vi.mocked(performLogin).mockImplementation( async (_env, _signal, options) => { @@ -314,13 +387,36 @@ describe("prisma-v8 auth logout", () => { expect(result.exitCode).toBe(0); expect(vi.mocked(performLogout)).toHaveBeenCalledTimes(1); - expect(result.stderr).toBe( - "ℹ Clearing the current CLI session.\n" + - "session: local CLI state\n" + - "✔ Session removed from local CLI state.\n" + - "→ Sign in: prisma-cli auth login\n", - ); - expect(result.stdout).toBe("status: signed out\n"); + expect(result.stderr).toContain("Clearing the current CLI session."); + expect(result.stderr).toContain("Session removed from local CLI state."); + expect(result.presented?.presentation.next).toEqual([ + { + kind: "run-command", + label: "Sign in", + command: "prisma-cli auth login", + }, + ]); + expect(result.presented?.presentation.stdout).toEqual([ + "status: signed out", + ]); + }); + + it("maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID, exit 2", async () => { + vi.mocked(performLogout).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockRejectedValue(new EmptyServiceTokenError()); + + const result = await makeCli().run(["auth", "logout", "--json"]); + + expect(result.exitCode).toBe(2); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: false, + commandId: "auth.logout", + error: { + code: "AUTH.CONFIG_INVALID", + summary: "Authentication configuration is invalid", + }, + }); }); it("carries the post-logout auth state as the json envelope result", async () => { @@ -339,7 +435,7 @@ describe("prisma-v8 auth logout", () => { }); it("--workspace runs the shared workspace-logout operation with its presentation", async () => { - vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + vi.mocked(logoutAuthWorkspace).mockResolvedValue({ workspace: { id: "ws_2", name: "Globex" }, wasActive: false, activeWorkspace: { id: "ws_1", name: "Acme Inc" }, @@ -352,21 +448,29 @@ describe("prisma-v8 auth logout", () => { expect(result.exitCode).toBe(0); expect(vi.mocked(performLogout)).not.toHaveBeenCalled(); - expect(vi.mocked(logoutRealAuthWorkspace)).toHaveBeenCalledWith( + expect(vi.mocked(logoutAuthWorkspace)).toHaveBeenCalledWith( expect.anything(), "Globex", ); - expect(result.stderr).toBe( - "ℹ Removing a local OAuth workspace session.\n" + - "workspace: Globex\n" + - "active: Acme Inc\n" + - "✔ Removed workspace session.\n" + - "→ List authenticated workspaces: prisma-cli auth workspace list\n", + expect(result.stderr).toContain( + "Removing a local OAuth workspace session.", ); + expect(result.stderr).toContain("Removed workspace session."); + expect(result.presented?.presentation.stdout).toEqual([ + "workspace: Globex", + "active: Acme Inc", + ]); + expect(result.presented?.presentation.next).toEqual([ + { + kind: "run-command", + label: "List authenticated workspaces", + command: "prisma-cli auth workspace list", + }, + ]); }); it("--workspace reports the mounted command id auth.logout in json mode", async () => { - vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + vi.mocked(logoutAuthWorkspace).mockResolvedValue({ workspace: { id: "ws_2", name: "Globex" }, wasActive: false, activeWorkspace: null, @@ -389,7 +493,7 @@ describe("prisma-v8 auth logout", () => { }); it("--workspace maps a missing workspace to AUTH.WORKSPACE_NOT_AUTHENTICATED, exit 2", async () => { - vi.mocked(logoutRealAuthWorkspace).mockRejectedValue( + vi.mocked(logoutAuthWorkspace).mockRejectedValue( workspaceNotAuthenticatedError("nope"), ); @@ -418,40 +522,53 @@ describe("prisma-v8 auth logout", () => { describe("prisma-v8 auth workspace list", () => { it("renders the workspace table without the source column for a single source", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); const result = await makeCli().run(["auth", "workspace", "list"], { isTty: { stdout: true }, }); expect(result.exitCode).toBe(0); - expect(result.stderr).toBe( - "ℹ Listing authenticated workspaces on this machine.\n" + - "auth source: local OAuth\n" + - "name id status\n" + - "Acme Inc ws_1 active\n" + - "Globex ws_2 \n", + const table = result.presented?.presentation.human.find( + (block) => block.kind === "table", ); - expect(result.stdout).toBe("Acme Inc ws_1 active\nGlobex ws_2\n"); + expect(table).toEqual({ + kind: "table", + columns: ["name", "id", "status"], + rows: [ + ["Acme Inc", "ws_1", "active"], + ["Globex", "ws_2", ""], + ], + }); + expect(result.presented?.presentation.stdout).toEqual([ + "Acme Inc ws_1 active", + "Globex ws_2", + ]); }); it("adds the source column only when sources are mixed", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(MIXED_SOURCES); + vi.mocked(listAuthWorkspaces).mockResolvedValue(MIXED_SOURCES); const result = await makeCli().run(["auth", "workspace", "list"], { isTty: { stdout: true }, }); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("name id source status\n"); - expect(result.stderr).toContain( - "Token WS ws_tok service token active\n", + const table = result.presented?.presentation.human.find( + (block) => block.kind === "table", ); - expect(result.stderr).toContain("Acme Inc ws_1 OAuth \n"); + expect(table).toEqual({ + kind: "table", + columns: ["name", "id", "source", "status"], + rows: [ + ["Token WS", "ws_tok", "service token", "active"], + ["Acme Inc", "ws_1", "OAuth", ""], + ], + }); }); it("serializes the ported list shape in json mode", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); const result = await makeCli().run(["auth", "workspace", "list", "--json"]); @@ -490,8 +607,27 @@ describe("prisma-v8 auth workspace list", () => { }); }); + it("maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID, exit 2 (parity with whoami/login/logout)", async () => { + vi.mocked(listAuthWorkspaces).mockRejectedValue( + new EmptyServiceTokenError(), + ); + + const result = await makeCli().run(["auth", "workspace", "list", "--json"]); + + expect(result.exitCode).toBe(2); + const frame = resultFrame(result.json); + expect(frame.envelope).toMatchObject({ + ok: false, + commandId: "auth.workspace.list", + error: { + code: "AUTH.CONFIG_INVALID", + summary: "Authentication configuration is invalid", + }, + }); + }); + it("shows the empty state with the sign-in follow-up while signed out", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(EMPTY_LIST); + vi.mocked(listAuthWorkspaces).mockResolvedValue(EMPTY_LIST); const result = await makeCli().run(["auth", "workspace", "list"], { isTty: { stdout: true }, @@ -506,7 +642,7 @@ describe("prisma-v8 auth workspace list", () => { describe("prisma-v8 auth workspace use", () => { it("switches by explicit ref and renders the mutation card", async () => { - vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + vi.mocked(useAuthWorkspace).mockResolvedValue({ previousWorkspace: { id: "ws_1", name: "Acme Inc" }, workspace: { id: "ws_2", name: "Globex" }, }); @@ -516,23 +652,25 @@ describe("prisma-v8 auth workspace use", () => { }); expect(result.exitCode).toBe(0); - expect(vi.mocked(useRealAuthWorkspace)).toHaveBeenCalledWith( + expect(vi.mocked(useAuthWorkspace)).toHaveBeenCalledWith( expect.anything(), "ws_2", ); - expect(result.stderr).toBe( - "ℹ Switching the local CLI workspace.\n" + - "previous: Acme Inc\n" + - "workspace: Globex\n" + - "✔ Local OAuth workspace selection updated.\n" + - "→ Show the signed-in identity: prisma-cli auth whoami\n" + - "→ List projects: prisma-cli project list\n", - ); - expect(result.stdout).toBe("previous: Acme Inc\nworkspace: Globex\n"); + expect(result.stderr).toContain("Switching the local CLI workspace."); + expect(result.stderr).toContain("Local OAuth workspace selection updated."); + expect(result.presented?.presentation.stdout).toEqual([ + "previous: Acme Inc", + "workspace: Globex", + ]); + expect( + result.presented?.presentation.next.map((action) => + action.kind === "run-command" ? action.command : action.label, + ), + ).toEqual(["prisma-cli auth whoami", "prisma-cli project list"]); }); it("carries the raw use result in the json envelope", async () => { - vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + vi.mocked(useAuthWorkspace).mockResolvedValue({ previousWorkspace: null, workspace: { id: "ws_2", name: "Globex" }, }); @@ -558,7 +696,7 @@ describe("prisma-v8 auth workspace use", () => { }); it("maps an ambiguous name to AUTH.WORKSPACE_AMBIGUOUS with the match list, exit 2", async () => { - vi.mocked(useRealAuthWorkspace).mockRejectedValue( + vi.mocked(useAuthWorkspace).mockRejectedValue( workspaceAmbiguousError("Acme Inc", [ { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, @@ -609,11 +747,11 @@ describe("prisma-v8 auth workspace use", () => { expect(result.stderr).toContain( "✖ [AUTH.WORKSPACE_SWITCH_UNAVAILABLE] Workspace switching is unavailable\n", ); - expect(vi.mocked(listRealAuthWorkspaces)).not.toHaveBeenCalled(); + expect(vi.mocked(listAuthWorkspaces)).not.toHaveBeenCalled(); }); it("fails with AUTH.USAGE_ERROR when no switchable workspaces exist", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(EMPTY_LIST); + vi.mocked(listAuthWorkspaces).mockResolvedValue(EMPTY_LIST); const result = await makeCli().run(["auth", "workspace", "use"], { isTty: { stdout: true }, @@ -626,11 +764,11 @@ describe("prisma-v8 auth workspace use", () => { }); it("auto-selects the only switchable workspace without prompting", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue({ + vi.mocked(listAuthWorkspaces).mockResolvedValue({ ...TWO_OAUTH_WORKSPACES, workspaces: [TWO_OAUTH_WORKSPACES.workspaces[0]], }); - vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + vi.mocked(useAuthWorkspace).mockResolvedValue({ previousWorkspace: null, workspace: { id: "ws_1", name: "Acme Inc" }, }); @@ -640,15 +778,15 @@ describe("prisma-v8 auth workspace use", () => { }); expect(result.exitCode).toBe(0); - expect(vi.mocked(useRealAuthWorkspace)).toHaveBeenCalledWith( + expect(vi.mocked(useAuthWorkspace)).toHaveBeenCalledWith( expect.anything(), "ws_1", ); }); it("prompts a select over the workspaces and switches to the answer", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); - vi.mocked(useRealAuthWorkspace).mockResolvedValue({ + vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + vi.mocked(useAuthWorkspace).mockResolvedValue({ previousWorkspace: { id: "ws_1", name: "Acme Inc" }, workspace: { id: "ws_2", name: "Globex" }, }); @@ -659,14 +797,14 @@ describe("prisma-v8 auth workspace use", () => { }); expect(result.exitCode).toBe(0); - expect(vi.mocked(useRealAuthWorkspace)).toHaveBeenCalledWith( + expect(vi.mocked(useAuthWorkspace)).toHaveBeenCalledWith( expect.anything(), "ws_2", ); }); it("fails an invalid select answer with CLI.PROMPT_INVALID, exit 2", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); const result = await makeCli().run(["auth", "workspace", "use"], { isTty: { stdout: true, stdin: true }, @@ -675,11 +813,11 @@ describe("prisma-v8 auth workspace use", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain("[CLI.PROMPT_INVALID]"); - expect(vi.mocked(useRealAuthWorkspace)).not.toHaveBeenCalled(); + expect(vi.mocked(useAuthWorkspace)).not.toHaveBeenCalled(); }); it("fails non-interactively with the engine's structural prompt error, exit 2", async () => { - vi.mocked(listRealAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); const result = await makeCli().run(["auth", "workspace", "use"], { isTty: { stdout: true, stdin: false }, @@ -687,13 +825,13 @@ describe("prisma-v8 auth workspace use", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain("[CLI.PROMPT_REQUIRED]"); - expect(vi.mocked(useRealAuthWorkspace)).not.toHaveBeenCalled(); + expect(vi.mocked(useAuthWorkspace)).not.toHaveBeenCalled(); }); }); describe("prisma-v8 auth workspace logout", () => { it("removes a non-active session and keeps the active workspace", async () => { - vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + vi.mocked(logoutAuthWorkspace).mockResolvedValue({ workspace: { id: "ws_2", name: "Globex" }, wasActive: false, activeWorkspace: { id: "ws_1", name: "Acme Inc" }, @@ -705,18 +843,25 @@ describe("prisma-v8 auth workspace logout", () => { ); expect(result.exitCode).toBe(0); - expect(result.stderr).toBe( - "ℹ Removing a local OAuth workspace session.\n" + - "workspace: Globex\n" + - "active: Acme Inc\n" + - "✔ Removed workspace session.\n" + - "→ List authenticated workspaces: prisma-cli auth workspace list\n", + expect(result.stderr).toContain( + "Removing a local OAuth workspace session.", ); - expect(result.stdout).toBe("workspace: Globex\nactive: Acme Inc\n"); + expect(result.stderr).toContain("Removed workspace session."); + expect(result.presented?.presentation.stdout).toEqual([ + "workspace: Globex", + "active: Acme Inc", + ]); + expect(result.presented?.presentation.next).toEqual([ + { + kind: "run-command", + label: "List authenticated workspaces", + command: "prisma-cli auth workspace list", + }, + ]); }); it("reports the was-active removal with no auto-fallthrough and the use follow-up", async () => { - vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + vi.mocked(logoutAuthWorkspace).mockResolvedValue({ workspace: { id: "ws_1", name: "Acme Inc" }, wasActive: true, activeWorkspace: null, @@ -738,7 +883,7 @@ describe("prisma-v8 auth workspace logout", () => { }); it("carries the raw logout result in the json envelope", async () => { - vi.mocked(logoutRealAuthWorkspace).mockResolvedValue({ + vi.mocked(logoutAuthWorkspace).mockResolvedValue({ workspace: { id: "ws_2", name: "Globex" }, wasActive: false, activeWorkspace: { id: "ws_1", name: "Acme Inc" }, @@ -766,7 +911,7 @@ describe("prisma-v8 auth workspace logout", () => { }); it("maps an ambiguous name to AUTH.WORKSPACE_AMBIGUOUS, exit 2", async () => { - vi.mocked(logoutRealAuthWorkspace).mockRejectedValue( + vi.mocked(logoutAuthWorkspace).mockRejectedValue( workspaceAmbiguousError("Acme Inc", [ { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, @@ -793,6 +938,6 @@ describe("prisma-v8 auth workspace logout", () => { expect(result.stderr).toContain( "✖ [AUTH.USAGE_ERROR] Workspace required\n", ); - expect(vi.mocked(logoutRealAuthWorkspace)).not.toHaveBeenCalled(); + expect(vi.mocked(logoutAuthWorkspace)).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/tests/v8-bin.test.ts b/packages/cli/tests/v8-bin.test.ts index a6bbd403..55b4d46a 100644 --- a/packages/cli/tests/v8-bin.test.ts +++ b/packages/cli/tests/v8-bin.test.ts @@ -36,7 +36,10 @@ function makeProcess(overrides?: { const exitedWith: number[] = []; const proc = { argv: overrides?.argv ?? ["node", "bin.js"], - env: overrides?.env ?? {}, + // Telemetry env opt-out so main()'s gating resolution stays inert + // (no first-run notice on stderr, no dependence on the developer's + // real user config). + env: { PRISMA_NEXT_DISABLE_TELEMETRY: "1", ...overrides?.env }, cwd: () => "/tmp/v8-bin-test-cwd", listeners, exitedWith, diff --git a/packages/cli/tests/v8-golden-rendering.test.ts b/packages/cli/tests/v8-golden-rendering.test.ts new file mode 100644 index 00000000..e921d38c --- /dev/null +++ b/packages/cli/tests/v8-golden-rendering.test.ts @@ -0,0 +1,141 @@ +/** + * The sanctioned golden-rendering suite (S2 ruling: byte-exact pins + * live here, one representative per rendering surface — card, table, + * error). Every other v8 test asserts semantically (envelope / + * presented / events / exit code); when the engine's rendering style + * changes deliberately, THIS file is the one place the new bytes get + * re-pinned. The S1 whoami byte pins in v8-whoami.test.ts remain the + * whoami-specific baseline. + */ +import { createTestCli } from "@prisma/cli-engine/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + listAuthWorkspaces, + logoutAuthWorkspace, + performLogout, + readAuthState, +} from "../src/auth"; +import { workspaceAmbiguousError } from "../src/shell/errors"; +import { authLogoutCommand } from "../src/v8/auth/logout"; +import { authWorkspaceListCommand } from "../src/v8/auth/workspace-list"; +import { authWorkspaceLogoutCommand } from "../src/v8/auth/workspace-logout"; + +vi.mock("../src/auth", async (importOriginal) => ({ + ...(await importOriginal()), + performLogout: vi.fn(), + readAuthState: vi.fn(), + listAuthWorkspaces: vi.fn(), + logoutAuthWorkspace: vi.fn(), +})); + +function makeCli() { + return createTestCli({ + commands: { + "auth logout": authLogoutCommand, + "auth workspace list": authWorkspaceListCommand, + "auth workspace logout": authWorkspaceLogoutCommand, + }, + groups: { + auth: { brief: "Manage local authentication for the CLI" }, + "auth workspace": { brief: "Manage local workspace sessions" }, + }, + now: () => new Date(0), + }); +} + +beforeEach(() => { + vi.mocked(performLogout).mockReset(); + vi.mocked(readAuthState).mockReset(); + vi.mocked(listAuthWorkspaces).mockReset(); + vi.mocked(logoutAuthWorkspace).mockReset(); +}); + +describe("v8 golden rendering", () => { + it("human card (representative: auth logout)", async () => { + vi.mocked(performLogout).mockResolvedValue(undefined); + vi.mocked(readAuthState).mockResolvedValue({ + authenticated: false, + provider: null, + user: null, + workspace: null, + credential: null, + }); + + const result = await makeCli().run(["auth", "logout"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe( + "ℹ Clearing the current CLI session.\n" + + "session: local CLI state\n" + + "✔ Session removed from local CLI state.\n" + + "→ Sign in: prisma-cli auth login\n", + ); + expect(result.stdout).toBe("status: signed out\n"); + }); + + it("table (representative: auth workspace list)", async () => { + vi.mocked(listAuthWorkspaces).mockResolvedValue({ + authSource: "oauth", + activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + workspaces: [ + { + id: "ws_1", + name: "Acme Inc", + credentialWorkspaceId: "cred_1", + active: true, + source: "oauth", + switchable: true, + lastSeenAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "ws_2", + name: "Globex", + credentialWorkspaceId: "cred_2", + active: false, + source: "oauth", + switchable: true, + lastSeenAt: null, + }, + ], + }); + + const result = await makeCli().run(["auth", "workspace", "list"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe( + "ℹ Listing authenticated workspaces on this machine.\n" + + "auth source: local OAuth\n" + + "name id status\n" + + "Acme Inc ws_1 active\n" + + "Globex ws_2 \n", + ); + expect(result.stdout).toBe("Acme Inc ws_1 active\nGlobex ws_2\n"); + }); + + it("error (representative: AUTH.WORKSPACE_AMBIGUOUS)", async () => { + vi.mocked(logoutAuthWorkspace).mockRejectedValue( + workspaceAmbiguousError("Acme Inc", [ + { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, + { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, + ]), + ); + + const result = await makeCli().run( + ["auth", "workspace", "logout", "Acme Inc"], + { isTty: { stdout: true } }, + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe( + "✖ [AUTH.WORKSPACE_AMBIGUOUS] Workspace name is ambiguous\n" + + ' why: Multiple authenticated workspaces matched "Acme Inc".\n' + + "→ Run prisma-cli auth workspace list and switch by workspace id.\n", + ); + expect(result.stdout).toBe(""); + }); +}); diff --git a/packages/cli/tests/v8-telemetry-reporting.test.ts b/packages/cli/tests/v8-telemetry-reporting.test.ts index 4ae22743..35722bfc 100644 --- a/packages/cli/tests/v8-telemetry-reporting.test.ts +++ b/packages/cli/tests/v8-telemetry-reporting.test.ts @@ -9,7 +9,7 @@ import { } from "@repo/cli-telemetry"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveTelemetryHooks } from "../src/v8/telemetry/wiring"; +import { resolveTelemetryHooks } from "../src/v8/telemetry/reporting"; const V4_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; @@ -133,7 +133,28 @@ describe("resolveTelemetryHooks — the attached hook", () => { expect(proc.stderrText).toBe(""); }); - it("discloses on stderr and mints the shared installation id on the first enabled run", () => { + it("discloses on stderr at resolve time — pre-run, before any command output", () => { + const proc = makeProc(); + resolveTelemetryHooks(proc, { + inCI: false, + fire: vi.fn(), + senderPath: "/sender/path.js", + }); + + // The notice printed without any settlement having happened. + expect(proc.stderrText).toContain( + "Prisma collects anonymous CLI usage data, enabled by default.", + ); + expect(proc.stderrText).toContain( + "https://www.prisma.io/docs/orm/tools/prisma-cli", + ); + expect(proc.stderrText).toContain('"prisma-cli telemetry disable"'); + expect(proc.stderrText).toContain(userConfigPath()); + // Nothing minted yet: disclosure is print-only. + expect(readUserConfig().installationId).toBeUndefined(); + }); + + it("mints the shared installation id at the first settlement", () => { const fire = vi.fn().mockReturnValue({ spawned: true }); const proc = makeProc(); const hooks = resolveTelemetryHooks(proc, { @@ -144,10 +165,6 @@ describe("resolveTelemetryHooks — the attached hook", () => { hooks?.onSettled?.(makeSummary()); - expect(proc.stderrText).toContain( - "Prisma collects anonymous CLI usage data, enabled by default.", - ); - expect(proc.stderrText).toContain(userConfigPath()); const stored = readUserConfig(); expect(stored.installationId).toMatch(V4_UUID); // The mint records no consent the user never gave. @@ -156,6 +173,26 @@ describe("resolveTelemetryHooks — the attached hook", () => { expect(inputs.userConfig?.installationId).toBe(stored.installationId); }); + it("never mints on a telemetry-family settlement (status must keep reporting 'not stored')", () => { + const fire = vi.fn(); + const proc = makeProc(); + const hooks = resolveTelemetryHooks(proc, { inCI: false, fire }); + + hooks?.onSettled?.( + makeSummary({ + commandId: "telemetry.status", + snapshot: { + commandPath: ["telemetry", "status"], + flags: [], + positionalCount: 0, + }, + }), + ); + + expect(fire).not.toHaveBeenCalled(); + expect(readUserConfig().installationId).toBeUndefined(); + }); + it("never fires for the telemetry command family itself", () => { writeFileSync( userConfigPath(), diff --git a/packages/cli/tests/v8-telemetry.test.ts b/packages/cli/tests/v8-telemetry.test.ts index 4bd3ef0d..5824b28e 100644 --- a/packages/cli/tests/v8-telemetry.test.ts +++ b/packages/cli/tests/v8-telemetry.test.ts @@ -10,12 +10,10 @@ import { dirname, join } from "node:path"; import { createTestCli } from "@prisma/cli-engine/testing"; import { readUserConfig, userConfigPath } from "@repo/cli-telemetry"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - telemetryDisableCommand, - telemetryEnableCommand, - telemetryStatusCommand, -} from "../src/v8/telemetry/commands"; +import { telemetryDisableCommand } from "../src/v8/telemetry/disable"; +import { telemetryEnableCommand } from "../src/v8/telemetry/enable"; import { isCI } from "../src/v8/telemetry/is-ci"; +import { telemetryStatusCommand } from "../src/v8/telemetry/status"; vi.mock("../src/v8/telemetry/is-ci", () => ({ isCI: vi.fn(() => false) })); diff --git a/packages/cli/tests/v8-update-check.test.ts b/packages/cli/tests/v8-update-check.test.ts index 37f1af5d..369a7cde 100644 --- a/packages/cli/tests/v8-update-check.test.ts +++ b/packages/cli/tests/v8-update-check.test.ts @@ -25,7 +25,9 @@ function makeProcess(overrides: { }): HostProcess & { stdoutText: string; stderrText: string } { const proc = { argv: overrides.argv ?? ["node", "bin.js", "auth", "whoami"], - env: overrides.env ?? {}, + // Keep main()'s telemetry gating inert — these tests pin the + // update-check wiring only. + env: { PRISMA_NEXT_DISABLE_TELEMETRY: "1", ...overrides.env }, cwd: () => "/tmp/v8-update-check-cwd", stdoutText: "", stderrText: "", @@ -97,6 +99,41 @@ describe("v8 main update-check wiring", () => { expect(proc.stdoutText).toBe(""); }); + it("writes the notice before the command dispatches (ordering, not just presence)", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + await seedStaleUpdate(updateCheckDir); + const proc = makeProcess({ env: updateCheckEnv(updateCheckDir) }); + + const exitCode = await main(proc, () => ({ + run: async () => { + proc.stderr.write("COMMAND-DISPATCH-MARKER\n"); + return 0; + }, + })); + + expect(exitCode).toBe(0); + const noticeAt = proc.stderrText.indexOf("Update available"); + const dispatchAt = proc.stderrText.indexOf("COMMAND-DISPATCH-MARKER"); + expect(noticeAt).toBeGreaterThanOrEqual(0); + expect(dispatchAt).toBeGreaterThan(noticeAt); + }); + + it("still notifies under the v8 spelling --format json (literal-argv quirk, copied from legacy)", async () => { + const updateCheckDir = await makeUpdateCheckDir(); + await seedStaleUpdate(updateCheckDir); + const proc = makeProcess({ + argv: ["node", "bin.js", "auth", "whoami", "--format", "json"], + env: updateCheckEnv(updateCheckDir), + }); + + await main(proc, stubCli); + + // The suppression check matches the literal tokens --json/--quiet/-q + // only; the v8 spelling `--format json` is NOT suppressed. Recorded + // in the S2a parity divergence list. + expect(proc.stderrText).toContain("Update available"); + }); + it("stays silent inside the notification interval", async () => { const updateCheckDir = await makeUpdateCheckDir(); await seedStaleUpdate(updateCheckDir); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 00000000..4defbd07 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + // Suite-wide telemetry guard, mirroring the reference repo's + // repo-wide setting and cli-telemetry's own config: any test that + // reaches production wiring reading the real `process.env` (the v8 + // bin's main(), resolveTelemetryHooks defaults) resolves gating + // disabled instead of printing a first-run notice, minting into + // the developer's real user config, or forking the sender toward + // the real endpoint. Tests that exercise the enabled path pass an + // explicit env object, which this does not touch. + env: { + PRISMA_NEXT_DISABLE_TELEMETRY: "1", + }, + }, +}); From 3453108686e13713e175159c0b7e7e88b97ab083 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 03:31:48 +0200 Subject: [PATCH 16/67] docs(drive): record review-round divergences and contract errata for s2a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-process-sanctioned S2 doc amendments from the review round: - parity-divergences.md: workspace list joins the AUTH_CONFIG_INVALID mapping row; new sections for the empty-service-token structuring and the two telemetry divergences (config enrichment dropped; onSettled emission timing with pre-run disclosure — crashed/killed/process.exit runs emit nothing). - s2a-foundations.md §3: the workspace operation exports are the *AuthWorkspaces names, with a one-line erratum; §6: CI is part of the gating resolution, and the wording separates spawn semantics (copied) from timing (onSettled by design, pre-run disclosure). - s2d-init-and-retirement.md R-S2d-4: known survivors named — src/state-dir.ts and the CliError base class residue behind src/auth/errors.ts and the v8 error mapping. - s2b-resources.md D1: build-time test requirement that the family maps and the mount map cover the same command set. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/s2/parity-divergences.md | 25 +++++++++++++++++- .../prisma-cli-v8/plans/s2b-resources.md | 4 +++ .../prisma-cli-v8/specs/s2a-foundations.md | 26 +++++++++++++------ .../specs/s2d-init-and-retirement.md | 10 ++++++- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index 10f88e33..7a0d647b 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -25,7 +25,7 @@ whoami precedent), while `meta` is preserved verbatim. | Legacy flat code (exit) | v8 dotted code (exit) | Commands | | --- | --- | --- | -| `AUTH_CONFIG_INVALID` (1) | `AUTH.CONFIG_INVALID` (2) | whoami, login, logout | +| `AUTH_CONFIG_INVALID` (1) | `AUTH.CONFIG_INVALID` (2) | whoami, login, logout, workspace list | | `WORKSPACE_SWITCH_UNAVAILABLE` (1) | `AUTH.WORKSPACE_SWITCH_UNAVAILABLE` (2) | workspace use | | `WORKSPACE_NOT_AUTHENTICATED` (1) | `AUTH.WORKSPACE_NOT_AUTHENTICATED` (2) | workspace use, workspace logout, logout --workspace | | `WORKSPACE_AMBIGUOUS` (2) | `AUTH.WORKSPACE_AMBIGUOUS` (2) | workspace use, workspace logout, logout --workspace | @@ -129,6 +129,29 @@ No documented 4–99 codes exist in this family. alias). Same for `--version`, CI, non-TTY stderr, and `NO_UPDATE_NOTIFIER`. +### `auth workspace list` — empty service token + +- An empty/blank `PRISMA_SERVICE_TOKEN` now errors as + `AUTH.CONFIG_INVALID` (exit 2), matching whoami/login/logout. Legacy + let the raw `EmptyServiceTokenError` crash unstructured. + +### Telemetry (§6) + +- **Config enrichment dropped.** The ORM CLI's detached sender loaded + `prisma-next.config.*` via c12 (evaluating arbitrary user TS in the + child) to derive the `databaseTarget` and `extensions` event fields. + That config file does not exist in this product, so the load was + removed: `databaseTarget` ships `null` (unless a parent-side override + is supplied on the wire, kept for compatibility) and `extensions` + ships `[]`, always. The wire shape is unchanged. +- **Emission timing.** The ORM CLI emitted from a commander `preAction` + hook, before the command body ran. v8 emits at settlement + (`onSettled`, by design) with the first-run disclosure printed + pre-run, before the command's output. Consequence: a run that + crashes, is SIGKILLed, or leaves through `process.exit` before + settlement emits NO telemetry event, where the reference emitted one + before the command started. + ### Test surface - `tests/auth.test.ts` fixture-mode cases covering the six ported diff --git a/.drive/projects/prisma-cli-v8/plans/s2b-resources.md b/.drive/projects/prisma-cli-v8/plans/s2b-resources.md index 0bbcdc55..9b53516b 100644 --- a/.drive/projects/prisma-cli-v8/plans/s2b-resources.md +++ b/.drive/projects/prisma-cli-v8/plans/s2b-resources.md @@ -12,6 +12,10 @@ entries. Includes the R-S2b-6 picker (`project link`) and two consent commands. Hands the group-porting pattern (file layout, presentation helpers, test matrix template) to every later dispatch — this dispatch's structure IS the template; later dispatches copy it. +Includes a build-time test asserting the command-family maps and the +shell's mount map cover exactly the same command set (no command +mounted without a family entry, none declared but unmounted) — part of +the template every later dispatch inherits. ### D2 — postgres group (rename included) All 11 database→postgres commands incl. backup + connection; three diff --git a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md index 8d5405e2..aa1c7272 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md +++ b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md @@ -82,9 +82,11 @@ everything else in `src/auth/` is internal to it. Exports exactly: `EmptyServiceTokenError`, `isEmptyServiceTokenError`, `SERVICE_TOKEN_ENV_VAR`, `getApiBaseUrl`, `CLIENT_ID`, the workspace list/use/logout operations currently in `controllers/auth.ts`'s -real-mode helpers (`listRealAuthWorkspaces`, `useRealAuthWorkspace`, -`logoutRealAuthWorkspace` — extracted from the controller into -`src/auth/workspaces.ts`, controller delegates), and +real-mode helpers (`listAuthWorkspaces`, `useAuthWorkspace`, +`logoutAuthWorkspace` — extracted from the controller into +`src/auth/workspaces.ts`, controller delegates; erratum: the review +loop dropped the interim `*Real*` spelling — there is no fixture-mode +counterpart to distinguish from), and `makeGetCredentials` (moved from `src/v8/runtime.ts`; the v8 runtime imports it from here), plus `WorkspaceSelectionError` and `StoredAuthWorkspace` (production consumers exist). The legacy shell @@ -151,8 +153,10 @@ implementation moves to this repo. cli-telemetry` (reference clone: `wip/repos/prisma`). Preserve UNCHANGED: the user-config path and format (shared installation id with the ORM CLI), gating resolution (consent state, CI detection, - env opt-outs), the detached-subprocess sender, endpoint and wire - protocol, the sanitizer's value-free discipline. + env opt-outs — CI is part of the gating resolution itself, one total + resolver returning enabled/disabled with a reason), the + detached-subprocess sender, endpoint and wire protocol, the + sanitizer's value-free discipline. - Replace the Commander snapshot type with the engine shape: `EngineCommandSnapshot { commandPath: readonly string[]; flags: ReadonlyArray<{ name: string; source: "cli" | "env" | "default" }>; @@ -165,9 +169,15 @@ implementation moves to this repo. swallowed (a telemetry bug must not break a command). Draft §10 amendment. `durationMs` from the injectable clock. - Bin wiring (`src/v8/main.ts`): resolve gating; when enabled, pass an - `onSettled` hook that spawns the detached sender — sequencing and - spawn semantics copied from the ORM CLI's `preAction`/util wiring - (reference: `wip/repos/prisma/.../cli/src/utils/telemetry.ts`). + `onSettled` hook that spawns the detached sender. Spawn semantics + (fork + IPC + disconnect + unref, every failure swallowed) are + copied from the ORM CLI's util wiring (reference: + `wip/repos/prisma/.../cli/src/utils/telemetry.ts`); TIMING is + onSettled by design — the event fires at settlement, not from a + preAction-style pre-run hook — with the first-run disclosure printed + pre-run (before the command's output). The consequence (crashed / + killed / process.exit runs emit nothing) is recorded in the + divergence list. - Commands `telemetry status|enable|disable` port from the ORM CLI's consent surface as engine result commands, mounted shell-owned (no family), group `telemetry`. Copy the ORM's semantics and copy; diff --git a/.drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md b/.drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md index cd7945f3..b3c90750 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md +++ b/.drive/projects/prisma-cli-v8/specs/s2d-init-and-retirement.md @@ -46,7 +46,15 @@ commit series): the commander shell (`src/cli.ts` program wiring, deletion checklist), all remaining fixture-mode tests, the `PRISMA_CLI_MOCK_FIXTURE_PATH` env surface, and `--trace`. Legacy presenters/controllers survive ONLY where S2b/S2c handlers still call -them as operation layers (enumerate survivors in the PR). +them as operation layers (enumerate survivors in the PR). Known +survivors as of S2a: `src/state-dir.ts` (relocated out of the shell; +`shell/runtime.ts` merely re-exports it) and the `CliError` base class +in `shell/errors.ts` — `src/auth/errors.ts` still constructs CliError +instances (the auth module's one remaining legacy dependency) and +`src/v8/auth/errors.ts` maps them to structured errors; when the +legacy shell dies, either CliError moves to a durable home or the auth +operations throw structured errors directly and both mapping layers +go. R-S2d-5 **Grammar completeness check**: a build-time test asserts the mounted tree equals the S2 target grammar exactly (every inventory From ea53a452a7ab0b448cf4e59ca3dc0f2ef21476d5 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 03:33:39 +0200 Subject: [PATCH 17/67] drive(prisma-cli-v8): ledger Q6-Q8 from the S2a review loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry docs URL, config-enrichment drop, disclosure timing — each built to a stated default awaiting ratification. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../projects/prisma-cli-v8/specs/s2-overview.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.drive/projects/prisma-cli-v8/specs/s2-overview.md b/.drive/projects/prisma-cli-v8/specs/s2-overview.md index 1bc13eb9..1bfcccd5 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2-overview.md +++ b/.drive/projects/prisma-cli-v8/specs/s2-overview.md @@ -92,6 +92,22 @@ can overrule before the affected dispatch runs. engine rules make these 2 (structural) and 3 (cancel). Built to engine rules; ratify. +Added during the S2a review loop (2026-08-10, built to the stated +defaults): + +- **Q6 — telemetry docs URL.** The first-run disclosure and telemetry + help need this CLI's real telemetry docs page; interim: the existing + prisma.io CLI docs URL. Supply the final URL. +- **Q7 — telemetry config enrichment dropped.** The ORM sender + evaluates `prisma-next.config.*` (c12, arbitrary TS in a detached + child) for two wire fields; dead in this product, so the port drops + the load — `databaseTarget` null, `extensions` empty. Ratify, or + rule a `prisma.config.ts`-based replacement (interacts with Q4). +- **Q8 — disclosure timing.** Events fire at settlement (`onSettled`, + per contract); the first-run privacy disclosure prints pre-run so + users learn before output, but crashed/killed runs emit nothing + (the ORM's preAction timing emitted before the command). Ratify. + ## Definition of done (whole slice) - Every platform command runs on the engine; the commander shell and From 2b06ee9f9c65b4aa312b6de08903b8e4966b90e8 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:02:31 +0200 Subject: [PATCH 18/67] feat(release): adopt prisma/prisma version machinery and the 8.0.0-rc line Operator ruling 2026-08-10: this repo takes over prisma@8.0.0-rcX, so it adopts prisma/prisma's versioning scripts AND its version number. Ports determine-version / set-version / bump-version (+ pure-helper tests, run via the new root test:scripts) from wip/repos/prisma, adapted to node:path and this repo's package set. Stamps 8.0.0-rc.1 in lockstep across the root, cli, cli-engine, cli-telemetry, and tsconfig manifests with workspace:8.0.0-rc.1 internal pins (the reference's convention). @prisma/compute is hard-excluded from the lockstep (second ruling: it versions independently pending extraction); its manifest moves to its honest npm state 0.1.0-beta.0 and resolve-package-version.mjs survives trimmed to the dev/next-beta commands publish-compute.yml still uses, with its test moved into the script test suite (the cli-package copy is deleted). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 3 + packages/cli-engine/package.json | 4 +- packages/cli-telemetry/package.json | 4 +- packages/cli/package.json | 8 +- .../cli/tests/resolve-package-version.test.ts | 92 --------- packages/compute/package.json | 2 +- packages/tsconfig/package.json | 2 +- pnpm-lock.yaml | 10 +- scripts/bump-version.ts | 60 ++++++ scripts/determine-version-utils.test.ts | 193 ++++++++++++++++++ scripts/determine-version-utils.ts | 114 +++++++++++ scripts/determine-version.ts | 166 +++++++++++++++ scripts/resolve-package-version.d.mts | 6 - scripts/resolve-package-version.mjs | 30 +-- scripts/resolve-package-version.test.mjs | 93 +++++++++ scripts/set-version-utils.test.ts | 160 +++++++++++++++ scripts/set-version-utils.ts | 75 +++++++ scripts/set-version.ts | 108 ++++++++++ 18 files changed, 993 insertions(+), 137 deletions(-) delete mode 100644 packages/cli/tests/resolve-package-version.test.ts create mode 100644 scripts/bump-version.ts create mode 100644 scripts/determine-version-utils.test.ts create mode 100644 scripts/determine-version-utils.ts create mode 100644 scripts/determine-version.ts create mode 100644 scripts/resolve-package-version.test.mjs create mode 100644 scripts/set-version-utils.test.ts create mode 100644 scripts/set-version-utils.ts create mode 100644 scripts/set-version.ts diff --git a/package.json b/package.json index 4d05ca36..fc4ce36b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "prisma-cli", + "version": "8.0.0-rc.1", "private": true, "engines": { "node": ">=22.13.0" @@ -13,7 +14,9 @@ "lint": "biome check .", "lint:fix": "biome check . --write", "smoke:cli-nextjs": "node scripts/smoke-cli-nextjs-artifact.mjs", + "bump-version": "node scripts/bump-version.ts", "test": "turbo run test", + "test:scripts": "node --test scripts/determine-version-utils.test.ts scripts/set-version-utils.test.ts scripts/resolve-package-version.test.mjs", "typecheck": "turbo run typecheck", "prisma-cli": "tsx packages/cli/src/bin.ts", "prisma": "tsx packages/cli/src/bin.ts", diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 0691e546..e6709568 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.1.0", + "version": "8.0.0-rc.1", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { @@ -51,7 +51,7 @@ "@stricli/core": "1.3.0" }, "devDependencies": { - "@repo/tsconfig": "workspace:*", + "@repo/tsconfig": "workspace:8.0.0-rc.1", "@types/node": "^22.19.19", "tsdown": "^0.21.10", "typescript": "^6.0.3", diff --git a/packages/cli-telemetry/package.json b/packages/cli-telemetry/package.json index d39cf591..1eaee91b 100644 --- a/packages/cli-telemetry/package.json +++ b/packages/cli-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@repo/cli-telemetry", "private": true, - "version": "0.0.0", + "version": "8.0.0-rc.1", "description": "CLI telemetry client: detached subprocess sender, gating resolution, user-config store, and the value-free command sanitizer", "type": "module", "sideEffects": [ @@ -35,7 +35,7 @@ "@vercel/detect-agent": "^1.2.3" }, "devDependencies": { - "@repo/tsconfig": "workspace:*", + "@repo/tsconfig": "workspace:8.0.0-rc.1", "@types/node": "^22.19.19", "tsdown": "^0.21.10", "typescript": "^6.0.3", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2297a5bc..a38e2e2a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli", - "version": "3.0.0-development", + "version": "8.0.0-rc.1", "description": "Command-line interface for the Prisma Developer Platform.", "type": "module", "bin": { @@ -45,7 +45,7 @@ }, "dependencies": { "@clack/prompts": "1.5.0", - "@prisma/cli-engine": "workspace:*", + "@prisma/cli-engine": "workspace:8.0.0-rc.1", "@prisma/compute-sdk": "0.39.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "1.55.0", @@ -62,8 +62,8 @@ "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@repo/cli-telemetry": "workspace:*", - "@repo/tsconfig": "workspace:*", + "@repo/cli-telemetry": "workspace:8.0.0-rc.1", + "@repo/tsconfig": "workspace:8.0.0-rc.1", "@types/node": "^22.19.19", "tsdown": "^0.21.10", "tsx": "^4.22.4", diff --git a/packages/cli/tests/resolve-package-version.test.ts b/packages/cli/tests/resolve-package-version.test.ts deleted file mode 100644 index 8b4c7a6d..00000000 --- a/packages/cli/tests/resolve-package-version.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { execFile } from "node:child_process"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; - -import { describe, expect, it } from "vitest"; - -// The script is exercised as a subprocess, exactly as CI invokes it -// (`node scripts/resolve-package-version.mjs ...`). Importing it directly -// breaks on Windows: vitest hands the out-of-root shebang `.mjs` to native -// ESM, which rejects the `#!` line with a SyntaxError. Base version 3.0.0 is -// resolved from `packages/cli`, matching the package's current major line. -const execFileAsync = promisify(execFile); -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../../..", -); -const scriptPath = path.join(repoRoot, "scripts/resolve-package-version.mjs"); - -async function runScript( - args: string[], -): Promise<{ stdout: string; stderr: string; failed: boolean }> { - try { - // The command must stay first; option flags follow in any order. - const { stdout, stderr } = await execFileAsync(process.execPath, [ - scriptPath, - ...args, - "--package-dir", - "packages/cli", - ]); - 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 package version", () => { - 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", 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", 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", 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", async () => { - await expect( - runScript(["pr", "--pr-number", "43", "--sha", "f1110dd704a9382c429b"]), - ).resolves.toMatchObject({ - stdout: "version=3.0.0-pr.43.shaf1110dd704a9\n", - failed: false, - }); - }); -}); diff --git a/packages/compute/package.json b/packages/compute/package.json index 7bd9b1d6..e7ca3650 100644 --- a/packages/compute/package.json +++ b/packages/compute/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/compute", - "version": "0.1.0-development", + "version": "0.1.0-beta.0", "description": "Utilities for applications running on the Prisma compute runtime.", "type": "module", "exports": { diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 57b231d8..8fd359a8 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,7 +1,7 @@ { "name": "@repo/tsconfig", "private": true, - "version": "0.0.0", + "version": "8.0.0-rc.1", "description": "Base tsconfig providing package for the monorepo", "license": "Apache-2.0", "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9719b73c..2ff8d44b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: 1.5.0 version: 1.5.0 '@prisma/cli-engine': - specifier: workspace:* + specifier: workspace:8.0.0-rc.1 version: link:../cli-engine '@prisma/compute-sdk': specifier: 0.39.0 @@ -73,10 +73,10 @@ importers: version: 10.0.0 devDependencies: '@repo/cli-telemetry': - specifier: workspace:* + specifier: workspace:8.0.0-rc.1 version: link:../cli-telemetry '@repo/tsconfig': - specifier: workspace:* + specifier: workspace:8.0.0-rc.1 version: link:../tsconfig '@types/node': specifier: ^22.19.19 @@ -107,7 +107,7 @@ importers: version: 1.3.0 devDependencies: '@repo/tsconfig': - specifier: workspace:* + specifier: workspace:8.0.0-rc.1 version: link:../tsconfig '@types/node': specifier: ^22.19.19 @@ -129,7 +129,7 @@ importers: version: 1.2.4 devDependencies: '@repo/tsconfig': - specifier: workspace:* + specifier: workspace:8.0.0-rc.1 version: link:../tsconfig '@types/node': specifier: ^22.19.19 diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 00000000..d65d0fa6 --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +/** + * Maintainer-facing release bump. + * + * Reads the root `package.json` version *as committed at HEAD*, computes + * the next release version (`8.0.0-rc.1` → `8.0.0-rc.2`; a pre-8 stable + * base transitions onto the RC line as `8.0.0-rc.1` — see + * docs/oss/versioning.md), and writes that value to every lockstep + * workspace `package.json` via `set-version.ts`. + * + * Reading from HEAD (rather than disk) is what makes the script + * idempotent: re-running it without committing the previous bump + * would otherwise read the *bumped* root version and double-advance. + * + * The caller (a maintainer, or tooling driving a release PR) is + * responsible for branch creation, commit, and PR opening — see + * docs/oss/versioning.md for the procedure. + */ + +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertCanonicalBase, + computeNextReleaseVersion, +} from "./determine-version-utils.ts"; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); + +function readRootVersionAtHead(): string { + const json = execFileSync("git", ["show", "HEAD:package.json"], { + cwd: rootDir, + encoding: "utf-8", + }); + + const parsed = JSON.parse(json) as { version?: unknown }; + if (typeof parsed.version !== "string" || parsed.version.length === 0) { + throw new Error( + "Root package.json at HEAD is missing a `version` field. " + + "The bump-version script requires a canonical base to start from.", + ); + } + return parsed.version; +} + +const currentVersion = readRootVersionAtHead(); +assertCanonicalBase(currentVersion); + +const nextVersion = computeNextReleaseVersion(currentVersion); + +console.log(`Current root version (HEAD): ${currentVersion}`); +console.log(`Next release version: ${nextVersion}`); +console.log(""); + +const setVersionScript = join(rootDir, "scripts", "set-version.ts"); +execFileSync("node", [setVersionScript, nextVersion], { + cwd: rootDir, + stdio: "inherit", +}); diff --git a/scripts/determine-version-utils.test.ts b/scripts/determine-version-utils.test.ts new file mode 100644 index 00000000..1d7c2ff8 --- /dev/null +++ b/scripts/determine-version-utils.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + assertCanonicalBase, + composeDevVersion, + computeNextMinor, + computeNextReleaseVersion, + parseVersion, +} from "./determine-version-utils.ts"; + +describe("parseVersion", () => { + it("parses a clean release", () => { + assert.deepEqual(parseVersion("0.7.0"), { major: 0, minor: 7, patch: 0 }); + }); + + it("parses a multi-digit version", () => { + assert.deepEqual(parseVersion("12.34.567"), { + major: 12, + minor: 34, + patch: 567, + }); + }); + + it("tolerates a pre-release suffix", () => { + assert.deepEqual(parseVersion("0.7.0-dev.5"), { + major: 0, + minor: 7, + patch: 0, + }); + assert.deepEqual(parseVersion("1.2.3-foo"), { + major: 1, + minor: 2, + patch: 3, + }); + }); +}); + +describe("computeNextMinor", () => { + it("advances 0.7.0 to 0.8.0", () => { + assert.equal(computeNextMinor("0.7.0"), "0.8.0"); + }); + + it("zeros the patch component", () => { + assert.equal(computeNextMinor("1.2.5"), "1.3.0"); + }); + + it("ignores pre-release suffixes on the input", () => { + assert.equal(computeNextMinor("0.7.0-dev.5"), "0.8.0"); + }); +}); + +describe("computeNextReleaseVersion", () => { + it("advances an rc base to the next rc", () => { + assert.equal(computeNextReleaseVersion("8.0.0-rc.1"), "8.0.0-rc.2"); + }); + + it("advances a multi-digit rc counter", () => { + assert.equal(computeNextReleaseVersion("8.0.0-rc.9"), "8.0.0-rc.10"); + assert.equal(computeNextReleaseVersion("8.0.0-rc.41"), "8.0.0-rc.42"); + }); + + it("transitions a pre-8 stable base onto the v8 rc line", () => { + assert.equal(computeNextReleaseVersion("0.17.0"), "8.0.0-rc.1"); + assert.equal(computeNextReleaseVersion("0.18.0"), "8.0.0-rc.1"); + }); + + it("advances a stable 8.x base to the next minor", () => { + assert.equal(computeNextReleaseVersion("8.0.0"), "8.1.0"); + assert.equal(computeNextReleaseVersion("8.1.0"), "8.2.0"); + }); + + it("rejects a non-canonical base", () => { + assert.throws( + () => computeNextReleaseVersion("8.0.0-dev.1"), + /not canonical/, + ); + }); + + it("rejects rc bases outside the 8.0.0 line", () => { + assert.throws( + () => computeNextReleaseVersion("0.17.0-rc.1"), + /not canonical/, + ); + assert.throws( + () => computeNextReleaseVersion("8.0.1-rc.1"), + /not canonical/, + ); + assert.throws( + () => computeNextReleaseVersion("9.0.0-rc.1"), + /not canonical/, + ); + }); +}); + +describe("composeDevVersion", () => { + it("starts at dev.1 when no dev build exists yet", () => { + assert.deepEqual(composeDevVersion("0.17.0", undefined), { + version: "0.17.0-dev.1", + tag: "dev", + }); + }); + + it("increments the counter when the latest dev build shares the base", () => { + assert.deepEqual(composeDevVersion("0.17.0", "0.17.0-dev.4"), { + version: "0.17.0-dev.5", + tag: "dev", + }); + }); + + it("resets the counter when the base moved on", () => { + assert.deepEqual(composeDevVersion("0.18.0", "0.17.0-dev.9"), { + version: "0.18.0-dev.1", + tag: "dev", + }); + }); + + it("composes dev builds on an rc base", () => { + assert.deepEqual(composeDevVersion("8.0.0-rc.1", undefined), { + version: "8.0.0-rc.1-dev.1", + tag: "dev", + }); + assert.deepEqual(composeDevVersion("8.0.0-rc.1", "8.0.0-rc.1-dev.7"), { + version: "8.0.0-rc.1-dev.8", + tag: "dev", + }); + }); + + it("resets the counter when the rc counter moved on", () => { + assert.deepEqual(composeDevVersion("8.0.0-rc.2", "8.0.0-rc.1-dev.7"), { + version: "8.0.0-rc.2-dev.1", + tag: "dev", + }); + }); + + it("resets the counter across the stable-to-rc transition", () => { + assert.deepEqual(composeDevVersion("8.0.0-rc.1", "0.17.0-dev.12"), { + version: "8.0.0-rc.1-dev.1", + tag: "dev", + }); + }); +}); + +describe("assertCanonicalBase", () => { + it("accepts a clean release", () => { + assert.doesNotThrow(() => assertCanonicalBase("0.7.0")); + assert.doesNotThrow(() => assertCanonicalBase("1.2.3")); + }); + + it("accepts an rc base", () => { + assert.doesNotThrow(() => assertCanonicalBase("8.0.0-rc.1")); + assert.doesNotThrow(() => assertCanonicalBase("8.0.0-rc.42")); + }); + + it("rejects a dev suffix", () => { + assert.throws(() => assertCanonicalBase("0.7.0-dev.1"), /not canonical/); + assert.throws( + () => assertCanonicalBase("8.0.0-rc.1-dev.2"), + /not canonical/, + ); + }); + + it("rejects non-rc pre-release suffixes", () => { + assert.throws(() => assertCanonicalBase("8.0.0-beta.1"), /not canonical/); + assert.throws(() => assertCanonicalBase("8.0.0-rc"), /not canonical/); + assert.throws(() => assertCanonicalBase("8.0.0-rc."), /not canonical/); + }); + + it("rejects rc bases outside the 8.0.0 line", () => { + assert.throws(() => assertCanonicalBase("0.17.0-rc.1"), /not canonical/); + assert.throws(() => assertCanonicalBase("8.0.1-rc.1"), /not canonical/); + assert.throws(() => assertCanonicalBase("8.1.0-rc.1"), /not canonical/); + assert.throws(() => assertCanonicalBase("9.0.0-rc.1"), /not canonical/); + }); + + it("rejects rc.0 — the counter starts at rc.1", () => { + assert.throws(() => assertCanonicalBase("8.0.0-rc.0"), /not canonical/); + }); + + it("rejects a missing component", () => { + assert.throws(() => assertCanonicalBase("0.7"), /not canonical/); + }); + + it("rejects an empty string", () => { + assert.throws(() => assertCanonicalBase(""), /not canonical/); + }); + + it("rejects components with leading zeros", () => { + assert.throws(() => assertCanonicalBase("01.2.3"), /not canonical/); + assert.throws(() => assertCanonicalBase("1.02.3"), /not canonical/); + assert.throws(() => assertCanonicalBase("1.2.03"), /not canonical/); + assert.throws(() => assertCanonicalBase("8.0.0-rc.01"), /not canonical/); + }); +}); diff --git a/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts new file mode 100644 index 00000000..434b75d0 --- /dev/null +++ b/scripts/determine-version-utils.ts @@ -0,0 +1,114 @@ +// This module is intentionally pure. All npm/dist-tag I/O and filesystem +// reads live in the callers (`scripts/determine-version.ts`, +// `scripts/bump-version.ts`); this file is reserved for deterministic +// helpers exercised under `node --test` from `pnpm test:scripts`. + +const NUM = "(0|[1-9]\\d*)"; +const STABLE_BASE_PATTERN = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}$`); +// The one supported RC line (docs/oss/versioning.md): `8.0.0-rc.N`, +// counting from rc.1. Deliberately not a general X.Y.Z-rc.N shape — this +// is a publish guard, and a base like `9.2.3-rc.1` or `8.0.0-rc.0` +// reaching the pipeline is a mistake to fail on, not a case to support. +// A future RC line widens this constant. +const RC_BASE_PATTERN = /^8\.0\.0-rc\.([1-9]\d*)$/; +const DEV_PATTERN = /^(\d+\.\d+\.\d+(?:-rc\.\d+)?)-dev\.(\d+)$/; + +export interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +/** + * Parses a semver-shaped version string into its numeric components. + * Tolerant of pre-release suffixes (`0.7.0-foo` parses the same as + * `0.7.0`); strict on the leading `major.minor.patch` shape — anything + * else returns NaN-bearing components. + */ +export function parseVersion(version: string): ParsedVersion { + const [major, minor, patch] = version.split("-")[0].split(".").map(Number); + return { major, minor, patch }; +} + +/** + * Given the current version, computes the next minor's zero-patch + * form: `0.7.0` -> `0.8.0`, `1.2.5` -> `1.3.0`. Pure / deterministic. + * Pre-release suffixes on the input are ignored (`0.7.0-foo` -> `0.8.0`). + */ +export function computeNextMinor(current: string): string { + const { major, minor } = parseVersion(current); + return `${major}.${minor + 1}.0`; +} + +/** + * Computes the next release version from the current base + * (see docs/oss/versioning.md): + * + * - `8.0.0-rc.N` -> `8.0.0-rc.N+1` — the RC line advances its counter. + * - pre-8 stable (`0.17.0`) -> `8.0.0-rc.1` — the one-time transition + * onto the v8 RC line; there are no further pre-8 minors. + * - stable `>= 8` -> next minor. + */ +export function computeNextReleaseVersion(current: string): string { + assertCanonicalBase(current); + const rcMatch = current.match(RC_BASE_PATTERN); + if (rcMatch) { + return `8.0.0-rc.${Number(rcMatch[1]) + 1}`; + } + if (parseVersion(current).major < 8) { + return "8.0.0-rc.1"; + } + return computeNextMinor(current); +} + +export interface VersionResult { + version: string; + tag: string; +} + +/** + * Composes the `-dev.N` version for a routine (non-release) push, + * given the version currently published under the `dev` dist-tag. The + * counter continues while the base is unchanged and resets to 1 when + * the base moves (new minor, new rc counter, stable-to-rc transition). + */ +export function composeDevVersion( + baseVersion: string, + latestDevVersion: string | undefined, +): VersionResult { + let buildNumber = 1; + + if (latestDevVersion) { + const match = latestDevVersion.match(DEV_PATTERN); + + if (match) { + const [, devBase, build] = match; + if (devBase === baseVersion) { + buildNumber = Number.parseInt(build, 10) + 1; + } + } + } + + return { + version: `${baseVersion}-dev.${buildNumber}`, + tag: "dev", + }; +} + +/** + * Asserts that a base version is canonical: either a clean release + * (`major.minor.patch`) or a version on the supported RC line + * (`8.0.0-rc.N`, N ≥ 1). Used to fail-fast in the publish workflow if + * root `package.json` was edited to something other than a release + * shape — without this guard, a malformed root would compose nonsense + * publish versions like `0.7.0-foo-dev.1`. + */ +export function assertCanonicalBase(base: string): void { + if (!STABLE_BASE_PATTERN.test(base) && !RC_BASE_PATTERN.test(base)) { + throw new Error( + `Base version "${base}" is not canonical. ` + + 'The root package.json `version` must be a clean release shape ("0.7.0") ' + + 'or on the supported RC line ("8.0.0-rc.N", N >= 1); nothing else is permitted on `main`.', + ); + } +} diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts new file mode 100644 index 00000000..e9d6fe8e --- /dev/null +++ b/scripts/determine-version.ts @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +/** + * Composes the version + dist-tag the publish workflow will use. + * + * The base version comes from the root `package.json` (the workspace-wide + * lockstep source of truth — see docs/oss/versioning.md). This script is + * responsible only for the suffix and dist-tag appropriate to the GitHub + * event: + * + * - `push` → if the root `version` changed in this push, + * `` (no suffix), dist-tag `latest`. This + * is how a merged `chore(release): ...` PR + * ships a release automatically — on the RC + * line `latest` tracks the newest `8.0.0-rc.N`. + * Otherwise, `-dev.N`, dist-tag `dev` + * (N is the next available build number, + * discovered by querying npm). + * - `workflow_dispatch` → `` (no suffix), dist-tag from + * `INPUT_DIST_TAG` (defaults to `latest`). + * Useful as a manual escape hatch (re-publish + * after a transient failure, cut a beta). + * + * Outputs `version` and `tag` to `$GITHUB_OUTPUT` for downstream + * workflow steps to consume. + */ + +import { execFileSync, execSync } from "node:child_process"; +import { appendFileSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { VersionResult } from "./determine-version-utils.ts"; +import { + assertCanonicalBase, + composeDevVersion, +} from "./determine-version-utils.ts"; + +// `@prisma/cli` has the longest publish history in this repo (it carries +// the 3.x-era dev builds), so its `dev` dist-tag is the high-water mark +// for the build counter. Counting from a younger package would re-issue a +// `-dev.N` that npm already holds and fail the publish. +const PACKAGE_NAME = process.argv[2] ?? "@prisma/cli"; + +const ALL_ZERO_SHA_PATTERN = /^0+$/; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); + +function readRootVersion(): string { + const pkgPath = join(rootDir, "package.json"); + const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + version?: unknown; + }; + if (typeof parsed.version !== "string" || parsed.version.length === 0) { + throw new Error( + `Root package.json (${pkgPath}) is missing a \`version\` field. ` + + "The publish pipeline reads the version directly from the workspace root; " + + "set it (e.g. `pnpm bump-version`) before publishing.", + ); + } + return parsed.version; +} + +function run(command: string): string | undefined { + try { + return execSync(command, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return undefined; + } +} + +function getLatestDevVersion(): string | undefined { + return run(`npm view "${PACKAGE_NAME}" dist-tags.dev`); +} + +type PreviousVersionLookup = + | { available: true; version: string | undefined } + | { available: false }; + +/** + * Reads the root `package.json` `version` at `PUSH_BEFORE_SHA` (the ref + * that `main` pointed at *before* the push). Distinguishes "we + * successfully read the previous file" (so the comparison is meaningful) + * from "we couldn't" (shallow clone, missing SHA, etc.) so the caller + * can fall back to the safe `dev` path on any I/O hiccup. + */ +function readPreviousRootVersion(): PreviousVersionLookup { + const beforeSha = process.env.PUSH_BEFORE_SHA; + if (!beforeSha || ALL_ZERO_SHA_PATTERN.test(beforeSha)) { + return { available: false }; + } + try { + const json = execFileSync("git", ["show", `${beforeSha}:package.json`], { + cwd: rootDir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + const parsed = JSON.parse(json) as { version?: unknown }; + return { + available: true, + version: typeof parsed.version === "string" ? parsed.version : undefined, + }; + } catch { + return { available: false }; + } +} + +function writeGitHubOutput(result: VersionResult): void { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `version<-dev.N` tarball. + // + // `available: false` (shallow clone, missing SHA) deliberately falls + // through to the dev path: a transient git error must never silently + // promote to `latest`. + const previous = readPreviousRootVersion(); + const isReleaseBump = + previous.available && previous.version !== baseVersion; + if (isReleaseBump) { + console.log( + `Previous root version: ${previous.version ?? "(unset)"} → release bump detected.`, + ); + result = { version: baseVersion, tag: "latest" }; + } else { + result = composeDevVersion(baseVersion, getLatestDevVersion()); + } + break; + } + + default: + throw new Error(`don't know how to handle event ${eventName}`); +} + +console.log(`Resolved version: ${result.version}`); +console.log(`Resolved dist-tag: ${result.tag}`); +writeGitHubOutput(result); diff --git a/scripts/resolve-package-version.d.mts b/scripts/resolve-package-version.d.mts index 1d1acb2a..9760bba2 100644 --- a/scripts/resolve-package-version.d.mts +++ b/scripts/resolve-package-version.d.mts @@ -4,12 +4,6 @@ export declare function resolveDevVersion(options: { runAttempt?: string | number | null; }): string; -export declare function resolvePrVersion(options: { - baseVersion?: string | number | null; - prNumber?: string | number | null; - sha?: string | null; -}): string; - export declare function resolveNextBetaVersion(options: { baseVersion?: string | number | null; latest?: string | null; diff --git a/scripts/resolve-package-version.mjs b/scripts/resolve-package-version.mjs index d241c731..b2894697 100644 --- a/scripts/resolve-package-version.mjs +++ b/scripts/resolve-package-version.mjs @@ -1,5 +1,10 @@ #!/usr/bin/env node +// Serves `publish-compute.yml` only. `@prisma/compute` is excluded from +// the workspace's lockstep versioning (operator ruling 2026-08-10) and +// keeps this legacy dev/next-beta scheme; the CLI packages publish via +// `publish.yml` + `determine-version.ts` / `set-version.ts` instead. + import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,14 +20,6 @@ export function resolveDevVersion(options) { return `${baseVersion}-dev.${runNumber}.${runAttempt}`; } -export function resolvePrVersion(options) { - const baseVersion = requireValue(options.baseVersion, "baseVersion"); - const prNumber = requireValue(options.prNumber, "prNumber"); - const sha = shortSha(requireValue(options.sha, "sha")); - - return `${baseVersion}-pr.${prNumber}.sha${sha}`; -} - export function resolveNextBetaVersion(options) { const baseVersion = requireValue(options.baseVersion, "baseVersion"); const latest = options.latest; @@ -102,10 +99,6 @@ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function shortSha(sha) { - return sha.slice(0, 12); -} - function requireValue(value, name) { if (value === undefined || value === null || String(value).trim() === "") { throw new Error(`${name} is required.`); @@ -159,17 +152,6 @@ function main() { return; } - if (command === "pr") { - process.stdout.write( - `version=${resolvePrVersion({ - baseVersion, - prNumber: options["pr-number"], - sha: options.sha, - })}\n`, - ); - return; - } - if (command === "next-beta") { const latest = options.latest ?? ""; process.stdout.write(`latest=${latest}\n`); @@ -180,7 +162,7 @@ function main() { } throw new Error( - "Usage: resolve-package-version.mjs [--package-dir ] [options]", + "Usage: resolve-package-version.mjs [--package-dir ] [options]", ); } diff --git a/scripts/resolve-package-version.test.mjs b/scripts/resolve-package-version.test.mjs new file mode 100644 index 00000000..df841b87 --- /dev/null +++ b/scripts/resolve-package-version.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +// The script is exercised as a subprocess, exactly as CI invokes it +// (`node scripts/resolve-package-version.mjs ...`). Base version 0.1.0 is +// resolved from `packages/compute` — the one remaining consumer +// (`publish-compute.yml`); the manifest carries `0.1.0-beta.0` and the +// base derivation strips the pre-release suffix. +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const scriptPath = path.join(repoRoot, "scripts/resolve-package-version.mjs"); + +async function runScript(args) { + try { + // The command must stay first; option flags follow in any order. + const { stdout, stderr } = await execFileAsync(process.execPath, [ + scriptPath, + ...args, + "--package-dir", + "packages/compute", + ]); + return { stdout, stderr, failed: false }; + } catch (error) { + return { + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + failed: true, + }; + } +} + +describe("resolve package version (compute)", () => { + it("computes the first beta when npm latest is missing", async () => { + assert.deepEqual(await runScript(["next-beta", "--latest", ""]), { + stdout: "latest=\nversion=0.1.0-beta.0\n", + stderr: "", + failed: false, + }); + }); + + it("increments the beta number from the current npm latest", async () => { + assert.deepEqual( + await runScript(["next-beta", "--latest", "0.1.0-beta.0"]), + { + stdout: "latest=0.1.0-beta.0\nversion=0.1.0-beta.1\n", + stderr: "", + failed: false, + }, + ); + }); + + it("fails when npm latest is outside the supported beta line", async () => { + const result = await runScript(["next-beta", "--latest", "0.1.0"]); + assert.equal(result.failed, true); + assert.match( + result.stderr, + /Cannot compute the next beta from npm latest \(0\.1\.0\)\./, + ); + }); + + it("computes a unique dev build version", async () => { + assert.deepEqual( + await runScript(["dev", "--run-number", "123", "--run-attempt", "2"]), + { + stdout: "version=0.1.0-dev.123.2\n", + stderr: "", + failed: false, + }, + ); + }); + + it("rejects the retired pr command", async () => { + const result = await runScript([ + "pr", + "--pr-number", + "43", + "--sha", + "f1110dd704a9", + ]); + assert.equal(result.failed, true); + assert.match( + result.stderr, + /Usage: resolve-package-version\.mjs /, + ); + }); +}); diff --git a/scripts/set-version-utils.test.ts b/scripts/set-version-utils.test.ts new file mode 100644 index 00000000..27d36fc0 --- /dev/null +++ b/scripts/set-version-utils.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + type MutablePackageJson, + participatesInLockstep, + rewriteWorkspaceDeps, +} from "./set-version-utils.ts"; + +describe("participatesInLockstep", () => { + it("true for a project-boundary manifest with workspace pins (not a workspace member)", () => { + assert.equal( + participatesInLockstep({ + name: "smoke-app", + version: "8.0.0-rc.1", + dependencies: { "@prisma/cli": "workspace:8.0.0-rc.1" }, + }), + true, + ); + }); + + it("true when the only workspace pin is a devDependency", () => { + assert.equal( + participatesInLockstep({ + name: "x", + devDependencies: { "@repo/tsconfig": "workspace:*" }, + }), + true, + ); + }); + + it("false for a fixture manifest with only registry-style specs", () => { + assert.equal( + participatesInLockstep({ + name: "fixture-app", + version: "8.0.0-rc.1", + dependencies: { "@prisma/cli": "8.0.0-rc.1", lodash: "^4.17.21" }, + }), + false, + ); + }); + + it("false for a manifest with no dependency fields", () => { + assert.equal( + participatesInLockstep({ name: "bare", version: "1.0.0" }), + false, + ); + }); +}); + +describe("rewriteWorkspaceDeps", () => { + it("leaves a package with no workspace deps unchanged", () => { + const pkg: MutablePackageJson = { + name: "a-no-workspace-deps", + version: "0.7.0", + dependencies: { lodash: "^4.17.21" }, + devDependencies: { vitest: "^4.0.0" }, + }; + const before = JSON.stringify(pkg); + rewriteWorkspaceDeps(pkg, "0.8.0"); + assert.equal(JSON.stringify(pkg), before); + }); + + it("rewrites workspace:* and workspace: in lockstep", () => { + const pkg: MutablePackageJson = { + name: "b-mixed-workspace-deps", + version: "0.7.0", + dependencies: { + "@prisma/cli-engine": "workspace:*", + "@repo/cli-telemetry": "workspace:0.6.0", + arktype: "^2.1.29", + }, + devDependencies: { + "@repo/tsconfig": "workspace:*", + }, + }; + rewriteWorkspaceDeps(pkg, "0.8.0"); + assert.deepEqual(pkg.dependencies, { + "@prisma/cli-engine": "workspace:0.8.0", + "@repo/cli-telemetry": "workspace:0.8.0", + arktype: "^2.1.29", + }); + assert.deepEqual(pkg.devDependencies, { + "@repo/tsconfig": "workspace:0.8.0", + }); + }); + + it("is idempotent — re-running with the same version produces no further change", () => { + const pkg: MutablePackageJson = { + name: "c-already-pinned", + version: "0.8.0", + dependencies: { + "@prisma/cli-engine": "workspace:0.8.0", + }, + peerDependencies: { + "@repo/cli-telemetry": "workspace:0.8.0", + }, + }; + const before = JSON.stringify(pkg); + rewriteWorkspaceDeps(pkg, "0.8.0"); + assert.equal(JSON.stringify(pkg), before); + rewriteWorkspaceDeps(pkg, "0.8.0"); + assert.equal(JSON.stringify(pkg), before); + }); + + it("rewrites across every dep field (dependencies, peer, dev, optional)", () => { + const pkg: MutablePackageJson = { + name: "all-fields", + version: "0.7.0", + dependencies: { "@repo/a": "workspace:*" }, + peerDependencies: { "@repo/b": "workspace:*" }, + devDependencies: { "@repo/c": "workspace:*" }, + optionalDependencies: { "@repo/d": "workspace:*" }, + }; + rewriteWorkspaceDeps(pkg, "1.0.0"); + assert.equal(pkg.dependencies?.["@repo/a"], "workspace:1.0.0"); + assert.equal(pkg.peerDependencies?.["@repo/b"], "workspace:1.0.0"); + assert.equal(pkg.devDependencies?.["@repo/c"], "workspace:1.0.0"); + assert.equal(pkg.optionalDependencies?.["@repo/d"], "workspace:1.0.0"); + }); + + it("does not rewrite a non-workspace spec (e.g. a published-version pin)", () => { + const pkg: MutablePackageJson = { + name: "consumer-with-published-pins", + version: "0.7.0", + dependencies: { + "@prisma/cli-engine": "0.7.0", + "@prisma/cli": "^0.7.0", + }, + }; + rewriteWorkspaceDeps(pkg, "0.8.0"); + assert.equal(pkg.dependencies?.["@prisma/cli-engine"], "0.7.0"); + assert.equal(pkg.dependencies?.["@prisma/cli"], "^0.7.0"); + }); + + it("rewrites every scope, because the lockstep spans published and private packages", () => { + const pkg: MutablePackageJson = { + name: "with-deps-across-scopes", + version: "0.7.0", + dependencies: { + "@prisma/cli-engine": "workspace:*", + "@repo/cli-telemetry": "workspace:*", + "@repo/tsconfig": "workspace:*", + }, + }; + rewriteWorkspaceDeps(pkg, "0.8.0"); + assert.deepEqual(pkg.dependencies, { + "@prisma/cli-engine": "workspace:0.8.0", + "@repo/cli-telemetry": "workspace:0.8.0", + "@repo/tsconfig": "workspace:0.8.0", + }); + }); + + it("tolerates a package with missing dep-field objects", () => { + const pkg: MutablePackageJson = { name: "sparse", version: "0.7.0" }; + rewriteWorkspaceDeps(pkg, "0.8.0"); + assert.equal(pkg.version, "0.7.0"); // version is the caller's job, not the helper's + assert.equal(pkg.dependencies, undefined); + }); +}); diff --git a/scripts/set-version-utils.ts b/scripts/set-version-utils.ts new file mode 100644 index 00000000..66b458b1 --- /dev/null +++ b/scripts/set-version-utils.ts @@ -0,0 +1,75 @@ +// Pure helpers consumed by `set-version.ts`. Kept side-effect-free so +// the unit tests in `set-version-utils.test.ts` can exercise them +// without running the full publish-time version-stamp pipeline. + +export interface MutablePackageJson { + name?: string; + version?: string; + private?: boolean; + dependencies?: Record; + peerDependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + [key: string]: unknown; +} + +const DEP_FIELDS = [ + "dependencies", + "peerDependencies", + "devDependencies", + "optionalDependencies", +] as const; + +/** + * Rewrite every workspace dep spec in `packageJson` to + * `workspace:`. Mutates in place. Idempotent: re-running with the + * same version is a no-op. + * + * The literal-version form is the mechanism that gives every published + * package an exact-version pin on its siblings: pnpm rewrites + * `workspace:` to exactly `X.Y.Z` at publish time, while resolving to + * the local workspace package during development. + * + * Every scope is rewritten rather than one, because every lockstep package + * in this workspace is versioned together — the published `@prisma/*` + * packages and the private `@repo/*` packages alike. Singling out one scope + * would leave the others pinned at whatever version they were last released + * under. + * + * Non-workspace specs (e.g. caret ranges from the registry, catalog entries) + * are intentionally left alone; only `workspace:` specifiers are rewritten. + */ +export function rewriteWorkspaceDeps( + packageJson: MutablePackageJson, + version: string, +): void { + for (const field of DEP_FIELDS) { + const deps = packageJson[field]; + if (!deps) continue; + for (const [name, spec] of Object.entries(deps)) { + if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue; + deps[name] = `workspace:${version}`; + } + } +} + +/** + * Whether a manifest that is NOT a workspace member still versions in + * lockstep with the workspace. A `workspace:` dep spec is the tell: + * project-boundary manifests pin workspace packages and go stale on every + * bump unless swept. Fixture manifests with only registry-style specs are + * test data and must be left alone. + */ +export function participatesInLockstep( + packageJson: MutablePackageJson, +): boolean { + for (const field of DEP_FIELDS) { + const deps = packageJson[field]; + if (!deps) continue; + for (const spec of Object.values(deps)) { + if (typeof spec === "string" && spec.startsWith("workspace:")) + return true; + } + } + return false; +} diff --git a/scripts/set-version.ts b/scripts/set-version.ts new file mode 100644 index 00000000..a7b70176 --- /dev/null +++ b/scripts/set-version.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import { execSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + participatesInLockstep, + rewriteWorkspaceDeps, +} from "./set-version-utils.ts"; + +// Operator ruling 2026-08-10: `@prisma/compute` versions independently +// pending extraction to another repo; the lockstep set is the root plus +// the CLI packages. Its manifest and publish workflow stay untouched. +const LOCKSTEP_EXCLUDED = new Set(["@prisma/compute"]); + +const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +const version = process.argv[2]; + +if (!version) { + const script = path.relative(process.cwd(), process.argv[1]); + console.error(`Usage: node ${script} `); + console.error(`Example: node ${script} 8.0.0-rc.2`); + process.exit(1); +} + +interface PnpmPackage { + name: string; + version: string; + path: string; + private: boolean; +} + +interface PackageJson { + name: string; + version: string; + private?: boolean; + [key: string]: unknown; +} + +const output = execSync("pnpm list -r --json", { + cwd: rootDir, + encoding: "utf-8", +}); + +const workspacePackages: PnpmPackage[] = JSON.parse(output); + +let updatedCount = 0; + +// Every workspace package — publishable, private, and the workspace +// root — gets the same version (excluding LOCKSTEP_EXCLUDED, above). +// Lockstep is the invariant that lets a single read of the root +// `package.json` answer "what version are we shipping right now?"; if +// private packages drifted, that invariant would be silently violated +// by direct invocations of this script. +for (const pkg of workspacePackages) { + if (LOCKSTEP_EXCLUDED.has(pkg.name)) { + console.log(`Skipped ${pkg.name} (excluded from lockstep)`); + continue; + } + const packageJsonPath = path.join(pkg.path, "package.json"); + const content = await fs.readFile(packageJsonPath, "utf-8"); + const packageJson: PackageJson = JSON.parse(content); + + packageJson.version = version; + rewriteWorkspaceDeps(packageJson, version); + await fs.writeFile( + packageJsonPath, + `${JSON.stringify(packageJson, null, 2)}\n`, + ); + + console.log(`Updated ${pkg.name} to ${version}`); + updatedCount++; +} + +// Project-boundary manifests (tracked package.json files that are not +// workspace members but carry `workspace:` pins) version in lockstep +// too. Without this sweep they go stale on every bump and fail at +// install once the old version leaves the registry. +const memberPaths = new Set( + workspacePackages.map((pkg) => path.join(pkg.path, "package.json")), +); +const trackedManifests = execSync("git ls-files -- '*package.json'", { + cwd: rootDir, + encoding: "utf-8", +}) + .split("\n") + .filter(Boolean) + .map((rel) => path.join(rootDir, rel)) + .filter((abs) => !memberPaths.has(abs)); + +for (const manifestPath of trackedManifests) { + const packageJson: PackageJson = JSON.parse( + await fs.readFile(manifestPath, "utf-8"), + ); + if (!participatesInLockstep(packageJson)) continue; + packageJson.version = version; + rewriteWorkspaceDeps(packageJson, version); + await fs.writeFile(manifestPath, `${JSON.stringify(packageJson, null, 2)}\n`); + console.log( + `Updated ${path.relative(rootDir, manifestPath)} (project-boundary manifest) to ${version}`, + ); + updatedCount++; +} + +console.log(`\nDone! Updated ${updatedCount} packages.`); From a09c20a53bb26128be623dcc074881f06a18d7bd Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:02:45 +0200 Subject: [PATCH 19/67] ci: replace the CLI publish model with prisma/prisma's committed-version workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports publish.yml from the reference verbatim per the operator ruling (2026-08-10): a push to main with the root version unchanged publishes -dev.N under the dev dist-tag; a push that changes the root version (a merged bump PR) publishes the committed base under latest for BOTH @prisma/cli-engine and @prisma/cli (engine first — the cli depends on it) plus a GitHub Release marked pre-release on the rc line. The operator's words: merging a version bump PR counts as a deliberate explicit action to alter latest. workflow_dispatch keeps the chosen dist-tag + dry-run escape hatch. publish-cli.yml is deleted whole — both its jobs are superseded. The PR preview drops version stamping and copies the reference's pkg.pr.new model (the committed rc base ships as-is at per-commit URLs); the engine is published alongside the cli so its workspace pin resolves to the preview build. pr-quality's test job now runs the ported script tests. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/pr-quality.yml | 3 + .github/workflows/preview-cli-package.yml | 22 +-- .github/workflows/publish-cli.yml | 176 ---------------------- .github/workflows/publish.yml | 168 +++++++++++++++++++++ 4 files changed, 177 insertions(+), 192 deletions(-) delete mode 100644 .github/workflows/publish-cli.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index bef0fa26..009e2962 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -83,3 +83,6 @@ jobs: # package's tests, so imports of built dist output resolve. - name: Test run: pnpm test + + - name: Script tests + run: pnpm test:scripts diff --git a/.github/workflows/preview-cli-package.yml b/.github/workflows/preview-cli-package.yml index 66748b40..32196aab 100644 --- a/.github/workflows/preview-cli-package.yml +++ b/.github/workflows/preview-cli-package.yml @@ -36,14 +36,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Resolve preview version - id: cli_version - run: | - node scripts/resolve-package-version.mjs pr \ - --package-dir packages/cli \ - --sha '${{ github.event.pull_request.head.sha }}' \ - --pr-number '${{ github.event.pull_request.number }}' >> "$GITHUB_OUTPUT" - # Build every workspace package up front via the turbo graph so # workspace:* dependencies (the CLI's @prisma/cli-engine) have # their dist output before the test and build steps below. @@ -66,15 +58,15 @@ jobs: id: compute_build run: pnpm --filter @prisma/compute build - - name: Set preview package version - id: set_preview_version - working-directory: packages/cli - run: pnpm version '${{ steps.cli_version.outputs.version }}' --no-git-tag-version - + # No version stamping: pkg.pr.new serves the tarball at a per-commit + # URL, so the committed base version (`8.0.0-rc.N`) ships as-is — + # same model as prisma/prisma's preview-publish.yml. The engine is + # published alongside the cli so its `workspace:` pin resolves to + # the preview build rather than the registry. - name: Publish installable PR preview id: publish_preview continue-on-error: ${{ vars.CLI_PR_PREVIEW_REQUIRED != 'true' }} - run: pnpm exec pkg-pr-new publish --bin --comment=update packages/cli + run: pnpm exec pkg-pr-new publish --bin --comment=update packages/cli packages/cli-engine - name: Summarize PR preview publish if: ${{ always() }} @@ -82,12 +74,10 @@ jobs: { echo "## Preview CLI Package" echo - echo "- Version: \`${{ steps.cli_version.outputs.version }}\`" echo "- CLI tests: \`${{ steps.cli_tests.outcome || 'skipped' }}\`" echo "- Compute tests: \`${{ steps.compute_tests.outcome || 'skipped' }}\`" echo "- Build: \`${{ steps.cli_build.outcome || 'skipped' }}\`" echo "- Compute build: \`${{ steps.compute_build.outcome || 'skipped' }}\`" - echo "- Version injection: \`${{ steps.set_preview_version.outcome || 'skipped' }}\`" echo "- pkg.pr.new publish: \`${{ steps.publish_preview.outcome || 'skipped' }}\`" PUBLISH_OUTCOME="${{ steps.publish_preview.outcome || 'skipped' }}" if [ "${PUBLISH_OUTCOME}" != "success" ]; then diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml deleted file mode 100644 index ca316980..00000000 --- a/.github/workflows/publish-cli.yml +++ /dev/null @@ -1,176 +0,0 @@ -name: Publish CLI - -on: - push: - branches: - - main - workflow_dispatch: - inputs: - dry_run: - description: Validate the next official beta release without publishing or tagging - required: false - type: boolean - default: false - -concurrency: - group: publish-cli-${{ github.event_name }} - cancel-in-progress: false - -jobs: - publish-dev: - name: Publish dev package - if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - with: - cache: true - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Resolve dev version - id: cli_version - run: | - node scripts/resolve-package-version.mjs dev \ - --package-dir packages/cli \ - --run-number "${GITHUB_RUN_NUMBER}" \ - --run-attempt "${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - - - name: Set package version - working-directory: packages/cli - run: pnpm version '${{ steps.cli_version.outputs.version }}' --no-git-tag-version - - - name: Publish dev package to npm - working-directory: packages/cli - run: npm publish --access public --tag dev --provenance - - - name: Summarize dev publish - run: | - VERSION='${{ steps.cli_version.outputs.version }}' - { - echo "## Publish CLI Dev" - echo - echo "- npm package: \`@prisma/cli@${VERSION}\`" - echo "- npm dist-tag: \`dev\`" - } >> "$GITHUB_STEP_SUMMARY" - - publish-official: - name: Publish official release - if: ${{ github.event_name == 'workflow_dispatch' }} - runs-on: ubuntu-latest - permissions: - contents: write - id-token: write - - steps: - - name: Ensure workflow runs from main - run: | - if [ "${GITHUB_REF}" != "refs/heads/main" ]; then - echo "This workflow only publishes official releases from main." - exit 1 - fi - - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - with: - cache: true - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Resolve next beta version - id: cli_version - run: | - PACKAGE='@prisma/cli' - LATEST="$(pnpm view "${PACKAGE}" dist-tags.latest --silent 2>/dev/null || true)" - echo "latest=${LATEST}" >> "$GITHUB_OUTPUT" - node scripts/resolve-package-version.mjs next-beta \ - --package-dir packages/cli \ - --latest "${LATEST}" >> "$GITHUB_OUTPUT" - - - name: Fail if release tag already exists - run: | - VERSION='${{ steps.cli_version.outputs.version }}' - TAG="cli-v${VERSION}" - if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "Release tag ${TAG} already exists." - exit 1 - fi - - - name: Set package version - working-directory: packages/cli - run: pnpm version '${{ steps.cli_version.outputs.version }}' --no-git-tag-version - - - name: Validate package artifact - working-directory: packages/cli - run: pnpm pack --dry-run - - - name: Ensure release still targets the latest main - if: ${{ !inputs.dry_run }} - run: | - git fetch origin main - if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then - echo "main moved while the release was running. Rerun the workflow from the latest main." - exit 1 - fi - - - name: Publish official package to npm - if: ${{ !inputs.dry_run }} - working-directory: packages/cli - run: npm publish --access public --tag latest --provenance - - - name: Create release tag - if: ${{ !inputs.dry_run }} - env: - GH_TOKEN: ${{ github.token }} - run: | - VERSION='${{ steps.cli_version.outputs.version }}' - TAG="cli-v${VERSION}" - SHA="$(git rev-parse HEAD)" - gh api \ - --method POST \ - "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="refs/tags/${TAG}" \ - -f sha="${SHA}" - - - name: Summarize release - run: | - VERSION='${{ steps.cli_version.outputs.version }}' - LATEST='${{ steps.cli_version.outputs.latest }}' - { - echo "## Publish CLI Official" - echo - echo "- Previous npm latest: \`${LATEST:-none}\`" - echo "- Version: \`${VERSION}\`" - echo "- Dry run: \`${{ inputs.dry_run }}\`" - if [ '${{ inputs.dry_run }}' = 'true' ]; then - echo "- Publish: skipped" - echo "- Tag creation: skipped" - else - echo "- npm package: \`@prisma/cli@${VERSION}\`" - echo "- npm dist-tag: \`latest\`" - echo "- git tag: \`cli-v${VERSION}\`" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..313ea47c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,168 @@ +name: Publish to npm + +# Source-of-truth model (ported from prisma/prisma; see +# docs/oss/versioning.md): +# The version comes from the root `package.json` `version` field. +# Maintainers advance it via `pnpm bump-version`. This workflow can +# never publish a version other than what is committed at HEAD. +# +# Trigger model: +# - push to `main` with the root `version` unchanged → publish +# `-dev.N` under dist-tag `dev`. +# - push to `main` with the root `version` changed → publish `` +# under dist-tag `latest` and create a GitHub Release (marked +# pre-release for RC-line versions like `8.0.0-rc.N`). `latest` +# tracks the newest release, RC or stable. This is how a merged +# `chore(release): ...` PR auto-ships — merging the bump PR is the +# operator's deliberate act that moves `latest`. +# - workflow_dispatch → publish `` +# under the chosen dist-tag (default `latest`); also the dry-run path. +# +# Scope: publishes `@prisma/cli-engine` then `@prisma/cli` (the cli +# depends on the engine, so the engine goes first). `@prisma/compute` is +# excluded from the lockstep by operator ruling (2026-08-10) and keeps +# its own workflow (`publish-compute.yml`). + +on: + push: + branches: [main] + tags: ["!**"] + workflow_dispatch: + inputs: + dist-tag: + description: "npm dist-tag (e.g., latest, dev, beta)" + required: true + default: "latest" + type: string + dry-run: + description: "Dry-run only (build + pack, no npm publish, no GitHub Release)." + required: false + default: true + type: boolean + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + publish: + name: Publish packages to npm + runs-on: ubuntu-latest + # Only `main` may produce a real publish. A dry-run dispatch is permitted + # from any branch so maintainers can validate the pipeline before merging + # changes that touch publishing. The dry-run path performs no registry + # writes and skips the GitHub Release step, so non-main runs cannot + # affect production state. + if: ${{ github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'true') }} + permissions: + contents: write # Required to create the GitHub Release + tag for latest publishes + id-token: write # Required for npm OIDC Trusted Publishing + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + # Need history reaching `github.event.before` so `determine-version.ts` + # can compare the root `package.json` version at that ref to HEAD and + # decide whether this push is a release bump (publish `latest`) or a + # routine commit (publish `dev`). A multi-commit push can place + # `before` arbitrarily far back, so fetch the full history. + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + cache: pnpm + + - name: Configure npm + run: pnpm config set registry https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Determine version + id: version + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + INPUT_DIST_TAG: ${{ github.event.inputs.dist-tag }} + # `before` is the ref `main` pointed at before this push. + # `determine-version.ts` reads the root `package.json` at that ref + # to detect release bumps. Empty for `workflow_dispatch`, which + # the script also handles. + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: node scripts/determine-version.ts + + - name: Set package versions + run: node scripts/set-version.ts "${{ steps.version.outputs.version }}" + + - name: Build packages + run: pnpm build + + - name: Run script tests + run: pnpm test:scripts + + # NODE_AUTH_TOKEN is intentionally NOT set. npm detects the OIDC + # environment (id-token: write) and authenticates via Trusted + # Publishing automatically. Setting NODE_AUTH_TOKEN to any value -- + # even empty string -- would block OIDC. + # + # `pnpm publish` (not `npm publish`) so `workspace:` + # specifiers are rewritten to exact versions in the published + # manifest. `--no-git-checks` because the version stamp above + # dirties the tree by design. + # + # Publish order: the engine first, then the cli that depends on it. + + # Dry-run path: exercises the full publish pipeline (pack, validate + # tarball contents, dependency rewriting) without touching the npm + # registry. Use from any branch via `workflow_dispatch` to validate + # changes that affect publishing before merging. + - name: Publish packages (dry-run) + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'true' }} + run: | + pnpm --filter @prisma/cli-engine publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run + pnpm --filter @prisma/cli publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks --dry-run + + - name: Publish packages + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true' }} + env: + NPM_CONFIG_PROVENANCE: "true" + run: | + pnpm --filter @prisma/cli-engine publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks + pnpm --filter @prisma/cli publish --tag "${{ steps.version.outputs.tag }}" --access public --no-git-checks + + # Emit a GitHub Release for `latest` publishes only (marked + # pre-release when the version is on the RC line). Dev / beta builds + # publish to npm but do not produce a Release — those would drown out + # the changelog signal. The Release is created at $GITHUB_SHA so the + # tag points at the same commit the publish ran from. + # + # Idempotent on workflow rerun: if a Release for `v$VERSION` already + # exists (e.g. a previous run published to npm but failed before this + # step), edit it in place rather than re-creating it. + - name: Create GitHub Release for latest publishes + if: ${{ steps.version.outputs.tag == 'latest' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + PRERELEASE_FLAG="" + case "$VERSION" in + *-rc.*) PRERELEASE_FLAG="--prerelease" ;; + esac + if gh release view "v$VERSION" >/dev/null 2>&1; then + gh release edit "v$VERSION" \ + --target "$GITHUB_SHA" \ + --title "v$VERSION" + else + gh release create "v$VERSION" \ + --target "$GITHUB_SHA" \ + --title "v$VERSION" \ + --generate-notes \ + $PRERELEASE_FLAG + fi From 303d2573c7a1f4f1cd05e18e52d61015a110be8e Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:02:55 +0200 Subject: [PATCH 20/67] docs: port versioning.md and align release docs with the 8.0.0-rc ruling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts prisma/prisma's docs/oss/versioning.md to this repo (package names, the verbatim latest model, the @prisma/compute exclusion pending extraction — operator rulings 2026-08-10). README/CONTRIBUTING publish sections and ADR 0001's status now point at the committed-version model; the S2a contract carries a one-line erratum (engine version 0.1.0 -> 8.0.0-rc.1); the rollout plan's step 2 and its latest invariant are reworded per the ruling — latest moves only through a deliberately merged version-bump PR (or manual dispatch), superseding the earlier next-tag interim. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../prisma-cli-v8/assets/rollout-plan.md | 20 +++-- .../prisma-cli-v8/specs/s2a-foundations.md | 5 +- CONTRIBUTING.md | 10 ++- README.md | 35 +++++---- docs/README.md | 1 + .../0001-preview-package-and-publishing.md | 5 +- docs/oss/versioning.md | 75 +++++++++++++++++++ 7 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 docs/oss/versioning.md diff --git a/.drive/projects/prisma-cli-v8/assets/rollout-plan.md b/.drive/projects/prisma-cli-v8/assets/rollout-plan.md index a2556f7a..7e05c01f 100644 --- a/.drive/projects/prisma-cli-v8/assets/rollout-plan.md +++ b/.drive/projects/prisma-cli-v8/assets/rollout-plan.md @@ -8,7 +8,7 @@ unified CLI reaches npm, from first pre-release through owning the bare | npm name | Today | End state | | --- | --- | --- | -| `@prisma/cli` | Platform CLI 3.x, published from this repo (OIDC) | Carries v8 pre-releases under the `next` dist-tag; deprecated at cutover | +| `@prisma/cli` | Platform CLI 3.x, published from this repo (OIDC) | Carries v8 RC releases under `latest` via merged bump PRs; deprecated at cutover | | `prisma-next` | Prisma 8 ORM CLI, published from prisma/prisma `main` | Handed off to this repo at S5; rc channel for ORM early adopters; deprecated at cutover | | `prisma7` | Does not exist yet | The v7-and-under release train's new home, published from prisma/prisma | | `prisma` | Prisma 7 CLI, published by prisma/prisma's release train | Owned by this repo via OIDC trusted publishing; the unified CLI | @@ -19,11 +19,15 @@ unified CLI reaches npm, from first pre-release through owning the bare releases are manual-dispatch only and are frozen for the migration; the automatic `dev`-tag publish on main merges continues and is harmless. -2. **S2 done (platform family ported).** v8 betas publish as - `@prisma/cli` under the **`next` dist-tag**. This repo already owns - the name with OIDC; no cross-repo coordination. Semantically honest: - the first v8 surface is the platform family, i.e. the next major of - the platform CLI. `latest` stays on the 3.x line throughout. +2. **S2 done (platform family ported).** v8 RC-line versions + (`8.0.0-rc.N`) publish as `@prisma/cli` under **`latest`**, moved + only by a deliberately merged version-bump PR (operator ruling + 2026-08-10, supersedes the earlier next-tag interim: "Me creating a + version bump PR and merging it counts as a deliberate explicit + action to alter latest"). This repo already owns the name with + OIDC; no cross-repo coordination. Semantically honest: the first + v8 surface is the platform family, i.e. the next major of the + platform CLI. 3. **S5 done (ORM family ported).** The `prisma-next` name is handed off: prisma/prisma stops publishing it and its npm trusted-publisher config moves to this repo (npm allows one publisher config per @@ -50,7 +54,9 @@ unified CLI reaches npm, from first pre-release through owning the bare - Every publish path uses OIDC trusted publishing with provenance; no pasted tokens anywhere (a manual-token fallback for prisma/prisma was considered and rejected — the `prisma7` rename makes it unnecessary). -- `latest` never moves automatically, on any of the names. +- `latest` moves only through a deliberately merged version-bump PR (or + a manual workflow dispatch), on any of the names (reworded per the + 2026-08-10 operator ruling; previously "never moves automatically"). - Version pins across the tandem packages follow the committed-versions ruling (S3). diff --git a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md index aa1c7272..aa2acf7f 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md +++ b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md @@ -11,7 +11,10 @@ reference implementation cited for it is the specification. ## 1. Engine publishable + production dependency - `packages/cli-engine/package.json` gains publish metadata: - `version: "0.1.0"`, `description` (one line: the execution engine of + `version: "8.0.0-rc.1"` (erratum: originally `"0.1.0"`; operator + ruling 2026-08-10 adopted prisma/prisma's versioning machinery and + number — the lockstep jumps to the 8.0.0-rc line), `description` + (one line: the execution engine of the unified Prisma CLI), `license: "Apache-2.0"`, `files: ["dist", "README.md", "LICENSE"]`, `repository` (type git, url https://github.com/prisma/prisma-cli.git, directory diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a85ab19..bf408878 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,11 +60,13 @@ The CLI must preserve the unified command model: ## Package Channels -Official beta releases use `@prisma/cli` and expose the `prisma-cli` binary. -The `latest` dist-tag points at the latest manually published beta. +Releases use `@prisma/cli` (exposing the `prisma-cli` binary) plus its +dependency `@prisma/cli-engine`, versioned in lockstep from the root +`package.json` on the v8 RC line (`8.0.0-rc.N`). The `latest` dist-tag moves +when a version-bump PR merges; see `docs/oss/versioning.md`. The `dev` dist-tag points at the latest successful `main` build, published as -`3.0.0-dev..`. Commit traceability comes from npm +`-dev.N`. Commit traceability comes from npm provenance and the GitHub Actions run. Trusted same-repo pull requests receive pkg.pr.new preview comments for testing exact unmerged commits. Fork pull requests do not publish preview packages automatically. Preview publishing is @@ -74,7 +76,7 @@ repository. Once that app is installed, set the repository variable Do not publish from a local checkout unless the release owner has explicitly asked you to do so. Release publishing is intended to happen through the -configured `Publish CLI` GitHub Actions workflow. +configured `Publish to npm` GitHub Actions workflow. ## Pull Requests diff --git a/README.md b/README.md index c9385e6d..3371e8dd 100644 --- a/README.md +++ b/README.md @@ -144,22 +144,23 @@ inside an example only when you want to run manual end-to-end checks. ## Publishing -Publishing happens through the `Publish CLI` GitHub Actions workflow. Do not +Publishing happens through the `Publish to npm` GitHub Actions workflow. Do not publish from a local checkout unless the release owner explicitly asks you to do -so. +so. The full policy and procedure live in `docs/oss/versioning.md`. -The committed `packages/cli/package.json` version is a development placeholder. -Release versions are injected by CI before the package is packed and published. +The committed root `package.json` version (mirrored across the workspace in +lockstep) is the release source of truth. The workflow publishes +`@prisma/cli-engine` and then `@prisma/cli`. Release channels: -- `@prisma/cli` / `latest`: official beta releases. Run `Publish CLI` manually; - it computes the next `3.0.0-beta.N`, publishes with the `latest` dist-tag, and - creates `cli-v`. -- `@prisma/cli@dev`: latest successful `main` build. Every push to `main` - publishes a unique `3.0.0-dev..` version with the - `dev` dist-tag. Commit traceability comes from npm provenance and the GitHub - Actions run. +- `latest`: releases on the v8 RC line (`8.0.0-rc.N`). A merged + `chore(release)` PR that bumps the root version (via `pnpm bump-version`) + publishes automatically; `workflow_dispatch` is the manual escape hatch and + the dry-run path. +- `dev`: latest successful `main` build. Every push to `main` that does not + change the root version publishes `-dev.N` under the `dev` dist-tag. + Commit traceability comes from npm provenance and the GitHub Actions run. - PR preview packages: trusted same-repo pull requests get an installable pkg.pr.new comment for the exact commit. Fork PRs do not publish preview packages automatically. Preview publishing is best-effort and requires the @@ -167,12 +168,10 @@ Release channels: installed, set the repository variable `CLI_PR_PREVIEW_REQUIRED=true` to make preview publishing failures block CI. -For an official beta release: - -1. Run `Publish CLI` with `dry_run: true`. -2. Check the computed version and package checks. -3. Run `Publish CLI` with `dry_run: false`. +For a release: run `pnpm bump-version` on a branch, commit, open the release +PR, and merge it — the merge is the publish trigger (`docs/oss/versioning.md` +has the full procedure). If a release workflow fails after the npm publish step, check npm before -rerunning. The package version may already be published even if tag creation -failed. +rerunning. The package version may already be published even if the GitHub +Release creation failed. diff --git a/docs/README.md b/docs/README.md index 3a395174..b0659a96 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ For local development, continue with: - [Glossary](reference/glossary.md) - [Testing patterns](reference/testing-patterns.md) +- [Versioning](oss/versioning.md) ## Contribution Standard diff --git a/docs/architecture/adrs/0001-preview-package-and-publishing.md b/docs/architecture/adrs/0001-preview-package-and-publishing.md index 65b14778..4003a197 100644 --- a/docs/architecture/adrs/0001-preview-package-and-publishing.md +++ b/docs/architecture/adrs/0001-preview-package-and-publishing.md @@ -2,7 +2,10 @@ ## Status -Accepted +Superseded for versioning and release publishing by the prisma/prisma-ported +model in `docs/oss/versioning.md` (operator ruling 2026-08-10): committed +lockstep versions on the `8.0.0-rc.N` line, no CI version injection for +releases. The pkg.pr.new preview channel and the `dev` dist-tag remain. ## Context diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md new file mode 100644 index 00000000..73b5df24 --- /dev/null +++ b/docs/oss/versioning.md @@ -0,0 +1,75 @@ +# Versioning + +This page covers the **version contract** the unified Prisma CLI offers to its users, and the **mechanism** that delivers it. The first half is the policy you can rely on; the second half is the procedure maintainers follow to honour it. The model (scripts, workflow, and this document) is ported from [prisma/prisma](https://github.com/prisma/prisma) by operator ruling (2026-08-10): this repo adopts that versioning machinery *and* its version number — the unified CLI takes over the `8.0.0-rc.N` line. + +## The v8 RC line + +Prisma 8 ships as a release-candidate line ahead of `8.0.0` final: releases are versioned `8.0.0-rc.1`, `8.0.0-rc.2`, … with the counter advancing on every release publish. "The v8 RC" is the product name; the version number underneath iterates freely, and there is no promise that the final RC is literally numbered `rc.1`. Versions are immutable on npm — a botched publish burns a counter value, which is fine; skip it and never reuse a number. + +RC respins may include breaking changes until `8.0.0` final ships. There are no patch releases on the RC line — a fix ships as the next `rc.N`. + +For the packages this repository's publish workflow ships (`@prisma/cli`, `@prisma/cli-engine`), **`latest` tracks the newest release — RC or stable**. `latest` moves only through a deliberately merged version-bump PR (or a manual `workflow_dispatch`); creating and merging the bump PR is the operator's explicit act that alters `latest`. Existing installs are unaffected — lockfiles pin resolved versions, and stable ranges like `^3.x` can never resolve to an RC (pre-releases don't match stable ranges), so nobody is moved onto the RC line by `npm update`; new installs get the newest RC. + +The transition onto the RC line is a one-time bump from the pre-8 base to `8.0.0-rc.1`; `pnpm bump-version` encodes it (a pre-8 stable base advances to `8.0.0-rc.1`, an RC base advances its counter). + +## Lockstep across the workspace + +Every lockstep workspace package — publishable, private, and the workspace root — carries the same `version`. One read of root [`package.json`](../../package.json) answers "what version is this code?" for the repository. + +**Exception:** `@prisma/compute` versions independently, pending extraction to another repository (operator ruling 2026-08-10). It is hard-excluded in [`scripts/set-version.ts`](../../scripts/set-version.ts) and keeps its own publish workflow ([`publish-compute.yml`](../../.github/workflows/publish-compute.yml)). + +The lockstep set is: the workspace root, `packages/cli`, `packages/cli-engine`, `packages/cli-telemetry`, and `packages/tsconfig`. Private packages are never published (`pnpm publish` skips them), but they still version in lockstep so a contributor cloning the repo at any commit sees one consistent answer to "what version is this code?". Workspace-internal dependencies are pinned as `workspace:` (e.g. `workspace:8.0.0-rc.1`); pnpm resolves them locally during development and rewrites them to the exact version at publish time, so every published package carries an exact-version pin on its siblings. + +## Dist-tag convention + +The npm registry exposes the CLI packages under these dist-tags: + +- **`latest`** — the most recent release, RC or stable (`8.0.0-rc.N` on the RC line). Default for any bare `npm install`. New `latest` releases happen only when a release PR merges (see procedure below) or a maintainer dispatches the publish workflow. +- **`dev`** — every push to `main` that doesn't change the root `version` produces a `-dev.N` tarball under this tag (on the RC line: `8.0.0-rc.X-dev.N`). Use these to pin reproductions or hand someone a "try `@dev` to get the bleeding edge" link. **No stability promise** — they may be yanked freely. +- **`beta`** — reserved for hand-cut previews ahead of significant changes. Routine releases do not use this tag. + +PR previews go through [`pkg.pr.new`](https://pkg.pr.new) ([`preview-cli-package.yml`](../../.github/workflows/preview-cli-package.yml)); they carry the committed base version and install via per-commit URLs, not dist-tags. + +## Who can publish + +Publishing requires: + +- **Push access to `main`** — pushing to `main` or merging a release PR is restricted to maintainers. +- **A green run of the [`Publish to npm`](../../.github/workflows/publish.yml) workflow.** The workflow uses npm OIDC trusted publishing — no long-lived `NPM_TOKEN` exists in repository secrets, so a leaked secret cannot be used to publish out-of-band. Each published tarball carries an [npm provenance attestation](https://docs.npmjs.com/generating-provenance-statements) tying it to this repository and the workflow run that produced it. +- The workflow only publishes from `main`. Dry-runs are permitted from any branch (see "validate publish changes" below); every step that would mutate external state is independently guarded. + +## Mechanism: how we deliver the contract + +The version this repository ships is the **`version` field of the root [`package.json`](../../package.json)**. The publish workflow ([`publish.yml`](../../.github/workflows/publish.yml)) reads this value at the workflow's git ref and refuses to publish anything else. There is no `workflow_dispatch` input to override the version, no per-package `version` drift, and no separate "release manifest" file. + +This is by design. The alternatives cause silent problems: + +- **Querying the npm registry for the latest tag** makes the next version implicit. A yanked release, a manually-rewritten dist-tag, or registry latency all silently shift what the next CI build calls itself. +- **A separate `versions.json`** would diverge from the per-package `version` in tooling that only inspects `package.json` (npm, dependency analyzers, supply-chain scanners, downstream consumers). Keeping the source in `package.json` means there is nothing to keep in sync. + +[`scripts/set-version.ts`](../../scripts/set-version.ts) is what enforces lockstep: a single invocation walks every lockstep workspace `package.json` and writes the requested version (rewriting `workspace:` dependency pins to match). The publish workflow uses the same script, so per-package and root values cannot diverge through the publish path. + +The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships the new version under dist-tag `latest` — on the RC line that means `latest` moves to the new `8.0.0-rc.N`, and the accompanying GitHub Release is marked pre-release. Pushes that don't change the root `version` produce `-dev.N` tarballs under dist-tag `dev` instead. This is what makes "merge the release PR" the publish trigger — there is no separate dispatch step. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it). + +[`scripts/determine-version.ts`](../../scripts/determine-version.ts) composes the version + dist-tag for the run and refuses non-canonical bases (anything other than `X.Y.Z` or `8.0.0-rc.N`), so a malformed root `version` fails the publish before anything reaches the registry. + +## Procedure: cut the next release + +The release cadence is one PR per release (on the RC line: one PR per `rc.N`). A maintainer: + +1. **Runs `pnpm bump-version`** on a fresh branch off `main`. The script reads the root version committed at HEAD, computes the next release version (`8.0.0-rc.N` → `8.0.0-rc.N+1`), and writes it to every lockstep `package.json`. Run `pnpm install` afterwards so the lockfile picks up the rewritten `workspace:` pins, then commit and open a `chore(release): 8.0.0-rc.N+1` PR. +2. **Reviews and merges the PR.** This is the point where a human verifies the release is intended — merging the bump PR is the deliberate act that moves `latest`. The resulting push to `main` carries the bumped root `version`, the publish workflow detects the change, publishes under `latest`, and creates a matching GitHub Release (marked pre-release on the RC line). + +If the publish needs to be re-run (transient registry failure, etc.), a maintainer can dispatch the [`Publish to npm`](../../.github/workflows/publish.yml) workflow from `main` with `dist-tag=latest` and `dry-run=false`; the workflow re-publishes the version currently committed at HEAD. This is the same path used to cut a hand-rolled `beta` (`dist-tag=beta`). + +## Procedure: validate publish changes + +The publish workflow's `dry-run` mode (the input default) can be invoked from any branch to validate that the publish pipeline still works after touching `publish.yml`, `set-version.ts`, `determine-version.ts`, or the build scripts. A dry-run exercises `pnpm publish --dry-run` against both CLI packages and skips the registry publish + GitHub Release. + +The pure version-computation helpers are covered by `pnpm test:scripts` (run in CI by the PR Quality workflow), including RC-line advancement and dev-counter idempotence. + +## Non-goals + +- **Independent per-package versioning** (beyond the ruled `@prisma/compute` exclusion). Lockstep is the invariant the rest of the contract is built on. +- **A scripted `beta` cadence.** The `beta` dist-tag exists but cutting beta builds is a manual `workflow_dispatch`. (The RC cadence, by contrast, *is* the routine scripted path.) +- **Patch releases on the RC line.** A fix ships as the next `rc.N`. From 0db70b258f5797be522db12cd09805b13b25e674 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:08:31 +0200 Subject: [PATCH 21/67] test(cli): make telemetry config paths platform-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v8 telemetry suites pointed only XDG_CONFIG_HOME at their temp dir, but on win32 userConfigPath() resolves from APPDATA first, so every read and write on the Windows runner hit the real %APPDATA%\prisma-next\config.json. That file is never cleaned between tests, so earlier cases leaked stored ids and consent choices into later ones — the six Windows-only failures. Point APPDATA at the same temp dir alongside XDG_CONFIG_HOME and restore both afterwards; expected paths already derive from userConfigPath(). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/tests/v8-telemetry-reporting.test.ts | 25 +++++++++++++------ packages/cli/tests/v8-telemetry.test.ts | 25 +++++++++++++------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/cli/tests/v8-telemetry-reporting.test.ts b/packages/cli/tests/v8-telemetry-reporting.test.ts index 35722bfc..84cdbc5f 100644 --- a/packages/cli/tests/v8-telemetry-reporting.test.ts +++ b/packages/cli/tests/v8-telemetry-reporting.test.ts @@ -42,21 +42,32 @@ function makeProc(env: Record = {}) { return proc; } +// The config path resolves from XDG_CONFIG_HOME on POSIX and APPDATA on +// win32, so both must point at the temp dir for the tests to be hermetic +// on every platform. +const CONFIG_ENV_VARS = ["XDG_CONFIG_HOME", "APPDATA"] as const; + let xdgRoot: string; -let originalXdg: string | undefined; +let originalConfigEnv: Record; beforeEach(() => { xdgRoot = mkdtempSync(join(tmpdir(), "v8-telemetry-wiring-")); - originalXdg = process.env["XDG_CONFIG_HOME"]; - process.env["XDG_CONFIG_HOME"] = xdgRoot; + originalConfigEnv = {}; + for (const name of CONFIG_ENV_VARS) { + originalConfigEnv[name] = process.env[name]; + process.env[name] = xdgRoot; + } mkdirSync(dirname(userConfigPath()), { recursive: true }); }); afterEach(() => { - if (originalXdg === undefined) { - delete process.env["XDG_CONFIG_HOME"]; - } else { - process.env["XDG_CONFIG_HOME"] = originalXdg; + for (const name of CONFIG_ENV_VARS) { + const original = originalConfigEnv[name]; + if (original === undefined) { + delete process.env[name]; + } else { + process.env[name] = original; + } } rmSync(xdgRoot, { recursive: true, force: true }); }); diff --git a/packages/cli/tests/v8-telemetry.test.ts b/packages/cli/tests/v8-telemetry.test.ts index 5824b28e..97ca40e6 100644 --- a/packages/cli/tests/v8-telemetry.test.ts +++ b/packages/cli/tests/v8-telemetry.test.ts @@ -34,14 +34,22 @@ function makeCli() { }); } +// The config path resolves from XDG_CONFIG_HOME on POSIX and APPDATA on +// win32, so both must point at the temp dir for the tests to be hermetic +// on every platform. +const CONFIG_ENV_VARS = ["XDG_CONFIG_HOME", "APPDATA"] as const; + let xdgRoot: string; -let originalXdg: string | undefined; +let originalConfigEnv: Record; let configPath: string; beforeEach(() => { xdgRoot = mkdtempSync(join(tmpdir(), "v8-telemetry-cmd-")); - originalXdg = process.env["XDG_CONFIG_HOME"]; - process.env["XDG_CONFIG_HOME"] = xdgRoot; + originalConfigEnv = {}; + for (const name of CONFIG_ENV_VARS) { + originalConfigEnv[name] = process.env[name]; + process.env[name] = xdgRoot; + } configPath = userConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); vi.mocked(isCI).mockReset(); @@ -49,10 +57,13 @@ beforeEach(() => { }); afterEach(() => { - if (originalXdg === undefined) { - delete process.env["XDG_CONFIG_HOME"]; - } else { - process.env["XDG_CONFIG_HOME"] = originalXdg; + for (const name of CONFIG_ENV_VARS) { + const original = originalConfigEnv[name]; + if (original === undefined) { + delete process.env[name]; + } else { + process.env[name] = original; + } } rmSync(xdgRoot, { recursive: true, force: true }); }); From 46a8aea2c6b8e3f46f2582721c9ac5ef2364f053 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:16:40 +0200 Subject: [PATCH 22/67] chore(publish): releases require the chore(release) commit marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling 2026-08-10: a version change alone must not move latest — merging PR #130 jumps the lockstep to 8.0.0-rc.1 but only a deliberately merged 'chore(release): ...' bump PR publishes a release. Any other version-changing merge publishes a dev build, loudly. pkg.pr.new previews are unaffected and already cover both packages. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/publish.yml | 8 +++++++- docs/oss/versioning.md | 2 +- scripts/determine-version-utils.test.ts | 19 +++++++++++++++++++ scripts/determine-version-utils.ts | 4 ++++ scripts/determine-version.ts | 13 +++++++++++-- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 313ea47c..d2686284 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,8 @@ name: Publish to npm # Trigger model: # - push to `main` with the root `version` unchanged → publish # `-dev.N` under dist-tag `dev`. -# - push to `main` with the root `version` changed → publish `` +# - push to `main` with the root `version` changed BY A +# `chore(release): ...` COMMIT (the merged bump PR) → publish `` # under dist-tag `latest` and create a GitHub Release (marked # pre-release for RC-line versions like `8.0.0-rc.N`). `latest` # tracks the newest release, RC or stable. This is how a merged @@ -95,6 +96,11 @@ jobs: # to detect release bumps. Empty for `workflow_dispatch`, which # the script also handles. PUSH_BEFORE_SHA: ${{ github.event.before }} + # A release additionally requires the merged commit to be a + # release-bump commit (`chore(release): ...`) — a version change + # arriving through any other PR publishes a dev build only + # (operator ruling 2026-08-10). + HEAD_COMMIT_SUBJECT: ${{ github.event.head_commit.message }} run: node scripts/determine-version.ts - name: Set package versions diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index 73b5df24..4244b27d 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -49,7 +49,7 @@ This is by design. The alternatives cause silent problems: [`scripts/set-version.ts`](../../scripts/set-version.ts) is what enforces lockstep: a single invocation walks every lockstep workspace `package.json` and writes the requested version (rewriting `workspace:` dependency pins to match). The publish workflow uses the same script, so per-package and root values cannot diverge through the publish path. -The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships the new version under dist-tag `latest` — on the RC line that means `latest` moves to the new `8.0.0-rc.N`, and the accompanying GitHub Release is marked pre-release. Pushes that don't change the root `version` produce `-dev.N` tarballs under dist-tag `dev` instead. This is what makes "merge the release PR" the publish trigger — there is no separate dispatch step. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it). +The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip arriving via a merged `chore(release): ...` bump PR (the commit-subject marker is checked alongside the version change; a version change without it publishes a dev build only) is recognised as a release bump and ships the new version under dist-tag `latest` — on the RC line that means `latest` moves to the new `8.0.0-rc.N`, and the accompanying GitHub Release is marked pre-release. Pushes that don't change the root `version` produce `-dev.N` tarballs under dist-tag `dev` instead. This is what makes "merge the release PR" the publish trigger — there is no separate dispatch step. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it). [`scripts/determine-version.ts`](../../scripts/determine-version.ts) composes the version + dist-tag for the run and refuses non-canonical bases (anything other than `X.Y.Z` or `8.0.0-rc.N`), so a malformed root `version` fails the publish before anything reaches the registry. diff --git a/scripts/determine-version-utils.test.ts b/scripts/determine-version-utils.test.ts index 1d7c2ff8..378577f3 100644 --- a/scripts/determine-version-utils.test.ts +++ b/scripts/determine-version-utils.test.ts @@ -5,6 +5,7 @@ import { composeDevVersion, computeNextMinor, computeNextReleaseVersion, + isReleaseCommitSubject, parseVersion, } from "./determine-version-utils.ts"; @@ -191,3 +192,21 @@ describe("assertCanonicalBase", () => { assert.throws(() => assertCanonicalBase("8.0.0-rc.01"), /not canonical/); }); }); + +describe("isReleaseCommitSubject", () => { + it("accepts the bump-PR convention", () => { + assert.equal(isReleaseCommitSubject("chore(release): 8.0.0-rc.2"), true); + }); + + it("rejects every other subject, including version-changing feature merges", () => { + assert.equal( + isReleaseCommitSubject("feat: adopt lockstep versioning"), + false, + ); + assert.equal(isReleaseCommitSubject("chore: release prep"), false); + assert.equal( + isReleaseCommitSubject(" chore(release): leading space"), + false, + ); + }); +}); diff --git a/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts index 434b75d0..852c7799 100644 --- a/scripts/determine-version-utils.ts +++ b/scripts/determine-version-utils.ts @@ -112,3 +112,7 @@ export function assertCanonicalBase(base: string): void { ); } } + +export function isReleaseCommitSubject(subject: string): boolean { + return subject.startsWith("chore(release):"); +} diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts index e9d6fe8e..bafc5a48 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -33,6 +33,7 @@ import type { VersionResult } from "./determine-version-utils.ts"; import { assertCanonicalBase, composeDevVersion, + isReleaseCommitSubject, } from "./determine-version-utils.ts"; // `@prisma/cli` has the longest publish history in this repo (it carries @@ -144,14 +145,22 @@ switch (eventName) { // through to the dev path: a transient git error must never silently // promote to `latest`. const previous = readPreviousRootVersion(); - const isReleaseBump = + const versionChanged = previous.available && previous.version !== baseVersion; - if (isReleaseBump) { + const isReleaseCommit = isReleaseCommitSubject( + process.env.HEAD_COMMIT_SUBJECT ?? "", + ); + if (versionChanged && isReleaseCommit) { console.log( `Previous root version: ${previous.version ?? "(unset)"} → release bump detected.`, ); result = { version: baseVersion, tag: "latest" }; } else { + if (versionChanged) { + console.log( + "Root version changed without a chore(release) commit — publishing a dev build only. Releases ship exclusively through a merged bump PR.", + ); + } result = composeDevVersion(baseVersion, getLatestDevVersion()); } break; From 5e585402f6316184fe42f9cb9de6d8de6238c2cb Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:19:20 +0200 Subject: [PATCH 23/67] docs: adopt the publish-npm-version maintainer skill from prisma/prisma The release procedure's step 1 is now the ported skill, not a bare script invocation: fresh worktree off origin/main, pnpm bump-version, lockfile refresh, diff sanity check, and the release PR. Adapted for this repo: the PR title must carry the chore(release) marker because squash merges make the PR title the commit subject the publish workflow checks; compute exclusion and generated release notes noted. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/oss/versioning.md | 2 +- skills-contrib/publish-npm-version/SKILL.md | 131 ++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 skills-contrib/publish-npm-version/SKILL.md diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index 4244b27d..df7817e6 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -57,7 +57,7 @@ The publish workflow is **triggered by a change to the root `version`**: a push The release cadence is one PR per release (on the RC line: one PR per `rc.N`). A maintainer: -1. **Runs `pnpm bump-version`** on a fresh branch off `main`. The script reads the root version committed at HEAD, computes the next release version (`8.0.0-rc.N` → `8.0.0-rc.N+1`), and writes it to every lockstep `package.json`. Run `pnpm install` afterwards so the lockfile picks up the rewritten `workspace:` pins, then commit and open a `chore(release): 8.0.0-rc.N+1` PR. +1. **Runs the [`publish-npm-version` skill](../../skills-contrib/publish-npm-version/SKILL.md)**, which drives `pnpm bump-version` in a fresh worktree off `origin/main` and opens the release PR under real maintainer credentials (so CI runs on it normally). The script reads the root version committed at HEAD, computes the next release version (`8.0.0-rc.N` → `8.0.0-rc.N+1`), and writes it to every lockstep `package.json`. Run `pnpm install` afterwards so the lockfile picks up the rewritten `workspace:` pins, then commit and open a `chore(release): 8.0.0-rc.N+1` PR. 2. **Reviews and merges the PR.** This is the point where a human verifies the release is intended — merging the bump PR is the deliberate act that moves `latest`. The resulting push to `main` carries the bumped root `version`, the publish workflow detects the change, publishes under `latest`, and creates a matching GitHub Release (marked pre-release on the RC line). If the publish needs to be re-run (transient registry failure, etc.), a maintainer can dispatch the [`Publish to npm`](../../.github/workflows/publish.yml) workflow from `main` with `dist-tag=latest` and `dry-run=false`; the workflow re-publishes the version currently committed at HEAD. This is the same path used to cut a hand-rolled `beta` (`dist-tag=beta`). diff --git a/skills-contrib/publish-npm-version/SKILL.md b/skills-contrib/publish-npm-version/SKILL.md new file mode 100644 index 00000000..f296dac0 --- /dev/null +++ b/skills-contrib/publish-npm-version/SKILL.md @@ -0,0 +1,131 @@ +--- +name: publish-npm-version +description: >- + Cuts the next release of the unified Prisma CLI: bumps the root + package.json version (on the v8 RC line: 8.0.0-rc.N → rc.N+1), + propagates it to every lockstep workspace package, and opens a PR + titled "chore(release): bump to ". When the maintainer + merges the PR, the `Publish to npm` workflow ships @prisma/cli-engine + and @prisma/cli under dist-tag `latest` (RC versions get a + pre-release GitHub Release). Use when a maintainer asks to "cut the + next RC", "cut the next release", "bump to the next version", "open + a release PR", or "prepare a publish PR". +--- + +# Publish next npm version + +## Audience + +Maintainers of the unified Prisma CLI with permission to push branches +and open PRs in prisma/prisma-cli. The skill runs locally, never as a +GitHub Action — a locally opened PR triggers CI normally, which is the +point of a reviewable release PR. + +## Background reading + +Read [`docs/oss/versioning.md`](../../docs/oss/versioning.md) first. It +covers the source-of-truth model (root `package.json` `version`), the +lockstep guarantee (`@prisma/compute` excluded by ruling), the v8 RC +line, the dist-tag convention, and the release procedure this skill is +one step of. Two rules specific to this repo: + +- **Releases require the commit-subject marker.** The publish workflow + ships a release only when the push to `main` both changes the root + `version` AND carries a `chore(release): ...` subject. Any other + version-changing merge publishes a `dev` build only. +- **This repo squash-merges.** The subject that lands on `main` is the + PR TITLE, not the branch commit's. The PR title below therefore + matters mechanically: it must be `chore(release): bump to ` or the + merge will not publish. + +## Pre-flight + +No requirement to be on `main` or have a clean tree — all work happens +in a fresh worktree off `origin/main`. Confirm `git fetch origin main` +succeeds; if not, stop and surface the issue. + +## Procedure + +1. **Fetch and determine the target version.** + + ```bash + git fetch origin main + CURRENT=$(git show origin/main:package.json | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0,"utf8")).version)') + NEXT=$(node -e "import('./scripts/determine-version-utils.ts').then(m => process.stdout.write(m.computeNextReleaseVersion(process.argv[1])))" "$CURRENT") + echo "$CURRENT → $NEXT" + ``` + + `$NEXT` names the branch and PR; step 3 recomputes the authoritative + value inside the fresh worktree and the two must agree. + +2. **Create a fresh worktree off `origin/main`.** + + ```bash + git worktree add -b "release/$NEXT" "../release-$NEXT" origin/main + cd "../release-$NEXT" + ``` + +3. **Bump.** `pnpm install --frozen-lockfile --ignore-scripts` if the + worktree has no `node_modules`, then `pnpm bump-version`. Confirm the + version it wrote matches `$NEXT`; on mismatch the worktree's value is + authoritative — remove worktree and branch, restart from step 1. + +4. **Refresh the lockfile.** Internal deps are pinned + `workspace:`, so run `pnpm install --lockfile-only`; + without it the release PR fails with `ERR_PNPM_OUTDATED_LOCKFILE`. + +5. **Sanity-check the diff.** Only `package.json` files and + `pnpm-lock.yaml`; manifests change exactly `version` + + `workspace: → workspace:`; the excluded + `packages/compute/package.json` is untouched. + +6. **Commit** everything in one commit: + + ```text + chore(release): bump to + ``` + +7. **Push the branch** to `origin`. + +8. **Open the PR** with `gh pr create`. Title (mandatory — the squash + commit inherits it and the publish workflow checks it): + + ```text + chore(release): bump to + ``` + + Body: previous → new version; link + [`docs/oss/versioning.md`](../../docs/oss/versioning.md); note that + **merging this PR ships the release** — the squashed push to `main` + changes the root version with the release marker, the `Publish to + npm` workflow publishes `@prisma/cli-engine` then `@prisma/cli` + under `latest`, and a GitHub Release (pre-release on the RC line) is + created with generated notes. This repo has no committed + release-notes file gate; review the generated notes on the Release + after merge. + +9. **Stop and report** the PR URL and the worktree path. Do not merge — + the merge is the human gate that triggers the publish. The + maintainer removes the worktree after merge + (`git worktree remove ../release-`). + +## Idempotency + +`pnpm bump-version` reads the root version from `git show +HEAD:package.json`, so re-running in the same worktree cannot +double-bump. The skill as a whole is not idempotent: step 2 fails if +the branch/worktree already exist — remove them +(`git worktree remove ../release-$NEXT`; `git branch -D +release/$NEXT`) or continue inside the existing worktree from step 3. +Do not stack bumps. + +## Out of scope + +- **Merging the PR** (human gate; merging is the publish trigger). +- **Patch releases.** On the RC line a fix is just the next `rc.N`. +- **Beta tags** — hand-cut via `workflow_dispatch` of `Publish to npm` + with the `beta` dist-tag; this skill always advances the release + version. +- **`@prisma/compute`** — excluded from the lockstep by operator ruling + (2026-08-10); it versions and publishes independently + (`publish-compute.yml`) pending extraction to another repo. From 0419b794a5f374fc16ecaf7b6af9da52829d64e2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:23:44 +0200 Subject: [PATCH 24/67] =?UTF-8?q?chore(publish):=20drop=20the=20release-ma?= =?UTF-8?q?rker=20gate=20=E2=80=94=20machinery=20identical=20to=20prisma/p?= =?UTF-8?q?risma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling: the publish machinery must match prisma/prisma exactly so it never needs remembering. The transient problem the gate solved (the lockstep-adoption merge changing the root version without being a release) is handled by sequencing instead: a root-version pre-PR lands on main under the old workflows first, so this branch's merge shows no version change to the new workflow. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/publish.yml | 8 +------- docs/oss/versioning.md | 2 +- scripts/determine-version-utils.test.ts | 19 ------------------- scripts/determine-version-utils.ts | 4 ---- scripts/determine-version.ts | 13 ++----------- skills-contrib/publish-npm-version/SKILL.md | 17 ++++------------- 6 files changed, 8 insertions(+), 55 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d2686284..313ea47c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,8 +9,7 @@ name: Publish to npm # Trigger model: # - push to `main` with the root `version` unchanged → publish # `-dev.N` under dist-tag `dev`. -# - push to `main` with the root `version` changed BY A -# `chore(release): ...` COMMIT (the merged bump PR) → publish `` +# - push to `main` with the root `version` changed → publish `` # under dist-tag `latest` and create a GitHub Release (marked # pre-release for RC-line versions like `8.0.0-rc.N`). `latest` # tracks the newest release, RC or stable. This is how a merged @@ -96,11 +95,6 @@ jobs: # to detect release bumps. Empty for `workflow_dispatch`, which # the script also handles. PUSH_BEFORE_SHA: ${{ github.event.before }} - # A release additionally requires the merged commit to be a - # release-bump commit (`chore(release): ...`) — a version change - # arriving through any other PR publishes a dev build only - # (operator ruling 2026-08-10). - HEAD_COMMIT_SUBJECT: ${{ github.event.head_commit.message }} run: node scripts/determine-version.ts - name: Set package versions diff --git a/docs/oss/versioning.md b/docs/oss/versioning.md index df7817e6..219d7237 100644 --- a/docs/oss/versioning.md +++ b/docs/oss/versioning.md @@ -49,7 +49,7 @@ This is by design. The alternatives cause silent problems: [`scripts/set-version.ts`](../../scripts/set-version.ts) is what enforces lockstep: a single invocation walks every lockstep workspace `package.json` and writes the requested version (rewriting `workspace:` dependency pins to match). The publish workflow uses the same script, so per-package and root values cannot diverge through the publish path. -The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip arriving via a merged `chore(release): ...` bump PR (the commit-subject marker is checked alongside the version change; a version change without it publishes a dev build only) is recognised as a release bump and ships the new version under dist-tag `latest` — on the RC line that means `latest` moves to the new `8.0.0-rc.N`, and the accompanying GitHub Release is marked pre-release. Pushes that don't change the root `version` produce `-dev.N` tarballs under dist-tag `dev` instead. This is what makes "merge the release PR" the publish trigger — there is no separate dispatch step. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it). +The publish workflow is **triggered by a change to the root `version`**: a push to `main` whose root `package.json` carries a different `version` than the previous tip is recognised as a release bump and ships the new version under dist-tag `latest` — on the RC line that means `latest` moves to the new `8.0.0-rc.N`, and the accompanying GitHub Release is marked pre-release. Pushes that don't change the root `version` produce `-dev.N` tarballs under dist-tag `dev` instead. This is what makes "merge the release PR" the publish trigger — there is no separate dispatch step. Within a publish, `@prisma/cli-engine` goes first, then `@prisma/cli` (which depends on it). [`scripts/determine-version.ts`](../../scripts/determine-version.ts) composes the version + dist-tag for the run and refuses non-canonical bases (anything other than `X.Y.Z` or `8.0.0-rc.N`), so a malformed root `version` fails the publish before anything reaches the registry. diff --git a/scripts/determine-version-utils.test.ts b/scripts/determine-version-utils.test.ts index 378577f3..1d7c2ff8 100644 --- a/scripts/determine-version-utils.test.ts +++ b/scripts/determine-version-utils.test.ts @@ -5,7 +5,6 @@ import { composeDevVersion, computeNextMinor, computeNextReleaseVersion, - isReleaseCommitSubject, parseVersion, } from "./determine-version-utils.ts"; @@ -192,21 +191,3 @@ describe("assertCanonicalBase", () => { assert.throws(() => assertCanonicalBase("8.0.0-rc.01"), /not canonical/); }); }); - -describe("isReleaseCommitSubject", () => { - it("accepts the bump-PR convention", () => { - assert.equal(isReleaseCommitSubject("chore(release): 8.0.0-rc.2"), true); - }); - - it("rejects every other subject, including version-changing feature merges", () => { - assert.equal( - isReleaseCommitSubject("feat: adopt lockstep versioning"), - false, - ); - assert.equal(isReleaseCommitSubject("chore: release prep"), false); - assert.equal( - isReleaseCommitSubject(" chore(release): leading space"), - false, - ); - }); -}); diff --git a/scripts/determine-version-utils.ts b/scripts/determine-version-utils.ts index 852c7799..434b75d0 100644 --- a/scripts/determine-version-utils.ts +++ b/scripts/determine-version-utils.ts @@ -112,7 +112,3 @@ export function assertCanonicalBase(base: string): void { ); } } - -export function isReleaseCommitSubject(subject: string): boolean { - return subject.startsWith("chore(release):"); -} diff --git a/scripts/determine-version.ts b/scripts/determine-version.ts index bafc5a48..e9d6fe8e 100644 --- a/scripts/determine-version.ts +++ b/scripts/determine-version.ts @@ -33,7 +33,6 @@ import type { VersionResult } from "./determine-version-utils.ts"; import { assertCanonicalBase, composeDevVersion, - isReleaseCommitSubject, } from "./determine-version-utils.ts"; // `@prisma/cli` has the longest publish history in this repo (it carries @@ -145,22 +144,14 @@ switch (eventName) { // through to the dev path: a transient git error must never silently // promote to `latest`. const previous = readPreviousRootVersion(); - const versionChanged = + const isReleaseBump = previous.available && previous.version !== baseVersion; - const isReleaseCommit = isReleaseCommitSubject( - process.env.HEAD_COMMIT_SUBJECT ?? "", - ); - if (versionChanged && isReleaseCommit) { + if (isReleaseBump) { console.log( `Previous root version: ${previous.version ?? "(unset)"} → release bump detected.`, ); result = { version: baseVersion, tag: "latest" }; } else { - if (versionChanged) { - console.log( - "Root version changed without a chore(release) commit — publishing a dev build only. Releases ship exclusively through a merged bump PR.", - ); - } result = composeDevVersion(baseVersion, getLatestDevVersion()); } break; diff --git a/skills-contrib/publish-npm-version/SKILL.md b/skills-contrib/publish-npm-version/SKILL.md index f296dac0..5e54c3f6 100644 --- a/skills-contrib/publish-npm-version/SKILL.md +++ b/skills-contrib/publish-npm-version/SKILL.md @@ -27,16 +27,8 @@ Read [`docs/oss/versioning.md`](../../docs/oss/versioning.md) first. It covers the source-of-truth model (root `package.json` `version`), the lockstep guarantee (`@prisma/compute` excluded by ruling), the v8 RC line, the dist-tag convention, and the release procedure this skill is -one step of. Two rules specific to this repo: - -- **Releases require the commit-subject marker.** The publish workflow - ships a release only when the push to `main` both changes the root - `version` AND carries a `chore(release): ...` subject. Any other - version-changing merge publishes a `dev` build only. -- **This repo squash-merges.** The subject that lands on `main` is the - PR TITLE, not the branch commit's. The PR title below therefore - matters mechanically: it must be `chore(release): bump to ` or the - merge will not publish. +one step of. One note specific to this +repo: the lockstep excludes `@prisma/compute` by operator ruling. ## Pre-flight @@ -87,11 +79,10 @@ succeeds; if not, stop and surface the issue. 7. **Push the branch** to `origin`. -8. **Open the PR** with `gh pr create`. Title (mandatory — the squash - commit inherits it and the publish workflow checks it): +8. **Open the PR** with `gh pr create`. Use the title: ```text - chore(release): bump to + Bump to version ``` Body: previous → new version; link From a5594ca8c4b035a334b2e1d4b329834165de40a6 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:27:19 +0200 Subject: [PATCH 25/67] refactor(cli): rename useAuthWorkspace to switchAuthWorkspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The use- prefix read as a React hook. The operation switches the active workspace session — its own presenter already says so. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../prisma-cli-v8/specs/s2a-foundations.md | 2 +- packages/cli/src/auth/index.ts | 2 +- packages/cli/src/auth/workspaces.ts | 2 +- packages/cli/src/controllers/auth.ts | 4 +-- packages/cli/src/v8/auth/workspace-use.ts | 4 +-- packages/cli/tests/v8-auth.test.ts | 26 +++++++++---------- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md index aa2acf7f..613a0a82 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md +++ b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md @@ -85,7 +85,7 @@ everything else in `src/auth/` is internal to it. Exports exactly: `EmptyServiceTokenError`, `isEmptyServiceTokenError`, `SERVICE_TOKEN_ENV_VAR`, `getApiBaseUrl`, `CLIENT_ID`, the workspace list/use/logout operations currently in `controllers/auth.ts`'s -real-mode helpers (`listAuthWorkspaces`, `useAuthWorkspace`, +real-mode helpers (`listAuthWorkspaces`, `switchAuthWorkspace`, `logoutAuthWorkspace` — extracted from the controller into `src/auth/workspaces.ts`, controller delegates; erratum: the review loop dropped the interim `*Real*` spelling — there is no fixture-mode diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 4d7998f2..bd59fd8c 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -26,6 +26,6 @@ export { export { listAuthWorkspaces, logoutAuthWorkspace, - useAuthWorkspace, + switchAuthWorkspace, type WorkspaceOperationContext, } from "./workspaces"; diff --git a/packages/cli/src/auth/workspaces.ts b/packages/cli/src/auth/workspaces.ts index 4adf7d88..a5bc4f40 100644 --- a/packages/cli/src/auth/workspaces.ts +++ b/packages/cli/src/auth/workspaces.ts @@ -84,7 +84,7 @@ export async function listAuthWorkspaces( }; } -export async function useAuthWorkspace( +export async function switchAuthWorkspace( context: WorkspaceOperationContext, workspaceRef: string, ): Promise { diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index 107d00e4..3fb4aa2e 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -5,7 +5,7 @@ import { performLogout, readAuthState, SERVICE_TOKEN_ENV_VAR, - useAuthWorkspace, + switchAuthWorkspace, type WorkspaceOperationContext, } from "../auth"; import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; @@ -150,7 +150,7 @@ export async function runAuthWorkspaceUse( : await selectWorkspaceSession(context); const result = isRealMode(context) - ? await useAuthWorkspace( + ? await switchAuthWorkspace( workspaceOperationContext(context), selectedWorkspaceRef, ) diff --git a/packages/cli/src/v8/auth/workspace-use.ts b/packages/cli/src/v8/auth/workspace-use.ts index f482dd68..959289a7 100644 --- a/packages/cli/src/v8/auth/workspace-use.ts +++ b/packages/cli/src/v8/auth/workspace-use.ts @@ -9,7 +9,7 @@ import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; import { listAuthWorkspaces, SERVICE_TOKEN_ENV_VAR, - useAuthWorkspace, + switchAuthWorkspace, } from "../../auth"; import { CLI_NAME } from "../../cli-name"; import type { @@ -142,7 +142,7 @@ export const authWorkspaceUseCommand = defineCommand({ let result: AuthWorkspaceUseResult; try { - result = await useAuthWorkspace(operationContext(ctx), workspaceRef); + result = await switchAuthWorkspace(operationContext(ctx), workspaceRef); } catch (error) { const mapped = mapAuthOperationError(error); if (mapped) { diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index 8e30fba6..829adaea 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -12,7 +12,7 @@ import { performLogin, performLogout, readAuthState, - useAuthWorkspace, + switchAuthWorkspace, } from "../src/auth"; import { workspaceAmbiguousError, @@ -35,7 +35,7 @@ vi.mock("../src/auth", async (importOriginal) => ({ performLogout: vi.fn(), readAuthState: vi.fn(), listAuthWorkspaces: vi.fn(), - useAuthWorkspace: vi.fn(), + switchAuthWorkspace: vi.fn(), logoutAuthWorkspace: vi.fn(), })); @@ -146,7 +146,7 @@ beforeEach(() => { vi.mocked(performLogout).mockReset(); vi.mocked(readAuthState).mockReset(); vi.mocked(listAuthWorkspaces).mockReset(); - vi.mocked(useAuthWorkspace).mockReset(); + vi.mocked(switchAuthWorkspace).mockReset(); vi.mocked(logoutAuthWorkspace).mockReset(); }); @@ -642,7 +642,7 @@ describe("prisma-v8 auth workspace list", () => { describe("prisma-v8 auth workspace use", () => { it("switches by explicit ref and renders the mutation card", async () => { - vi.mocked(useAuthWorkspace).mockResolvedValue({ + vi.mocked(switchAuthWorkspace).mockResolvedValue({ previousWorkspace: { id: "ws_1", name: "Acme Inc" }, workspace: { id: "ws_2", name: "Globex" }, }); @@ -652,7 +652,7 @@ describe("prisma-v8 auth workspace use", () => { }); expect(result.exitCode).toBe(0); - expect(vi.mocked(useAuthWorkspace)).toHaveBeenCalledWith( + expect(vi.mocked(switchAuthWorkspace)).toHaveBeenCalledWith( expect.anything(), "ws_2", ); @@ -670,7 +670,7 @@ describe("prisma-v8 auth workspace use", () => { }); it("carries the raw use result in the json envelope", async () => { - vi.mocked(useAuthWorkspace).mockResolvedValue({ + vi.mocked(switchAuthWorkspace).mockResolvedValue({ previousWorkspace: null, workspace: { id: "ws_2", name: "Globex" }, }); @@ -696,7 +696,7 @@ describe("prisma-v8 auth workspace use", () => { }); it("maps an ambiguous name to AUTH.WORKSPACE_AMBIGUOUS with the match list, exit 2", async () => { - vi.mocked(useAuthWorkspace).mockRejectedValue( + vi.mocked(switchAuthWorkspace).mockRejectedValue( workspaceAmbiguousError("Acme Inc", [ { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, @@ -768,7 +768,7 @@ describe("prisma-v8 auth workspace use", () => { ...TWO_OAUTH_WORKSPACES, workspaces: [TWO_OAUTH_WORKSPACES.workspaces[0]], }); - vi.mocked(useAuthWorkspace).mockResolvedValue({ + vi.mocked(switchAuthWorkspace).mockResolvedValue({ previousWorkspace: null, workspace: { id: "ws_1", name: "Acme Inc" }, }); @@ -778,7 +778,7 @@ describe("prisma-v8 auth workspace use", () => { }); expect(result.exitCode).toBe(0); - expect(vi.mocked(useAuthWorkspace)).toHaveBeenCalledWith( + expect(vi.mocked(switchAuthWorkspace)).toHaveBeenCalledWith( expect.anything(), "ws_1", ); @@ -786,7 +786,7 @@ describe("prisma-v8 auth workspace use", () => { it("prompts a select over the workspaces and switches to the answer", async () => { vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); - vi.mocked(useAuthWorkspace).mockResolvedValue({ + vi.mocked(switchAuthWorkspace).mockResolvedValue({ previousWorkspace: { id: "ws_1", name: "Acme Inc" }, workspace: { id: "ws_2", name: "Globex" }, }); @@ -797,7 +797,7 @@ describe("prisma-v8 auth workspace use", () => { }); expect(result.exitCode).toBe(0); - expect(vi.mocked(useAuthWorkspace)).toHaveBeenCalledWith( + expect(vi.mocked(switchAuthWorkspace)).toHaveBeenCalledWith( expect.anything(), "ws_2", ); @@ -813,7 +813,7 @@ describe("prisma-v8 auth workspace use", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain("[CLI.PROMPT_INVALID]"); - expect(vi.mocked(useAuthWorkspace)).not.toHaveBeenCalled(); + expect(vi.mocked(switchAuthWorkspace)).not.toHaveBeenCalled(); }); it("fails non-interactively with the engine's structural prompt error, exit 2", async () => { @@ -825,7 +825,7 @@ describe("prisma-v8 auth workspace use", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain("[CLI.PROMPT_REQUIRED]"); - expect(vi.mocked(useAuthWorkspace)).not.toHaveBeenCalled(); + expect(vi.mocked(switchAuthWorkspace)).not.toHaveBeenCalled(); }); }); From e03e71d3588c961f7694941856e72a531e406555 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:34:39 +0200 Subject: [PATCH 26/67] refactor(cli): rename requireComputeAuth to authenticatedManagementApiClient The name was wrong twice: 'Compute' is branding residue that now collides with the unrelated @prisma/compute package, and 'require' implied a throw where the function returns null. It resolves an authenticated management API client (service token, else stored OAuth with refresh) or null. Legacy-shell-only; dies with it in S2d. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/auth/guard.ts | 2 +- packages/cli/src/auth/index.ts | 2 +- packages/cli/src/auth/operations.ts | 10 +- packages/cli/src/auth/recipient.ts | 2 +- packages/cli/src/controllers/app-env.ts | 4 +- packages/cli/src/controllers/app.ts | 4 +- packages/cli/src/controllers/branch.ts | 4 +- packages/cli/src/controllers/bucket.ts | 6 +- packages/cli/src/controllers/build.ts | 4 +- packages/cli/src/controllers/database.ts | 6 +- packages/cli/src/controllers/project.ts | 18 +- .../cli/tests/app-branch-database.test.ts | 56 +-- packages/cli/tests/app-controller.test.ts | 368 +++++++++--------- packages/cli/tests/app-env-vars.test.ts | 12 +- packages/cli/tests/app-env.test.ts | 2 +- packages/cli/tests/auth-ops.test.ts | 52 +-- packages/cli/tests/branch-controller.test.ts | 2 +- packages/cli/tests/project-controller.test.ts | 16 +- packages/cli/tests/project-real-mode.test.ts | 26 +- 19 files changed, 298 insertions(+), 298 deletions(-) diff --git a/packages/cli/src/auth/guard.ts b/packages/cli/src/auth/guard.ts index 48659159..ca107846 100644 --- a/packages/cli/src/auth/guard.ts +++ b/packages/cli/src/auth/guard.ts @@ -16,7 +16,7 @@ import { FileTokenStorage } from "./token-storage"; * * Returns null if not authenticated. */ -export async function requireComputeAuth( +export async function authenticatedManagementApiClient( env: NodeJS.ProcessEnv = process.env, signal?: AbortSignal, ): Promise { diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index bd59fd8c..92a50905 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -5,7 +5,7 @@ export { workspaceNotAuthenticatedError, workspaceSwitchUnavailableError, } from "./errors"; -export { requireComputeAuth } from "./guard"; +export { authenticatedManagementApiClient } from "./guard"; export { EmptyServiceTokenError, isEmptyServiceTokenError, diff --git a/packages/cli/src/auth/operations.ts b/packages/cli/src/auth/operations.ts index a3db2e9e..0d3e93e8 100644 --- a/packages/cli/src/auth/operations.ts +++ b/packages/cli/src/auth/operations.ts @@ -1,7 +1,7 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; import type { AuthStateResult } from "../types/auth"; import { SERVICE_TOKEN_ENV_VAR } from "./client"; -import { requireComputeAuth } from "./guard"; +import { authenticatedManagementApiClient } from "./guard"; import { login } from "./login"; import { FileTokenStorage } from "./token-storage"; @@ -81,7 +81,7 @@ export async function readAuthState( // auth state from the token itself and intentionally skip FileTokenStorage, // so behavior is independent of any OAuth session that happens to be stored // on the runner. This matches the precedence already documented on - // `requireComputeAuth` and keeps `auth whoami` and downstream commands + // `authenticatedManagementApiClient` and keeps `auth whoami` and downstream commands // (e.g. `app deploy`) reading the same source of truth. const rawServiceToken = env[SERVICE_TOKEN_ENV_VAR]; if (rawServiceToken !== undefined) { @@ -105,7 +105,7 @@ export async function readAuthState( }; } - const client = await requireComputeAuth(env, signal); + const client = await authenticatedManagementApiClient(env, signal); const currentPrincipal = await readCurrentPrincipalAuthState(client, signal); if (currentPrincipal) { if (currentPrincipal.authenticated && currentPrincipal.workspace) { @@ -139,7 +139,7 @@ async function readServiceTokenAuthState( env: NodeJS.ProcessEnv, signal?: AbortSignal, ): Promise { - const client = await requireComputeAuth(env, signal); + const client = await authenticatedManagementApiClient(env, signal); const currentPrincipal = await readCurrentPrincipalAuthState(client, signal); if (currentPrincipal) { return currentPrincipal; @@ -187,7 +187,7 @@ async function buildAuthState({ let workspaceId = workspaceIdFromCredential; let workspaceName = workspaceIdFromCredential; - client ??= await requireComputeAuth(env, signal); + client ??= await authenticatedManagementApiClient(env, signal); if (client) { try { diff --git a/packages/cli/src/auth/recipient.ts b/packages/cli/src/auth/recipient.ts index c22e1747..de8ea9e3 100644 --- a/packages/cli/src/auth/recipient.ts +++ b/packages/cli/src/auth/recipient.ts @@ -37,7 +37,7 @@ export async function resolveRecipientWorkspaceSession( const pinnedStorage = new FileTokenStorage(env, signal, { activateOnSetTokens: false, // createManagementApiSdk wraps refresh writes in withRefreshLock; see - // requireComputeAuth for why setTokens must not re-acquire the lock. + // authenticatedManagementApiClient for why setTokens must not re-acquire the lock. lockSetTokens: false, pinnedWorkspaceId: workspace.credentialWorkspaceId, }); diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index b1e64ca1..8a391d86 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/performance/noAwaitInLoops: API pagination loops are intentionally sequential. import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { requireComputeAuth } from "../auth"; +import { authenticatedManagementApiClient } from "../auth"; import { type EnvScope, type EnvVarRole, @@ -521,7 +521,7 @@ async function requireClientAndProject( verboseContext: EnvResolvedContext; }> { const authState = await requireAuthenticatedAuthState(context); - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 8b39d369..cdb3a13e 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -21,7 +21,7 @@ import { FileTokenStorage, getApiBaseUrl, readAuthState, - requireComputeAuth, + authenticatedManagementApiClient, SERVICE_TOKEN_ENV_VAR, } from "../auth"; import { @@ -3264,7 +3264,7 @@ async function requirePreviewAppProviderWithClient( client: ManagementApiClient; provider: ReturnType; }> { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 1a85e905..8eb6c37c 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Branch pagination requests must run sequentially. import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { requireComputeAuth } from "../auth"; +import { authenticatedManagementApiClient } from "../auth"; import { projectResolutionErrorToCliError, resolveProjectTarget, @@ -62,7 +62,7 @@ async function listRealBranches( context: CommandContext, ): Promise { const authState = await requireAuthenticatedAuthState(context); - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); diff --git a/packages/cli/src/controllers/bucket.ts b/packages/cli/src/controllers/bucket.ts index 1fe6033b..38a7bf52 100644 --- a/packages/cli/src/controllers/bucket.ts +++ b/packages/cli/src/controllers/bucket.ts @@ -1,4 +1,4 @@ -import { requireComputeAuth } from "../auth"; +import { authenticatedManagementApiClient } from "../auth"; import { type BucketProvider, createManagementBucketProvider, @@ -272,7 +272,7 @@ async function resolveBucketProvider( context: CommandContext, ): Promise { if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -296,7 +296,7 @@ async function requireBucketContext( } if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); diff --git a/packages/cli/src/controllers/build.ts b/packages/cli/src/controllers/build.ts index 7543db58..5cf1b997 100644 --- a/packages/cli/src/controllers/build.ts +++ b/packages/cli/src/controllers/build.ts @@ -1,4 +1,4 @@ -import { requireComputeAuth } from "../auth"; +import { authenticatedManagementApiClient } from "../auth"; import { authRequiredError, CliError } from "../shell/errors"; import { writeJsonEvent } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; @@ -36,7 +36,7 @@ export async function runBuildLogs( buildId: string, options: BuildLogsOptions = {}, ): Promise { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts index a39ae95d..9bd094c9 100644 --- a/packages/cli/src/controllers/database.ts +++ b/packages/cli/src/controllers/database.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { requireComputeAuth } from "../auth"; +import { authenticatedManagementApiClient } from "../auth"; import { type PrismaCliPackageCommandFormatter, resolvePrismaCliPackageCommandFormatterSync, @@ -714,7 +714,7 @@ async function requireDatabaseContext( } if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -768,7 +768,7 @@ async function requireDatabaseProviderOnly( const authState = await requireAuthenticatedAuthState(context); if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 421f5617..477cbe24 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -13,7 +13,7 @@ import { import { FileTokenStorage, RecipientSessionInvalidError, - requireComputeAuth, + authenticatedManagementApiClient, resolveRecipientWorkspaceSession, SERVICE_TOKEN_ENV_VAR, WorkspaceSelectionError, @@ -150,7 +150,7 @@ export async function runProjectList( } if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -283,7 +283,7 @@ export async function runProjectCreate( ); } - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -349,7 +349,7 @@ export async function runProjectLink( let provider: ReturnType | null = null; let projects: ProjectCandidate[]; if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -893,7 +893,7 @@ async function requireProjectCommandContext( async function requireProjectClient( context: CommandContext, ): Promise { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -1118,7 +1118,7 @@ export async function runGitConnect( } if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -1260,7 +1260,7 @@ export async function runGitDisconnect( } if (isRealMode(context)) { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -1348,7 +1348,7 @@ async function resolveProjectShowInRealMode( workspace: AuthWorkspace, explicitProject: string | undefined, ): Promise { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); @@ -1376,7 +1376,7 @@ async function resolveRequiredProjectInRealMode( explicitProject: string | undefined, commandName: string, ): Promise { - const client = await requireComputeAuth( + const client = await authenticatedManagementApiClient( context.runtime.env, context.runtime.signal, ); diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 0f0d1853..06d71938 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -68,7 +68,7 @@ async function writePrismaComputeSkillsLock(cwd: string): Promise { describe("app deploy branch database setup", () => { it("deploy --db creates a branch database and writes branch env overrides before deploying", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -120,7 +120,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { const actual = @@ -221,7 +221,7 @@ describe("app deploy branch database setup", () => { }); it("deploy --db creates a database and writes production env vars on first production deploy", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const listApps = vi.fn().mockResolvedValue([ { @@ -281,7 +281,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { const actual = @@ -372,7 +372,7 @@ describe("app deploy branch database setup", () => { }); it("deploy --db creates a branch database and applies a Prisma Next config before deploying", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_next"; const listApps = vi.fn().mockResolvedValue([ { @@ -422,7 +422,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { const actual = @@ -501,7 +501,7 @@ describe("app deploy branch database setup", () => { }); it("deploy --db leaves an existing branch DATABASE_URL override unchanged", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -548,7 +548,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -611,7 +611,7 @@ describe("app deploy branch database setup", () => { }); it("deploy --db treats existing production database env vars as BYO DB and leaves them unchanged", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const listApps = vi.fn().mockResolvedValue([ { @@ -673,7 +673,7 @@ describe("app deploy branch database setup", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -750,7 +750,7 @@ describe("app deploy branch database setup", () => { existingKey, envVarId, }) => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const listApps = vi.fn().mockResolvedValue([ { @@ -802,7 +802,7 @@ describe("app deploy branch database setup", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -867,7 +867,7 @@ describe("app deploy branch database setup", () => { }); it("deploy --db repairs a branch that only has DIRECT_URL", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -931,7 +931,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { const actual = @@ -1011,7 +1011,7 @@ describe("app deploy branch database setup", () => { }); it("deploy --db removes stale DIRECT_URL when the new branch database has no direct URL", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -1070,7 +1070,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { const actual = @@ -1150,7 +1150,7 @@ describe("app deploy branch database setup", () => { }); it("prompts for branch database setup when a preview deploy appears to use a database", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const confirmPrompt = vi.fn().mockResolvedValue(true); const listApps = vi.fn().mockResolvedValue([ @@ -1193,7 +1193,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/shell/prompt", async () => { const actual = await vi.importActual< @@ -1271,7 +1271,7 @@ describe("app deploy branch database setup", () => { }); it("--yes alone does not create a database during first production deploy", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const createBranchDatabase = vi.fn(); const deployApp = vi.fn().mockResolvedValue({ @@ -1290,7 +1290,7 @@ describe("app deploy branch database setup", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -1361,13 +1361,13 @@ describe("app deploy branch database setup", () => { }); it("rejects --db for production apps that already have a live deployment", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -1448,12 +1448,12 @@ describe("app deploy branch database setup", () => { }); it("rejects --db when deploy also passes database env vars", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -1517,7 +1517,7 @@ describe("app deploy branch database setup", () => { }); it("cleans up the created branch database when env wiring fails", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -1542,7 +1542,7 @@ describe("app deploy branch database setup", () => { const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { const actual = @@ -1728,12 +1728,12 @@ describe("app deploy branch database setup", () => { }); it("rejects --db for non-Postgres Prisma Next configs before creating a branch database", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 6a6ee32e..c6357f0f 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -186,7 +186,7 @@ async function setupAgentPromptDeployTest(options: { deployApp?: ReturnType; runAgentInstall?: ReturnType; }) { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const runAgentInstall = options.runAgentInstall ?? vi.fn(); const deployApp = options.deployApp ?? @@ -206,7 +206,7 @@ async function setupAgentPromptDeployTest(options: { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/controllers/agent", () => ({ runAgentInstall, @@ -257,7 +257,7 @@ async function setupAgentPromptDeployTest(options: { return { context, deployApp, - requireComputeAuth, + authenticatedManagementApiClient, runAgentInstall, runAppDeploy, }; @@ -265,7 +265,7 @@ async function setupAgentPromptDeployTest(options: { 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 authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -287,7 +287,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -354,7 +354,7 @@ describe("app controller", () => { it("uses the configured region when creating a new app from config", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "prisma-cli-")); - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -372,7 +372,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -448,7 +448,7 @@ describe("app controller", () => { }); it("uses --region when creating a new app", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -466,7 +466,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -541,7 +541,7 @@ describe("app controller", () => { }); it("rejects --region when the selected app already exists in another region", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_frontend", @@ -554,7 +554,7 @@ describe("app controller", () => { const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -597,7 +597,7 @@ describe("app controller", () => { }); it("rejects --region when PRISMA_APP_ID selects an app in another region", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_frontend", @@ -610,7 +610,7 @@ describe("app controller", () => { const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -654,12 +654,12 @@ describe("app controller", () => { }); it("deploy-all stops at the first failing target and reports the rest", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockRejectedValue(new Error("upload exploded")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -755,7 +755,7 @@ describe("app controller", () => { }); 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 authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_api", @@ -792,7 +792,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -852,7 +852,7 @@ describe("app controller", () => { }); it("deploy selects the correct existing app when --app is provided", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_2", @@ -883,7 +883,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -958,7 +958,7 @@ describe("app controller", () => { }); it("does not treat branch name as production authority", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const app = { id: "app_1", name: "hello-world", @@ -984,7 +984,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -1024,7 +1024,7 @@ describe("app controller", () => { }); it("forwards deploy build options and HTTP port overrides to the provider", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1051,7 +1051,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -1107,7 +1107,7 @@ describe("app controller", () => { }); it("add_on_active_domain_does_not_retrigger_verification", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const activeDomain = createDomain({ status: "active" }); const listApps = vi.fn().mockResolvedValue([ { @@ -1125,7 +1125,7 @@ describe("app controller", () => { const retryDomain = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1204,7 +1204,7 @@ describe("app controller", () => { }); it("domain add lets explicit --project skip stale local pins", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const domain = createDomain({ status: "active" }); const listApps = vi.fn().mockResolvedValue([ { @@ -1221,7 +1221,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1272,13 +1272,13 @@ describe("app controller", () => { }); it("domain add requires Project setup instead of entering interactive setup", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn(); const addDomain = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1341,7 +1341,7 @@ describe("app controller", () => { }); it("domain add does not synthesize DNS records when the API omits them", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1357,7 +1357,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1398,7 +1398,7 @@ describe("app controller", () => { }); it("domain add maps quota conflicts to DOMAIN_QUOTA_EXCEEDED", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1410,7 +1410,7 @@ describe("app controller", () => { ]); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1462,7 +1462,7 @@ describe("app controller", () => { }); it("domain add maps already-registered conflicts to DOMAIN_ALREADY_REGISTERED", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1474,7 +1474,7 @@ describe("app controller", () => { ]); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1532,7 +1532,7 @@ describe("app controller", () => { }); it("domain add maps DNS preflight failures to DOMAIN_DNS_NOT_CONFIGURED", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1544,7 +1544,7 @@ describe("app controller", () => { ]); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1601,7 +1601,7 @@ describe("app controller", () => { }); it("domain add does not invent a DNS target when the API omits one", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1613,7 +1613,7 @@ describe("app controller", () => { ]); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1667,7 +1667,7 @@ describe("app controller", () => { }); it("domain remove reports list-domain failures with the remove command label", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1680,7 +1680,7 @@ describe("app controller", () => { const listDomains = vi.fn().mockRejectedValue(new Error("list failed")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1754,7 +1754,7 @@ describe("app controller", () => { }); it("domain retry maps API 409 to DOMAIN_RETRY_NOT_ELIGIBLE", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1769,7 +1769,7 @@ describe("app controller", () => { .mockResolvedValue([createDomain({ status: "provisioning_tls" })]); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1821,7 +1821,7 @@ describe("app controller", () => { }); it("domain wait supports poll-once timeout mode", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1837,7 +1837,7 @@ describe("app controller", () => { const showDomain = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", async (importOriginal) => { const actual = @@ -1930,7 +1930,7 @@ describe("app controller", () => { }), POST: vi.fn(), }; - const requireComputeAuth = vi.fn().mockResolvedValue(client); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(client); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -1950,7 +1950,7 @@ describe("app controller", () => { })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2072,7 +2072,7 @@ describe("app controller", () => { }); it("uses and renders configured build entrypoints for custom deploys", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -2090,7 +2090,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2168,12 +2168,12 @@ describe("app controller", () => { }); it("returns LOCAL_STATE_WRITE_FAILED when deploy cannot store the local binding", async () => { - const requireComputeAuth = vi + const authenticatedManagementApiClient = vi .fn() .mockResolvedValue(createProjectClient("proj_my_app")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2223,7 +2223,7 @@ describe("app controller", () => { }); it("fails with migration guidance for a customized prisma.app.json", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -2236,7 +2236,7 @@ describe("app controller", () => { const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2298,7 +2298,7 @@ describe("app controller", () => { }); it("warns about and ignores a matching prisma.app.json", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -2325,7 +2325,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2388,7 +2388,7 @@ describe("app controller", () => { }); it("writes the local binding before build failures and renders build-failure copy", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -2400,7 +2400,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2458,7 +2458,7 @@ describe("app controller", () => { }); it("surfaces a concrete Next.js standalone-output recovery action", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -2472,7 +2472,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2536,7 +2536,7 @@ describe("app controller", () => { }); it("renders runtime-failure copy with deployment logs after the container starts", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); let appName = ""; const listApps = vi.fn().mockImplementation(async () => [ { @@ -2575,7 +2575,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2639,7 +2639,7 @@ describe("app controller", () => { }); it("renders deploy-failure copy when failure happens before runtime starts", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); let appName = ""; const listApps = vi.fn().mockImplementation(async () => [ { @@ -2670,7 +2670,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2777,7 +2777,7 @@ describe("app controller", () => { expectedEntrypoint, expectedBuildType, }) => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -2804,7 +2804,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2857,7 +2857,7 @@ describe("app controller", () => { }); it("lets PRISMA_PROJECT_ID skip the local pin and resolve the project", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -2878,7 +2878,7 @@ describe("app controller", () => { })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -2939,13 +2939,13 @@ describe("app controller", () => { }); it("returns PROJECT_SETUP_REQUIRED for non-interactive unbound deploy without mutating local state", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn(); const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3082,7 +3082,7 @@ describe("app controller", () => { }); it("interactive first deploy can select an existing Project and write the local pin", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ @@ -3101,7 +3101,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3160,7 +3160,7 @@ describe("app controller", () => { }); it("interactive first deploy previews detected framework and runtime before the customization prompt", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ @@ -3179,7 +3179,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3246,7 +3246,7 @@ describe("app controller", () => { }); it("prompts to install the Prisma Compute skill during interactive deploy", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const confirmPrompt = vi.fn().mockResolvedValue(true); const runAgentInstall = vi.fn().mockResolvedValue({ command: "agent.install", @@ -3273,7 +3273,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/controllers/agent", () => ({ runAgentInstall, @@ -3345,7 +3345,7 @@ describe("app controller", () => { }); it("prompts for Prisma skills before deploy setup failures", async () => { - const requireComputeAuth = vi + const authenticatedManagementApiClient = vi .fn() .mockRejectedValue(new Error("auth setup failed")); const confirmPrompt = vi.fn().mockResolvedValue(true); @@ -3360,7 +3360,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/controllers/agent", () => ({ runAgentInstall, @@ -3409,7 +3409,7 @@ describe("app controller", () => { }); expect(runAgentInstall.mock.calls[0][2]).toBe("install"); expect(runAgentInstall.mock.calls[0][3]).toEqual({ cwd }); - expect(requireComputeAuth).toHaveBeenCalled(); + expect(authenticatedManagementApiClient).toHaveBeenCalled(); }); it("does not prompt again after agent setup is declined during deploy", async () => { @@ -3467,7 +3467,7 @@ describe("app controller", () => { }); it("interactive first deploy can create a new Project from an editable suggested name", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "interactive-project", @@ -3489,7 +3489,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3558,10 +3558,10 @@ describe("app controller", () => { }); it("returns FRAMEWORK_NOT_DETECTED before deploy when framework inference fails", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3602,12 +3602,12 @@ describe("app controller", () => { }); it("returns LOCAL_PROJECT_WORKSPACE_MISMATCH when deploy pin belongs to another workspace", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3665,12 +3665,12 @@ describe("app controller", () => { }); it("returns LOCAL_STATE_STALE when the pinned project is gone", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3721,12 +3721,12 @@ describe("app controller", () => { }); it("returns LOCAL_STATE_STALE when the local pin has unsupported keys", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3806,7 +3806,7 @@ describe("app controller", () => { }); it("returns APP_AMBIGUOUS for duplicate app names in non-interactive mode", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -3826,7 +3826,7 @@ describe("app controller", () => { const deployApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -3931,7 +3931,7 @@ describe("app controller", () => { }); it("interactive first deploy can create a new app when none is selected", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -3951,7 +3951,7 @@ describe("app controller", () => { })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4004,7 +4004,7 @@ describe("app controller", () => { }); it("auto-creates the inferred app without prompting in non-interactive mode", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -4024,7 +4024,7 @@ describe("app controller", () => { })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4068,7 +4068,7 @@ describe("app controller", () => { }); it("omits region from deployApp when --region is not passed for a new app", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -4086,7 +4086,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4133,7 +4133,7 @@ describe("app controller", () => { }); it("creates a project before first deploy when --create-project is provided", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn(async (options: { name: string }) => ({ id: "proj_new", name: options.name, @@ -4155,7 +4155,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4235,7 +4235,7 @@ describe("app controller", () => { }); it("passes --region to createProject when --create-project and --region are used together", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn(async (options: { name: string }) => ({ id: "proj_new", name: options.name, @@ -4257,7 +4257,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4304,7 +4304,7 @@ describe("app controller", () => { it("reuses the created project on second deploy instead of creating another one", async () => { const client = createProjectClient(); - const requireComputeAuth = vi.fn().mockResolvedValue(client); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(client); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "next-smoke", @@ -4352,7 +4352,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4460,7 +4460,7 @@ describe("app controller", () => { }); it("creates an explicit deploy-time project without depending on repo config preflight", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "next-smoke", @@ -4481,7 +4481,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4539,13 +4539,13 @@ describe("app controller", () => { }); it("returns PROJECT_CREATE_FAILED when explicit deploy-time project creation is rejected with 401", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi .fn() .mockRejectedValue(new Error("Authentication failed (HTTP 401)")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4595,13 +4595,13 @@ describe("app controller", () => { }); it("returns PROJECT_CREATE_FAILED when explicit deploy-time project creation fails", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi .fn() .mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4651,7 +4651,7 @@ describe("app controller", () => { }); it("does not use saved app selection as the deploy target source", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -4676,7 +4676,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4724,7 +4724,7 @@ describe("app controller", () => { }); it("list-deploys sorts deployments newest first for the selected app", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -4759,7 +4759,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4797,11 +4797,11 @@ describe("app controller", () => { }); it("returns PROJECT_NOT_FOUND when the resolved project is not accessible in real mode", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockRejectedValue(new Error("Resource Not Found")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4838,7 +4838,7 @@ describe("app controller", () => { }); it("list-deploys uses the local known live deployment when the provider cannot confirm it", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -4873,7 +4873,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4922,11 +4922,11 @@ describe("app controller", () => { }); it("show requires Project setup even when package name matches a Project", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -4983,11 +4983,11 @@ describe("app controller", () => { }); it("show returns undeployed state when the resolved project has no apps", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5032,7 +5032,7 @@ describe("app controller", () => { }); it("show returns selected app, live deployment, live URL, and recent deployments", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5069,7 +5069,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5138,7 +5138,7 @@ describe("app controller", () => { }); it("show uses the local known live hint when provider live state is incomplete", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5175,7 +5175,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5229,7 +5229,7 @@ describe("app controller", () => { }); it("show-deploy returns deployment detail without branch inference", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -5247,7 +5247,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5294,7 +5294,7 @@ describe("app controller", () => { }); it("show-deploy uses the local known live deployment when available", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -5312,7 +5312,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5353,7 +5353,7 @@ describe("app controller", () => { }); it("show-deploy ignores known live deployments from another workspace", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -5371,7 +5371,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5417,13 +5417,13 @@ describe("app controller", () => { }); it("show-deploy surfaces provider failures instead of reporting not found", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi .fn() .mockRejectedValue(new Error("Missing or invalid authorization token")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5461,7 +5461,7 @@ describe("app controller", () => { it("open launches only in interactive human mode", async () => { const openUrl = vi.fn().mockResolvedValue(undefined); - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5494,7 +5494,7 @@ describe("app controller", () => { default: openUrl, })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5535,7 +5535,7 @@ describe("app controller", () => { it("open returns the URL without launching the browser in json mode", async () => { const openUrl = vi.fn().mockResolvedValue(undefined); - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5568,7 +5568,7 @@ describe("app controller", () => { default: openUrl, })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5619,7 +5619,7 @@ describe("app controller", () => { }); it("open returns NO_DEPLOYMENTS when the selected app has not been deployed yet", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5641,7 +5641,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5680,7 +5680,7 @@ describe("app controller", () => { }); it("open returns FEATURE_UNAVAILABLE when deployments exist but no live URL is exposed", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5710,7 +5710,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5750,7 +5750,7 @@ describe("app controller", () => { }); it("promote switches the selected app to the requested deployment", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5786,7 +5786,7 @@ describe("app controller", () => { const promoteDeployment = vi.fn().mockResolvedValue(undefined); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5844,7 +5844,7 @@ describe("app controller", () => { }); it("promote returns a warning when the requested deployment is already live", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5873,7 +5873,7 @@ describe("app controller", () => { const promoteDeployment = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5914,7 +5914,7 @@ describe("app controller", () => { }); it("promote rebinds instead of assuming the newest deployment is live when there is no authoritative live signal", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5943,7 +5943,7 @@ describe("app controller", () => { const promoteDeployment = vi.fn().mockResolvedValue(undefined); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -5987,7 +5987,7 @@ describe("app controller", () => { }); it("rollback chooses the previous deployment when no explicit target is provided", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6023,7 +6023,7 @@ describe("app controller", () => { const promoteDeployment = vi.fn().mockResolvedValue(undefined); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6082,7 +6082,7 @@ describe("app controller", () => { }); it("rollback uses the local known live deployment when the provider cannot confirm it", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6118,7 +6118,7 @@ describe("app controller", () => { const promoteDeployment = vi.fn().mockResolvedValue(undefined); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6171,7 +6171,7 @@ describe("app controller", () => { }); it("rollback uses an explicit deployment target when provided", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6214,7 +6214,7 @@ describe("app controller", () => { const promoteDeployment = vi.fn().mockResolvedValue(undefined); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6258,7 +6258,7 @@ describe("app controller", () => { }); it("rollback returns NO_PREVIOUS_DEPLOYMENT when only one deployment exists", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6286,7 +6286,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6327,7 +6327,7 @@ describe("app controller", () => { }); it("does not reuse the wrong saved app when the resolved project changes", async () => { - const requireComputeAuth = vi + const authenticatedManagementApiClient = vi .fn() .mockResolvedValue(createProjectClient("proj_456")); const listApps = vi.fn().mockResolvedValue([]); @@ -6349,7 +6349,7 @@ describe("app controller", () => { })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6400,7 +6400,7 @@ describe("app controller", () => { }); it("logs streams the live deployment for the selected app", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6447,7 +6447,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6491,7 +6491,7 @@ describe("app controller", () => { }); it("logs streams an explicit deployment for the selected app", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6538,7 +6538,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6579,7 +6579,7 @@ describe("app controller", () => { }); it("logs rejects an explicit deployment that does not belong to the selected app", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6608,7 +6608,7 @@ describe("app controller", () => { const streamDeploymentLogs = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6648,7 +6648,7 @@ describe("app controller", () => { }); it("logs emits newline-delimited JSON events in --json mode", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6696,7 +6696,7 @@ describe("app controller", () => { ); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6752,7 +6752,7 @@ describe("app controller", () => { }); it("remove deletes the selected app when --yes is passed", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6767,7 +6767,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6836,7 +6836,7 @@ describe("app controller", () => { }); it("remove scopes the app lookup to an explicit --branch", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6851,7 +6851,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6899,12 +6899,12 @@ describe("app controller", () => { }); it("remove rejects an explicitly empty --branch instead of inferring a branch", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const removeApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -6968,7 +6968,7 @@ describe("app controller", () => { }); it("app remove forwards --branch from the parsed command through to the provider", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6983,7 +6983,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -7039,7 +7039,7 @@ describe("app controller", () => { }); it("remove prompts for confirmation in interactive mode", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7055,7 +7055,7 @@ describe("app controller", () => { const textPrompt = vi.fn().mockResolvedValue("hello-world"); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/shell/prompt", async () => { const actual = await vi.importActual< @@ -7112,7 +7112,7 @@ describe("app controller", () => { }); it("remove returns CONFIRMATION_REQUIRED in non-interactive mode without --yes", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7124,7 +7124,7 @@ describe("app controller", () => { const removeApp = vi.fn(); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -7167,7 +7167,7 @@ describe("app controller", () => { }); it("remove returns REMOVE_FAILED when remote deletion fails", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7181,7 +7181,7 @@ describe("app controller", () => { .mockRejectedValue(new Error("Resource Not Found")); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -7225,7 +7225,7 @@ describe("app controller", () => { }); it("remove returns a warning when local cleanup fails after remote deletion", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7240,7 +7240,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -7291,7 +7291,7 @@ describe("app controller", () => { }); it("deploy --no-promote builds the candidate without promoting it", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7321,7 +7321,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => @@ -7379,7 +7379,7 @@ describe("app controller", () => { }); it("deploy --no-promote on the production branch bypasses the production gate", async () => { - const requireComputeAuth = vi + const authenticatedManagementApiClient = vi .fn() .mockResolvedValue(createProjectClient("proj_123", { isDefault: true })); const listApps = vi.fn().mockResolvedValue([ @@ -7412,7 +7412,7 @@ describe("app controller", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index e151620c..a2ca93b8 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -285,10 +285,10 @@ describe("app env vars", () => { }); it("project env list requires explicit or durable Project binding", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { createTempCwd, createTestCommandContext } = await import( @@ -357,10 +357,10 @@ describe("app env vars", () => { throw new Error(`Unexpected path ${pathName}`); }), }; - const requireComputeAuth = vi.fn().mockResolvedValue(client); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(client); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { createTempCwd, createTestCommandContext } = await import( @@ -472,7 +472,7 @@ describe("app env vars", () => { }); it("passes env vars to provider deploy without surfacing values", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -499,7 +499,7 @@ describe("app env vars", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index cd6c0933..24f2577c 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -125,7 +125,7 @@ async function loadControllers(client: MockClient, projectId: string) { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi.fn().mockResolvedValue(client), + authenticatedManagementApiClient: vi.fn().mockResolvedValue(client), })); const { createTempCwd, createTestCommandContext } = await import("./helpers"); diff --git a/packages/cli/tests/auth-ops.test.ts b/packages/cli/tests/auth-ops.test.ts index 89ec3290..291689dc 100644 --- a/packages/cli/tests/auth-ops.test.ts +++ b/packages/cli/tests/auth-ops.test.ts @@ -42,7 +42,7 @@ describe("readAuthState", () => { accessToken: encodeJwt({ sub: "user:usr_123" }), refreshToken: "refresh-token", }); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockImplementation((pathName: string) => { if (pathName === "/v1/me") { return { @@ -76,7 +76,7 @@ describe("readAuthState", () => { FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { readAuthState } = await import("../src/auth/operations"); @@ -111,7 +111,7 @@ describe("readAuthState", () => { refreshToken: "refresh-token", }, ]); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockImplementation((pathName: string) => { if (pathName === "/v1/me") { return { @@ -141,7 +141,7 @@ describe("readAuthState", () => { }); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { readAuthState } = await import("../src/auth/operations"); @@ -179,7 +179,7 @@ describe("readAuthState", () => { "header.eyJzdWIiOiJ1c2VyOmNsaXQ0YnNxMTAwMjBvMDBoNDUzcWo1cTEiLCJlbWFpbCI6Imx1YW5AZXhhbXBsZS5jb20ifQ.signature", refreshToken: "refresh-token", }); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi .fn() .mockImplementation( @@ -211,7 +211,7 @@ describe("readAuthState", () => { FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { readAuthState } = await import("../src/auth/operations"); @@ -237,7 +237,7 @@ describe("readAuthState", () => { "header.eyJzdWIiOiJ1c2VyOmNsaXQ0YnNxMTAwMjBvMDBoNDUzcWo1cTEifQ.signature", refreshToken: "refresh-token", }); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockResolvedValue({ data: { data: { @@ -253,7 +253,7 @@ describe("readAuthState", () => { FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { readAuthState } = await import("../src/auth/operations"); @@ -276,7 +276,7 @@ describe("readAuthState", () => { accessToken: encodeJwt({ sub: "user:usr_123" }), refreshToken: "refresh-token", }); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockResolvedValue({ data: { data: { @@ -291,7 +291,7 @@ describe("readAuthState", () => { FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { readAuthState } = await import("../src/auth/operations"); @@ -308,7 +308,7 @@ describe("readAuthState", () => { it("derives authenticated state from PRISMA_SERVICE_TOKEN without consulting FileTokenStorage", async () => { const getTokens = vi.fn(); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi .fn() .mockImplementation( @@ -359,7 +359,7 @@ describe("readAuthState", () => { FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { readAuthState } = await import("../src/auth/operations"); @@ -401,7 +401,7 @@ describe("readAuthState", () => { }), refreshToken: "refresh-token", }); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockResolvedValue({ data: { data: { @@ -416,7 +416,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -437,7 +437,7 @@ describe("readAuthState", () => { // which made `auth whoami` look fine for a token the API was already // rejecting. Now `auth whoami` reports the truth and downstream // commands trigger the standard AUTH_REQUIRED flow. - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockResolvedValue({ data: undefined, error: { message: "Unauthorized" }, @@ -449,7 +449,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -470,7 +470,7 @@ describe("readAuthState", () => { // place: the credential is presumably valid but the workspace lookup // didn't succeed, so we keep authenticated state and use the // workspace id as a placeholder name. - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockResolvedValue({ data: undefined, error: { message: "Internal Server Error" }, @@ -482,7 +482,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -502,7 +502,7 @@ describe("readAuthState", () => { }); it("falls back to the workspace id when the API lookup fails for a service token", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockRejectedValue(new Error("network down")), }); @@ -510,7 +510,7 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -532,7 +532,7 @@ describe("readAuthState", () => { it("rejects when cancellation aborts the current principal lookup", async () => { const controller = new AbortController(); const reason = new DOMException("Command canceled", "AbortError"); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockImplementation(() => { controller.abort(reason); throw reason; @@ -545,7 +545,7 @@ describe("readAuthState", () => { getTokens: vi.fn(), })), })); - vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -561,7 +561,7 @@ describe("readAuthState", () => { it("rejects when cancellation aborts the workspace fallback lookup", async () => { const controller = new AbortController(); const reason = new DOMException("Command canceled", "AbortError"); - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ GET: vi.fn().mockImplementation((pathName: string) => { if (pathName === "/v1/me") { return { data: { data: null } }; @@ -578,7 +578,7 @@ describe("readAuthState", () => { getTokens: vi.fn(), })), })); - vi.doMock("../src/auth/guard", () => ({ requireComputeAuth })); + vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -598,7 +598,7 @@ describe("readAuthState", () => { FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi.fn(), + authenticatedManagementApiClient: vi.fn(), })); const { readAuthState } = await import("../src/auth/operations"); @@ -624,7 +624,7 @@ describe("readAuthState", () => { FileTokenStorage: mockFileTokenStorage(getTokens), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi.fn(), + authenticatedManagementApiClient: vi.fn(), })); const { readAuthState } = await import("../src/auth/operations"); diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index d94f5ad4..788affed 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -115,7 +115,7 @@ async function loadController(client: ReturnType) { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi.fn().mockResolvedValue(client), + authenticatedManagementApiClient: vi.fn().mockResolvedValue(client), })); return import("../src/controllers/branch"); diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 6d1c2a3d..f769e8c7 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -156,7 +156,7 @@ describe("project controller", () => { }); it("creates a project and writes the local pin", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ token: "token" }); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "New Dashboard", @@ -179,7 +179,7 @@ describe("project controller", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -226,7 +226,7 @@ describe("project controller", () => { }); it("passes region to createProject when --region is provided", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ token: "token" }); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "New Dashboard", @@ -250,7 +250,7 @@ describe("project controller", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -281,7 +281,7 @@ describe("project controller", () => { }); it("bare project link can create a new project from the interactive setup picker", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ token: "token", GET: vi.fn().mockResolvedValue({ data: { @@ -321,7 +321,7 @@ describe("project controller", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ @@ -375,7 +375,7 @@ describe("project controller", () => { }); it("returns PROJECT_CREATE_FAILED when project creation fails", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ token: "token" }); const createProject = vi .fn() .mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); @@ -397,7 +397,7 @@ describe("project controller", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); vi.doMock("../src/lib/app/app-provider", () => ({ createAppProvider: vi.fn(() => ({ diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index 20eb483e..015d3188 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -257,7 +257,7 @@ function expectSourceRepositoryPost(post: ReturnType): void { describe("real project mode", () => { it("uses the real API path for project list and sorts by name then id", async () => { const readAuthState = mockAuthState(); - const requireComputeAuth = vi.fn().mockResolvedValue(mockClient()); + const authenticatedManagementApiClient = vi.fn().mockResolvedValue(mockClient()); vi.doMock("../src/auth", async (importOriginal) => ({ ...(await importOriginal()), @@ -266,7 +266,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth, + authenticatedManagementApiClient, })); const { createTempCwd, createTestCommandContext } = await import( @@ -290,7 +290,7 @@ describe("real project mode", () => { context.runtime.env, context.runtime.signal, ); - expect(requireComputeAuth).toHaveBeenCalledWith( + expect(authenticatedManagementApiClient).toHaveBeenCalledWith( context.runtime.env, context.runtime.signal, ); @@ -333,7 +333,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi.fn().mockResolvedValue(mockClient()), + authenticatedManagementApiClient: vi.fn().mockResolvedValue(mockClient()), })); const { createTempCwd, createTestCommandContext } = await import( @@ -409,7 +409,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -494,7 +494,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -579,7 +579,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -691,7 +691,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -784,7 +784,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -919,7 +919,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -989,7 +989,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -1065,7 +1065,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, POST: post })), })); @@ -1139,7 +1139,7 @@ describe("real project mode", () => { performLogout: vi.fn(), })); vi.doMock("../src/auth/guard", () => ({ - requireComputeAuth: vi + authenticatedManagementApiClient: vi .fn() .mockResolvedValue(mockClient({ GET: get, DELETE: del })), })); From 0d617cbb6d47e47464bdcd2457a9f8b660b96dea Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 10:35:10 +0200 Subject: [PATCH 27/67] chore: format after the auth-client rename Import ordering and line reflow from the longer identifier. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/controllers/app.ts | 2 +- packages/cli/src/controllers/project.ts | 2 +- .../cli/tests/app-branch-database.test.ts | 56 ++- packages/cli/tests/app-controller.test.ts | 340 +++++++++++++----- packages/cli/tests/app-env-vars.test.ts | 8 +- packages/cli/tests/auth-ops.test.ts | 24 +- packages/cli/tests/project-controller.test.ts | 12 +- packages/cli/tests/project-real-mode.test.ts | 4 +- 8 files changed, 335 insertions(+), 113 deletions(-) diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index cdb3a13e..88252759 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -18,10 +18,10 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { matchError, Result } from "better-result"; import open from "open"; import { + authenticatedManagementApiClient, FileTokenStorage, getApiBaseUrl, readAuthState, - authenticatedManagementApiClient, SERVICE_TOKEN_ENV_VAR, } from "../auth"; import { diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 477cbe24..ccbc7a1f 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -11,9 +11,9 @@ import { readGitOriginRemote, } from "../adapters/git"; import { + authenticatedManagementApiClient, FileTokenStorage, RecipientSessionInvalidError, - authenticatedManagementApiClient, resolveRecipientWorkspaceSession, SERVICE_TOKEN_ENV_VAR, WorkspaceSelectionError, diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 06d71938..acf5c529 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -68,7 +68,9 @@ async function writePrismaComputeSkillsLock(cwd: string): Promise { describe("app deploy branch database setup", () => { it("deploy --db creates a branch database and writes branch env overrides before deploying", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -221,7 +223,9 @@ describe("app deploy branch database setup", () => { }); it("deploy --db creates a database and writes production env vars on first production deploy", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const listApps = vi.fn().mockResolvedValue([ { @@ -372,7 +376,9 @@ describe("app deploy branch database setup", () => { }); it("deploy --db creates a branch database and applies a Prisma Next config before deploying", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_feature_next"; const listApps = vi.fn().mockResolvedValue([ { @@ -501,7 +507,9 @@ describe("app deploy branch database setup", () => { }); it("deploy --db leaves an existing branch DATABASE_URL override unchanged", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -611,7 +619,9 @@ describe("app deploy branch database setup", () => { }); it("deploy --db treats existing production database env vars as BYO DB and leaves them unchanged", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const listApps = vi.fn().mockResolvedValue([ { @@ -750,7 +760,9 @@ describe("app deploy branch database setup", () => { existingKey, envVarId, }) => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const listApps = vi.fn().mockResolvedValue([ { @@ -867,7 +879,9 @@ describe("app deploy branch database setup", () => { }); it("deploy --db repairs a branch that only has DIRECT_URL", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -1011,7 +1025,9 @@ describe("app deploy branch database setup", () => { }); it("deploy --db removes stale DIRECT_URL when the new branch database has no direct URL", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -1150,7 +1166,9 @@ describe("app deploy branch database setup", () => { }); it("prompts for branch database setup when a preview deploy appears to use a database", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const confirmPrompt = vi.fn().mockResolvedValue(true); const listApps = vi.fn().mockResolvedValue([ @@ -1271,7 +1289,9 @@ describe("app deploy branch database setup", () => { }); it("--yes alone does not create a database during first production deploy", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const createBranchDatabase = vi.fn(); const deployApp = vi.fn().mockResolvedValue({ @@ -1361,7 +1381,9 @@ describe("app deploy branch database setup", () => { }); it("rejects --db for production apps that already have a live deployment", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_main"; const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); @@ -1448,7 +1470,9 @@ describe("app deploy branch database setup", () => { }); it("rejects --db when deploy also passes database env vars", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); @@ -1517,7 +1541,9 @@ describe("app deploy branch database setup", () => { }); it("cleans up the created branch database when env wiring fails", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ { @@ -1728,7 +1754,9 @@ describe("app deploy branch database setup", () => { }); it("rejects --db for non-Postgres Prisma Next configs before creating a branch database", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createBranchDatabase = vi.fn(); const deployApp = vi.fn(); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index c6357f0f..e2b4b62a 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -186,7 +186,9 @@ async function setupAgentPromptDeployTest(options: { deployApp?: ReturnType; runAgentInstall?: ReturnType; }) { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const runAgentInstall = options.runAgentInstall ?? vi.fn(); const deployApp = options.deployApp ?? @@ -265,7 +267,9 @@ async function setupAgentPromptDeployTest(options: { describe("app controller", () => { it("deploy with a multi-app config and no target deploys every target in order", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -354,7 +358,9 @@ describe("app controller", () => { it("uses the configured region when creating a new app from config", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "prisma-cli-")); - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -448,7 +454,9 @@ describe("app controller", () => { }); it("uses --region when creating a new app", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -541,7 +549,9 @@ describe("app controller", () => { }); it("rejects --region when the selected app already exists in another region", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_frontend", @@ -597,7 +607,9 @@ describe("app controller", () => { }); it("rejects --region when PRISMA_APP_ID selects an app in another region", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_frontend", @@ -654,7 +666,9 @@ describe("app controller", () => { }); it("deploy-all stops at the first failing target and reports the rest", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockRejectedValue(new Error("upload exploded")); @@ -755,7 +769,9 @@ describe("app controller", () => { }); it("show run from inside a target root uses the root project pin and the config app name", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_api", @@ -852,7 +868,9 @@ describe("app controller", () => { }); it("deploy selects the correct existing app when --app is provided", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_2", @@ -958,7 +976,9 @@ describe("app controller", () => { }); it("does not treat branch name as production authority", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const app = { id: "app_1", name: "hello-world", @@ -1024,7 +1044,9 @@ describe("app controller", () => { }); it("forwards deploy build options and HTTP port overrides to the provider", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1107,7 +1129,9 @@ describe("app controller", () => { }); it("add_on_active_domain_does_not_retrigger_verification", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const activeDomain = createDomain({ status: "active" }); const listApps = vi.fn().mockResolvedValue([ { @@ -1204,7 +1228,9 @@ describe("app controller", () => { }); it("domain add lets explicit --project skip stale local pins", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const domain = createDomain({ status: "active" }); const listApps = vi.fn().mockResolvedValue([ { @@ -1272,7 +1298,9 @@ describe("app controller", () => { }); it("domain add requires Project setup instead of entering interactive setup", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn(); const addDomain = vi.fn(); @@ -1341,7 +1369,9 @@ describe("app controller", () => { }); it("domain add does not synthesize DNS records when the API omits them", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1398,7 +1428,9 @@ describe("app controller", () => { }); it("domain add maps quota conflicts to DOMAIN_QUOTA_EXCEEDED", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1462,7 +1494,9 @@ describe("app controller", () => { }); it("domain add maps already-registered conflicts to DOMAIN_ALREADY_REGISTERED", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1532,7 +1566,9 @@ describe("app controller", () => { }); it("domain add maps DNS preflight failures to DOMAIN_DNS_NOT_CONFIGURED", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1601,7 +1637,9 @@ describe("app controller", () => { }); it("domain add does not invent a DNS target when the API omits one", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1667,7 +1705,9 @@ describe("app controller", () => { }); it("domain remove reports list-domain failures with the remove command label", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1754,7 +1794,9 @@ describe("app controller", () => { }); it("domain retry maps API 409 to DOMAIN_RETRY_NOT_ELIGIBLE", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1821,7 +1863,9 @@ describe("app controller", () => { }); it("domain wait supports poll-once timeout mode", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -2072,7 +2116,9 @@ describe("app controller", () => { }); it("uses and renders configured build entrypoints for custom deploys", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -2223,7 +2269,9 @@ describe("app controller", () => { }); it("fails with migration guidance for a customized prisma.app.json", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -2298,7 +2346,9 @@ describe("app controller", () => { }); it("warns about and ignores a matching prisma.app.json", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -2388,7 +2438,9 @@ describe("app controller", () => { }); it("writes the local binding before build failures and renders build-failure copy", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -2458,7 +2510,9 @@ describe("app controller", () => { }); it("surfaces a concrete Next.js standalone-output recovery action", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -2536,7 +2590,9 @@ describe("app controller", () => { }); it("renders runtime-failure copy with deployment logs after the container starts", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); let appName = ""; const listApps = vi.fn().mockImplementation(async () => [ { @@ -2639,7 +2695,9 @@ describe("app controller", () => { }); it("renders deploy-failure copy when failure happens before runtime starts", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); let appName = ""; const listApps = vi.fn().mockImplementation(async () => [ { @@ -2777,7 +2835,9 @@ describe("app controller", () => { expectedEntrypoint, expectedBuildType, }) => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -2857,7 +2917,9 @@ describe("app controller", () => { }); it("lets PRISMA_PROJECT_ID skip the local pin and resolve the project", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -2939,7 +3001,9 @@ describe("app controller", () => { }); it("returns PROJECT_SETUP_REQUIRED for non-interactive unbound deploy without mutating local state", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn(); const deployApp = vi.fn(); @@ -3082,7 +3146,9 @@ describe("app controller", () => { }); it("interactive first deploy can select an existing Project and write the local pin", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ @@ -3160,7 +3226,9 @@ describe("app controller", () => { }); it("interactive first deploy previews detected framework and runtime before the customization prompt", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn(); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ @@ -3246,7 +3314,9 @@ describe("app controller", () => { }); it("prompts to install the Prisma Compute skill during interactive deploy", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const confirmPrompt = vi.fn().mockResolvedValue(true); const runAgentInstall = vi.fn().mockResolvedValue({ command: "agent.install", @@ -3467,7 +3537,9 @@ describe("app controller", () => { }); it("interactive first deploy can create a new Project from an editable suggested name", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "interactive-project", @@ -3558,7 +3630,9 @@ describe("app controller", () => { }); it("returns FRAMEWORK_NOT_DETECTED before deploy when framework inference fails", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient, @@ -3602,7 +3676,9 @@ describe("app controller", () => { }); it("returns LOCAL_PROJECT_WORKSPACE_MISMATCH when deploy pin belongs to another workspace", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const deployApp = vi.fn(); @@ -3665,7 +3741,9 @@ describe("app controller", () => { }); it("returns LOCAL_STATE_STALE when the pinned project is gone", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const deployApp = vi.fn(); @@ -3721,7 +3799,9 @@ describe("app controller", () => { }); it("returns LOCAL_STATE_STALE when the local pin has unsupported keys", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const deployApp = vi.fn(); @@ -3806,7 +3886,9 @@ describe("app controller", () => { }); it("returns APP_AMBIGUOUS for duplicate app names in non-interactive mode", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -3931,7 +4013,9 @@ describe("app controller", () => { }); it("interactive first deploy can create a new app when none is selected", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -4004,7 +4088,9 @@ describe("app controller", () => { }); it("auto-creates the inferred app without prompting in non-interactive mode", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi .fn() @@ -4068,7 +4154,9 @@ describe("app controller", () => { }); it("omits region from deployApp when --region is not passed for a new app", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -4133,7 +4221,9 @@ describe("app controller", () => { }); it("creates a project before first deploy when --create-project is provided", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn(async (options: { name: string }) => ({ id: "proj_new", name: options.name, @@ -4235,7 +4325,9 @@ describe("app controller", () => { }); it("passes --region to createProject when --create-project and --region are used together", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn(async (options: { name: string }) => ({ id: "proj_new", name: options.name, @@ -4460,7 +4552,9 @@ describe("app controller", () => { }); it("creates an explicit deploy-time project without depending on repo config preflight", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "next-smoke", @@ -4539,7 +4633,9 @@ describe("app controller", () => { }); it("returns PROJECT_CREATE_FAILED when explicit deploy-time project creation is rejected with 401", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi .fn() .mockRejectedValue(new Error("Authentication failed (HTTP 401)")); @@ -4595,7 +4691,9 @@ describe("app controller", () => { }); it("returns PROJECT_CREATE_FAILED when explicit deploy-time project creation fails", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const createProject = vi .fn() .mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); @@ -4651,7 +4749,9 @@ describe("app controller", () => { }); it("does not use saved app selection as the deploy target source", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -4724,7 +4824,9 @@ describe("app controller", () => { }); it("list-deploys sorts deployments newest first for the selected app", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -4797,7 +4899,9 @@ describe("app controller", () => { }); it("returns PROJECT_NOT_FOUND when the resolved project is not accessible in real mode", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockRejectedValue(new Error("Resource Not Found")); vi.doMock("../src/auth/guard", () => ({ @@ -4838,7 +4942,9 @@ describe("app controller", () => { }); it("list-deploys uses the local known live deployment when the provider cannot confirm it", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -4922,7 +5028,9 @@ describe("app controller", () => { }); it("show requires Project setup even when package name matches a Project", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn(); vi.doMock("../src/auth/guard", () => ({ @@ -4983,7 +5091,9 @@ describe("app controller", () => { }); it("show returns undeployed state when the resolved project has no apps", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); vi.doMock("../src/auth/guard", () => ({ @@ -5032,7 +5142,9 @@ describe("app controller", () => { }); it("show returns selected app, live deployment, live URL, and recent deployments", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5138,7 +5250,9 @@ describe("app controller", () => { }); it("show uses the local known live hint when provider live state is incomplete", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5229,7 +5343,9 @@ describe("app controller", () => { }); it("show-deploy returns deployment detail without branch inference", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -5294,7 +5410,9 @@ describe("app controller", () => { }); it("show-deploy uses the local known live deployment when available", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -5353,7 +5471,9 @@ describe("app controller", () => { }); it("show-deploy ignores known live deployments from another workspace", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -5417,7 +5537,9 @@ describe("app controller", () => { }); it("show-deploy surfaces provider failures instead of reporting not found", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const showDeployment = vi .fn() .mockRejectedValue(new Error("Missing or invalid authorization token")); @@ -5461,7 +5583,9 @@ describe("app controller", () => { it("open launches only in interactive human mode", async () => { const openUrl = vi.fn().mockResolvedValue(undefined); - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5535,7 +5659,9 @@ describe("app controller", () => { it("open returns the URL without launching the browser in json mode", async () => { const openUrl = vi.fn().mockResolvedValue(undefined); - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5619,7 +5745,9 @@ describe("app controller", () => { }); it("open returns NO_DEPLOYMENTS when the selected app has not been deployed yet", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5680,7 +5808,9 @@ describe("app controller", () => { }); it("open returns FEATURE_UNAVAILABLE when deployments exist but no live URL is exposed", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5750,7 +5880,9 @@ describe("app controller", () => { }); it("promote switches the selected app to the requested deployment", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5844,7 +5976,9 @@ describe("app controller", () => { }); it("promote returns a warning when the requested deployment is already live", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5914,7 +6048,9 @@ describe("app controller", () => { }); it("promote rebinds instead of assuming the newest deployment is live when there is no authoritative live signal", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -5987,7 +6123,9 @@ describe("app controller", () => { }); it("rollback chooses the previous deployment when no explicit target is provided", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6082,7 +6220,9 @@ describe("app controller", () => { }); it("rollback uses the local known live deployment when the provider cannot confirm it", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6171,7 +6311,9 @@ describe("app controller", () => { }); it("rollback uses an explicit deployment target when provided", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6258,7 +6400,9 @@ describe("app controller", () => { }); it("rollback returns NO_PREVIOUS_DEPLOYMENT when only one deployment exists", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6400,7 +6544,9 @@ describe("app controller", () => { }); it("logs streams the live deployment for the selected app", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6491,7 +6637,9 @@ describe("app controller", () => { }); it("logs streams an explicit deployment for the selected app", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6579,7 +6727,9 @@ describe("app controller", () => { }); it("logs rejects an explicit deployment that does not belong to the selected app", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6648,7 +6798,9 @@ describe("app controller", () => { }); it("logs emits newline-delimited JSON events in --json mode", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6752,7 +6904,9 @@ describe("app controller", () => { }); it("remove deletes the selected app when --yes is passed", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6836,7 +6990,9 @@ describe("app controller", () => { }); it("remove scopes the app lookup to an explicit --branch", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -6899,7 +7055,9 @@ describe("app controller", () => { }); it("remove rejects an explicitly empty --branch instead of inferring a branch", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn(); const removeApp = vi.fn(); @@ -6968,7 +7126,9 @@ describe("app controller", () => { }); it("app remove forwards --branch from the parsed command through to the provider", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7039,7 +7199,9 @@ describe("app controller", () => { }); it("remove prompts for confirmation in interactive mode", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7112,7 +7274,9 @@ describe("app controller", () => { }); it("remove returns CONFIRMATION_REQUIRED in non-interactive mode without --yes", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7167,7 +7331,9 @@ describe("app controller", () => { }); it("remove returns REMOVE_FAILED when remote deletion fails", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7225,7 +7391,9 @@ describe("app controller", () => { }); it("remove returns a warning when local cleanup fails after remote deletion", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -7291,7 +7459,9 @@ describe("app controller", () => { }); it("deploy --no-promote builds the candidate without promoting it", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index a2ca93b8..cb7b2cbb 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -285,7 +285,9 @@ describe("app env vars", () => { }); it("project env list requires explicit or durable Project binding", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient, @@ -472,7 +474,9 @@ describe("app env vars", () => { }); it("passes env vars to provider deploy without surfacing values", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(createProjectClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", diff --git a/packages/cli/tests/auth-ops.test.ts b/packages/cli/tests/auth-ops.test.ts index 291689dc..db9283de 100644 --- a/packages/cli/tests/auth-ops.test.ts +++ b/packages/cli/tests/auth-ops.test.ts @@ -416,7 +416,9 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(getTokens), })); - vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); + vi.doMock("../src/auth/guard", () => ({ + authenticatedManagementApiClient, + })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -449,7 +451,9 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); + vi.doMock("../src/auth/guard", () => ({ + authenticatedManagementApiClient, + })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -482,7 +486,9 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); + vi.doMock("../src/auth/guard", () => ({ + authenticatedManagementApiClient, + })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -510,7 +516,9 @@ describe("readAuthState", () => { ...(await importOriginal()), FileTokenStorage: mockFileTokenStorage(vi.fn()), })); - vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); + vi.doMock("../src/auth/guard", () => ({ + authenticatedManagementApiClient, + })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -545,7 +553,9 @@ describe("readAuthState", () => { getTokens: vi.fn(), })), })); - vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); + vi.doMock("../src/auth/guard", () => ({ + authenticatedManagementApiClient, + })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); @@ -578,7 +588,9 @@ describe("readAuthState", () => { getTokens: vi.fn(), })), })); - vi.doMock("../src/auth/guard", () => ({ authenticatedManagementApiClient })); + vi.doMock("../src/auth/guard", () => ({ + authenticatedManagementApiClient, + })); const { readAuthState } = await import("../src/auth/operations"); const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index f769e8c7..6bebfd74 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -156,7 +156,9 @@ describe("project controller", () => { }); it("creates a project and writes the local pin", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ token: "token" }); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue({ token: "token" }); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "New Dashboard", @@ -226,7 +228,9 @@ describe("project controller", () => { }); it("passes region to createProject when --region is provided", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ token: "token" }); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue({ token: "token" }); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "New Dashboard", @@ -375,7 +379,9 @@ describe("project controller", () => { }); it("returns PROJECT_CREATE_FAILED when project creation fails", async () => { - const authenticatedManagementApiClient = vi.fn().mockResolvedValue({ token: "token" }); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue({ token: "token" }); const createProject = vi .fn() .mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index 015d3188..536d5e14 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -257,7 +257,9 @@ function expectSourceRepositoryPost(post: ReturnType): void { describe("real project mode", () => { it("uses the real API path for project list and sorts by name then id", async () => { const readAuthState = mockAuthState(); - const authenticatedManagementApiClient = vi.fn().mockResolvedValue(mockClient()); + const authenticatedManagementApiClient = vi + .fn() + .mockResolvedValue(mockClient()); vi.doMock("../src/auth", async (importOriginal) => ({ ...(await importOriginal()), From d7ac27b5b68f46e93b57b98d504b205d162e081d Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 14:13:44 +0200 Subject: [PATCH 28/67] drive(prisma-cli-v8): credential-manager design for review The engine-modeled credential manager: Identity/Credential/Session/ Scope entities, the scalar session invariant, the manager API, engine integration replacing Runtime.getCredentials, the ruled auth command tree, runtime flows, and the open ends for the design review. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md new file mode 100644 index 00000000..1f6f7a61 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -0,0 +1,196 @@ +# Credential manager — design (for review) + +Status: operator-approved direction (2026-08-10), pre-implementation +design review pending (architect + principal engineer). Amends the v8 +draft on acceptance. Author: orchestrator, from the operator design +discussion of 2026-08-10. + +## 1. Why this exists + +The engine gives commands two auth surfaces: `ctx.api` (consume the +management API) and `ctx.getCredentials` (read the resolved +credential). Neither models the thing auth commands operate ON — the +credential machinery itself. The S2a auth-family port exposed the gap: +every auth command imported around the engine to reach the credential +store, and the ported surface reproduced the legacy CLI's +per-workspace credential registry, which contradicts the settled +premise (draft: "Workspace selection is session state, not a +credential"). + +Evidence trail behind the direction (operator-reviewed in full): + +- A 17-tool survey of developer CLIs found no precedent for a + registry of same-identity workspace-scoped credentials with + per-workspace login/logout; the universal model is one identity + + workspace as context; where scoped access tokens are required they + are minted invisibly from one root credential (Azure/MSAL, AWS SSO). +- Control-plane validation (pdp-control-plane): user OAuth tokens are + workspace-bound at consent time (`workspace_id` claim; authz checks + the claim); refresh cannot re-scope; the multi-workspace + authorization primitive (`ActorUser`) exists and serves Console, but + OAuth-issued user tokens do not use it today. Service tokens are + narrower still. +- Operator constraints: the platform's auth systems are not changing + now; the settled engine design (one credential) is not changing; + the registry surface is dropped. + +## 2. Ruled outcomes this design implements + +R1 The engine models the credential machinery as a first-class + surface named the **credential manager** (operator: "rather than + credential store, let's call this thing a manager… the surface we + present over it ought to be a bit more user-centric"). +R2 The auth state is a SCALAR: at most one session. Login replaces; + there is no registry, no cursor, no per-entry lifecycle. +R3 The command tree: `auth login`, `auth logout`, `auth whoami`. + The `auth workspace` subgroup (`list`/`use`/`logout`) and + `auth logout --workspace` are dropped. Switching workspace = + `prisma auth login` (re-consent; the consent screen is the + workspace picker today). Divergence-list entries accompany. +R4 Migration: an existing multi-entry legacy store is adopted by its + ACTIVE entry only; other entries are ignored (re-login reachable). + +## 3. Entities + +**Identity** — who is authenticated. `{ kind: "user"; id: string; +email?: string }` | `{ kind: "service"; label?: string }`. Derived +from credential claims/provenance; never independently stored. + +**Credential** — the proof: token material + mechanics. +`{ token: string; refreshToken?: string; expiresAt?: Date; +method: "oauth" | "service-token" }`. Machine-facing; `ctx.api` fuel. +(The engine's current `Credentials { token }` consumer shape is the +projection of this that request-signing needs.) + +**Session** — the user-centric composite and the manager's primary +read model: `{ identity: Identity; method: "oauth" | "service-token"; +scope: Scope; expiresAt?: Date }`. At most one. Mostly derivable from +the credential today; kept distinct because (a) commands speak +meaning, not material; (b) provenance is session truth the token does +not carry (env-supplied sessions have no stored credential and refuse +`endSession`); (c) the future multi-workspace scope adds selection +state no token encodes. + +**Scope** — what the session reaches: +`{ workspaces: readonly WorkspaceRef[] }` where `WorkspaceRef = +{ id: string; name?: string }`. Today exactly one element (the +consent-time claim). When OAuth tokens route onto the platform's +`ActorUser` primitive, the set grows — data change, not shape change. + +## 4. The CredentialManager interface + +```ts +interface CredentialManager { + /** The user-centric truth. Composes environment + stored + * credentials; an env service token wins over a stored OAuth + * credential (today's precedence, preserved). */ + session(): Promise; + + /** Login's write. begin/end verbs carry the scalar invariant: + * beginning a session REPLACES any prior one. */ + beginSession(credential: Credential, identity: Identity, scope: Scope): Promise; + + /** Logout. Rejects with a structured error when the active session + * is env-supplied (fix: names the env var to unset). */ + endSession(): Promise; + + /** The consumer path (absorbs Runtime.getCredentials). Resolves + * the credential that authorizes a request NOW: returns the + * session's credential, refreshing internally when expired. + * FUTURE SLOT: scope-targeted resolution (mint/exchange) lands + * here as an additive parameter. */ + credential(): Promise; +} +``` + +Custody boundary: the manager never CREATES credentials. Sources are +(1) the login flow (auth-module machinery: browser, consent, SDK code +exchange → the token service mints; the command hands the result to +`beginSession`); (2) refresh (the SDK's refresh exchange with the +manager as its token storage; rotation persisted internally, invisible +above); (3) the environment (`PRISMA_SERVICE_TOKEN`, composed at read +time, never stored); (4, future) scope-targeted exchange inside +`credential()`. + +## 5. Engine integration + +- `Runtime.getCredentials` is REPLACED by `Runtime.credentialManager: + CredentialManager` (the bin wires the auth module's implementation + over the existing on-disk store; the engine derives everything it + previously derived from `getCredentials` via + `manager.credential()`): + - the `needs.credentials` early check; + - `ctx.getCredentials` (kept, as the read-only consumer view); + - `ctx.api`'s per-request token source (which restores refresh to + the v8 path — the current implementation passes no refresh token + and treats 401 as terminal). +- Write access is DECLARED: `needs: { credentialManager: true }` puts + the full manager on the context (`ctx.credentialManager`) for + `auth login` / `auth logout` only. All other commands keep the + read-only surfaces. +- `session()` is also exposed read-only on every context + (recommendation; open end §8.3) so `whoami` needs no write + declaration. +- Harness: `createTestCli({ session?: Session; credential?: + Credential })` seeds an in-memory manager; `login`/`logout` become + engine-testable; `whoami` tests seed sessions instead of mocking + module internals. +- Draft amendments on acceptance: §4 (context surfaces), §6 + (`needs.credentialManager`), §10 (`Runtime.credentialManager` + replacing `getCredentials`), §11 (harness seeding). + +## 6. Runtime flows (normative behaviors) + +**Unauthenticated.** `needs.credentials` → engine fails early with +`CLI.CREDENTIALS_REQUIRED` (exit 2, sign-in nextAction), handler never +loads. Bare `ctx.api` touch → the same structured error thrown at +request time (single constructor). `whoami` → completes, "signed +out", exit 0. No auto-login anywhere (standing Q1 default). + +**Access token expired, refresh alive.** `ctx.api` request → 401 → +SDK refresh with the manager as token storage, under the existing +file lock (concurrent CLI processes: one refresher wins, the loser +re-reads); rotated pair persisted including the rotated refresh +token; request retried. Consumers never notice; per-request token +resolution keeps long runs current. The manager MAY refresh +preemptively off the `exp` claim. + +**Refresh dead (true session expiry).** Transient failures (network, +5xx): surface the API error; do NOT touch the stored credential. +Definitive rejection (`invalid_grant` / `AuthError.refreshTokenInvalid`): +the manager clears the dead credential (self-cleaning; no stale-state +resurrection) and the command settles `CLI.CREDENTIALS_REQUIRED` with +the `why` phrased as expiry. Same code as unauthenticated; different +reason text. + +**Service token.** No refresh path. 401 → structured error naming +`PRISMA_SERVICE_TOKEN` with a Console-pointing fix; nothing cleared. +Set-but-blank keeps its existing typed error. + +## 7. Future direction fit + +- OAuth → `ActorUser` routing (platform work, not scheduled): scope + becomes multi-element; `auth workspace use` may RETURN as a pure + context command writing session state; additive tree change. +- Token narrowing/minting: `credential()` gains a scope argument; + exchange happens inside the manager; nothing above changes. +- New auth methods: new `Identity.kind` / `Credential.method` values. + +## 8. Open ends for the design review + +1. Verbs: `beginSession`/`endSession` (invariant-carrying) vs plainer + `replace`/`clear`. Author recommends begin/end. +2. Context key: `ctx.credentialManager` (honest, long) — better name + welcome; `ctx.auth` rejected as vague. +3. `session()` on the read-only context for every command (author + recommends yes — see §5). +4. `Identity` display fields (email) from claims vs minimal identity + + API enrichment in `whoami`. Author recommends carrying claim + fields (offline `whoami` stays useful). +5. Whether `beginSession` takes (credential, identity, scope) as + separate arguments or a single `NewSession` object; the login flow + derives identity and scope from the credential's claims — should + the MANAGER do that derivation instead (guaranteeing consistency)? +6. Locking/atomicity contract for the store files under the manager + (the legacy machinery's split-brain env vars and read-path writes + must not survive the reimplementation). From 26d52f671e1fb7a8fd826d03443e0e3a8209f436 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 14:14:24 +0200 Subject: [PATCH 29/67] =?UTF-8?q?drive(prisma-cli-v8):=20design=20doc=20?= =?UTF-8?q?=E2=80=94=20the=20switching=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator question answered in the doc: workspace switch = re-login (replace); service token = env override; no multi-account registry; the parallel-terminals trade-off stated. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index 1f6f7a61..e91d22f3 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -167,6 +167,27 @@ reason text. `PRISMA_SERVICE_TOKEN` with a Console-pointing fix; nothing cleared. Set-but-blank keeps its existing typed error. +## 6a. Switching sessions + +There is no "switch between" stored sessions — at most one exists. + +- **Workspace, same identity**: `prisma auth login` again; re-consent + (the consent screen is the picker), the new session replaces the + old. With a live auth-service browser session this is + consent-only. +- **To/from a service token**: set/unset `PRISMA_SERVICE_TOKEN` — + the env credential wins over the stored session at read time, + per-invocation or per-shell, without disturbing the stored + session (also the scripts/parallel-terminal override). +- **Different identity**: log in as the other account; replace. No + multi-account registry (a distinct feature, additive later if + ever wanted). +- **Known trade-off**: simultaneous user-auth work in two workspaces + in parallel terminals is not served (accepted in the product-case + ruling; mitigations: a service token in one terminal, or the + future `ActorUser` routing, after which switching never touches + credentials). + ## 7. Future direction fit - OAuth → `ActorUser` routing (platform work, not scheduled): scope From 413a0d2a3caaf0876127f7c963b929e0453e16a8 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 14:51:14 +0200 Subject: [PATCH 30/67] =?UTF-8?q?drive(prisma-cli-v8):=20credential-manage?= =?UTF-8?q?r=20design=20revision=202=20=E2=80=94=20review=20adopted,=20nor?= =?UTF-8?q?mative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All architect + principal-engineer recommendations folded in: the manager owns SDK construction; Session.origin; beginSession derives from the credential; ctx.session read-only everywhere and ctx.getCredentials deleted; changesSession capability; no preemptive refresh; the transient/definitive mapping; the migration decision table; the full locking/atomicity contract; the PR #130 change checklist. Operator ruling: the fix lands on #130. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 514 +++++++++++------- 1 file changed, 327 insertions(+), 187 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index e91d22f3..6ece9b3f 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -1,217 +1,357 @@ -# Credential manager — design (for review) +# Credential manager — design, revision 2 (normative) -Status: operator-approved direction (2026-08-10), pre-implementation -design review pending (architect + principal engineer). Amends the v8 -draft on acceptance. Author: orchestrator, from the operator design -discussion of 2026-08-10. +Status: operator-adopted (2026-08-10) after pre-implementation design +review (architect + principal engineer, both accept-with-changes; all +recommendations adopted). NORMATIVE for the implementation, which +lands on PR #130 (operator ruling: "we fix it on 130"). Amends the v8 +draft as part of that implementation. Revision 1 is in git history. ## 1. Why this exists -The engine gives commands two auth surfaces: `ctx.api` (consume the +The engine gave commands two auth surfaces — `ctx.api` (consume the management API) and `ctx.getCredentials` (read the resolved -credential). Neither models the thing auth commands operate ON — the -credential machinery itself. The S2a auth-family port exposed the gap: -every auth command imported around the engine to reach the credential -store, and the ported surface reproduced the legacy CLI's -per-workspace credential registry, which contradicts the settled -premise (draft: "Workspace selection is session state, not a -credential"). - -Evidence trail behind the direction (operator-reviewed in full): - -- A 17-tool survey of developer CLIs found no precedent for a - registry of same-identity workspace-scoped credentials with - per-workspace login/logout; the universal model is one identity + - workspace as context; where scoped access tokens are required they - are minted invisibly from one root credential (Azure/MSAL, AWS SSO). -- Control-plane validation (pdp-control-plane): user OAuth tokens are - workspace-bound at consent time (`workspace_id` claim; authz checks - the claim); refresh cannot re-scope; the multi-workspace - authorization primitive (`ActorUser`) exists and serves Console, but - OAuth-issued user tokens do not use it today. Service tokens are - narrower still. -- Operator constraints: the platform's auth systems are not changing - now; the settled engine design (one credential) is not changing; - the registry surface is dropped. - -## 2. Ruled outcomes this design implements - -R1 The engine models the credential machinery as a first-class - surface named the **credential manager** (operator: "rather than - credential store, let's call this thing a manager… the surface we - present over it ought to be a bit more user-centric"). -R2 The auth state is a SCALAR: at most one session. Login replaces; - there is no registry, no cursor, no per-entry lifecycle. -R3 The command tree: `auth login`, `auth logout`, `auth whoami`. - The `auth workspace` subgroup (`list`/`use`/`logout`) and - `auth logout --workspace` are dropped. Switching workspace = - `prisma auth login` (re-consent; the consent screen is the - workspace picker today). Divergence-list entries accompany. -R4 Migration: an existing multi-entry legacy store is adopted by its - ACTIVE entry only; other entries are ignored (re-login reachable). +credential) — and no modeled surface for the thing auth commands +operate ON: the credential machinery. The S2a auth-family port +therefore imported around the engine and reproduced the legacy +per-workspace credential registry, contradicting the settled premise +(draft: "Workspace selection is session state, not a credential"). +Evidence trail (operator-reviewed): the 17-tool survey (no precedent +for a same-identity workspace-scoped credential registry; the +universal model is one identity + workspace as context), and +control-plane validation (user OAuth tokens are workspace-bound at +consent; refresh cannot re-scope; the platform's multi-workspace +authorization primitive `ActorUser` exists and serves Console but not +OAuth tokens today; the platform is not changing now). + +## 2. Ruled outcomes + +R1 The engine models the machinery as the **credential manager** + (user-centric surface over what is a dumb store today). +R2 Auth state is a SCALAR: at most one session. Login replaces. + No registry, no cursor, no per-entry lifecycle. +R3 Tree: `auth login`, `auth logout`, `auth whoami` only. The + `auth workspace` subgroup and `auth logout --workspace` are + dropped from the v8 tree. Switching workspace = `prisma auth + login` (re-consent; the consent screen is the picker today). +R4 Migration from the legacy store per the decision table in §7. +R5 The fix lands on PR #130 (the shipped registry port is reworked, + not merged as-is). ## 3. Entities -**Identity** — who is authenticated. `{ kind: "user"; id: string; -email?: string }` | `{ kind: "service"; label?: string }`. Derived -from credential claims/provenance; never independently stored. - -**Credential** — the proof: token material + mechanics. -`{ token: string; refreshToken?: string; expiresAt?: Date; -method: "oauth" | "service-token" }`. Machine-facing; `ctx.api` fuel. -(The engine's current `Credentials { token }` consumer shape is the -projection of this that request-signing needs.) - -**Session** — the user-centric composite and the manager's primary -read model: `{ identity: Identity; method: "oauth" | "service-token"; -scope: Scope; expiresAt?: Date }`. At most one. Mostly derivable from -the credential today; kept distinct because (a) commands speak -meaning, not material; (b) provenance is session truth the token does -not carry (env-supplied sessions have no stored credential and refuse -`endSession`); (c) the future multi-workspace scope adds selection -state no token encodes. - -**Scope** — what the session reaches: -`{ workspaces: readonly WorkspaceRef[] }` where `WorkspaceRef = -{ id: string; name?: string }`. Today exactly one element (the -consent-time claim). When OAuth tokens route onto the platform's -`ActorUser` primitive, the set grows — data change, not shape change. +No conditional properties (standing ruling): absent = `T | undefined`, +required key. All claim-derivable fields are derived by the manager, +never caller-supplied. + +```ts +type Identity = + | { readonly kind: "user"; readonly id: string; readonly email: string | undefined } + | { readonly kind: "service"; readonly id: string | undefined; readonly label: string | undefined }; + +interface Credential { + readonly token: string; + readonly refreshToken: string | undefined; + readonly expiresAt: Date | undefined; + readonly method: "user-oauth" | "service-token"; +} + +interface Workspace { // resolved pair; NOT the id-or-name + readonly id: string; // string users type (that concept is + readonly name: string | undefined; // called a ref elsewhere) +} + +interface Session { + readonly identity: Identity; + readonly method: "user-oauth" | "service-token"; + readonly origin: "stored" | "environment"; + readonly workspace: Workspace; + readonly expiresAt: Date | undefined; +} +``` + +Notes (review-settled): +- `Session.origin` is the field that justifies the Session/Credential + split: it drives `endSession`'s refusal and lets `whoami` explain an + env override. `method` is a different axis (how authenticated) from + `identity.kind` (who) — perfectly correlated today, genuinely + distinct, will diverge (e.g. a personal access token). +- There is no `Scope` entity (dissolved in review: the one-element + array served neither timeline, and the word collides with OAuth's + `scope` claim). Future `ActorUser` routing adds + `reachable: readonly Workspace[]` beside `workspace` — additive. +- `Session` corresponds closely to the platform's own "principal" + (`/v1/me`: credential + user + workspace). The user-centric name + Session is deliberate (operator ruling); this note is the bridge + for readers of both codebases. +- Identity/workspace/expiry are decoded from JWT claims by the + manager (claims only — see §4 boundaries). `name` on Workspace is + `undefined` unless an explicit write recorded it; there is no + read-path caching. ## 4. The CredentialManager interface ```ts interface CredentialManager { - /** The user-centric truth. Composes environment + stored - * credentials; an env service token wins over a stored OAuth - * credential (today's precedence, preserved). */ + /** User-centric truth. Local-only: composes env + stored state, + * decodes claims, NEVER touches the network. An env service token + * wins over a stored credential; when both exist, the Session is + * the env one (origin: "environment") and whoami is responsible + * for surfacing the override (§6). */ session(): Promise; - /** Login's write. begin/end verbs carry the scalar invariant: - * beginning a session REPLACES any prior one. */ - beginSession(credential: Credential, identity: Identity, scope: Scope): Promise; - - /** Logout. Rejects with a structured error when the active session - * is env-supplied (fix: names the env var to unset). */ + /** Login's write. The manager derives identity, workspace, and + * expiry from the credential's claims — single-argument by review + * ruling, so the stored session can never disagree with its + * token. Replaces any prior session (the scalar invariant lives + * in the begin/end verbs). */ + beginSession(credential: Credential): Promise; + + /** Logout. Local (does not revoke server-side; other processes' + * in-memory access tokens stay valid until expiry — user-facing + * text says so). Clears the ENTIRE stored state (reaps legacy + * orphan entries, §7). Rejects with a structured error when the + * active session is env-supplied; the error's why states whether + * a stored session also exists underneath. */ endSession(): Promise; - /** The consumer path (absorbs Runtime.getCredentials). Resolves - * the credential that authorizes a request NOW: returns the - * session's credential, refreshing internally when expired. - * FUTURE SLOT: scope-targeted resolution (mint/exchange) lands - * here as an additive parameter. */ + /** The consumer path. Resolves the credential that authorizes a + * request NOW; refresh happens inside (§6). Invariant: + * credential() === null ⟺ session() === null. */ credential(): Promise; + + /** The authenticated management API client, constructed and owned + * by the MANAGER (review blockers: the engine must not build a + * half-configured SDK — the real clientId lives with the auth + * module — and no SDK type may appear on this interface beyond + * the engine's existing ManagementApiClient alias). One client, + * one in-process refresh single-flight. */ + apiClient(): Promise; } ``` -Custody boundary: the manager never CREATES credentials. Sources are -(1) the login flow (auth-module machinery: browser, consent, SDK code -exchange → the token service mints; the command hands the result to -`beginSession`); (2) refresh (the SDK's refresh exchange with the -manager as its token storage; rotation persisted internally, invisible -above); (3) the environment (`PRISMA_SERVICE_TOKEN`, composed at read -time, never stored); (4, future) scope-targeted exchange inside -`credential()`. +Boundaries (review-settled): +- **Custody, not user interaction**: the manager never opens a + browser, never prompts, never talks to the user. (It DOES mint in + the narrow senses of refresh and future exchange — the earlier + "never creates" phrasing was wrong.) The login FLOW lives beside + it: `performLogin` changes shape to RETURN the minted credential + (today it persists internally and returns void); the login command + hands that credential to `beginSession`. +- **Claims only, never network**: `session()` on every command's + context (see §5) must be safe to call anywhere; enrichment (user + display name, workspace name) is `whoami`'s job through `ctx.api`. +- **Env is a construction input**: the manager receives `env` at + construction (like today's `makeGetCredentials(env)`); no library + below it may read `process.env` (this retires the + `PRISMA_PLATFORM_AUTH_FILE`-vs-`PRISMA_COMPUTE_AUTH_FILE` + split-brain: exactly one variable names the auth file, resolved + from the injected env; the other is accepted as a deprecated alias + with a one-time warning and never wins over an explicit path). +- **Error raising is single-sourced**: set-but-blank service token → + one structured error (the existing AUTH.CONFIG_INVALID content) + raised identically from `session()`, `credential()`, and the needs + check; unreadable store (EACCES/EPERM) → `CLI.CREDENTIALS_UNREADABLE`; + parse-corrupt store → signed out (self-heals on next login), never + an exception, never a write. ## 5. Engine integration -- `Runtime.getCredentials` is REPLACED by `Runtime.credentialManager: - CredentialManager` (the bin wires the auth module's implementation - over the existing on-disk store; the engine derives everything it - previously derived from `getCredentials` via - `manager.credential()`): - - the `needs.credentials` early check; - - `ctx.getCredentials` (kept, as the read-only consumer view); - - `ctx.api`'s per-request token source (which restores refresh to - the v8 path — the current implementation passes no refresh token - and treats 401 as terminal). -- Write access is DECLARED: `needs: { credentialManager: true }` puts - the full manager on the context (`ctx.credentialManager`) for - `auth login` / `auth logout` only. All other commands keep the - read-only surfaces. -- `session()` is also exposed read-only on every context - (recommendation; open end §8.3) so `whoami` needs no write - declaration. -- Harness: `createTestCli({ session?: Session; credential?: - Credential })` seeds an in-memory manager; `login`/`logout` become - engine-testable; `whoami` tests seed sessions instead of mocking - module internals. -- Draft amendments on acceptance: §4 (context surfaces), §6 - (`needs.credentialManager`), §10 (`Runtime.credentialManager` - replacing `getCredentials`), §11 (harness seeding). - -## 6. Runtime flows (normative behaviors) +- `Runtime.credentialManager: CredentialManager` REPLACES + `Runtime.getCredentials`, staged (review ruling — not atomic): + 1. add `credentialManager` optional; engine prefers it, falls back; + bin wires the real manager; harness gains seeding; + 2. move the needs check + `ctx.api` onto the manager; rework the + auth family; + 3. delete `getCredentials` and fix remaining Runtime literals in + one mechanical commit. +- `ctx.session(): Promise` appears on EVERY context + (read-only, local-only — tested to perform no network I/O). +- `ctx.getCredentials` is DELETED (no handler consumes it; the + context ends with fewer auth surfaces than before: `api` + + `session`). +- Write access is a CAPABILITY, not a need (a declaration never + fails a run): `changesSession: true` on the command definition puts + `ctx.credentialManager` on the context. `auth login`/`auth logout` + only. The doc is honest that this is documentation + testability, + not enforcement. +- `ctx.api` becomes a thin lazy proxy over `manager.apiClient()` + plus the engine-side error mapping (§6). The engine's placeholder + OAuth constants and its SDK construction are deleted. +- Harness: `createTestCli({ credential?: Credential; session?: Session })` + seeds a MUTABLE in-memory manager readable back by tests (login/ + logout tests observe state changes). Prefer seeding `credential` + and letting real derivation run; `session` is the escape hatch. + Additional fixture surface (review-required): an injectable + refresh/token endpoint so tests script 401 → rotated pair → retry + (asserting rotated-refresh persistence), `invalid_grant` + (asserting clear + expiry wording), 500/network-throw (asserting + credential UNTOUCHED and error is NOT credentials-required), plus + one real-filesystem two-process lock test (spawn two node + processes, both refresh, exactly one token exchange survives). +- Draft amendments land with the implementation: §4 (context: + session, api; getCredentials gone), §6 (`changesSession`), §10 + (`Runtime.credentialManager`), §11 (harness seeding + fixtures). + +## 6. Runtime flows (normative) **Unauthenticated.** `needs.credentials` → engine fails early with -`CLI.CREDENTIALS_REQUIRED` (exit 2, sign-in nextAction), handler never -loads. Bare `ctx.api` touch → the same structured error thrown at -request time (single constructor). `whoami` → completes, "signed -out", exit 0. No auto-login anywhere (standing Q1 default). - -**Access token expired, refresh alive.** `ctx.api` request → 401 → -SDK refresh with the manager as token storage, under the existing -file lock (concurrent CLI processes: one refresher wins, the loser -re-reads); rotated pair persisted including the rotated refresh -token; request retried. Consumers never notice; per-request token -resolution keeps long runs current. The manager MAY refresh -preemptively off the `exp` claim. - -**Refresh dead (true session expiry).** Transient failures (network, -5xx): surface the API error; do NOT touch the stored credential. -Definitive rejection (`invalid_grant` / `AuthError.refreshTokenInvalid`): -the manager clears the dead credential (self-cleaning; no stale-state -resurrection) and the command settles `CLI.CREDENTIALS_REQUIRED` with -the `why` phrased as expiry. Same code as unauthenticated; different -reason text. - -**Service token.** No refresh path. 401 → structured error naming +`CLI.CREDENTIALS_REQUIRED` (exit 2, sign-in nextAction), handler +never loads. Bare `ctx.api` touch → the same error (single +constructor) at request time. `whoami` → completes "signed out", +exit 0. No auto-login (standing Q1 default). + +**Refresh.** Driven by the SDK on 401, with the manager as its +`TokenStorage` — under the mandatory lock (§8). The manager MUST +implement `withRefreshLock` (the SDK silently skips locking without +it) and `clearTokensIfCurrent` (compare-and-clear; without it the +SDK's invalid-grant path wipes state a concurrent process just +wrote). Preemptive refresh is PROHIBITED (a second refresher outside +the SDK's single-flight can spend a rotated refresh token and +convert an optimization into a false sign-out). Per-request token +resolution keeps long runs current. + +**Refresh failure discrimination.** The SDK's +`AuthError.refreshTokenInvalid` is `true` ONLY for HTTP 4xx with +body error exactly `invalid_grant` — the reliable definitive +trigger. Engine-side mapping (replaces today's map-every-AuthError): +- `refreshTokenInvalid === true` → `CLI.CREDENTIALS_REQUIRED`, + expiry wording ("your session has expired — sign in again"). The + SDK has already cleared (compare-and-clear); the manager logs the + token-endpoint status + error value at debug level BEFORE the + clear so support can distinguish real expiry from a server bug. +- No credential at all → `CLI.CREDENTIALS_REQUIRED`, unauthenticated + wording — raised by the manager's own structured error, not the + SDK's synthesized message. +- Any other auth failure (network, 5xx, other 4xx) → a transient + auth-service error, surfaced as such. NOT credentials-required. + NOTHING cleared. +The sign-out decision is thereby the SDK's policy; the SDK version +is exact-pinned and a test asserts clearing happens on +`invalid_grant` and on nothing else. + +**Service token (env).** No refresh. 401 → structured error naming `PRISMA_SERVICE_TOKEN` with a Console-pointing fix; nothing cleared. -Set-but-blank keeps its existing typed error. +Unset → fall through to stored; set-but-blank or whitespace → the +single blank-token error (§4). `session()` reports the env session +(origin "environment"); when a stored session ALSO exists, `whoami` +surfaces a one-line note that the env var is overriding it. + +**Lock contention.** A refresh-lock wait timeout is NOT an engine +bug: it gets its own structured code, a why naming the lock path +("another prisma process may be refreshing"), and a next action. + +**Debug valve.** Same shape as the telemetry sender's +(`PRISMA_NEXT_DEBUG`): prints source won (env/stored), resolved auth +file path, refresh attempted, token-endpoint status + error field, +lock acquire/release/steal with holder ids. Token material (access, +refresh, JWTs, even truncated) NEVER appears in any log, error +message, meta, or envelope — the session read model structurally +cannot carry it. ## 6a. Switching sessions -There is no "switch between" stored sessions — at most one exists. - -- **Workspace, same identity**: `prisma auth login` again; re-consent - (the consent screen is the picker), the new session replaces the - old. With a live auth-service browser session this is - consent-only. -- **To/from a service token**: set/unset `PRISMA_SERVICE_TOKEN` — - the env credential wins over the stored session at read time, - per-invocation or per-shell, without disturbing the stored - session (also the scripts/parallel-terminal override). -- **Different identity**: log in as the other account; replace. No - multi-account registry (a distinct feature, additive later if - ever wanted). -- **Known trade-off**: simultaneous user-auth work in two workspaces - in parallel terminals is not served (accepted in the product-case - ruling; mitigations: a service token in one terminal, or the - future `ActorUser` routing, after which switching never touches - credentials). - -## 7. Future direction fit - -- OAuth → `ActorUser` routing (platform work, not scheduled): scope - becomes multi-element; `auth workspace use` may RETURN as a pure - context command writing session state; additive tree change. -- Token narrowing/minting: `credential()` gains a scope argument; - exchange happens inside the manager; nothing above changes. -- New auth methods: new `Identity.kind` / `Credential.method` values. - -## 8. Open ends for the design review - -1. Verbs: `beginSession`/`endSession` (invariant-carrying) vs plainer - `replace`/`clear`. Author recommends begin/end. -2. Context key: `ctx.credentialManager` (honest, long) — better name - welcome; `ctx.auth` rejected as vague. -3. `session()` on the read-only context for every command (author - recommends yes — see §5). -4. `Identity` display fields (email) from claims vs minimal identity - + API enrichment in `whoami`. Author recommends carrying claim - fields (offline `whoami` stays useful). -5. Whether `beginSession` takes (credential, identity, scope) as - separate arguments or a single `NewSession` object; the login flow - derives identity and scope from the credential's claims — should - the MANAGER do that derivation instead (guaranteeing consistency)? -6. Locking/atomicity contract for the store files under the manager - (the legacy machinery's split-brain env vars and read-path writes - must not survive the reimplementation). +No "switch between" — at most one session exists. Workspace, same +identity: `prisma auth login` (re-consent; consent-only with a live +auth-service browser session). To/from service token: set/unset the +env var (wins at read time; also the scripts/parallel-terminal +override). Different identity: log in as the other account. Known +trade-off (accepted in the product-case ruling): simultaneous +user-auth work in two workspaces in parallel terminals is not +served; mitigations are a service token in one terminal, or the +future ActorUser routing (after which switching never touches +credentials and `auth workspace use` may return as a pure context +command — additive). + +## 7. Migration from the legacy store (R4) + +Governing rule: **the migration read writes nothing.** The store is +mutated only by `beginSession`, `endSession`, and refresh. + +| Legacy store state | Rule | +| --- | --- | +| Context file exists, pointer targets an existing entry | Adopt that entry as the session | +| Context exists, pointer dangles | Signed out. No fallback, no write | +| Context exists, `activeWorkspaceId: null` | Signed out (legacy's explicit signed-out state) | +| No context, exactly one entry | Adopt it | +| No context, multiple entries | Signed out; why explains a legacy multi-workspace store was found and login replaces it | +| Auth file missing / unparseable / wrong shape | Signed out. Never delete, never rewrite | + +Ignored legacy entries stay on disk (deleting on read could destroy +a session the still-installed legacy CLI uses); `endSession` clears +the entire file, reaping them — first `prisma auth logout` removes +the orphaned refresh tokens. Divergence-list entry. New writes use +mode 0600 and tighten looser existing permissions on first write. + +## 8. Locking and atomicity contract + +- **One file** holds the whole credential state (credential + session + metadata). No context sidecar — the split-brain class dies by + construction. +- **One env var** names the auth file, resolved from injected env + (§4); the legacy second variable is a warned, deprecated alias. +- **Writes are atomic**: temp file in the same directory, fsync, + rename; mode 0600; whole-state replacement only. +- **Reads never write** (migration adoption is a pure read; the + legacy read-path-write bug class must not recur). Self-cleaning on + `invalid_grant` is a STATE TRANSITION (a write path), permitted; + opportunistic caching writes on reads are not. +- **Reads take no lock** (safe via atomic rename: old or new + complete state, never partial). +- **One advisory lock, every mutation**: `beginSession`, + `endSession`, refresh all serialize on the same lock file. + `withRefreshLock` is implemented (mandatory), so the SDK's refresh + runs under it. +- **Re-entrant within a process** via a held owner token (a nested + acquire is a no-op). No per-call-site "don't lock" flags — the + legacy `lockSetTokens: false` mechanism does not survive. +- **Heartbeated**: holder touches the lock every ~5s; stale + threshold ≥ 4× heartbeat; the token-exchange HTTP call carries a + hard timeout below the stale threshold (a live refresh can never + look stale); lock file records pid/hostname/start; steals are + debug-logged with both identities. +- **Every mutation re-reads under the lock**; refresh compares + against the credential that failed (the SDK does this given the + lock); `clearTokensIfCurrent` clears only on exact match. +- **Rotation durability**: the rotated pair is persisted (fsync + + rename) before the new access token is handed to any caller. The + unclosable client-side window (process killed between server + rotation and rename) is accepted; recovery is `prisma auth login`. + +## 9. Change surface on PR #130 (checklist) + +Engine (`packages/cli-engine`): Runtime staged swap (§5); +`ctx.session`; delete `ctx.getCredentials`; `changesSession` +capability; api-client.ts reduced to lazy proxy + §6 error mapping +(delete SDK construction + placeholder constants); draft amendments +(§4/§6/§10/§11); harness seeding + fixture surface; type-tests. + +Auth module (`packages/cli/src/auth`): the manager implementation +(persistence per §8, migration per §7, SDK construction with the +real CLIENT_ID, refresh integration); `performLogin` returns the +credential; the workspace OPERATIONS (`listAuthWorkspaces`, +`switchAuthWorkspace`, `logoutAuthWorkspace`) REMAIN — the legacy +shell consumes them until S2d; only their v8 exposure goes. + +v8 tree (`packages/cli/src/v8`): delete `auth/workspace-list.ts`, +`workspace-use.ts`, `workspace-logout.ts`, `workspace-shared.ts`, +`run-workspace-logout.ts`, and `logout.ts`'s `--workspace` flag; +rework `login`/`logout` on `ctx.credentialManager` and `whoami` on +`ctx.session()` + `ctx.api` enrichment; runtime wiring supplies the +manager. + +Docs: rewrite (not append) the auth sections of +`assets/s2/parity-divergences.md` (dropped subgroup; whoami json +shape change — the legacy `provider` field has no successor; +orphan-reaping logout; error-code notes); amend +`specs/s2a-foundations.md` §3/§4/acceptance (three commands, manager +exports, erratum note); S2 overview auth rows. + +## 10. Review disposition record + +Both reviews accept-with-changes; all recommendations adopted +(operator, 2026-08-10). Open ends resolved: begin/end verbs kept; +`ctx.credentialManager` kept as the context key with `changesSession` +as the declaration; `session()` on every context (local-only); +identity carries claim fields only; `beginSession(credential)` +single-argument; locking per §8. Naming: provenance field `origin`; +entity name Session kept with the principal-correspondence note. From 93e4190372879dbb2ef72250c80dc275a0d784f2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 15:02:36 +0200 Subject: [PATCH 31/67] =?UTF-8?q?drive(prisma-cli-v8):=20credential-manage?= =?UTF-8?q?r=20design=20revision=203=20=E2=80=94=20the=20grants=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling: keep the user-facing workspace functionality; the manager exists to manage multiple workspace credentials. One identity, plural grants, one active cursor; engine consumers keep the scalar view; vocabulary moves to grants (list/use/forget); the migration table adopts legacy entries as grants; invalid_grant blast radius narrows to one grant. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 157 ++++++++++++------ 1 file changed, 106 insertions(+), 51 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index 6ece9b3f..825a1e35 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -1,10 +1,13 @@ -# Credential manager — design, revision 2 (normative) +# Credential manager — design, revision 3 (normative) -Status: operator-adopted (2026-08-10) after pre-implementation design -review (architect + principal engineer, both accept-with-changes; all -recommendations adopted). NORMATIVE for the implementation, which -lands on PR #130 (operator ruling: "we fix it on 130"). Amends the v8 -draft as part of that implementation. Revision 1 is in git history. +Status: operator-adopted (2026-08-10). Revision 2 folded in the +pre-implementation design review (architect + principal engineer); +revision 3 adopts the GRANTS model by operator ruling: user-facing +workspace functionality is kept — "We have a credential manager now +literally to support managing multiple credentials for access to +different workspaces." NORMATIVE for the implementation, which lands +on PR #130. A delta re-review (both reviewers) covers rev2→rev3. +Prior revisions are in git history. ## 1. Why this exists @@ -27,12 +30,22 @@ OAuth tokens today; the platform is not changing now). R1 The engine models the machinery as the **credential manager** (user-centric surface over what is a dumb store today). -R2 Auth state is a SCALAR: at most one session. Login replaces. - No registry, no cursor, no per-entry lifecycle. -R3 Tree: `auth login`, `auth logout`, `auth whoami` only. The - `auth workspace` subgroup and `auth logout --workspace` are - dropped from the v8 tree. Switching workspace = `prisma auth - login` (re-consent; the consent screen is the picker today). +R2 Auth state: ONE identity, PLURAL workspace grants, ONE active + grant. The active-grant cursor is session state (satisfying the + draft premise: workspace selection is session state, not a + credential). Toward every engine consumer the view is SCALAR — + `session()`/`credential()`/`ctx.api`/`needs.credentials` see only + the active grant. The plurality lives entirely inside the + manager. No per-workspace login/logout vocabulary anywhere: you + log in as yourself; you hold, select, and forget GRANTS. +R3 Tree: `auth login`, `auth logout`, `auth whoami`, + `auth workspace list` (your held grants), `auth workspace use` + (activate a held grant, or acquire one via the consent flow when + not held), `auth workspace forget ` (drop one grant — + replaces the legacy `auth workspace logout`; nothing is a + per-workspace "logout"). `auth logout --workspace` does not + return (superseded by `forget`). Divergence entries accompany + every rename/semantic change. R4 Migration from the legacy store per the decision table in §7. R5 The fix lands on PR #130 (the shipped registry port is reworked, not merged as-is). @@ -64,11 +77,22 @@ interface Session { readonly identity: Identity; readonly method: "user-oauth" | "service-token"; readonly origin: "stored" | "environment"; - readonly workspace: Workspace; + readonly workspace: Workspace; // the ACTIVE grant's workspace readonly expiresAt: Date | undefined; } + +interface GrantSummary { // user-centric listing; NEVER + readonly workspace: Workspace; // carries credential material + readonly expiresAt: Date | undefined; + readonly active: boolean; +} ``` +A GRANT is the pairing of a workspace with the credential the user's +consent minted for it. The manager stores grants internally +(credential material included); everything it EXPOSES about the set +is `GrantSummary` — token material structurally cannot leave. + Notes (review-settled): - `Session.origin` is the field that justifies the Session/Credential split: it drives `endSession`'s refusal and lets `whoami` explain an @@ -99,21 +123,34 @@ interface CredentialManager { * for surfacing the override (§6). */ session(): Promise; - /** Login's write. The manager derives identity, workspace, and - * expiry from the credential's claims — single-argument by review - * ruling, so the stored session can never disagree with its - * token. Replaces any prior session (the scalar invariant lives - * in the begin/end verbs). */ + /** Login's write. Derives identity, workspace, and expiry from + * the credential's claims (single-argument by review ruling). + * UPSERTS the grant for that workspace and makes it ACTIVE (your + * newest consent is what you meant to use). Other grants are + * untouched. */ beginSession(credential: Credential): Promise; - /** Logout. Local (does not revoke server-side; other processes' - * in-memory access tokens stay valid until expiry — user-facing - * text says so). Clears the ENTIRE stored state (reaps legacy - * orphan entries, §7). Rejects with a structured error when the - * active session is env-supplied; the error's why states whether - * a stored session also exists underneath. */ + /** Logout, whole-identity. Local (does not revoke server-side; + * other processes' in-memory access tokens stay valid until + * expiry — user-facing text says so). Clears ALL grants and the + * cursor (also reaps legacy orphan entries, §7). Rejects with a + * structured error when the active session is env-supplied; the + * error's why states whether stored grants also exist. */ endSession(): Promise; + /** The held grants, as summaries (no credential material). */ + grants(): Promise; + + /** Move the cursor to a HELD grant (id or case-insensitive name). + * Structured error when no grant matches — the COMMAND catches it + * and runs the consent flow, then beginSession (the manager never + * interacts with the user). Ambiguity is a structured error. */ + activateGrant(ref: string): Promise; + + /** Drop one grant. If it was active, the cursor clears (no + * auto-promotion of another grant). Env sessions unaffected. */ + forgetGrant(ref: string): Promise; + /** The consumer path. Resolves the credential that authorizes a * request NOW; refresh happens inside (§6). Invariant: * credential() === null ⟺ session() === null. */ @@ -201,11 +238,13 @@ constructor) at request time. `whoami` → completes "signed out", exit 0. No auto-login (standing Q1 default). **Refresh.** Driven by the SDK on 401, with the manager as its -`TokenStorage` — under the mandatory lock (§8). The manager MUST -implement `withRefreshLock` (the SDK silently skips locking without -it) and `clearTokensIfCurrent` (compare-and-clear; without it the -SDK's invalid-grant path wipes state a concurrent process just -wrote). Preemptive refresh is PROHIBITED (a second refresher outside +`TokenStorage` — the storage view the SDK sees is the ACTIVE GRANT +only, under the mandatory lock (§8). The manager MUST implement +`withRefreshLock` (the SDK silently skips locking without it) and +`clearTokensIfCurrent` scoped to compare-and-clear THE ACTIVE GRANT +ENTRY (an invalid_grant on the active grant removes that grant and +clears the cursor; other grants are untouched — the blast radius of +a definitive refresh failure is one workspace, not the identity). Preemptive refresh is PROHIBITED (a second refresher outside the SDK's single-flight can spend a rotated refresh token and convert an optimization into a false sign-out). Per-request token resolution keeps long runs current. @@ -269,24 +308,27 @@ mutated only by `beginSession`, `endSession`, and refresh. | Legacy store state | Rule | | --- | --- | -| Context file exists, pointer targets an existing entry | Adopt that entry as the session | -| Context exists, pointer dangles | Signed out. No fallback, no write | -| Context exists, `activeWorkspaceId: null` | Signed out (legacy's explicit signed-out state) | -| No context, exactly one entry | Adopt it | -| No context, multiple entries | Signed out; why explains a legacy multi-workspace store was found and login replaces it | -| Auth file missing / unparseable / wrong shape | Signed out. Never delete, never rewrite | - -Ignored legacy entries stay on disk (deleting on read could destroy -a session the still-installed legacy CLI uses); `endSession` clears -the entire file, reaping them — first `prisma auth logout` removes -the orphaned refresh tokens. Divergence-list entry. New writes use -mode 0600 and tighten looser existing permissions on first write. +| Context file exists, pointer targets an existing entry | All entries adopted as grants; that one is active | +| Context exists, pointer dangles | All entries adopted as grants; NO active grant (commands needing credentials fail with a why suggesting `auth workspace use` or login) | +| Context exists, `activeWorkspaceId: null` | Grants adopted; no active (legacy's explicit signed-out-of-active state preserved) | +| No context, exactly one entry | Adopted as the single grant, active | +| No context, multiple entries | All adopted as grants; NO active (no coin-flip; the user activates or logs in) | +| Auth file missing / unparseable / wrong shape | No grants. Never delete, never rewrite | + +The migration read writes nothing; the adopted view is materialized +into the NEW single-file format only on the first mutation +(beginSession / activateGrant / forgetGrant / endSession / refresh +rotation). Until then the legacy files stay untouched, so a +still-installed legacy CLI keeps working. `endSession` clears +everything including legacy files. New writes use mode 0600 and +tighten looser existing permissions on first write. ## 8. Locking and atomicity contract -- **One file** holds the whole credential state (credential + session - metadata). No context sidecar — the split-brain class dies by - construction. +- **One file** holds the whole credential state (the grants array + + the active cursor + metadata). No context sidecar — the + split-brain class dies by construction. Every write replaces the + whole state, so grants and cursor can never disagree. - **One env var** names the auth file, resolved from injected env (§4); the legacy second variable is a warned, deprecated alias. - **Writes are atomic**: temp file in the same directory, fsync, @@ -332,12 +374,17 @@ credential; the workspace OPERATIONS (`listAuthWorkspaces`, `switchAuthWorkspace`, `logoutAuthWorkspace`) REMAIN — the legacy shell consumes them until S2d; only their v8 exposure goes. -v8 tree (`packages/cli/src/v8`): delete `auth/workspace-list.ts`, -`workspace-use.ts`, `workspace-logout.ts`, `workspace-shared.ts`, -`run-workspace-logout.ts`, and `logout.ts`'s `--workspace` flag; -rework `login`/`logout` on `ctx.credentialManager` and `whoami` on -`ctx.session()` + `ctx.api` enrichment; runtime wiring supplies the -manager. +v8 tree (`packages/cli/src/v8`): REWORK (not delete) the workspace +commands onto the manager: `workspace-list.ts` presents `grants()` +(help text says held grants, not memberships); `workspace-use.ts` +tries `activateGrant(ref)` and on grant-not-held runs the consent +flow then `beginSession`; `workspace-logout.ts` becomes +`workspace-forget.ts` (`auth workspace forget`, `forgetGrant`); +`logout.ts` drops `--workspace` (superseded). `login`/`logout` move +onto `ctx.credentialManager`; `whoami` onto `ctx.session()` + +`ctx.api` enrichment (and may show held-grant count). Runtime wiring +supplies the manager. The legacy operations in `src/auth` still +serve the legacy shell until S2d. Docs: rewrite (not append) the auth sections of `assets/s2/parity-divergences.md` (dropped subgroup; whoami json @@ -348,10 +395,18 @@ exports, erratum note); S2 overview auth rows. ## 10. Review disposition record -Both reviews accept-with-changes; all recommendations adopted +Rev 2: both reviews accept-with-changes; all recommendations adopted (operator, 2026-08-10). Open ends resolved: begin/end verbs kept; `ctx.credentialManager` kept as the context key with `changesSession` as the declaration; `session()` on every context (local-only); identity carries claim fields only; `beginSession(credential)` single-argument; locking per §8. Naming: provenance field `origin`; entity name Session kept with the principal-correspondence note. + +Rev 3 (grants model, operator-ruled): user-facing workspace +functionality kept; plurality contained in the manager; engine +consumer view stays scalar; vocabulary shifts from per-workspace +sessions to grants (list / use / forget). The product-team framing +updates accordingly: functionality kept, ontology fixed, the +multi-entry consistency surface now properly owned by one modeled +component. Delta re-review pending. From e65a505dbf28105b01ee4a2a3996f77390d36584 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 15:14:02 +0200 Subject: [PATCH 32/67] =?UTF-8?q?drive(prisma-cli-v8):=20design=20rev=204?= =?UTF-8?q?=20draft=20=E2=80=94=20architect=20delta=20findings=20folded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three delta blockers and eight should-fixes applied: switching- grants rewrite, one-identity enforcement at beginSession, grant-bound refresh view with exact compare-and-clear, honest §1, the managesCredentials capability across five commands, rememberWorkspaceName, uniform env-override mutation refusal, verb state-effect table. PE delta findings fold next; then rev 4 final. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 181 ++++++++++++------ 1 file changed, 123 insertions(+), 58 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index 825a1e35..38e5ce26 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -15,16 +15,21 @@ The engine gave commands two auth surfaces — `ctx.api` (consume the management API) and `ctx.getCredentials` (read the resolved credential) — and no modeled surface for the thing auth commands operate ON: the credential machinery. The S2a auth-family port -therefore imported around the engine and reproduced the legacy -per-workspace credential registry, contradicting the settled premise -(draft: "Workspace selection is session state, not a credential"). -Evidence trail (operator-reviewed): the 17-tool survey (no precedent -for a same-identity workspace-scoped credential registry; the -universal model is one identity + workspace as context), and -control-plane validation (user OAuth tokens are workspace-bound at -consent; refresh cannot re-scope; the platform's multi-workspace -authorization primitive `ActorUser` exists and serves Console but not -OAuth tokens today; the platform is not changing now). +therefore imported around the engine. The defect being corrected is +the ONTOLOGY and the PLUMBING, not credential plurality: the legacy +model had no active-identity concept (per-workspace "sessions" with +login/logout verbs — identity conflated with credential scope), a +split-brain context sidecar, read paths that write, and commands +bypassing the engine. Control-plane facts (operator-validated): user +OAuth tokens are workspace-bound at consent; refresh cannot +re-scope; the platform's multi-workspace primitive (`ActorUser`) +serves Console but not OAuth tokens today, and the platform is not +changing now — so multi-workspace access REQUIRES holding multiple +workspace-bound credentials. The 17-tool survey found no precedent +for exposing that plurality as per-workspace sessions; this design +KNOWINGLY keeps the plurality (operator ruling: functionality is not +dropped) while fixing the ontology: one identity, grants as +authorization artifacts, scalar view to every consumer. ## 2. Ruled outcomes @@ -84,8 +89,10 @@ interface Session { interface GrantSummary { // user-centric listing; NEVER readonly workspace: Workspace; // carries credential material readonly expiresAt: Date | undefined; - readonly active: boolean; -} + readonly active: boolean; // cursor position; when an env +} // token overrides, the LISTING + // command must say the env + // session is what's in force ``` A GRANT is the pairing of a workspace with the credential the user's @@ -125,9 +132,12 @@ interface CredentialManager { /** Login's write. Derives identity, workspace, and expiry from * the credential's claims (single-argument by review ruling). - * UPSERTS the grant for that workspace and makes it ACTIVE (your - * newest consent is what you meant to use). Other grants are - * untouched. */ + * Same identity as the held grants: UPSERTS the grant for that + * workspace and makes it ACTIVE; other grants untouched. + * DIFFERENT identity: all existing grants are discarded and + * replaced by this one — the one-identity invariant is enforced + * here, and the stored state carries the identity ONCE beside + * the grants array so the invariant is structural. */ beginSession(credential: Credential): Promise; /** Logout, whole-identity. Local (does not revoke server-side; @@ -138,17 +148,28 @@ interface CredentialManager { * error's why states whether stored grants also exist. */ endSession(): Promise; - /** The held grants, as summaries (no credential material). */ + /** The held grants, as summaries (no credential material). + * Local-only like session(): never touches the network. */ grants(): Promise; - /** Move the cursor to a HELD grant (id or case-insensitive name). - * Structured error when no grant matches — the COMMAND catches it - * and runs the consent flow, then beginSession (the manager never - * interacts with the user). Ambiguity is a structured error. */ + /** Records a human-readable workspace name learned by a command + * (e.g. from the consent flow or an API response). The sanctioned + * name-write path — beginSession stays single-argument and + * claims-only. Explicit write; never a read-path side effect. */ + rememberWorkspaceName(workspaceId: string, name: string): Promise; + + /** Move the cursor to a HELD grant. Ref resolution is the + * manager's, against held grants only: exact id first, then + * case-insensitive name; ambiguity is a structured error; + * no match is a structured error the COMMAND catches to run the + * consent flow + beginSession (the manager never interacts with + * the user). Activating an expired grant succeeds locally (no + * network check) and fails at first use — grants() exposes + * expiresAt so commands can warn. */ activateGrant(ref: string): Promise; /** Drop one grant. If it was active, the cursor clears (no - * auto-promotion of another grant). Env sessions unaffected. */ + * auto-promotion of another grant). */ forgetGrant(ref: string): Promise; /** The consumer path. Resolves the credential that authorizes a @@ -166,6 +187,26 @@ interface CredentialManager { } ``` +Mutation rule under an env override (uniform): while a +`PRISMA_SERVICE_TOKEN` session is in force, EVERY manager mutation — +`endSession`, `activateGrant`, `forgetGrant`, `beginSession` — refuses +with the same structured error family (why names the env var and +states whether stored grants exist underneath). Stored state is never +mutated while the user cannot observe it as their session. Reads +(`session()`, `grants()`, `credential()`) work normally. + +State effects, at a glance: + +| Verb | Effect | +| --- | --- | +| `beginSession` | upsert one grant (same identity) or replace all (new identity); set cursor | +| `activateGrant` | cursor only | +| `forgetGrant` | remove one grant; clear cursor if it was active | +| `endSession` | remove ALL grants and cursor | + +(The single-workspace inverse of `beginSession` is `forgetGrant`, +not `endSession`.) + Boundaries (review-settled): - **Custody, not user interaction**: the manager never opens a browser, never prompts, never talks to the user. (It DOES mint in @@ -206,11 +247,15 @@ Boundaries (review-settled): - `ctx.getCredentials` is DELETED (no handler consumes it; the context ends with fewer auth surfaces than before: `api` + `session`). -- Write access is a CAPABILITY, not a need (a declaration never - fails a run): `changesSession: true` on the command definition puts - `ctx.credentialManager` on the context. `auth login`/`auth logout` - only. The doc is honest that this is documentation + testability, - not enforcement. +- Manager access is a CAPABILITY, not a need (a declaration never + fails a run): `managesCredentials: true` on the command definition + puts `ctx.credentialManager` on the context. Declared by exactly: + `auth login`, `auth logout`, `auth workspace list`, `auth + workspace use`, `auth workspace forget`. `whoami` uses + `ctx.session()` only. `grants()` lives ONLY on the manager — never + on the universal context (the plural view stays contained). The + doc is honest that this is documentation + testability, not + enforcement. - `ctx.api` becomes a thin lazy proxy over `manager.apiClient()` plus the engine-side error mapping (§6). The engine's placeholder OAuth constants and its SDK construction are deleted. @@ -226,7 +271,7 @@ Boundaries (review-settled): one real-filesystem two-process lock test (spawn two node processes, both refresh, exactly one token exchange survives). - Draft amendments land with the implementation: §4 (context: - session, api; getCredentials gone), §6 (`changesSession`), §10 + session, api; getCredentials gone), §6 (`managesCredentials`), §10 (`Runtime.credentialManager`), §11 (harness seeding + fixtures). ## 6. Runtime flows (normative) @@ -237,14 +282,24 @@ never loads. Bare `ctx.api` touch → the same error (single constructor) at request time. `whoami` → completes "signed out", exit 0. No auto-login (standing Q1 default). +**Grants held, none active** (migration rows; forget-active): same +code `CLI.CREDENTIALS_REQUIRED`, distinct why ("you hold grants but +none is active") and nextAction `auth workspace use` alongside +sign-in. Single-sourced like the other credential errors: session(), +credential(), and the needs check produce it identically. NOTHING +auto-promotes a grant. + **Refresh.** Driven by the SDK on 401, with the manager as its -`TokenStorage` — the storage view the SDK sees is the ACTIVE GRANT -only, under the mandatory lock (§8). The manager MUST implement -`withRefreshLock` (the SDK silently skips locking without it) and -`clearTokensIfCurrent` scoped to compare-and-clear THE ACTIVE GRANT -ENTRY (an invalid_grant on the active grant removes that grant and -clears the cursor; other grants are untouched — the blast radius of -a definitive refresh failure is one workspace, not the identity). Preemptive refresh is PROHIBITED (a second refresher outside +`TokenStorage` — the storage view the SDK sees is bound to THE GRANT +the client was constructed for (not the cursor: a concurrent +`workspace use` must not redirect a refresh mid-flight), under the +mandatory lock (§8). The manager MUST implement `withRefreshLock` +(the SDK silently skips locking without it) and +`clearTokensIfCurrent` scoped to that grant: remove it if and only +if its STORED credential still exactly matches the one that failed; +clear the cursor only if that grant is still the active one. Blast +radius of a definitive refresh failure: one workspace, never the +identity. Preemptive refresh is PROHIBITED (a second refresher outside the SDK's single-flight can spend a rotated refresh token and convert an optimization into a false sign-out). Per-request token resolution keeps long runs current. @@ -287,19 +342,22 @@ refresh, JWTs, even truncated) NEVER appears in any log, error message, meta, or envelope — the session read model structurally cannot carry it. -## 6a. Switching sessions - -No "switch between" — at most one session exists. Workspace, same -identity: `prisma auth login` (re-consent; consent-only with a live -auth-service browser session). To/from service token: set/unset the -env var (wins at read time; also the scripts/parallel-terminal -override). Different identity: log in as the other account. Known -trade-off (accepted in the product-case ruling): simultaneous -user-auth work in two workspaces in parallel terminals is not -served; mitigations are a service token in one terminal, or the -future ActorUser routing (after which switching never touches -credentials and `auth workspace use` may return as a pure context -command — additive). +## 6a. Switching grants + +`auth workspace use ` is the switch: it activates a held grant +(cursor move, §4), or — when no grant matches — announces in one line +that it is opening the browser to authorize that workspace, runs the +consent flow, and hands the minted credential to `beginSession`. Run +fully signed out, `use` is effectively a full login and announces +itself as such. `auth login` is for signing in (first grant) or +changing identity. To/from a service token: set/unset the env var +(wins at read time; also the scripts/parallel-terminal override). +Known trade-off (accepted): the ACTIVE cursor is one shared piece of +state on disk, so two terminals share it; simultaneous work against +two workspaces uses a service token in one terminal. A per-process +workspace override selecting among held grants without moving the +cursor is a recognized future affordance — explicitly deferred, not +designed here. ## 7. Migration from the legacy store (R4) @@ -318,10 +376,16 @@ mutated only by `beginSession`, `endSession`, and refresh. The migration read writes nothing; the adopted view is materialized into the NEW single-file format only on the first mutation (beginSession / activateGrant / forgetGrant / endSession / refresh -rotation). Until then the legacy files stay untouched, so a -still-installed legacy CLI keeps working. `endSession` clears -everything including legacy files. New writes use mode 0600 and -tighten looser existing permissions on first write. +rotation), and materialization writes the FULL adopted set, not only +the mutated grant. Once the new file exists, the legacy files are +ignored entirely (a legacy CLI writing after materialization is +invisible to v8 — accepted). Until then the legacy files stay +untouched, so a still-installed legacy CLI keeps working. +`endSession` clears everything including legacy files. New writes +use mode 0600 and tighten looser existing permissions on first +write. Mixed-identity legacy entries: adoption applies the +one-identity rule — see the beginSession invariant; the PE delta +review's rule is folded in §7 when it lands. ## 8. Locking and atomicity contract @@ -362,7 +426,7 @@ tighten looser existing permissions on first write. ## 9. Change surface on PR #130 (checklist) Engine (`packages/cli-engine`): Runtime staged swap (§5); -`ctx.session`; delete `ctx.getCredentials`; `changesSession` +`ctx.session`; delete `ctx.getCredentials`; `managesCredentials` capability; api-client.ts reduced to lazy proxy + §6 error mapping (delete SDK construction + placeholder constants); draft amendments (§4/§6/§10/§11); harness seeding + fixture surface; type-tests. @@ -387,17 +451,18 @@ supplies the manager. The legacy operations in `src/auth` still serve the legacy shell until S2d. Docs: rewrite (not append) the auth sections of -`assets/s2/parity-divergences.md` (dropped subgroup; whoami json -shape change — the legacy `provider` field has no successor; -orphan-reaping logout; error-code notes); amend -`specs/s2a-foundations.md` §3/§4/acceptance (three commands, manager -exports, erratum note); S2 overview auth rows. +`assets/s2/parity-divergences.md` (subgroup reworked and renamed — +`workspace logout` → `workspace forget` is itself a divergence; +whoami json shape change — the legacy `provider` field has no +successor, held-grant count added; orphan-reaping logout; error-code +notes); amend `specs/s2a-foundations.md` §3/§4/acceptance (six +commands, manager exports, erratum note); S2 overview auth rows. ## 10. Review disposition record Rev 2: both reviews accept-with-changes; all recommendations adopted (operator, 2026-08-10). Open ends resolved: begin/end verbs kept; -`ctx.credentialManager` kept as the context key with `changesSession` +`ctx.credentialManager` kept as the context key with `managesCredentials` as the declaration; `session()` on every context (local-only); identity carries claim fields only; `beginSession(credential)` single-argument; locking per §8. Naming: provenance field `origin`; From ee1aa6364b0304f9960af633519a38e143fea5f3 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 15:26:11 +0200 Subject: [PATCH 33/67] drive(prisma-cli-v8): credential-manager design rev 4 final Folds the PE delta re-review (accept-with-changes): setTokens rotation-write semantics, the mixed-identity migration rule, per-workspace API-client caching, slice-ownership mutation table, identity guards, complete lock-constant ordering, per-lock-file re-entrancy, env-override rule split (beginSession allowed with notice; rememberWorkspaceName exempt), grant-removed discrimination by state re-read, expanded fixture surface and required tests. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 338 ++++++++++++++---- 1 file changed, 271 insertions(+), 67 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index 38e5ce26..e724ae8c 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -1,13 +1,16 @@ -# Credential manager — design, revision 3 (normative) +# Credential manager — design, revision 4 (normative, final) Status: operator-adopted (2026-08-10). Revision 2 folded in the pre-implementation design review (architect + principal engineer); -revision 3 adopts the GRANTS model by operator ruling: user-facing +revision 3 adopted the GRANTS model by operator ruling: user-facing workspace functionality is kept — "We have a credential manager now literally to support managing multiple credentials for access to -different workspaces." NORMATIVE for the implementation, which lands -on PR #130. A delta re-review (both reviewers) covers rev2→rev3. -Prior revisions are in git history. +different workspaces." Revision 4 folds the delta re-review +(architect + PE, both accept-with-changes) — notably the rotation +write (`setTokens`) semantics, the mixed-identity migration rule, +per-workspace API-client binding, and the §8 amendments. NORMATIVE +for the implementation, which lands on PR #130. Prior revisions are +in git history. ## 1. Why this exists @@ -155,7 +158,12 @@ interface CredentialManager { /** Records a human-readable workspace name learned by a command * (e.g. from the consent flow or an API response). The sanctioned * name-write path — beginSession stays single-argument and - * claims-only. Explicit write; never a read-path side effect. */ + * claims-only. Explicit write; never a read-path side effect. + * Runs under the §8 lock like every mutation. No grant held for + * that workspace id → no-op, not an error. Only `login` and + * `workspace use` may call it (both already mutate) — a name + * write triggers §7 materialization, so read-only listing + * commands must not call it. */ rememberWorkspaceName(workspaceId: string, name: string): Promise; /** Move the cursor to a HELD grant. Ref resolution is the @@ -181,19 +189,36 @@ interface CredentialManager { * by the MANAGER (review blockers: the engine must not build a * half-configured SDK — the real clientId lives with the auth * module — and no SDK type may appear on this interface beyond - * the engine's existing ManagementApiClient alias). One client, - * one in-process refresh single-flight. */ + * the engine's existing ManagementApiClient alias). Clients are + * cached PER WORKSPACE ID, and any cursor-moving mutation + * (`beginSession`, `activateGrant`) invalidates the cached + * "active" client — a command that switches grants and then + * touches `ctx.api` (workspace use doing name enrichment, whoami + * after login) must get a client bound to the NEW grant, not a + * stale one that could read the old workspace's data and write + * it onto the new grant. Refresh single-flight is per client, + * which is correct because refresh is per grant. */ apiClient(): Promise; } ``` -Mutation rule under an env override (uniform): while a -`PRISMA_SERVICE_TOKEN` session is in force, EVERY manager mutation — -`endSession`, `activateGrant`, `forgetGrant`, `beginSession` — refuses -with the same structured error family (why names the env var and -states whether stored grants exist underneath). Stored state is never -mutated while the user cannot observe it as their session. Reads -(`session()`, `grants()`, `credential()`) work normally. +Mutation rules under an env override: while a `PRISMA_SERVICE_TOKEN` +session is in force — +- `endSession`, `activateGrant`, `forgetGrant` REFUSE with one + structured error family: the why names the env var and states + whether stored grants exist underneath; the nextAction is the + literal `unset PRISMA_SERVICE_TOKEN` command. State the user + cannot observe as their session is never changed or destroyed. +- `beginSession` is ALLOWED (PE delta ruling): login is interactive, + additive in the same-identity case, and the user just performed a + browser consent — refusing it strands anyone with the var exported + in a shell profile or a CI image. Mandatory one-line notice: the + env token remains in force and the new grant takes effect only + once it is unset. +- `rememberWorkspaceName` is EXEMPT: it annotates an existing grant + and never changes which credential is in force; refusing would + break enrichment exactly in the CI case where names are cosmetic. +Reads (`session()`, `grants()`, `credential()`) work normally. State effects, at a glance: @@ -263,13 +288,48 @@ Boundaries (review-settled): seeds a MUTABLE in-memory manager readable back by tests (login/ logout tests observe state changes). Prefer seeding `credential` and letting real derivation run; `session` is the escape hatch. - Additional fixture surface (review-required): an injectable - refresh/token endpoint so tests script 401 → rotated pair → retry - (asserting rotated-refresh persistence), `invalid_grant` - (asserting clear + expiry wording), 500/network-throw (asserting - credential UNTOUCHED and error is NOT credentials-required), plus - one real-filesystem two-process lock test (spawn two node - processes, both refresh, exactly one token exchange survives). + Grants-model extension: seeding accepts `{ identity, grants, + activeWorkspaceId }` with the WHOLE state readable back after the + run (grants, per-grant credentials, cursor); identity seedable + independently of grants so the invalid combination (a grant whose + `sub` disagrees with the recorded identity) is constructible. +- Fixture surface (review-required), beyond the rev-2 list (an + injectable refresh/token endpoint scripting 401 → rotated pair → + retry, `invalid_grant`, 5xx/network-throw with credential + untouched): + - a JWT minter (claims `sub`, `workspace_id`, `exp`, `email`) plus + an undecodable-token entry — migration, expiry, and + identity-guard tests all need it; + - a legacy-store builder writing real legacy auth + context files + (pointer valid / dangling / null / absent; one or many entries; + entries from two `sub`s; corrupt context; wrong shape); + - a deterministic clock for expiry, heartbeat, stale threshold; + - an interleaving hook — a pause point inside every mutation + between read-under-lock and write — so cursor-versus-rotation + orderings are testable in-process without sleeps, plus a way for + a real second process to hold the lock. +- Required tests (normative list): + - real-filesystem multi-process races, one each: `activateGrant` + vs rotation (final state = the cursor move + the rotated + tokens); `forgetGrant` vs rotation (no resurrection); different- + identity login vs rotation (write refused, no resurrection); + two refreshers (exactly one exchange); + - env-override matrix: every mutation × {unset, set, blank, + whitespace}, asserting the error family and that the auth + file's bytes are unchanged; + - forget-active / grants-held-none-active: one shared assertion + over `session()`, `credential()`, and the needs check (code, + why, next action); + - name persistence across refresh (the named legacy-regression + test): seed a named grant, script 401 → rotation, assert the + name survives and `expiresAt` was re-derived; + - a reads-never-write probe: a filesystem spy over the auth + directory asserting ZERO writes across every read path + including migration adoption; + - a token-material leak scan: run each command with a known + secret seeded, assert it appears in no stdout, stderr, debug + log, error meta, or envelope; + - lock-constant ordering (§8). - Draft amendments land with the implementation: §4 (context: session, api; getCredentials gone), §6 (`managesCredentials`), §10 (`Runtime.credentialManager`), §11 (harness seeding + fixtures). @@ -293,17 +353,62 @@ auto-promotes a grant. `TokenStorage` — the storage view the SDK sees is bound to THE GRANT the client was constructed for (not the cursor: a concurrent `workspace use` must not redirect a refresh mid-flight), under the -mandatory lock (§8). The manager MUST implement `withRefreshLock` -(the SDK silently skips locking without it) and -`clearTokensIfCurrent` scoped to that grant: remove it if and only -if its STORED credential still exactly matches the one that failed; -clear the cursor only if that grant is still the active one. Blast -radius of a definitive refresh failure: one workspace, never the -identity. Preemptive refresh is PROHIBITED (a second refresher outside +mandatory lock (§8). "Bound to the grant" means bound to the +WORKSPACE ID, never to a credential snapshot: the view's +`getTokens()` reads the current on-disk value for that workspace id +on every call, no in-memory caching. That is what makes the benign +race work — if another process already rotated the grant, the SDK's +compare fails and it returns the fresh tokens without a second +exchange; a cached snapshot would re-spend a spent refresh token. +The manager MUST implement `withRefreshLock` (the SDK silently +skips locking without it) and `clearTokensIfCurrent` scoped to that +grant: remove it if and only if its STORED credential still exactly +matches the one that failed; clear the cursor only if that grant is +still the active one. "Exactly matches" is defined over the SDK's +three compared fields ONLY — `workspaceId`, `accessToken`, +`refreshToken` — so a `rememberWorkspaceName` write or a re-derived +`expiresAt` between construction and failure cannot defeat the +compare and leave a dead grant in place. (The SDK's compare +including the ACCESS token is desired behavior, not a limitation: a +grant another process already rotated correctly declines to clear. +Do not "fix" it to compare refresh tokens only.) Blast radius of a +definitive refresh failure: one workspace, never the identity. +Preemptive refresh is PROHIBITED (a second refresher outside the SDK's single-flight can spend a rotated refresh token and convert an optimization into a false sign-out). Per-request token resolution keeps long runs current. +**The rotation write (`setTokens`).** The write that runs on every +successful refresh, specified as the inverse of compare-and-clear — +this is the path where the legacy store destroyed state (every +refresh overwrote the human workspace name with the raw id and +could move the active pointer). Normative rules: +- Updates IN PLACE the grant whose workspace id matches, and only + the fields `token`, `refreshToken`, `expiresAt`. The SDK's token + pair carries no expiry: `expiresAt` is always re-derived by the + manager from the new access token's claims. +- NEVER creates a grant. If the freshly-read state under the lock + holds no grant for that workspace id (another process ran + `forgetGrant`, `endSession`, or logged in as someone else), the + write is refused and `setTokens` throws — no resurrection of a + deliberately removed grant. Losing the rotated refresh token is + correct: the grant is gone on purpose. +- NEVER moves the cursor, and never touches `workspace.name` or any + other grant field. Name preservation across refresh gets its own + named regression test (the legacy defect). +- If the new access token's `workspace_id` claim disagrees with the + bound grant's id: refuse and throw. Refresh cannot re-scope; a + mismatch is a fault, not a new grant. +- Identity guard: if the fresh state's identity differs from the + identity the client was constructed under, refuse. Same guard on + `clearTokensIfCurrent` — two accounts can hold grants on the same + workspace, so matching by workspace id alone is not enough. +Side effect worth naming: the SDK's own `login()` also persists via +`tokenStorage.setTokens`. Under these rules that path fails loudly +instead of quietly minting an unowned grant — desired, since +`performLogin` returns the credential and `beginSession` owns the +write. + **Refresh failure discrimination.** The SDK's `AuthError.refreshTokenInvalid` is `true` ONLY for HTTP 4xx with body error exactly `invalid_grant` — the reliable definitive @@ -313,10 +418,18 @@ trigger. Engine-side mapping (replaces today's map-every-AuthError): SDK has already cleared (compare-and-clear); the manager logs the token-endpoint status + error value at debug level BEFORE the clear so support can distinguish real expiry from a server bug. -- No credential at all → `CLI.CREDENTIALS_REQUIRED`, unauthenticated - wording — raised by the manager's own structured error, not the - SDK's synthesized message. -- Any other auth failure (network, 5xx, other 4xx) → a transient +- Any other `AuthError` (`refreshTokenInvalid !== true`) → the + manager RE-READS its own state under no lock: if the grant it was + bound to is gone (another process forgot it mid-flight — the SDK + throws its synthesized "No tokens available" here), + `CLI.CREDENTIALS_REQUIRED` with grant-removed wording; only + otherwise is it the transient auth-service error. This is a state + check, never message parsing — no string-matching of SDK errors + anywhere. +- No credential at all up front → `CLI.CREDENTIALS_REQUIRED`, + unauthenticated wording — raised by the manager's own structured + error, not the SDK's synthesized message. +- Non-auth failures (network, 5xx, other 4xx) → a transient auth-service error, surfaced as such. NOT credentials-required. NOTHING cleared. The sign-out decision is thereby the SDK's policy; the SDK version @@ -328,7 +441,10 @@ is exact-pinned and a test asserts clearing happens on Unset → fall through to stored; set-but-blank or whitespace → the single blank-token error (§4). `session()` reports the env session (origin "environment"); when a stored session ALSO exists, `whoami` -surfaces a one-line note that the env var is overriding it. +surfaces a one-line note that the env var is overriding it, and +`auth workspace list` must state that the env session is what is in +force (normative — this was previously only a type comment on +`GrantSummary`). **Lock contention.** A refresh-lock wait timeout is NOT an engine bug: it gets its own structured code, a why naming the lock path @@ -366,33 +482,68 @@ mutated only by `beginSession`, `endSession`, and refresh. | Legacy store state | Rule | | --- | --- | -| Context file exists, pointer targets an existing entry | All entries adopted as grants; that one is active | -| Context exists, pointer dangles | All entries adopted as grants; NO active grant (commands needing credentials fail with a why suggesting `auth workspace use` or login) | -| Context exists, `activeWorkspaceId: null` | Grants adopted; no active (legacy's explicit signed-out-of-active state preserved) | +| Context file exists, pointer targets an existing entry | All entries FOR THE ADOPTED IDENTITY (rule below) adopted as grants; that one is active | +| Context exists, pointer dangles | Adopted-identity entries adopted as grants; NO active grant (commands needing credentials fail with a why suggesting `auth workspace use` or login) | +| Context exists, `activeWorkspaceId: null` | Adopted-identity entries adopted; no active (legacy's explicit signed-out-of-active state preserved) | | No context, exactly one entry | Adopted as the single grant, active | -| No context, multiple entries | All adopted as grants; NO active (no coin-flip; the user activates or logs in) | +| No context, multiple entries | Adopted-identity entries adopted; NO active (no coin-flip; the user activates or logs in) | +| Entries from more than one account | One identity adopted per the rule below; the rest are ignored and reported once | | Auth file missing / unparseable / wrong shape | No grants. Never delete, never rewrite | +**The mixed-identity rule** (the legacy store never recorded +identity, so entries from two accounts are a real state, not a +hypothetical). Adoption is still a pure read; nothing is written: + +1. Decode each entry's access token `sub` claim. Entries that will + not decode or carry no `sub` are NOT adopted — they cannot be + accounted to an identity. (Expired tokens still decode; expiry + is irrelevant here.) +2. Choose the identity to adopt: + - The legacy context pointer targets an existing entry → that + entry's identity wins (the credential the user was actually + using — adoption never looks like a silent account switch). + - Otherwise (no context, dangling pointer, `activeWorkspaceId: + null`, multiple entries) → the identity of the LAST valid + entry in store order wins — the same last-wins tie-break the + legacy CLI itself applies. + - Neither yields a decodable identity → adopt nothing (signed + out). Never a coin flip. +3. Adopt only entries whose `sub` equals the chosen identity's. The + rest are ignored, not deleted — they stay in the legacy files + until `endSession` (materialization writes the adopted set only). +4. Identity is keyed on `sub` ALONE; `email` may be absent from + claims and never participates in the comparison. +5. When entries were dropped for belonging to a different account, + `auth workspace list` and `whoami` say so once — the count, and + that signing in as that account brings its workspaces back. + Workspaces must never silently disappear. + The migration read writes nothing; the adopted view is materialized into the NEW single-file format only on the first mutation (beginSession / activateGrant / forgetGrant / endSession / refresh -rotation), and materialization writes the FULL adopted set, not only -the mutated grant. Once the new file exists, the legacy files are -ignored entirely (a legacy CLI writing after materialization is -invisible to v8 — accepted). Until then the legacy files stay -untouched, so a still-installed legacy CLI keeps working. -`endSession` clears everything including legacy files. New writes -use mode 0600 and tighten looser existing permissions on first -write. Mixed-identity legacy entries: adoption applies the -one-identity rule — see the beginSession invariant; the PE delta -review's rule is folded in §7 when it lands. +rotation / rememberWorkspaceName), and materialization writes the +FULL adopted set, not only the mutated grant. A name write is a +mutation, so it materializes and permanently cuts over from the +legacy files — which is why only `login` and `workspace use` may +call `rememberWorkspaceName` (§4): a read-only listing command must +never end legacy-CLI interop as a side effect. Once the new file +exists, the legacy files are ignored entirely (a legacy CLI writing +after materialization is invisible to v8 — accepted). Until then +the legacy files stay untouched, so a still-installed legacy CLI +keeps working. `endSession` clears everything including legacy +files. New writes use mode 0600 and tighten looser existing +permissions on first write. ## 8. Locking and atomicity contract -- **One file** holds the whole credential state (the grants array + - the active cursor + metadata). No context sidecar — the - split-brain class dies by construction. Every write replaces the - whole state, so grants and cursor can never disagree. +- **One file** holds the whole credential state. The shape is + normative: `{ version, identity, grants: [{ workspaceId, name?, + token, refreshToken?, expiresAt?, method }], activeWorkspaceId | + null }` — identity recorded ONCE beside the grants array, which + makes the one-identity invariant structural rather than upheld by + convention. No context sidecar — the split-brain class dies by + construction. Every write replaces the whole state, so grants and + cursor can never disagree. - **One env var** names the auth file, resolved from injected env (§4); the legacy second variable is a warned, deprecated alias. - **Writes are atomic**: temp file in the same directory, fsync, @@ -404,20 +555,50 @@ review's rule is folded in §7 when it lands. - **Reads take no lock** (safe via atomic rename: old or new complete state, never partial). - **One advisory lock, every mutation**: `beginSession`, - `endSession`, refresh all serialize on the same lock file. + `endSession`, `activateGrant`, `forgetGrant`, + `rememberWorkspaceName`, and refresh (`setTokens` / + `clearTokensIfCurrent`) all serialize on the same lock file. `withRefreshLock` is implemented (mandatory), so the SDK's refresh runs under it. -- **Re-entrant within a process** via a held owner token (a nested - acquire is a no-op). No per-call-site "don't lock" flags — the - legacy `lockSetTokens: false` mechanism does not survive. -- **Heartbeated**: holder touches the lock every ~5s; stale - threshold ≥ 4× heartbeat; the token-exchange HTTP call carries a - hard timeout below the stale threshold (a live refresh can never - look stale); lock file records pid/hostname/start; steals are - debug-logged with both identities. -- **Every mutation re-reads under the lock**; refresh compares - against the credential that failed (the SDK does this given the - lock); `clearTokensIfCurrent` clears only on exact match. +- **Re-entrant within a process**, scoped PER LOCK FILE PER PROCESS + via an owner token SHARED between the manager and the + `TokenStorage` adapter the SDK holds (a nested acquire is a + no-op). The SDK calls `setTokens`/`clearTokensIfCurrent` from + INSIDE the `withRefreshLock` the manager supplied — per-instance + re-entrancy would deadlock the adapter against the manager. This + is also why the legacy `lockSetTokens: false` escape hatch can be + retired: correct re-entrancy makes the nested write safe without + a bypass flag. No per-call-site "don't lock" flags survive. +- **Heartbeated**: holder touches the lock every ~5s; lock file + records pid/hostname/start; steals are debug-logged with both + identities. Constant ordering is normative and COMPLETE: + heartbeat < exchange timeout < stale threshold < wait timeout, + with stale ≥ 4× heartbeat. (A live refresh can never look stale, + and a waiter must outlive the stale threshold so it is entitled + to steal a dead holder's lock before giving up — the legacy + constants had wait 25s < stale 30s, turning every crashed holder + into a contention error instead of recovery.) A test asserts the + ordering of the constants themselves. +- **Every mutation re-reads under the lock** and owns only its + slice of the freshly-read state: + + | Mutation | May modify in the freshly-read state | + | --- | --- | + | `setTokens` (rotation) | `token`/`refreshToken`/`expiresAt` of its own grant | + | `clearTokensIfCurrent` | removes its own grant; clears cursor only if it names that grant | + | `activateGrant` | cursor only | + | `forgetGrant` | one grant; cursor if it named that grant | + | `rememberWorkspaceName` | `name` of one grant | + | `beginSession` | whole state, by definition | + | `endSession` | whole state | + + Standing rules: no mutation may write state it read BEFORE + acquiring the lock, and no mutation carrying an identity + assumption (`setTokens`, `clearTokensIfCurrent`) may write at all + if the fresh state's identity is not the one it assumed. Refresh + compares against the credential that failed (the SDK does this + given the lock); `clearTokensIfCurrent` clears only on exact + match over the three compared fields (§6). - **Rotation durability**: the rotated pair is persisted (fsync + rename) before the new access token is handed to any caller. The unclosable client-side window (process killed between server @@ -455,8 +636,12 @@ Docs: rewrite (not append) the auth sections of `workspace logout` → `workspace forget` is itself a divergence; whoami json shape change — the legacy `provider` field has no successor, held-grant count added; orphan-reaping logout; error-code -notes); amend `specs/s2a-foundations.md` §3/§4/acceptance (six -commands, manager exports, erratum note); S2 overview auth rows. +notes; commands that NEWLY fail under a set `PRISMA_SERVICE_TOKEN` +get their own entries — `auth logout` in a CI teardown and +`auth workspace forget` after a revoked grant used to succeed and +now exit non-zero); amend `specs/s2a-foundations.md` +§3/§4/acceptance (six commands, manager exports, erratum note); S2 +overview auth rows. ## 10. Review disposition record @@ -474,4 +659,23 @@ consumer view stays scalar; vocabulary shifts from per-workspace sessions to grants (list / use / forget). The product-team framing updates accordingly: functionality kept, ontology fixed, the multi-entry consistency surface now properly owned by one modeled -component. Delta re-review pending. +component. + +Rev 4 (delta re-review folded, 2026-08-10): architect delta and PE +delta both accept-with-changes at the grants model; all findings +adopted. PE blockers: `setTokens` rotation-write semantics (§6), +the mixed-identity migration rule (§7), per-workspace-id API-client +caching with cursor-move invalidation (§4). PE should-fixes: +grant-bound view binds a workspace id not a snapshot; exact-match +defined over the SDK's three compared fields; slice-ownership +mutation table + identity guard; env-override rule split +(`beginSession` allowed with notice — CHANGES the rev-3 uniform +refusal, operator veto point; `rememberWorkspaceName` exempt); +grant-removed-mid-flight discrimination by state re-read, never +message parsing; complete lock-constant ordering including the wait +timeout; re-entrancy per lock file per process with a shared owner +token; the env-override listing rule made normative; new-failure +divergence entries. Testability list expanded (§5). Operator veto +points at this revision: the env-override split above, and +`workspace use` announcing the browser-open in one line without a +confirm. From 00b4207d829f3feb964c78b5828c794a46f8b06a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 15:35:14 +0200 Subject: [PATCH 34/67] drive(prisma-cli-v8): S2c handover brief for an independent agent Self-contained: project context, engine primer with file pointers, per-group porting substance from the inventory, standing rulings restated, branch/coordination mechanics for parallel execution off s2b-resources, process and acceptance criteria inline. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/briefs/s2c-handover.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 .drive/projects/prisma-cli-v8/assets/briefs/s2c-handover.md diff --git a/.drive/projects/prisma-cli-v8/assets/briefs/s2c-handover.md b/.drive/projects/prisma-cli-v8/assets/briefs/s2c-handover.md new file mode 100644 index 00000000..715c9cab --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/briefs/s2c-handover.md @@ -0,0 +1,305 @@ +# S2c handover brief — execute slice s2c-services + +Written 2026-08-10 for an independent orchestrating agent with NO +prior context on this project. Everything you need to start is in +this file or in the named documents; where this brief summarizes a +document, the document wins. The operator is Will Madden ("the +operator" below). All paths are repo-relative unless absolute. + +## 1. What this project is + +Repo `prisma/prisma-cli` is the v8 rewrite of Prisma's platform CLI. +The legacy CLI (still in this repo, `packages/cli/src/` outside the +`v8/` directory — a commander-based shell) is being ported command by +command onto `@prisma/cli-engine` (`packages/cli-engine/`), a +declarative command engine built in slice S1. The engine owns +argument parsing, help, output envelopes, JSON mode, prompts, +telemetry, error presentation, and exit codes; commands are +definitions plus handlers that receive a `CommandContext` and return +data. When the port completes (slice S2d) the commander shell is +deleted. + +Slice map (S1 merged as PR #129; S2a is PR #130, open): + +| Slice | Scope | State | +| --- | --- | --- | +| S2a | Engine production-readiness: `ctx.api`, auth module, prompts (clack), telemetry, versioning/publish machinery, `version`, auth command family | PR #130 open; an auth rework is landing on it (see §6) | +| S2b | `project *`, `postgres *` (renamed from `database`), `bucket *`, `branch list`, `git *` — 30 commands | Handed to another independent agent; branch `s2b-resources` exists, work not yet pushed | +| S2c | **THIS SLICE**: `service *` (renamed from `app`), `build logs`, `agent *`, `feedback` — 24 commands + 1 parked | Yours | +| S2d | `init` wizard, commander-shell deletion, fixture-machinery deletion, final parity review | Not started | + +## 2. Your normative documents, in reading order + +All under `.drive/projects/prisma-cli-v8/`. Precedence when they +disagree: contract > overview rulings > inventory > this brief. A +contradiction between any of them, or a fact none of them pins, is a +STOP: surface it to the operator; never improvise a resolution. + +1. `specs/s2-overview.md` — the S2 PR split, ten standing rulings + (all bind you; summarized in §5 below), and the operator question + ledger Q1–Q8. Ledger items are ruled defaults unless marked open. +2. `specs/s2c-services.md` — YOUR CONTRACT: mapping rules + R-S2c-1..7, the exact command list, acceptance checklist. It + inherits S2b's mapping rules R-S2b-2/3/4/5/6/9/10 verbatim, so + read `specs/s2b-resources.md` for those (they cover error-code + namespacing, consent/exit-code unification, prompt porting, the + test matrix R-S2b-9, and divergence-entry duty). +3. `plans/s2c-services.md` — your four dispatches D1–D4 (group + core → progress operations → streams → agent/feedback/closure). +4. `assets/s2/command-inventory.md` — the normative record of what + every legacy command does today: flags, API calls, prompts, + errors, exit codes, side effects, test coverage. §4 of it has a + per-command entry for each of your 25 commands (`prisma app *`, + `build logs`, `agent *`, `feedback`). Port from THIS, not from + your own reading of the legacy code; if the inventory and the + code disagree, that too is a STOP (the inventory has a + spec-discrepancies section — check it first). +5. `assets/engine/engine-interface-draft.ts` — the normative engine + interface with commentary. The engine implementation matches it. +6. `assets/s2/parity-divergences.md` — the S2a divergence entries; + your entries follow their format but go in a NEW file (§7). + +## 3. How a command is built on this engine (primer) + +Read the real code in this order; it is the fastest orientation: + +- `packages/cli-engine/src/commands.ts` + `command-family.ts` — how + commands and groups are defined (`CommandFamily` is the ownership + entity; never abbreviate it in identifiers). +- `packages/cli-engine/src/context.ts` — what handlers receive: + `ctx.api` (the authenticated management API client — your ONLY + path to the platform API), `ctx.session()` (read-only auth state), + prompts, logger, injectable clock, `ctx.env`/`ctx.cwd`. +- `packages/cli-engine/src/events.ts` + `presentation.ts` + + `protocol.ts` — the output model. Human output and machine output + are both derived from what the handler returns/emits; you never + write to stdout/stderr yourself. Channel discipline: explanatory + blocks go to stderr, payload to stdout; `--json` frames events. +- `packages/cli-engine/src/execution/` — command kinds. Sync work is + a RESULT command (return a value + serializer). Long-running work + with progress is a SESSION command (emit `step-started/finished`, + `progress`, `status` events). Line-by-line output over time is a + STREAM command (records map to `output` events with a + `data`-vs-`diagnostic` channel per record). +- `packages/cli-engine/src/testing.ts` (exported as + `@prisma/cli-engine/testing`) — `createTestCli`: runs a command + in-process with `ctx.api` faked, prompts scripted, clock + controlled; assertions target the envelope, presented data, events, + and exit codes. This is how ALL your tests work (standing ruling: + semantic-first; never byte-pin output outside the one small golden + suite per output mode). +- `packages/cli/src/v8/` — the ported CLI: `cli.ts` mounts command + groups (the "mount map"); each command lives in one file named for + the command; shared presentation helpers live in named modules. + The `auth/` subtree there is the existing (pre-rework) porting + precedent for file layout. By the time you read this, S2b's + `project/` group may exist on `s2b-resources` — if so, it is the + layout template for your groups. +- Errors: structured, with a stable code (yours are namespaced + `SERVICE.*`, `BUILD.*`, `AGENT.*`, `FEEDBACK.*`), a `why`, and + typed `nextActions` (never free-text "fix" hints). Exit codes: + 0 success, 1 runtime failure, 2 structural/usage/consent-required, + 3 user-canceled, 130 SIGINT. +- Prompts return their input value directly (or throw on cancel); + consent prompts are `prompt.consent`; `--yes` satisfies + consent-grade prompts; non-interactive without `--yes` is the + structured consent-required error (exit 2). These unifications + CHANGE some legacy exit codes — ledger Q5 rules this; every + changed code gets a divergence entry. + +Telemetry: automatic. The engine reports command runs the way the +ORM CLI does, via `@repo/cli-telemetry`. You wire nothing per +command. Tests must NEVER contact the production telemetry endpoint +(the cli package's vitest config already sets +`PRISMA_NEXT_DISABLE_TELEMETRY=1`; telemetry-behavior tests use the +mock endpoint fixture only). + +## 4. What you are porting (the substance) + +Contract scope: 24 commands + 1 parked. The rename is ruled +(R-S2c-1): the deployable unit's noun is **Service** — `app` ports +as `service` in all paths, ids, help, and presenters, with NO alias; +one divergence entry per command. Scope note from the contract: env +vars live under `project env` (S2b); the app group has a `domain` +subgroup and NO env subgroup — follow the inventory. + +Highlights per group (full detail: inventory §4): + +- **`service deploy`** — the flagship and the hardest command in the + CLI. Multi-step session command (upload/build/deploy/promote with + progress callbacks), first-deploy interactive customization, + `--db` branch-database wiring with its own consent, production + protection (second-and-later production deploys require `--prod` + plus `--yes`/interactive confirm; cancel exits per Q5's unified + codes), deploy-all mode for multi-target configs (rejects + per-app inputs), a dozen error codes with build-phase-aware hints. + Budget the most time here; its legacy test files (app.test.ts, + deploy-plan.test.ts, production-deploy-gate.test.ts, and five + more) enumerate the behavior matrix. +- **`service remove`** — destructive; the CLI's only TYPE-THE-NAME + confirmation. Ports to `prompt.consent` + its current flag per + R-S2b-3. +- **`service promote` / `rollback`** — remote operations with + progress (session commands). NOTE the inventory flag: legacy + `rollback` has NO confirmation today despite being + production-affecting. Port as-is (parity) and record it in your + divergence file as a flagged follow-up for the operator — do not + add a prompt unilaterally. +- **`service logs`** (stream) and **`build logs`** (stream) — + R-S2c-2: per-record `source`/`level` routing maps to the engine's + `data` vs `diagnostic` channels; the legacy JSON wrapper-event + opt-out for `build logs` does not port (divergence). `build logs` + has ZERO legacy tests — you write its first ever; full R-S2b-9 + matrix applies. Its terminal-record protocol (a `terminal error` + record sets exit 1 without throwing) must map onto engine stream + termination status. +- **`service domain wait`** — canonical poll→status-events case: + emits a status event per change on the injectable clock, + `--timeout` default 15m, terminal states active/failed/timeout. +- **`service build`** — R-S2c-5: fully local result command (no + `ctx.api`), framework build via child processes, progress events + from the SDK build reporter. +- **`service open`** — R-S2c-6: URL as an `endpoint` event + the + operation layer's existing browser opener; never open without a + TTY (report url + `opened: false`). +- **`agent install|update|status`** — local child-process commands + (spawn `skills-cli` via pnpm dlx/bunx/npx); no auth, no API; + `--dry-run` returns `{status:"would-install", command}`. +- **`feedback`** — no auth; POSTs to the feedback service URL + (env-overridable, 3s timeout). Legacy has no JSON serializer for + it; under the engine it gets the standard envelope (divergence). + The crash-recovery flow pre-fills this command, so the v8 shell + keeps an equivalent hook. +- **PARKED: `service run`** (ledger Q2, OPEN — operator decision). + It passes the child dev-server's exit code through as the CLI's + exit code; engine session commands have no exit-code channel. + DO NOT port it until the operator rules whether the passthrough + mechanism is built in S2c or deferred to Composer's S3. Raise Q2 + with the operator EARLY (your first report), because the answer + shapes your D3. + +Auth for your commands: the app group and `build logs` use +`needs.credentials` + `ctx.api` and never auto-login (the legacy +TTY auto-login does not port — ledger Q1). `agent` and `feedback` +declare no credential needs. The compute-plane operations (deploy, +logs streaming) authenticate the compute SDK client with the +credential — that wiring lives in the auth/operations layer you +consume, not in your command files; if you find no sanctioned path +to an authenticated compute client when you get there, STOP and +surface it (do not read token storage yourself). + +## 5. Standing rulings that bind every line you write + +Full text: `specs/s2-overview.md`. The ones violated most easily: + +1. `CommandFamily` is the contribution entity — never "product", + never "manifest", never shortened. +2. No conditional properties on stored/normalized types: absent = + `T | undefined` with the key required. +3. Tests semantic-first through `createTestCli`; management API + faked at `ctx.api`; auth stubbed at the auth-module seam; no + byte-matching outside the golden suite; delete legacy fixture + tests ONLY for commands you port. +4. No dynamic imports of handlers. No lazy handler loading. +5. Naming: no invented jargon, no mechanism names as domain names, + no dropped meaning-carrying qualifiers, no transient project IDs + in shipped code. Command examples never include the binary name. +6. Comments are a last resort; public-facing doc comments terse. +7. `--json` sets format only; `--quiet`/`--verbose` set log level + only and are not otherwise retained; `--interactive` re-enables + prompts under `--json`. +8. Every user-visible behavior change from legacy gets a divergence + entry (see §7) — renames, exit-code changes, dropped flags, + envelope shape changes, all of it. + +## 6. State of the world and coordination (read carefully) + +Three streams are active in this repo: + +- `s2a-foundations` (PR #130, the base of everything): a + credential-manager rework of the auth family is landing on it + RIGHT NOW (design: `assets/engine/credential-manager-design.md`, + rev 4 final). Consumer-facing surfaces you depend on — `ctx.api`, + `needs.credentials`, `ctx.session()` — are semantically stable; + the test-harness credential-seeding options may change shape once. + If a merge-down changes the seeding surface, adopt the new one + during the merge rather than pinning the old one into new files. +- `s2b-resources`: another independent agent, in progress. You do + not coordinate with it directly; you consume its branch. +- Yours: branch `s2c-services` **off the current tip of + `s2b-resources`** (the operator ruled parallel execution; the + contract's "off main after S2b merges" describes the eventual + merged geometry). Open your PR with base `s2b-resources` — NOT + `main` — and retarget when S2b merges. Merge down from + `s2b-resources` regularly; expected conflict surface is only + `packages/cli/src/v8/cli.ts` (the mount map) and the lockfile. + If S2b has not pushed command work yet when you start, D1 + proceeds anyway: the S2a auth family under `packages/cli/src/v8/` + is a sufficient layout precedent, and you adopt S2b's template on + your first merge-down if it differs. + +Hard boundaries — never modify: +- `packages/cli-engine/**` (an engine gap → STOP, surface to the + operator with the exact need; do not extend the engine yourself), +- `packages/cli/src/auth/**` and `packages/cli/src/v8/auth/**` + (mid-rework by the S2a stream), +- `.github/workflows/publish.yml`, `scripts/determine-version*`, + root/package version fields (publish machinery is settled), +- `assets/s2/parity-divergences.md` (being rewritten by the auth + stream) and anything under `.drive/projects/prisma-cli-v8/specs/` + other than reading it, +- `wip/**` anywhere, if present — never stage it. + +## 7. Your divergence file + +Create `.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s2c.md`, +same entry format as `parity-divergences.md` (S2d consolidates the +per-slice files). Every R-S2c-1 rename, the `build logs` wrapper +drop, every Q5 exit-code change, the `feedback` envelope addition, +the rollback-has-no-confirm flag, and anything else user-visible. + +## 8. Process (non-negotiable) + +- Orchestrate via the drive process: dispatch implementer subagents + (model: Fable) per plan dispatch D1–D4; run the slice review loop + (architect + principal-engineer reviewers, model: Opus) before + the PR leaves draft; fix findings before reporting done. +- Git identity — you are the `wmadden-electric` bot: + - stage files EXPLICITLY by path; never `git add -A`/`-u`; + - commit: `git commit -s --trailer "Signed-off-by: Will Madden + "`, body's last line + `Co-Authored-By: Claude Fable 5 `; + - push ONLY to the bot remote + `git@github-wmadden-electric:prisma/prisma-cli.git`. +- Verification per dispatch, all green before commit: + `pnpm --filter @prisma/cli test`, + `pnpm --filter @prisma/cli-engine test` (must stay untouched and + green), `pnpm typecheck`, `pnpm lint` — judged by pnpm's OWN exit + code, not a pipeline tail's. +- PR: ≥1k LOC, one PR for the slice. Description structure (ruled): + a grounding example first (a real command run, before/after), then + the decision, then the narrative, alternatives last; no internal + process codes or dispatch labels in the description. +- Reporting to the operator: plain English, full sentences, no + invented shorthand; spell out anything slice-internal. Banned + words: "load-bearing", "smoking gun", "belt and suspenders", + "gate". Bring QUESTIONS to decide, not decisions to ratify. STOP + items (contradictions, unpinned facts, engine gaps) go to the + operator immediately with your recommendation attached. +- Do not use the question UI; write questions in plain messages. + +## 9. What done looks like + +The contract's acceptance list, restated: all 24 commands mounted +and green on the R-S2b-9 test matrix (streams included, `build +logs` tested for the first time); no `app` path surviving anywhere +in v8; deploy/promote/rollback/remove event sequences pinned by +semantic tests; the divergence file complete; Q2 either ruled and +implemented or still parked with the legacy path intact and a note +for S2d; legacy fixture tests for your commands deleted; root +verification green; review loop run and findings fixed; PR open +against `s2b-resources` with the ruled description structure. + +Your first report to the operator should contain: confirmation you +read the four normative docs, your Q2 question, the S2b template +status you found, and your D1 dispatch plan. Then execute. From 6a3563ab3c9832a3a4c1d90ce01eaeba391f14fc Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 15:41:33 +0200 Subject: [PATCH 35/67] drive(prisma-cli-v8): remove credential() from the manager interface Restores the adopted rev-2 architect ruling that rev 3 mistakenly dropped: credential resolution is internal to the manager (inside apiClient() and the needs check); no public method returns credential material. The interface has 8 methods. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index e724ae8c..eb7ef2df 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -42,7 +42,7 @@ R2 Auth state: ONE identity, PLURAL workspace grants, ONE active grant. The active-grant cursor is session state (satisfying the draft premise: workspace selection is session state, not a credential). Toward every engine consumer the view is SCALAR — - `session()`/`credential()`/`ctx.api`/`needs.credentials` see only + `session()`/`ctx.api`/`needs.credentials` see only the active grant. The plurality lives entirely inside the manager. No per-workspace login/logout vocabulary anywhere: you log in as yourself; you hold, select, and forget GRANTS. @@ -180,11 +180,6 @@ interface CredentialManager { * auto-promotion of another grant). */ forgetGrant(ref: string): Promise; - /** The consumer path. Resolves the credential that authorizes a - * request NOW; refresh happens inside (§6). Invariant: - * credential() === null ⟺ session() === null. */ - credential(): Promise; - /** The authenticated management API client, constructed and owned * by the MANAGER (review blockers: the engine must not build a * half-configured SDK — the real clientId lives with the auth @@ -218,7 +213,18 @@ session is in force — - `rememberWorkspaceName` is EXEMPT: it annotates an existing grant and never changes which credential is in force; refusing would break enrichment exactly in the CI case where names are cosmetic. -Reads (`session()`, `grants()`, `credential()`) work normally. +Reads (`session()`, `grants()`) work normally. + +Credential RESOLUTION is internal (ruled at rev-2 adoption — +architect recommendation: "let `credential()` be internal"; rev 3 +mistakenly restored it to the interface, corrected here). The +manager resolves the credential that authorizes a request inside +`apiClient()` and the needs check; no public method returns +credential material. Internal invariant: resolution yields null ⟺ +`session()` is null. The one known future consumer of raw +credential material — S2c's compute SDK client — comes back as an +operator question when that slice reaches it, not as a silent +re-add. State effects, at a glance: @@ -252,8 +258,8 @@ Boundaries (review-settled): with a one-time warning and never wins over an explicit path). - **Error raising is single-sourced**: set-but-blank service token → one structured error (the existing AUTH.CONFIG_INVALID content) - raised identically from `session()`, `credential()`, and the needs - check; unreadable store (EACCES/EPERM) → `CLI.CREDENTIALS_UNREADABLE`; + raised identically from `session()`, the needs check, and the + internal credential resolution behind `apiClient()`; unreadable store (EACCES/EPERM) → `CLI.CREDENTIALS_UNREADABLE`; parse-corrupt store → signed out (self-heals on next login), never an exception, never a write. @@ -318,7 +324,8 @@ Boundaries (review-settled): whitespace}, asserting the error family and that the auth file's bytes are unchanged; - forget-active / grants-held-none-active: one shared assertion - over `session()`, `credential()`, and the needs check (code, + over `session()`, the needs check, and a bare `ctx.api` touch + (code, why, next action); - name persistence across refresh (the named legacy-regression test): seed a named grant, script 401 → rotation, assert the @@ -345,8 +352,9 @@ exit 0. No auto-login (standing Q1 default). **Grants held, none active** (migration rows; forget-active): same code `CLI.CREDENTIALS_REQUIRED`, distinct why ("you hold grants but none is active") and nextAction `auth workspace use` alongside -sign-in. Single-sourced like the other credential errors: session(), -credential(), and the needs check produce it identically. NOTHING +sign-in. Single-sourced like the other credential errors: session(), the +needs check, and the internal resolution behind ctx.api produce it +identically. NOTHING auto-promotes a grant. **Refresh.** Driven by the SDK on 401, with the manager as its From a8ef3fb09720b88f2ad930b058773dd6509e7ff5 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 15:51:51 +0200 Subject: [PATCH 36/67] feat(cli-engine): model the credential manager on the engine surface Stage 1+2 of the staged swap in the credential-manager design (rev 4, amended: credential resolution is internal to the manager): - Entity types (Identity, Credential, Workspace, Session, GrantSummary) and the 8-method CredentialManager interface, per design sections 3-4. - Runtime.credentialManager (optional during the swap); the engine prefers it and falls back to getCredentials where it still exists. - ctx.session() on every context: read-only, local-only, tested to perform no network I/O. - managesCredentials capability on result-command definitions: when declared, ctx.credentialManager appears on the context (two defineCommand overloads - a generic inference site collapses under contextual typing). A declaration never fails a run. - needs.credentials single-sourced from the manager: unauthenticated, grants-held-none-active (distinct why + auth workspace use next action), and manager-raised structured errors pass through identically from the needs check, ctx.session, and ctx.api. - ctx.api reduced to a thin lazy proxy over manager.apiClient() plus the engine-side design-section-6 error mapping (refreshTokenInvalid -> expired wording; other AuthError -> state re-read: grant gone -> grant-removed wording, else the transient auth-service error); the placeholder OAuth constants and the engine's SDK construction are deleted, along with the createSdk test seam. - Harness: createTestCli seeds a mutable in-memory manager ({ credential | session | identity/grants/activeWorkspaceId }), readable back via TestCli.credentialManager.state(); mintTestJwt supplies claim-bearing tokens for real derivation. - Draft amendments: engine-interface-draft.ts sections 4/6/10/11 updated to rev 4. Codes the design left unnamed, chosen here: CLI.AUTH_SERVICE_ERROR (transient refresh failure), AUTH.ENV_SESSION_IN_FORCE (env-override mutation refusal), AUTH.WORKSPACE_REF_AMBIGUOUS / AUTH.GRANT_NOT_HELD (ref resolution). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/engine-interface-draft.ts | 204 ++++++- packages/cli-engine/src/commands.ts | 73 ++- packages/cli-engine/src/context.ts | 22 +- packages/cli-engine/src/credential-errors.ts | 109 ++++ packages/cli-engine/src/credential-manager.ts | 142 +++++ .../cli-engine/src/execution/api-client.ts | 149 ++--- .../src/execution/command-context.ts | 22 +- packages/cli-engine/src/execution/engine.ts | 11 +- packages/cli-engine/src/execution/needs.ts | 42 +- packages/cli-engine/src/exports/index.ts | 14 + packages/cli-engine/src/exports/testing.ts | 7 + packages/cli-engine/src/runtime.ts | 8 + .../src/testing-credential-manager.ts | 373 ++++++++++++ packages/cli-engine/src/testing.ts | 57 ++ .../tests/credential-manager.test.ts | 558 ++++++++++++++++++ packages/cli-engine/tests/engine.test.ts | 9 +- packages/cli-engine/tests/engine.type-test.ts | 66 +++ .../cli-engine/tests/management-api.test.ts | 273 +++++---- 18 files changed, 1904 insertions(+), 235 deletions(-) create mode 100644 packages/cli-engine/src/credential-errors.ts create mode 100644 packages/cli-engine/src/credential-manager.ts create mode 100644 packages/cli-engine/src/testing-credential-manager.ts create mode 100644 packages/cli-engine/tests/credential-manager.test.ts diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index df8906d3..97a60da5 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -17,6 +17,12 @@ * family-supplied base; committed versions for releases; auth library * lives in the CLI repo, distinct from Prisma Cloud. Prior versions * preserved as -v1…-v7.ts; reviews in ./reviews/. + * Amended 2026-08-10 for the credential-manager design rev 4 + * (credential-manager-design.md, normative): §4 gains ctx.session and + * the CredentialManager entity surface (ctx.getCredentials removal is + * STAGED — the engine still carries it until the swap's final stage); + * §6 gains the managesCredentials capability; §10 gains + * Runtime.credentialManager; §11 gains manager seeding + fixtures. * * THE MODEL, in one analogy (operator, 2026-08-09): commands settle like * promises. A command can COMPLETE — and its completion can be @@ -367,18 +373,28 @@ export interface CommandContext PresentedResult - /** Management-API credentials, resolved at call time so long-lived - * sessions survive token refresh. Undefined when unauthenticated. - * Commands with needs.credentials never see undefined — the engine - * fails them early with the sign-in error. */ - readonly getCredentials: () => Promise - - /** The Management API client. Constructed lazily on first access, - * once per run, with its token source backed by ctx.getCredentials - * so refresh during long runs is picked up per request. A request - * made while getCredentials() resolves undefined throws the - * structured CLI.CREDENTIALS_REQUIRED error (the same constructor - * the needs.credentials check uses). */ + /** The current auth session, or null when signed out — on EVERY + * context. Read-only and local-only: safe to call anywhere; never + * touches the network. Raises the same single-sourced structured + * errors as the needs check for broken-but-not-signed-out states + * (grants held none active; blank env token). + * ctx.getCredentials is DELETED (staged: the engine carries it + * until the swap's final stage) — the context ends with fewer auth + * surfaces than before: `api` + `session`, plus + * `credentialManager` for the commands that declare the §6 + * capability. */ + readonly session: () => Promise + + /** The Management API client: a thin lazy proxy over the credential + * manager's apiClient(), constructed on first method CALL, once + * per run. Request failures pass through the engine-side mapping + * (design §6): refreshTokenInvalid === true → the expired + * CLI.CREDENTIALS_REQUIRED; any other SDK AuthError → a state + * re-read (grant gone → grant-removed CLI.CREDENTIALS_REQUIRED, + * otherwise the transient auth-service error) — state checks, + * never message parsing. A request made while signed out throws + * the structured CLI.CREDENTIALS_REQUIRED error (the same + * constructor the needs.credentials check uses). */ readonly api: ManagementApiClient /** The one way to emit while running (§1). */ @@ -412,14 +428,80 @@ export interface CommandContext Promise> } +/** Superseded by the credential-manager surface below; carried only + * through the staged swap (Runtime.getCredentials fallback), then + * deleted. */ export interface Credentials { - /** Opaque to the engine; shape owned by the Cloud auth - * library (placeholder pending its design). Workspace selection is - * session state, not a credential — it lives with that library, not - * here. */ readonly token: string } +// —— §4b The credential manager (design rev 4 §3/§4, normative) —— +// One identity, plural workspace grants, one active grant; every +// consumer surface is scalar. No conditional properties: absent = +// `T | undefined` with the key required. + +export type Identity = + | { readonly kind: 'user'; readonly id: string; readonly email: string | undefined } + | { readonly kind: 'service'; readonly id: string | undefined; readonly label: string | undefined } + +export interface Credential { + readonly token: string + readonly refreshToken: string | undefined + readonly expiresAt: Date | undefined + readonly method: 'user-oauth' | 'service-token' +} + +/** A resolved pair — NOT the id-or-name string users type (a ref). */ +export interface Workspace { + readonly id: string + readonly name: string | undefined +} + +export interface Session { + readonly identity: Identity + readonly method: 'user-oauth' | 'service-token' + readonly origin: 'stored' | 'environment' + /** The ACTIVE grant's workspace. */ + readonly workspace: Workspace + readonly expiresAt: Date | undefined +} + +/** User-centric grant listing; structurally NEVER carries credential + * material. `active` is the cursor position. */ +export interface GrantSummary { + readonly workspace: Workspace + readonly expiresAt: Date | undefined + readonly active: boolean +} + +/** Custody, not user interaction: never opens a browser, never + * prompts. Env is a construction input. Credential RESOLUTION is + * internal — it happens inside apiClient() and the needs check; no + * public method returns credential material. Full semantics + * (env-override mutation rules, error single-sourcing, locking) are + * normative in credential-manager-design.md §4/§6/§8. */ +export interface CredentialManager { + /** Local-only truth; never touches the network. */ + session(): Promise + /** Login's write: claims-derived; upserts (same identity) or + * replaces all (different identity); sets the cursor. */ + beginSession(credential: Credential): Promise + /** Logout, whole-identity; refuses under an env-supplied session. */ + endSession(): Promise + /** Held grants as summaries; local-only. */ + grants(): Promise + /** The sanctioned name-write path; no grant held → no-op. */ + rememberWorkspaceName(workspaceId: string, name: string): Promise + /** Cursor move to a HELD grant; ref = exact id, then + * case-insensitive name; ambiguity/no-match are structured errors. */ + activateGrant(ref: string): Promise + /** Drop one grant; clears the cursor if it was active. */ + forgetGrant(ref: string): Promise + /** The authenticated client, constructed and owned by the MANAGER; + * cached per workspace id, invalidated by cursor moves. */ + apiClient(): Promise +} + /** The SDK's typed client, re-exported by the engine so consumers * never import @prisma/management-api-sdk directly. */ export type ManagementApiClient = import('@prisma/management-api-sdk').ManagementApiClient @@ -665,6 +747,7 @@ export interface CommandDefinition< TPositionals extends Record> = {}, TConfig = undefined, TCode extends number = never, + TManagesCredentials extends boolean = false, > { readonly kind: 'result-command' readonly help: CommandHelp @@ -680,13 +763,24 @@ export interface CommandDefinition< */ readonly exitCodes: Readonly> + /** + * A CAPABILITY, not a need (design rev 4 §5): when true, + * ctx.credentialManager appears on the context. Declaring it never + * fails a run — documentation and testability, not enforcement. + * Declared by exactly: auth login, auth logout, auth workspace + * list, auth workspace use, auth workspace forget. whoami uses + * ctx.session() only; grants() lives ONLY on the manager, never on + * the universal context. + */ + readonly managesCredentials: TManagesCredentials + /** The handler function, referenced directly — never a dynamic import * (operator ruling, 2026-08-09: "DO NOT DYNAMICALLY IMPORT HANDLERS"). * R9's keep-heavy-work-out-of-startup concern is the handler BODY's * business: a handler that needs heavy dependencies imports them at * execution time, inside itself. A handler defined in another file is * imported statically and annotated CommandHandler. */ - readonly handler: Handler + readonly handler: Handler } export type Handler< @@ -694,16 +788,36 @@ export type Handler< TPositionals extends Record>, TConfig, TCode extends number = never, + TManagesCredentials extends boolean = false, > = ( args: Args, - ctx: CommandContext, + ctx: CommandContext & + (TManagesCredentials extends true + ? { readonly credentialManager: CredentialManager } + : unknown), ) => Promise, CliStructuredError>> /** For impl files: `const run: CommandHandler = …` */ -export type CommandHandler = D extends CommandDefinition - ? Handler +export type CommandHandler = D extends CommandDefinition + ? Handler : never +/** Two overloads (implementation detail worth documenting: a generic + * TManagesCredentials inference site collapses under contextual + * typing, so the capability is a literal in each overload). */ +export declare function defineCommand< + TFlags extends Record> = {}, + TPositionals extends Record> = {}, + TConfig = undefined, + TCode extends number = never, +>(def: { + readonly help: HelpSpec + readonly args?: ArgsSpec + readonly needs?: NeedsSpec + readonly exitCodes?: Readonly> + readonly managesCredentials: true + readonly handler: Handler +}): CommandDefinition export declare function defineCommand< TFlags extends Record> = {}, TPositionals extends Record> = {}, @@ -1023,6 +1137,12 @@ export interface Runtime { /** Loaded config + file-level diagnostics; the shell builds this via * the unified loader (R10). Tests hand in fixtures. */ readonly config: LoadedConfig + /** The credential manager the bin wires (design rev 4 §5). The + * engine prefers it for the needs check, ctx.session, and ctx.api. + * Optional only during the staged swap; getCredentials below is + * the fallback and is deleted — with the optionality — in the + * swap's final mechanical stage. */ + readonly credentialManager?: CredentialManager readonly getCredentials: () => Promise /** Management API endpoint config; the bin derives baseUrl from env * (getApiBaseUrl). */ @@ -1075,7 +1195,24 @@ export declare function createTestCli(spec: { readonly commands: MountedTree readonly groups?: Readonly> readonly config?: Readonly> + /** Legacy seed for the staged-swap getCredentials fallback: selects + * a manager-less runtime. Mutually exclusive with the manager + * seeds below; deleted with the swap's final stage. */ readonly credentials?: Credentials + /** Preferred manager seed: beginSession runs its real claims + * derivation on this credential (mint the token with mintTestJwt). */ + readonly credential?: Credential + /** Escape hatch: ctx.session() resolves exactly this. */ + readonly session?: Session + /** Grants-model seeding; identity is seedable independently of + * grants, so a grant whose token disagrees with the recorded + * identity is constructible. */ + readonly identity?: Identity + readonly grants?: ReadonlyArray<{ + readonly workspace: Workspace + readonly credential: Credential + }> + readonly activeWorkspaceId?: string /** baseUrl defaults to "https://test.invalid"; when `client` is * supplied, ctx.api IS that object (the uniform mock seam). */ readonly managementApi?: { @@ -1087,7 +1224,34 @@ export declare function createTestCli(spec: { readonly now?: () => Date }): TestCli +/** Mints an unsigned JWT whose payload is exactly `claims` — the + * harness's claim source (`sub`, `workspace_id`, `exp`, `email`) for + * beginSession derivation, migration, expiry, and identity-guard + * tests. The rest of the design's fixture surface (the injectable + * refresh/token endpoint scripting, legacy-store builder, + * deterministic clock, mutation interleaving hook) lands with the + * real manager implementation, whose behavior it exercises. */ +export declare function mintTestJwt(claims: Readonly>): string + export interface TestCli { + /** The MUTABLE in-memory credential manager backing the runs: the + * full CredentialManager interface plus state(), which reads the + * whole state back after a run — grants with their per-grant + * credentials, identity, and cursor (login/logout tests observe + * state changes). Undefined only when the legacy `credentials` + * seed selected the manager-less fallback runtime. */ + readonly credentialManager: + | (CredentialManager & { + state(): { + readonly identity: Identity | undefined + readonly grants: ReadonlyArray<{ + readonly workspace: Workspace + readonly credential: Credential + }> + readonly activeWorkspaceId: string | null + } + }) + | undefined run( argv: readonly string[], opts?: { diff --git a/packages/cli-engine/src/commands.ts b/packages/cli-engine/src/commands.ts index 7178f403..0a8acef6 100644 --- a/packages/cli-engine/src/commands.ts +++ b/packages/cli-engine/src/commands.ts @@ -7,6 +7,7 @@ import type { } from "./args"; import type { ConfigSection } from "./config-section"; import type { CommandContext } from "./context"; +import type { CredentialManager } from "./credential-manager"; import type { PresentedResult } from "./presentation"; import type { CliStructuredError, @@ -128,6 +129,7 @@ export interface CommandDefinition< >, TConfig = undefined, TCode extends number = never, + TManagesCredentials extends boolean = false, > { readonly kind: "result-command"; readonly help: CommandHelp; @@ -142,6 +144,15 @@ export interface CommandDefinition< */ readonly exitCodes: Readonly>; + /** + * A CAPABILITY, not a need: when true, ctx.credentialManager appears + * on the context. Declaring it never fails a run — this is + * documentation and testability, not enforcement. Declared by + * exactly the auth commands that operate ON the credential + * machinery. + */ + readonly managesCredentials: TManagesCredentials; + /** * The handler function, referenced directly — never a dynamic import * (operator ruling, 2026-08-09). A handler that needs heavy @@ -149,7 +160,13 @@ export interface CommandDefinition< * handler defined in another file is imported statically and * annotated CommandHandler. */ - readonly handler: Handler; + readonly handler: Handler< + TFlags, + TPositionals, + TConfig, + TCode, + TManagesCredentials + >; } export type Handler< @@ -157,17 +174,40 @@ export type Handler< TPositionals extends Record>, TConfig, TCode extends number = never, + TManagesCredentials extends boolean = false, > = ( args: Args, - ctx: CommandContext, + ctx: CommandContext & + (TManagesCredentials extends true + ? { readonly credentialManager: CredentialManager } + : unknown), ) => Promise, CliStructuredError>>; /** For impl files: `const run: CommandHandler = …` */ export type CommandHandler = - D extends CommandDefinition - ? Handler + D extends CommandDefinition + ? Handler : never; +export function defineCommand< + TFlags extends Record> = Record< + never, + FlagSpec + >, + TPositionals extends Record> = Record< + never, + PositionalSpec + >, + TConfig = undefined, + TCode extends number = never, +>(def: { + readonly help: HelpSpec; + readonly args?: ArgsSpec; + readonly needs?: NeedsSpec; + readonly exitCodes?: Readonly>; + readonly managesCredentials: true; + readonly handler: Handler; +}): CommandDefinition; export function defineCommand< TFlags extends Record> = Record< never, @@ -185,13 +225,33 @@ export function defineCommand< readonly needs?: NeedsSpec; readonly exitCodes?: Readonly>; readonly handler: Handler; -}): CommandDefinition { +}): CommandDefinition; +export function defineCommand< + TFlags extends Record> = Record< + never, + FlagSpec + >, + TPositionals extends Record> = Record< + never, + PositionalSpec + >, + TConfig = undefined, + TCode extends number = never, +>(def: { + readonly help: HelpSpec; + readonly args?: ArgsSpec; + readonly needs?: NeedsSpec; + readonly exitCodes?: Readonly>; + readonly managesCredentials?: boolean; + readonly handler: Handler; +}): CommandDefinition { return Object.freeze({ kind: "result-command" as const, help: normalizeHelp(def.help), args: normalizeArgs(def.args), needs: normalizeNeeds(def.needs), exitCodes: def.exitCodes ?? ({} as Readonly>), + managesCredentials: def.managesCredentials ?? false, handler: def.handler, }); } @@ -316,7 +376,8 @@ export type AnyCommand = Record>, Record>, unknown, - number + number, + boolean >, "handler" > & { readonly handler: unknown }) diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index e10584d4..f3f0c7a3 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -1,3 +1,4 @@ +import type { Session } from "./credential-manager"; import type { EngineEvent } from "./events"; import type { ManagementApiClient } from "./management-api"; import type { Outcome, Presentations, PresentedResult } from "./presentation"; @@ -29,19 +30,28 @@ export interface CommandContext< presentations: Presentations, ) => PresentedResult; + /** + * The current auth session, or null when signed out. Read-only and + * local-only — safe to call anywhere; never touches the network. + * Throws the same structured errors the needs check raises for + * broken-but-not-signed-out states (grants held, none active). + */ + readonly session: () => Promise; + /** * Management-API credentials, resolved at call time. Undefined when * unauthenticated; commands with needs.credentials never see - * undefined — the engine fails them early. + * undefined — the engine fails them early. Staged for deletion: + * ctx.session and ctx.api are the surviving auth surfaces. */ readonly getCredentials: () => Promise; /** - * The Management API client. Constructed lazily on first access, once - * per run, with its token source backed by ctx.getCredentials so - * refresh during long runs is picked up per request. A request made - * while getCredentials() resolves undefined throws the structured - * CLI.CREDENTIALS_REQUIRED error. + * The Management API client: a thin lazy proxy over the credential + * manager's apiClient(), constructed on first method call, once per + * run. A request made while signed out throws the structured + * CLI.CREDENTIALS_REQUIRED error (the same constructor the + * needs.credentials check uses). */ readonly api: ManagementApiClient; diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts new file mode 100644 index 00000000..a851ec39 --- /dev/null +++ b/packages/cli-engine/src/credential-errors.ts @@ -0,0 +1,109 @@ +import { CliStructuredError, type NextAction } from "./protocol"; + +const signInAction: NextAction = { + kind: "user-choice", + label: "Sign in, then run the command again.", +}; + +const activateGrantAction: NextAction = { + kind: "run-command", + label: "Activate one of your held workspace grants", + command: "prisma auth workspace use", +}; + +export type CredentialsRequiredReason = + | "unauthenticated" + | "expired" + | "grant-removed" + | "grants-held-none-active"; + +/** + * The single constructor of CLI.CREDENTIALS_REQUIRED. Raised by the + * needs check, by an unauthenticated ctx.api request, and by the + * credential manager's session()/credential() in the + * grants-held-none-active state — identically from all of them. + */ +export function credentialsRequiredError( + reason: CredentialsRequiredReason = "unauthenticated", +): CliStructuredError { + switch (reason) { + case "unauthenticated": + return new CliStructuredError( + "CLI.CREDENTIALS_REQUIRED", + "You must be signed in to run this command.", + { nextActions: [signInAction] }, + ); + case "expired": + return new CliStructuredError( + "CLI.CREDENTIALS_REQUIRED", + "Your session has expired — sign in again.", + { nextActions: [signInAction] }, + ); + case "grant-removed": + return new CliStructuredError( + "CLI.CREDENTIALS_REQUIRED", + "The workspace grant this command was using is no longer held.", + { + why: "It was removed while the command was running (for example by another prisma process).", + nextActions: [activateGrantAction, signInAction], + }, + ); + case "grants-held-none-active": + return new CliStructuredError( + "CLI.CREDENTIALS_REQUIRED", + "No workspace is active.", + { + why: "You hold workspace grants, but none is active.", + nextActions: [activateGrantAction, signInAction], + }, + ); + } +} + +/** + * A refresh attempt failed transiently (the auth service, not the + * credentials): nothing was cleared, and signing in again is not the + * fix. + */ +export function authServiceError(): CliStructuredError { + return new CliStructuredError( + "CLI.AUTH_SERVICE_ERROR", + "The authentication service could not refresh your session.", + { + why: "The refresh attempt failed transiently; your stored credentials were left untouched.", + nextActions: [ + { + kind: "user-choice", + label: + "Run the command again; sign in again only if the problem persists.", + }, + ], + }, + ); +} + +/** + * A mutation refused while an env-supplied session is in force: state + * the user cannot observe as their session is never changed. + */ +export function environmentSessionMutationError(spec: { + readonly envVar: string; + readonly storedGrantsExist: boolean; +}): CliStructuredError { + return new CliStructuredError( + "AUTH.ENV_SESSION_IN_FORCE", + `The current session comes from ${spec.envVar}, which this command cannot change.`, + { + why: spec.storedGrantsExist + ? `${spec.envVar} overrides your stored workspace grants; unsetting it restores them.` + : `${spec.envVar} supplies the only session; there is no stored state to change.`, + nextActions: [ + { + kind: "run-command", + label: `Unset ${spec.envVar}`, + command: `unset ${spec.envVar}`, + }, + ], + }, + ); +} diff --git a/packages/cli-engine/src/credential-manager.ts b/packages/cli-engine/src/credential-manager.ts new file mode 100644 index 00000000..f4176f57 --- /dev/null +++ b/packages/cli-engine/src/credential-manager.ts @@ -0,0 +1,142 @@ +import type { ManagementApiClient } from "./management-api"; + +/** + * Who is signed in. One identity per login; every held grant belongs to + * it. Claim-derived fields only. + */ +export type Identity = + | { + readonly kind: "user"; + readonly id: string; + readonly email: string | undefined; + } + | { + readonly kind: "service"; + readonly id: string | undefined; + readonly label: string | undefined; + }; + +/** Credential material. Leaves the manager only through credential(). */ +export interface Credential { + readonly token: string; + readonly refreshToken: string | undefined; + readonly expiresAt: Date | undefined; + readonly method: "user-oauth" | "service-token"; +} + +/** + * A resolved workspace pair — NOT the id-or-name string users type + * (that concept is called a ref). `name` is undefined unless an + * explicit write recorded it; there is no read-path caching. + */ +export interface Workspace { + readonly id: string; + readonly name: string | undefined; +} + +/** + * The scalar view of the auth state every engine consumer sees. + * `origin` distinguishes an env-supplied session from stored state (it + * drives endSession's refusal and lets whoami explain an override); + * `method` is how authenticated, a different axis from who + * (identity.kind). `workspace` is the ACTIVE grant's workspace. + */ +export interface Session { + readonly identity: Identity; + readonly method: "user-oauth" | "service-token"; + readonly origin: "stored" | "environment"; + readonly workspace: Workspace; + readonly expiresAt: Date | undefined; +} + +/** + * A held grant, as listed to the user. Structurally carries no + * credential material. `active` is the cursor position; when an env + * token overrides, the listing command must say the env session is + * what is in force. + */ +export interface GrantSummary { + readonly workspace: Workspace; + readonly expiresAt: Date | undefined; + readonly active: boolean; +} + +/** + * The credential machinery, modeled: one identity, plural workspace + * grants (workspace + the credential the user's consent minted for + * it), one active grant. The plurality lives entirely inside the + * manager; every consumer surface is scalar. The manager holds custody + * only: it never opens a browser, never prompts, never talks to the + * user. Env is a construction input — nothing below the manager reads + * process.env. + */ +export interface CredentialManager { + /** + * User-centric truth. Local-only: composes env + stored state, + * decodes claims, NEVER touches the network. An env service token + * wins over a stored credential (origin: "environment"); whoami is + * responsible for surfacing the override. + */ + session(): Promise; + + /** + * Login's write. Derives identity, workspace, and expiry from the + * credential's claims. Same identity as the held grants: UPSERTS + * the grant for that workspace and makes it ACTIVE; other grants + * untouched. Different identity: all existing grants are discarded + * and replaced by this one — the one-identity invariant is enforced + * here. + */ + beginSession(credential: Credential): Promise; + + /** + * Logout, whole-identity. Local (does not revoke server-side). + * Clears ALL grants and the cursor. Rejects with a structured error + * when the active session is env-supplied; the error's why states + * whether stored grants also exist. + */ + endSession(): Promise; + + /** + * The held grants, as summaries (no credential material). + * Local-only like session(): never touches the network. + */ + grants(): Promise; + + /** + * Records a human-readable workspace name learned by a command. The + * sanctioned name-write path — beginSession stays single-argument + * and claims-only. Explicit write; never a read-path side effect. + * No grant held for that workspace id → no-op, not an error. Only + * `login` and `workspace use` may call it (both already mutate). + */ + rememberWorkspaceName(workspaceId: string, name: string): Promise; + + /** + * Move the cursor to a HELD grant. Ref resolution is the manager's, + * against held grants only: exact id first, then case-insensitive + * name; ambiguity is a structured error; no match is a structured + * error the COMMAND catches to run the consent flow + beginSession. + * Activating an expired grant succeeds locally and fails at first + * use. + */ + activateGrant(ref: string): Promise; + + /** + * Drop one grant. If it was active, the cursor clears (no + * auto-promotion of another grant). + */ + forgetGrant(ref: string): Promise; + + /** + * The authenticated management API client, constructed and owned by + * the MANAGER. Credential RESOLUTION is internal: the manager + * resolves the credential that authorizes a request inside this + * method and the needs check — no public method returns credential + * material (internal invariant: resolution yields null ⟺ session() + * is null). Clients are cached per workspace id; any cursor-moving + * mutation (beginSession, activateGrant) invalidates the cached + * active client. + */ + apiClient(): Promise; +} diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 562d54cd..2319198e 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -1,35 +1,27 @@ -import type { createManagementApiSdk } from "@prisma/management-api-sdk"; +import { + authServiceError, + credentialsRequiredError, +} from "../credential-errors"; +import type { Session } from "../credential-manager"; import type { ManagementApiClient } from "../management-api"; import { CliStructuredError } from "../protocol"; import type { Invocation } from "./engine"; -import { credentialsRequiredError } from "./needs"; -/* Inert placeholders: the token source is ctx.getCredentials and never - * supplies a refresh token, so the SDK's own OAuth flow is unreachable - * and these values are never used in a request. */ -const CLIENT_ID = ""; -const REDIRECT_URI = ""; - -export type CreateManagementApiSdk = typeof createManagementApiSdk; - -/** ctx.api construction: the SDK reads tokens per request, so a token - * refreshed mid-run by the credentials source is picked up on the next - * request. A request while getCredentials() resolves undefined throws - * CLI.CREDENTIALS_REQUIRED. - * - * The SDK module itself is loaded lazily (dynamic import, mirroring - * the clack renderer pattern) and only when no `createSdk` test seam - * is injected, so a run that never issues a request never pays for — - * or depends on — the SDK module load. The returned client is a Proxy - * whose method wrappers await the lazy construction before applying - * the call; every client method is async, so the deferral is - * invisible to callers. */ +/** + * ctx.api: a thin lazy proxy over the credential manager's + * apiClient(). Nothing resolves until the first method CALL, so a run + * that never issues a request never touches the manager. Every request + * failure passes through the engine-side error mapping below; the + * returned client is a Proxy whose method wrappers await the lazy + * resolution before applying the call — every client method is async, + * so the deferral is invisible to callers. + */ export function buildManagementApiClient( invocation: Invocation, ): ManagementApiClient { let clientPromise: Promise | undefined; const resolveClient = (): Promise => { - clientPromise ??= constructClient(invocation); + clientPromise ??= resolveManagerClient(invocation); return clientPromise; }; @@ -41,63 +33,73 @@ export function buildManagementApiClient( return undefined; } return (...args: unknown[]): Promise => - resolveClient().then((client) => { - const value: unknown = Reflect.get(client, property); - if (typeof value !== "function") { - throw new TypeError( - `@prisma/cli-engine: ctx.api.${property} is not a function`, - ); - } - return Promise.resolve(Reflect.apply(value, client, args)).catch( - (cause: unknown) => { - throw restoreStructuredThrow(cause); - }, - ); - }); + resolveClient() + .then((client) => { + const value: unknown = Reflect.get(client, property); + if (typeof value !== "function") { + throw new TypeError( + `@prisma/cli-engine: ctx.api.${property} is not a function`, + ); + } + return Reflect.apply(value, client, args) as Promise; + }) + .catch(async (cause: unknown) => { + throw await mapRequestFailure(invocation, cause); + }); }, }); } -async function constructClient( +async function resolveManagerClient( invocation: Invocation, ): Promise { - const createSdk = - invocation.hooks.managementApi?.createSdk ?? - (await import("@prisma/management-api-sdk")).createManagementApiSdk; - const sdk = createSdk({ - clientId: CLIENT_ID, - redirectUri: REDIRECT_URI, - tokenStorage: { - getTokens: async () => { - const credentials = await invocation.runtime.getCredentials(); - if (credentials === undefined) { - throw credentialsRequiredError(); - } - return { workspaceId: "", accessToken: credentials.token }; - }, - setTokens: async () => {}, - clearTokens: async () => {}, - }, - apiBaseUrl: invocation.runtime.managementApi.baseUrl, - }); - return sdk.client; + const manager = invocation.runtime.credentialManager; + if (manager === undefined) { + throw credentialsRequiredError(); + } + return manager.apiClient(); } -/** The SDK wraps every request failure in its FetchError; a structured - * error raised inside the request pipeline (the unauthenticated - * CLI.CREDENTIALS_REQUIRED throw) is rethrown unwrapped so it settles - * as itself. The SDK's own AuthError (a 401 the SDK cannot recover - * from — no refresh token is ever supplied here) maps to the same - * shared CLI.CREDENTIALS_REQUIRED error the needs check raises. */ -function restoreStructuredThrow(cause: unknown): unknown { +/** + * The engine-side request-failure mapping. A structured error raised + * inside the pipeline is rethrown unwrapped so it settles as itself. + * An SDK AuthError is discriminated by STATE, never by message + * parsing: refreshTokenInvalid === true (the SDK's definitive + * invalid_grant signal, already cleared by compare-and-clear) maps to + * the expired CLI.CREDENTIALS_REQUIRED; any other AuthError triggers a + * re-read of the manager's state — the bound grant gone means the + * grant-removed CLI.CREDENTIALS_REQUIRED, otherwise the failure was + * the auth service's and nothing was cleared. + */ +async function mapRequestFailure( + invocation: Invocation, + cause: unknown, +): Promise { const structured = structuredCause(cause); if (structured !== undefined) { return structured; } - if (causeChainHasSdkAuthError(cause)) { + const authError = sdkAuthErrorInCauseChain(cause); + if (authError === undefined) { + return cause; + } + if (authError.refreshTokenInvalid === true) { + return credentialsRequiredError("expired"); + } + const manager = invocation.runtime.credentialManager; + if (manager === undefined) { return credentialsRequiredError(); } - return cause; + let session: Session | null; + try { + session = await manager.session(); + } catch (stateCause) { + return CliStructuredError.is(stateCause) ? stateCause : cause; + } + if (session === null) { + return credentialsRequiredError("grant-removed"); + } + return authServiceError(); } /** Cause chains can be cyclic (an Error whose `cause` eventually points @@ -128,14 +130,17 @@ function structuredCause(error: unknown): CliStructuredError | undefined { return undefined; } -/** Structural match (name discriminator) rather than instanceof, so a - * duplicate SDK module instance cannot defeat the mapping — and so - * this check itself never forces the SDK module to load. */ -function causeChainHasSdkAuthError(error: unknown): boolean { +/** Structural match (name discriminator plus the refreshTokenInvalid + * field) rather than instanceof, so a duplicate SDK module instance + * cannot defeat the mapping — and so this check never forces the SDK + * module to load. */ +function sdkAuthErrorInCauseChain( + error: unknown, +): { readonly refreshTokenInvalid: unknown } | undefined { for (const current of causeChain(error)) { if (current.name === "AuthError") { - return true; + return current as Error & { readonly refreshTokenInvalid: unknown }; } } - return false; + return undefined; } diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 31a9fd44..8be06846 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -1,4 +1,5 @@ import type { CommandContext, Credentials } from "../context"; +import type { CredentialManager, Session } from "../credential-manager"; import type { ManagementApiClient } from "../management-api"; import { PRESENTED, @@ -54,6 +55,7 @@ function materializePresentation( export function makeContext( invocation: Invocation, config: unknown, + managesCredentials: boolean, ): CommandContext { const state = invocation.state; const ui = makeUi(state.colorEnabled); @@ -84,9 +86,12 @@ export function makeContext( }); }; let api: ManagementApiClient | undefined; - return { + const context: CommandContext = { config, present: present as CommandContext["present"], + session: (): Promise => + invocation.runtime.credentialManager?.session() ?? + Promise.resolve(null), getCredentials: (): Promise => invocation.runtime.getCredentials(), get api(): ManagementApiClient { @@ -110,4 +115,19 @@ export function makeContext( ), ), }; + if (managesCredentials) { + Object.defineProperty(context, "credentialManager", { + enumerable: true, + get(): CredentialManager { + const manager = invocation.runtime.credentialManager; + if (manager === undefined) { + throw new Error( + "@prisma/cli-engine: the command declares managesCredentials but the Runtime supplies no credentialManager", + ); + } + return manager; + }, + }); + } + return context; } diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 47d66f2e..1ea95ff0 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -13,7 +13,6 @@ import type { Format, PresentedResult } from "../presentation"; import type { CliStructuredError, Result } from "../protocol"; import type { EngineCommandSnapshot, RunSummary } from "../run-summary"; import type { InputStream, Runtime } from "../runtime"; -import type { CreateManagementApiSdk } from "./api-client"; import { makeContext } from "./command-context"; import { buildCommandSnapshot } from "./command-snapshot"; import { buildCommandTree, type CommandTreeEntry } from "./command-tree"; @@ -59,11 +58,9 @@ export interface RunHooks { * not break a command. */ readonly onSettled?: (summary: RunSummary) => void; readonly answers?: ReadonlyArray; - /** Test seams: an injected `client` becomes ctx.api verbatim; an - * injected `createSdk` replaces the SDK factory. */ + /** Test seam: an injected `client` becomes ctx.api verbatim. */ readonly managementApi?: { readonly client?: ManagementApiClient; - readonly createSdk?: CreateManagementApiSdk; }; } @@ -323,7 +320,11 @@ export class EngineImpl implements Engine { flags: declaredFlags(entry.def, rawFlags), positionals: distributePositionals(entry.def, values), }; - const ctx = makeContext(invocation, needsOutcome.config); + const ctx = makeContext( + invocation, + needsOutcome.config, + entry.def.kind === "result-command" && entry.def.managesCredentials, + ); if (entry.def.kind === "session-command") { try { const result = await (handler as ErasedSessionHandler)(args, ctx); diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 0bea96ff..d6e927cb 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import type { AnyCommand } from "../commands"; import type { ConfigSection, SectionValidation } from "../config-section"; import type { Credentials } from "../context"; +import { credentialsRequiredError } from "../credential-errors"; +import type { CredentialManager } from "../credential-manager"; import { CliStructuredError, type Diagnostic } from "../protocol"; import type { Runtime } from "../runtime"; import type { Invocation } from "./engine"; @@ -100,6 +102,14 @@ function checkDependencies( return undefined; } +/** + * The credentials need, single-sourced from the credential manager + * when one is wired: session() is the local-only truth, its structured + * errors (grants held none active, blank env token) pass through + * verbatim so the needs check, ctx.session, and ctx.api raise + * identically. The getCredentials path below is the staged-swap + * fallback. + */ async function checkCredentials( needs: AnyCommand["needs"], invocation: Invocation, @@ -107,6 +117,21 @@ async function checkCredentials( if (!needs.credentials) { return undefined; } + const manager: CredentialManager | undefined = + invocation.runtime.credentialManager; + if (manager !== undefined) { + try { + if ((await manager.session()) === null) { + return needsErrored(credentialsRequiredError()); + } + return undefined; + } catch (cause) { + if (CliStructuredError.is(cause)) { + return needsErrored(cause); + } + throw cause; + } + } let credentials: Credentials | undefined; try { credentials = await invocation.runtime.getCredentials(); @@ -136,23 +161,6 @@ async function checkCredentials( return undefined; } -/** The single source of the sign-in error: raised by the needs check - * and by an unauthenticated ctx.api request. */ -export function credentialsRequiredError(): CliStructuredError { - return new CliStructuredError( - "CLI.CREDENTIALS_REQUIRED", - "You must be signed in to run this command.", - { - nextActions: [ - { - kind: "user-choice", - label: "Sign in, then run the command again.", - }, - ], - }, - ); -} - function checkConfiguration( section: ConfigSection, invocation: Invocation, diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 79d2d029..f771ac7b 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -52,6 +52,20 @@ export type { Credentials, PromptSurface, } from "../context"; +export { + authServiceError, + credentialsRequiredError, + type CredentialsRequiredReason, + environmentSessionMutationError, +} from "../credential-errors"; +export type { + Credential, + CredentialManager, + GrantSummary, + Identity, + Session, + Workspace, +} from "../credential-manager"; export type { EngineEvent, Severity, diff --git a/packages/cli-engine/src/exports/testing.ts b/packages/cli-engine/src/exports/testing.ts index 3b28d2a7..b85e3de3 100644 --- a/packages/cli-engine/src/exports/testing.ts +++ b/packages/cli-engine/src/exports/testing.ts @@ -3,3 +3,10 @@ * over in-memory streams. Implementation lives in ../testing.ts. */ export { createTestCli, type TestCli } from "../testing"; +export { + mintTestJwt, + TestCredentialManager, + type TestCredentialManagerSeed, + type TestCredentialManagerState, + type TestGrant, +} from "../testing-credential-manager"; diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index 3efa53ca..66211e8d 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -1,4 +1,5 @@ import type { Credentials } from "./context"; +import type { CredentialManager } from "./credential-manager"; import type { Diagnostic } from "./protocol"; /** Minimal structural stream types; no NodeJS.* in the public surface. */ @@ -44,6 +45,13 @@ export interface Runtime { * the unified loader. Tests hand in fixtures. */ readonly config: LoadedConfig; + /** + * The credential manager the bin wires. The engine prefers it for + * the needs check, ctx.session, and ctx.api; optional only during + * the staged swap — getCredentials below is the fallback and is + * deleted with the swap's final stage. + */ + readonly credentialManager?: CredentialManager; readonly getCredentials: () => Promise; /** Management API endpoint config; the bin derives baseUrl from env. */ readonly managementApi: { readonly baseUrl: string }; diff --git a/packages/cli-engine/src/testing-credential-manager.ts b/packages/cli-engine/src/testing-credential-manager.ts new file mode 100644 index 00000000..743d8943 --- /dev/null +++ b/packages/cli-engine/src/testing-credential-manager.ts @@ -0,0 +1,373 @@ +import { Buffer } from "node:buffer"; +import { + credentialsRequiredError, + environmentSessionMutationError, +} from "./credential-errors"; +import type { + Credential, + CredentialManager, + GrantSummary, + Identity, + Session, + Workspace, +} from "./credential-manager"; +import type { ManagementApiClient } from "./management-api"; +import { CliStructuredError } from "./protocol"; + +const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; + +/** A held grant with its credential material, as seeded into and read + * back from the test credential manager. */ +export interface TestGrant { + readonly workspace: Workspace; + readonly credential: Credential; +} + +export interface TestCredentialManagerSeed { + /** Preferred seed: runs beginSession's real claims derivation. The + * token must be a JWT (use mintTestJwt). */ + readonly credential?: Credential; + /** Escape hatch: session() returns exactly this. origin + * "environment" seeds an env-override session; origin "stored" + * materializes one active grant with a synthesized credential. */ + readonly session?: Session; + /** Grants-model seeding. `identity` is seedable independently of + * `grants`, so a grant whose token disagrees with the recorded + * identity is constructible. */ + readonly identity?: Identity; + readonly grants?: readonly TestGrant[]; + readonly activeWorkspaceId?: string; +} + +/** The whole manager state, readable back after a run. */ +export interface TestCredentialManagerState { + readonly identity: Identity | undefined; + readonly grants: readonly TestGrant[]; + readonly activeWorkspaceId: string | null; +} + +/** Mints an unsigned JWT whose payload is exactly `claims` — the + * harness's claim source for beginSession derivation (`sub`, + * `workspace_id`, `exp`, `email`). */ +export function mintTestJwt(claims: Readonly>): string { + const encode = (value: unknown): string => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.test-signature`; +} + +function decodeJwtClaims( + token: string, +): Record | undefined { + const parts = token.split("."); + if (parts.length < 2) { + return undefined; + } + try { + const parsed: unknown = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf8"), + ); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function stringClaim( + claims: Record, + key: string, +): string | undefined { + const value = claims[key]; + return typeof value === "string" ? value : undefined; +} + +interface DerivedClaims { + readonly identity: Identity; + readonly workspaceId: string; + readonly expiresAt: Date | undefined; +} + +function deriveFromClaims(credential: Credential): DerivedClaims { + const claims = decodeJwtClaims(credential.token); + const sub = claims === undefined ? undefined : stringClaim(claims, "sub"); + const workspaceId = + claims === undefined ? undefined : stringClaim(claims, "workspace_id"); + if (claims === undefined || sub === undefined || workspaceId === undefined) { + throw new Error( + "@prisma/cli-engine/testing: beginSession derives identity and workspace from the credential's claims — the token must be a JWT with `sub` and `workspace_id` (use mintTestJwt)", + ); + } + const exp = claims.exp; + const expiresAt = typeof exp === "number" ? new Date(exp * 1000) : undefined; + const identity: Identity = + credential.method === "service-token" + ? { kind: "service", id: sub, label: undefined } + : { kind: "user", id: sub, email: stringClaim(claims, "email") }; + return { identity, workspaceId, expiresAt }; +} + +function identityFromGrants( + grants: readonly TestGrant[], +): Identity | undefined { + for (const grant of grants) { + const claims = decodeJwtClaims(grant.credential.token); + const sub = claims === undefined ? undefined : stringClaim(claims, "sub"); + if (sub !== undefined) { + return grant.credential.method === "service-token" + ? { kind: "service", id: sub, label: undefined } + : { + kind: "user", + id: sub, + email: claims === undefined ? undefined : stringClaim(claims, "email"), + }; + } + } + return undefined; +} + +/** + * The harness's mutable in-memory CredentialManager: the same + * interface commands see, with the whole state readable back after a + * run. No persistence, no locking — those belong to the real manager + * and its own tests. + */ +export class TestCredentialManager implements CredentialManager { + private identity: Identity | undefined; + private heldGrants: TestGrant[]; + private activeWorkspaceId: string | null; + private environmentSession: Session | undefined; + private readonly client: ManagementApiClient | undefined; + + constructor( + seed: TestCredentialManagerSeed, + client?: ManagementApiClient, + ) { + this.client = client; + this.heldGrants = [...(seed.grants ?? [])]; + this.identity = seed.identity ?? identityFromGrants(this.heldGrants); + this.activeWorkspaceId = seed.activeWorkspaceId ?? null; + this.environmentSession = + seed.session?.origin === "environment" ? seed.session : undefined; + if (seed.session !== undefined && seed.session.origin === "stored") { + this.materializeStoredSession(seed.session); + } + if (seed.credential !== undefined) { + this.applyBeginSession(seed.credential); + } + } + + state(): TestCredentialManagerState { + return { + identity: this.identity, + grants: [...this.heldGrants], + activeWorkspaceId: this.activeWorkspaceId, + }; + } + + async session(): Promise { + if (this.environmentSession !== undefined) { + return this.environmentSession; + } + return this.storedSession(); + } + + async beginSession(credential: Credential): Promise { + return this.applyBeginSession(credential); + } + + async endSession(): Promise { + this.refuseUnderEnvironmentSession(); + this.identity = undefined; + this.heldGrants = []; + this.activeWorkspaceId = null; + } + + async grants(): Promise { + return this.heldGrants.map((grant) => ({ + workspace: grant.workspace, + expiresAt: grant.credential.expiresAt, + active: grant.workspace.id === this.activeWorkspaceId, + })); + } + + async rememberWorkspaceName(workspaceId: string, name: string): Promise { + this.heldGrants = this.heldGrants.map((grant) => + grant.workspace.id === workspaceId + ? { ...grant, workspace: { id: workspaceId, name } } + : grant, + ); + } + + async activateGrant(ref: string): Promise { + this.refuseUnderEnvironmentSession(); + this.activeWorkspaceId = this.resolveRef(ref).workspace.id; + const session = this.storedSession(); + if (session === null) { + throw credentialsRequiredError("grants-held-none-active"); + } + return session; + } + + async forgetGrant(ref: string): Promise { + this.refuseUnderEnvironmentSession(); + const grant = this.resolveRef(ref); + this.heldGrants = this.heldGrants.filter((held) => held !== grant); + if (this.activeWorkspaceId === grant.workspace.id) { + this.activeWorkspaceId = null; + } + } + + /** Credential resolution is internal per the interface ruling: only + * apiClient() consumes it. */ + private resolveCredential(): Credential | null { + if (this.environmentSession !== undefined) { + return { + token: "test-environment-token", + refreshToken: undefined, + expiresAt: this.environmentSession.expiresAt, + method: this.environmentSession.method, + }; + } + const session = this.storedSession(); + if (session === null) { + return null; + } + const active = this.heldGrants.find( + (grant) => grant.workspace.id === this.activeWorkspaceId, + ); + return active === undefined ? null : active.credential; + } + + async apiClient(): Promise { + if (this.resolveCredential() === null) { + throw credentialsRequiredError(); + } + if (this.client === undefined) { + throw new Error( + "@prisma/cli-engine/testing: supply managementApi.client to createTestCli before using apiClient()", + ); + } + return this.client; + } + + /** Shared throw semantics with session(): null when signed out; the + * structured grants-held-none-active error when grants are held but + * no cursor names one. */ + private storedSession(): Session | null { + if (this.identity === undefined || this.heldGrants.length === 0) { + return null; + } + const active = this.heldGrants.find( + (grant) => grant.workspace.id === this.activeWorkspaceId, + ); + if (active === undefined) { + throw credentialsRequiredError("grants-held-none-active"); + } + return { + identity: this.identity, + method: active.credential.method, + origin: "stored", + workspace: active.workspace, + expiresAt: active.credential.expiresAt, + }; + } + + private materializeStoredSession(session: Session): void { + this.identity = session.identity; + this.heldGrants = [ + { + workspace: session.workspace, + credential: { + token: "test-session-token", + refreshToken: undefined, + expiresAt: session.expiresAt, + method: session.method, + }, + }, + ]; + this.activeWorkspaceId = session.workspace.id; + } + + private applyBeginSession(credential: Credential): Session { + const derived = deriveFromClaims(credential); + const sameIdentity = + this.identity !== undefined && this.identity.id === derived.identity.id; + if (!sameIdentity) { + this.heldGrants = []; + } + this.identity = derived.identity; + const existing = this.heldGrants.find( + (grant) => grant.workspace.id === derived.workspaceId, + ); + const stored: TestGrant = { + workspace: { id: derived.workspaceId, name: existing?.workspace.name }, + credential: { + token: credential.token, + refreshToken: credential.refreshToken, + expiresAt: derived.expiresAt, + method: credential.method, + }, + }; + this.heldGrants = [ + ...this.heldGrants.filter((grant) => grant.workspace.id !== derived.workspaceId), + stored, + ]; + this.activeWorkspaceId = derived.workspaceId; + return { + identity: derived.identity, + method: credential.method, + origin: "stored", + workspace: stored.workspace, + expiresAt: derived.expiresAt, + }; + } + + private refuseUnderEnvironmentSession(): void { + if (this.environmentSession !== undefined) { + throw environmentSessionMutationError({ + envVar: SERVICE_TOKEN_ENV_VAR, + storedGrantsExist: this.heldGrants.length > 0, + }); + } + } + + private resolveRef(ref: string): TestGrant { + const byId = this.heldGrants.find((grant) => grant.workspace.id === ref); + if (byId !== undefined) { + return byId; + } + const byName = this.heldGrants.filter( + (grant) => grant.workspace.name?.toLowerCase() === ref.toLowerCase(), + ); + if (byName.length > 1) { + throw new CliStructuredError( + "AUTH.WORKSPACE_REF_AMBIGUOUS", + `'${ref}' names more than one held workspace grant.`, + { + nextActions: [ + { + kind: "user-choice", + label: "Refer to the workspace by its id instead.", + }, + ], + }, + ); + } + if (byName.length === 0) { + throw new CliStructuredError( + "AUTH.GRANT_NOT_HELD", + `You hold no workspace grant matching '${ref}'.`, + { + nextActions: [ + { + kind: "user-choice", + label: "Sign in to that workspace to acquire a grant for it.", + }, + ], + }, + ); + } + return byName[0]; + } +} diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index 4878032f..37559796 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -1,13 +1,29 @@ import type { CommandFamily, MountedTree } from "./command-family"; import type { Credentials } from "./context"; +import type { + Credential, + Identity, + Session, +} from "./credential-manager"; import type { EngineEvent, StreamEvent } from "./events"; import { buildEngine } from "./execution/engine"; import type { ManagementApiClient } from "./management-api"; import type { PresentedResult } from "./presentation"; import type { RunSummary } from "./run-summary"; import type { Runtime } from "./runtime"; +import { + TestCredentialManager, + type TestGrant, +} from "./testing-credential-manager"; export interface TestCli { + /** + * The mutable in-memory credential manager backing the runs — the + * whole state (grants, per-grant credentials, cursor) is readable + * back after a run via state(). Undefined only when the legacy + * `credentials` seed selected the getCredentials fallback path. + */ + readonly credentialManager: TestCredentialManager | undefined; run( argv: readonly string[], opts?: { @@ -71,7 +87,22 @@ export function createTestCli(spec: { readonly commands: MountedTree; readonly groups?: Readonly>; readonly config?: Readonly>; + /** + * Legacy seed for the staged-swap getCredentials fallback: selects + * a manager-less runtime. Mutually exclusive with the manager + * seeds below; deleted with the swap's final stage. + */ readonly credentials?: Credentials; + /** Preferred manager seed: beginSession runs its real claims + * derivation on this credential (mint the token with mintTestJwt). */ + readonly credential?: Credential; + /** Escape hatch: ctx.session() resolves exactly this. */ + readonly session?: Session; + /** Grants-model seeding; identity is independent of grants so + * mismatched states are constructible. */ + readonly identity?: Identity; + readonly grants?: readonly TestGrant[]; + readonly activeWorkspaceId?: string; /** baseUrl defaults to "https://test.invalid"; when `client` is * supplied, ctx.api IS that object. */ readonly managementApi?: { @@ -82,6 +113,30 @@ export function createTestCli(spec: { /** Fixed clock for deterministic stream timestamps. */ readonly now?: () => Date; }): TestCli { + const managerSeeded = + spec.credential !== undefined || + spec.session !== undefined || + spec.identity !== undefined || + spec.grants !== undefined || + spec.activeWorkspaceId !== undefined; + if (spec.credentials !== undefined && managerSeeded) { + throw new Error( + "@prisma/cli-engine/testing: the legacy `credentials` seed selects the manager-less fallback runtime and cannot be combined with credential-manager seeds", + ); + } + const credentialManager = + spec.credentials !== undefined + ? undefined + : new TestCredentialManager( + { + credential: spec.credential, + session: spec.session, + identity: spec.identity, + grants: spec.grants, + activeWorkspaceId: spec.activeWorkspaceId, + }, + spec.managementApi?.client, + ); const engine = buildEngine( { name: "prisma-test", @@ -93,6 +148,7 @@ export function createTestCli(spec: { { now: spec.now }, ); return { + credentialManager, async run(argv, opts) { let stdoutText = ""; let stderrText = ""; @@ -137,6 +193,7 @@ export function createTestCli(spec: { }; }, config: { sections: spec.config ?? {}, diagnostics: [] }, + credentialManager, getCredentials: async () => spec.credentials, managementApi: { baseUrl: spec.managementApi?.baseUrl ?? "https://test.invalid", diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts new file mode 100644 index 00000000..38754775 --- /dev/null +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -0,0 +1,558 @@ +/** + * The credential-manager engine surface: ctx.session on every context, + * the managesCredentials capability, the manager-backed needs check + * with its single-sourced errors, harness seeding with state + * read-back, and the no-token-material guarantees. + */ + +import { + type Credential, + defineCommand, + type GrantSummary, + type Session, +} from "@prisma/cli-engine"; +import { + type CliStructuredError, + notOk, + ok, +} from "@prisma/cli-engine/protocol"; +import { + createTestCli, + mintTestJwt, + type TestGrant, +} from "@prisma/cli-engine/testing"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +const userCredential = (overrides?: { + readonly sub?: string; + readonly workspaceId?: string; + readonly email?: string; + readonly exp?: number; + readonly token?: string; +}): Credential => ({ + token: + overrides?.token ?? + mintTestJwt({ + sub: overrides?.sub ?? "user-1", + workspace_id: overrides?.workspaceId ?? "workspace-1", + email: overrides?.email ?? "someone@example.com", + exp: overrides?.exp ?? 1_900_000_000, + }), + refreshToken: undefined, + expiresAt: undefined, + method: "user-oauth", +}); + +const grantFor = ( + workspaceId: string, + opts?: { readonly sub?: string; readonly name?: string }, +): TestGrant => ({ + workspace: { id: workspaceId, name: opts?.name }, + credential: userCredential({ + sub: opts?.sub ?? "user-1", + workspaceId, + }), +}); + +const sessionReader = () => { + let seen: Session | null | undefined; + const command = defineCommand({ + help: { summary: "Reads the session" }, + handler: async (_args, ctx) => { + seen = await ctx.session(); + return ok(ctx.present({ data: seen }, { human: () => [] })); + }, + }); + return { command, seen: () => seen }; +}; + +describe("ctx.session", () => { + test("resolves null on every context when signed out; the command still completes", async () => { + const reader = sessionReader(); + const cli = createTestCli({ commands: { toy: reader.command } }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(reader.seen()).toBeNull(); + }); + + test("a seeded credential runs real claims derivation: identity, workspace, and expiry come from the token", async () => { + const reader = sessionReader(); + const cli = createTestCli({ + commands: { toy: reader.command }, + credential: userCredential({ + sub: "user-42", + workspaceId: "workspace-9", + email: "user42@example.com", + exp: 1_900_000_000, + }), + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(reader.seen()).toEqual({ + identity: { kind: "user", id: "user-42", email: "user42@example.com" }, + method: "user-oauth", + origin: "stored", + workspace: { id: "workspace-9", name: undefined }, + expiresAt: new Date(1_900_000_000 * 1000), + }); + }); + + test("the seeded session escape hatch is returned verbatim", async () => { + const seeded: Session = { + identity: { kind: "service", id: "svc-1", label: undefined }, + method: "service-token", + origin: "environment", + workspace: { id: "workspace-env", name: undefined }, + expiresAt: undefined, + }; + const reader = sessionReader(); + const cli = createTestCli({ + commands: { toy: reader.command }, + session: seeded, + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(reader.seen()).toEqual(seeded); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("performs no network I/O", async () => { + const fetchSpy = vi.fn(() => { + throw new Error("ctx.session() touched the network"); + }); + vi.stubGlobal("fetch", fetchSpy); + const reader = sessionReader(); + const cli = createTestCli({ + commands: { toy: reader.command }, + credential: userCredential(), + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(reader.seen()).not.toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("the managesCredentials capability", () => { + test("declared: ctx.credentialManager is the harness manager, even signed out — a declaration never fails a run", async () => { + let sameInstance: boolean | undefined; + const toy = defineCommand({ + help: { summary: "Manages credentials" }, + managesCredentials: true, + handler: async (_args, ctx) => { + sameInstance = ctx.credentialManager === cli.credentialManager; + return ok(ctx.present({ data: null }, { human: () => [] })); + }, + }); + const cli = createTestCli({ commands: { toy } }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(sameInstance).toBe(true); + }); + + test("undeclared: credentialManager is absent from the context", async () => { + let present: boolean | undefined; + const toy = defineCommand({ + help: { summary: "Does not manage credentials" }, + handler: async (_args, ctx) => { + present = "credentialManager" in ctx; + return ok(ctx.present({ data: null }, { human: () => [] })); + }, + }); + const cli = createTestCli({ commands: { toy } }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(present).toBe(false); + }); +}); + +const needsCredentials = defineCommand({ + help: { summary: "Needs credentials" }, + needs: { credentials: true }, + handler: async (_args, ctx) => + ok(ctx.present({ data: null }, { human: () => [] })), +}); + +describe("the manager-backed needs check", () => { + test("signed out: fails early with CLI.CREDENTIALS_REQUIRED, exit 2", async () => { + const cli = createTestCli({ commands: { toy: needsCredentials } }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + expect(json.find((frame) => frame.kind === "result")).toMatchObject({ + envelope: { + ok: false, + error: { + code: "CLI.CREDENTIALS_REQUIRED", + summary: "You must be signed in to run this command.", + }, + }, + }); + }); + + test("grants held, none active: the identical single-sourced error from the needs check, ctx.session, and a bare ctx.api touch", async () => { + const seeds = { + grants: [grantFor("workspace-1"), grantFor("workspace-2")], + }; + + const fromNeedsCheck = await (async () => { + const cli = createTestCli({ + commands: { toy: needsCredentials }, + ...seeds, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + const result = json.find((frame) => frame.kind === "result"); + return result?.kind === "result" && result.envelope.ok === false + ? result.envelope.error + : undefined; + })(); + + const caughtBy = ( + body: (ctx: { + readonly session: () => Promise; + readonly api: { GET: (path: string, opts: unknown) => Promise }; + }) => Promise, + ) => + defineCommand({ + help: { summary: "Catches the structured error" }, + handler: async (_args, ctx) => { + try { + await body( + ctx as unknown as Parameters[0], + ); + } catch (cause) { + return notOk(cause as CliStructuredError); + } + return ok(ctx.present({ data: null }, { human: () => [] })); + }, + }); + + const runCaught = async ( + command: ReturnType, + ): Promise => { + const cli = createTestCli({ commands: { toy: command }, ...seeds }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + const result = json.find((frame) => frame.kind === "result"); + return result?.kind === "result" && result.envelope.ok === false + ? result.envelope.error + : undefined; + }; + + const fromSession = await runCaught( + caughtBy(async (ctx) => { + await ctx.session(); + }), + ); + const fromApiTouch = await runCaught( + caughtBy(async (ctx) => { + await ctx.api.GET("/v1/workspaces", {}); + }), + ); + + expect(fromNeedsCheck).toMatchObject({ + code: "CLI.CREDENTIALS_REQUIRED", + why: "You hold workspace grants, but none is active.", + nextActions: [ + { + kind: "run-command", + command: "prisma auth workspace use", + }, + { kind: "user-choice" }, + ], + }); + expect(fromSession).toEqual(fromNeedsCheck); + expect(fromApiTouch).toEqual(fromNeedsCheck); + }); + + test("with a seeded credential the need is met", async () => { + const cli = createTestCli({ + commands: { toy: needsCredentials }, + credential: userCredential(), + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + }); +}); + +const managed = ( + body: (manager: { + readonly beginSession: (credential: Credential) => Promise; + readonly endSession: () => Promise; + readonly activateGrant: (ref: string) => Promise; + readonly forgetGrant: (ref: string) => Promise; + readonly rememberWorkspaceName: ( + workspaceId: string, + name: string, + ) => Promise; + readonly grants: () => Promise; + }) => Promise, +) => + defineCommand({ + help: { summary: "Mutates through the manager" }, + managesCredentials: true, + handler: async (_args, ctx) => { + try { + await body(ctx.credentialManager); + } catch (cause) { + return notOk(cause as CliStructuredError); + } + return ok(ctx.present({ data: null }, { human: () => [] })); + }, + }); + +describe("mutations and state read-back", () => { + test("beginSession records identity, one grant, and the cursor", async () => { + const cli = createTestCli({ + commands: { + toy: managed(async (manager) => { + await manager.beginSession( + userCredential({ sub: "user-1", workspaceId: "workspace-1" }), + ); + }), + }, + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + const state = cli.credentialManager?.state(); + expect(state?.identity).toEqual({ + kind: "user", + id: "user-1", + email: "someone@example.com", + }); + expect(state?.grants.map((grant) => grant.workspace.id)).toEqual([ + "workspace-1", + ]); + expect(state?.activeWorkspaceId).toBe("workspace-1"); + }); + + test("same identity upserts and preserves a recorded workspace name; a different identity replaces every grant", async () => { + const sameIdentity = userCredential({ + sub: "user-1", + workspaceId: "workspace-1", + }); + const otherWorkspace = userCredential({ + sub: "user-1", + workspaceId: "workspace-2", + }); + const otherIdentity = userCredential({ + sub: "user-9", + workspaceId: "workspace-3", + }); + const cli = createTestCli({ + commands: { + toy: managed(async (manager) => { + await manager.beginSession(sameIdentity); + await manager.rememberWorkspaceName("workspace-1", "Acme Prod"); + await manager.beginSession(otherWorkspace); + await manager.beginSession(sameIdentity); + }), + replace: managed(async (manager) => { + await manager.beginSession(otherIdentity); + }), + }, + }); + expect((await cli.run(["toy"])).exitCode).toBe(0); + const upserted = cli.credentialManager?.state(); + expect( + upserted?.grants.map((grant) => [ + grant.workspace.id, + grant.workspace.name, + ]), + ).toEqual([ + ["workspace-2", undefined], + ["workspace-1", "Acme Prod"], + ]); + expect(upserted?.activeWorkspaceId).toBe("workspace-1"); + + expect((await cli.run(["replace"])).exitCode).toBe(0); + const replaced = cli.credentialManager?.state(); + expect(replaced?.identity).toMatchObject({ id: "user-9" }); + expect(replaced?.grants.map((grant) => grant.workspace.id)).toEqual([ + "workspace-3", + ]); + expect(replaced?.activeWorkspaceId).toBe("workspace-3"); + }); + + test("forgetGrant drops one grant and clears the cursor only when it named that grant", async () => { + const cli = createTestCli({ + commands: { + toy: managed(async (manager) => { + await manager.forgetGrant("workspace-1"); + }), + }, + grants: [grantFor("workspace-1"), grantFor("workspace-2")], + activeWorkspaceId: "workspace-1", + }); + expect((await cli.run(["toy"])).exitCode).toBe(0); + const state = cli.credentialManager?.state(); + expect(state?.grants.map((grant) => grant.workspace.id)).toEqual([ + "workspace-2", + ]); + expect(state?.activeWorkspaceId).toBeNull(); + }); + + test("activateGrant resolves an exact id first, then a case-insensitive name; ambiguity and no-match are structured errors", async () => { + const outcomes: Record = {}; + const cli = createTestCli({ + commands: { + toy: managed(async (manager) => { + outcomes.byId = (await manager.activateGrant("workspace-2")).workspace + .id; + outcomes.byName = (await manager.activateGrant("ACME staging")) + .workspace.id; + outcomes.ambiguous = await manager + .activateGrant("twin") + .then(() => "resolved") + .catch((cause: CliStructuredError) => cause.code); + outcomes.notHeld = await manager + .activateGrant("nowhere") + .then(() => "resolved") + .catch((cause: CliStructuredError) => cause.code); + }), + }, + grants: [ + grantFor("workspace-1", { name: "Acme Staging" }), + grantFor("workspace-2"), + grantFor("workspace-3", { name: "Twin" }), + grantFor("workspace-4", { name: "twin" }), + ], + activeWorkspaceId: "workspace-1", + }); + expect((await cli.run(["toy"])).exitCode).toBe(0); + expect(outcomes).toEqual({ + byId: "workspace-2", + byName: "workspace-1", + ambiguous: "AUTH.WORKSPACE_REF_AMBIGUOUS", + notHeld: "AUTH.GRANT_NOT_HELD", + }); + expect(cli.credentialManager?.state().activeWorkspaceId).toBe( + "workspace-1", + ); + }); + + test("rememberWorkspaceName records on a held grant and no-ops on an unheld id", async () => { + const cli = createTestCli({ + commands: { + toy: managed(async (manager) => { + await manager.rememberWorkspaceName("workspace-1", "Named"); + await manager.rememberWorkspaceName("workspace-unheld", "Ghost"); + }), + }, + grants: [grantFor("workspace-1")], + activeWorkspaceId: "workspace-1", + }); + expect((await cli.run(["toy"])).exitCode).toBe(0); + const state = cli.credentialManager?.state(); + expect( + state?.grants.map((grant) => [grant.workspace.id, grant.workspace.name]), + ).toEqual([["workspace-1", "Named"]]); + }); + + test("endSession clears identity, every grant, and the cursor", async () => { + const cli = createTestCli({ + commands: { + toy: managed(async (manager) => { + await manager.endSession(); + }), + }, + grants: [grantFor("workspace-1"), grantFor("workspace-2")], + activeWorkspaceId: "workspace-1", + }); + expect((await cli.run(["toy"])).exitCode).toBe(0); + expect(cli.credentialManager?.state()).toEqual({ + identity: undefined, + grants: [], + activeWorkspaceId: null, + }); + }); + + test("mutations refuse under an env-supplied session, naming the variable and the unset command; state is untouched", async () => { + const envSession: Session = { + identity: { kind: "service", id: "svc-1", label: undefined }, + method: "service-token", + origin: "environment", + workspace: { id: "workspace-env", name: undefined }, + expiresAt: undefined, + }; + const cli = createTestCli({ + commands: { + toy: managed(async (manager) => { + await manager.endSession(); + }), + }, + session: envSession, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + const result = json.find((frame) => frame.kind === "result"); + expect(result).toMatchObject({ + envelope: { + ok: false, + error: { + code: "AUTH.ENV_SESSION_IN_FORCE", + nextActions: [ + { kind: "run-command", command: "unset PRISMA_SERVICE_TOKEN" }, + ], + }, + }, + }); + }); +}); + +describe("token material never leaves", () => { + test("grants() summaries and the session expose no seeded token through any output channel", async () => { + const secret = mintTestJwt({ + sub: "user-1", + workspace_id: "workspace-1", + secret_marker: "SECRET-TOKEN-MATERIAL", + }); + const toy = defineCommand({ + help: { summary: "Lists grants" }, + managesCredentials: true, + handler: async (_args, ctx) => { + const grants = await ctx.credentialManager.grants(); + const session = await ctx.session(); + return ok( + ctx.present({ data: { grants, session } }, { human: () => [] }), + ); + }, + }); + const cli = createTestCli({ + commands: { toy }, + grants: [ + { + workspace: { id: "workspace-1", name: "Acme" }, + credential: { + token: secret, + refreshToken: "SECRET-REFRESH-TOKEN", + expiresAt: undefined, + method: "user-oauth", + }, + }, + ], + activeWorkspaceId: "workspace-1", + }); + const { exitCode, stdout, stderr, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(0); + const everything = stdout + stderr + JSON.stringify(json); + expect(everything).toContain("workspace-1"); + expect(everything).not.toContain(secret); + expect(everything).not.toContain("SECRET-REFRESH-TOKEN"); + }); +}); + +describe("harness seed validation", () => { + test("the legacy credentials seed cannot be combined with manager seeds", () => { + expect(() => + createTestCli({ + commands: {}, + credentials: { token: "legacy" }, + credential: userCredential(), + }), + ).toThrow(/legacy `credentials` seed/); + }); +}); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index abba1113..328b76af 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -17,13 +17,16 @@ describe("main export", () => { expect(Object.keys(engine).sort()).toEqual([ "PRESENTED", "PRISMA_CONFIG_VERSION", + "authServiceError", "createCli", + "credentialsRequiredError", "defineCommand", "defineCommandFamily", "defineConfig", "defineConfigSection", "defineServerCommand", "defineSessionCommand", + "environmentSessionMutationError", "flag", "loadConfig", "positional", @@ -31,7 +34,11 @@ describe("main export", () => { }); test("the ./testing subpath exposes exactly the harness", () => { - expect(Object.keys(testing).sort()).toEqual(["createTestCli"]); + expect(Object.keys(testing).sort()).toEqual([ + "TestCredentialManager", + "createTestCli", + "mintTestJwt", + ]); }); }); diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index e5bb5fc0..3f6a2274 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -9,6 +9,9 @@ import type { Char, CommandContext, CommandFamily, + CredentialManager, + GrantSummary, + Session, CommandHandler, CompletedEnvelope, ConfigSection, @@ -389,3 +392,66 @@ export const runtimeShape: Runtime = { managementApi: { baseUrl: "https://test.invalid" }, packageManager: "pnpm", }; + +// ————————————————————————————————————————————————————————————————————— +// The credential manager surface (design rev 4): managesCredentials is +// a capability — ctx.credentialManager exists exactly when declared; +// ctx.session exists on every context; the harness seeds a mutable +// in-memory manager. +// ————————————————————————————————————————————————————————————————————— + +export const managedCommand = defineCommand({ + help: { summary: "Operates on the credential machinery" }, + managesCredentials: true, + handler: async (_args, ctx) => { + const manager: CredentialManager = ctx.credentialManager; + const session: Session | null = await ctx.session(); + void manager; + void session; + return ok(ctx.present({ data: null }, { human: () => [] })); + }, +}); +export const managedIsDeclared: true = managedCommand.managesCredentials; + +export const unmanagedCommand = defineCommand({ + help: { summary: "Ordinary command" }, + handler: async (_args, ctx) => { + const session: Session | null = await ctx.session(); + void session; + // @ts-expect-error the capability was not declared, so the context carries no credentialManager + void ctx.credentialManager; + return ok(ctx.present({ data: null }, { human: () => [] })); + }, +}); +export const unmanagedIsUndeclared: false = unmanagedCommand.managesCredentials; + +export const grantSummaryHasNoTokenMaterial: "workspace" | "expiresAt" | "active" = + undefined as unknown as keyof GrantSummary; + +export const seededHarnessSpec: Parameters[0] = { + commands: tree, + credential: { + token: "jwt", + refreshToken: undefined, + expiresAt: undefined, + method: "user-oauth", + }, + identity: { kind: "user", id: "user-1", email: undefined }, + grants: [ + { + workspace: { id: "workspace-1", name: "Acme" }, + credential: { + token: "jwt", + refreshToken: undefined, + expiresAt: undefined, + method: "user-oauth", + }, + }, + ], + activeWorkspaceId: "workspace-1", +}; + +export const runtimeWithManager: Runtime = { + ...runtimeShape, + credentialManager: undefined as unknown as CredentialManager, +}; diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 7a0fbd9d..93dc3d5e 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -1,12 +1,16 @@ /** - * ctx.api: injected fakes, lazy construction, the unauthenticated - * throw path, and per-request credential pickup. + * ctx.api: injected fakes, the lazy proxy over the credential + * manager's apiClient(), the unauthenticated throw path, and the + * engine-side request-failure mapping (refresh-invalid → expired; + * other AuthError → state re-read). */ import { + type CredentialManager, defineCommand, type ManagementApiClient, type Runtime, + credentialsRequiredError, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli } from "@prisma/cli-engine/testing"; @@ -15,8 +19,30 @@ import { describe, expect, test } from "vitest"; import type { AnyCommand } from "../src/commands"; import { buildEngine, type RunHooks } from "../src/execution/engine"; +function unusedManagerMethod(name: string): () => never { + return () => { + throw new Error(`unexpected CredentialManager.${name} call`); + }; +} + +function fakeCredentialManager( + overrides: Partial, +): CredentialManager { + return { + session: unusedManagerMethod("session"), + beginSession: unusedManagerMethod("beginSession"), + endSession: unusedManagerMethod("endSession"), + grants: unusedManagerMethod("grants"), + rememberWorkspaceName: unusedManagerMethod("rememberWorkspaceName"), + activateGrant: unusedManagerMethod("activateGrant"), + forgetGrant: unusedManagerMethod("forgetGrant"), + apiClient: unusedManagerMethod("apiClient"), + ...overrides, + }; +} + function makeRuntime(overrides?: { - readonly getCredentials?: Runtime["getCredentials"]; + readonly credentialManager?: CredentialManager; }): Runtime & { readonly stderrText: () => string; readonly stdoutText: () => string; @@ -45,7 +71,8 @@ function makeRuntime(overrides?: { }, onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, - getCredentials: overrides?.getCredentials ?? (async () => undefined), + credentialManager: overrides?.credentialManager, + getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", stderrText: () => stderrText, @@ -56,7 +83,7 @@ function makeRuntime(overrides?: { async function runEngine( command: AnyCommand, runtime: Runtime, - hooks: RunHooks, + hooks: RunHooks = {}, ): Promise { const engine = buildEngine({ name: "t", @@ -79,6 +106,22 @@ const succeed = ( }, }); +const callApi = succeed(async (ctx) => { + const client = ctx.api as unknown as { call: () => Promise }; + await client.call(); +}); + +function throwingClientManager(failure: unknown): CredentialManager { + return fakeCredentialManager({ + apiClient: async () => + ({ + call: async () => { + throw failure; + }, + }) as unknown as ManagementApiClient, + }); +} + describe("ctx.api", () => { test("the harness client override IS ctx.api", async () => { const fake = { GET: async () => ({}) } as unknown as ManagementApiClient; @@ -96,16 +139,17 @@ describe("ctx.api", () => { expect(seen).toBe(fake); }); - test("no SDK construction when api is untouched", async () => { - const exitCode = await runEngine(succeed(), makeRuntime(), { - managementApi: { - createSdk: () => { - throw new Error( - "the SDK factory ran for a run that never touched ctx.api", - ); - }, - }, + test("the manager's apiClient is never resolved for a run that never issues a request", async () => { + const runtime = makeRuntime({ + credentialManager: fakeCredentialManager({}), }); + const exitCode = await runEngine( + succeed(async (ctx) => { + // Touch the property without invoking any request method. + void ctx.api; + }), + runtime, + ); expect(exitCode).toBe(0); }); @@ -134,120 +178,135 @@ describe("ctx.api", () => { }); }); - test("construction happens on first method call, not on ctx.api property access", async () => { + test("apiClient resolves on first method call and once per run; calls are proxied with their arguments", async () => { + let resolutions = 0; + const observed: unknown[] = []; + const runtime = makeRuntime({ + credentialManager: fakeCredentialManager({ + apiClient: async () => { + resolutions += 1; + return { + call: async (argument: unknown) => { + observed.push(argument); + }, + } as unknown as ManagementApiClient; + }, + }), + }); const exitCode = await runEngine( succeed(async (ctx) => { - // Touch the property without invoking any request method. - void ctx.api; + const client = ctx.api as unknown as { + call: (argument: unknown) => Promise; + }; + await client.call("first"); + await client.call("second"); }), - makeRuntime(), - { - managementApi: { - createSdk: () => { - throw new Error( - "the SDK factory ran for a run that never issued a request", - ); - }, - }, - }, + runtime, ); expect(exitCode).toBe(0); + expect(resolutions).toBe(1); + expect(observed).toEqual(["first", "second"]); }); - test("an SDK AuthError (401 / refresh unavailable) maps to CLI.CREDENTIALS_REQUIRED, exit 2", async () => { + test("refreshTokenInvalid === true maps to CLI.CREDENTIALS_REQUIRED with the expiry wording, exit 2", async () => { const runtime = makeRuntime({ - getCredentials: async () => ({ token: "stale-token" }), + credentialManager: throwingClientManager( + new AuthError("401 Unauthorized", true), + ), }); - const exitCode = await runEngine( - succeed(async (ctx) => { - const client = ctx.api as unknown as { call: () => Promise }; - await client.call(); + const exitCode = await runEngine(callApi, runtime); + expect(exitCode).toBe(2); + expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); + expect(runtime.stdoutText()).toContain( + "Your session has expired — sign in again.", + ); + }); + + test("another AuthError with the grant gone maps to the grant-removed CLI.CREDENTIALS_REQUIRED", async () => { + const runtime = makeRuntime({ + credentialManager: fakeCredentialManager({ + session: async () => null, + apiClient: async () => + ({ + call: async () => { + throw new AuthError("No tokens available", false); + }, + }) as unknown as ManagementApiClient, }), - runtime, - { - managementApi: { - createSdk: () => ({ - client: { - call: async () => { - throw new AuthError("401 Unauthorized", true); - }, - } as unknown as ManagementApiClient, - getLoginUrl: () => Promise.reject(new Error("unused")), - handleCallback: () => Promise.reject(new Error("unused")), - logout: () => Promise.reject(new Error("unused")), - }), + }); + const exitCode = await runEngine(callApi, runtime); + expect(exitCode).toBe(2); + expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); + expect(runtime.stdoutText()).toContain("no longer held"); + }); + + test("another AuthError with a session still present maps to the transient auth-service error, exit 2", async () => { + const runtime = makeRuntime({ + credentialManager: fakeCredentialManager({ + session: async () => ({ + identity: { kind: "user", id: "u1", email: undefined }, + method: "user-oauth", + origin: "stored", + workspace: { id: "w1", name: undefined }, + expiresAt: undefined, + }), + apiClient: async () => + ({ + call: async () => { + throw new AuthError("token endpoint returned 500", false); + }, + }) as unknown as ManagementApiClient, + }), + }); + const exitCode = await runEngine(callApi, runtime); + expect(exitCode).toBe(2); + expect(runtime.stdoutText()).toContain('"code":"CLI.AUTH_SERVICE_ERROR"'); + expect(runtime.stdoutText()).not.toContain("CLI.CREDENTIALS_REQUIRED"); + }); + + test("another AuthError while the state re-read raises a structured error surfaces that error", async () => { + const runtime = makeRuntime({ + credentialManager: fakeCredentialManager({ + session: async () => { + throw credentialsRequiredError("grants-held-none-active"); }, - }, + apiClient: async () => + ({ + call: async () => { + throw new AuthError("refresh raced a forget", false); + }, + }) as unknown as ManagementApiClient, + }), + }); + const exitCode = await runEngine(callApi, runtime); + expect(exitCode).toBe(2); + expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); + expect(runtime.stdoutText()).toContain( + "You hold workspace grants, but none is active.", ); + }); + + test("a structured error raised inside the request pipeline settles as itself", async () => { + const runtime = makeRuntime({ + credentialManager: throwingClientManager( + credentialsRequiredError("grants-held-none-active"), + ), + }); + const exitCode = await runEngine(callApi, runtime); expect(exitCode).toBe(2); - // Non-TTY runtime auto-selects json format: the errored envelope - // streams to stdout. expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); + expect(runtime.stdoutText()).toContain("No workspace is active."); }); test("a cyclic cause chain on a request failure terminates and settles as a bug", async () => { const cyclic = new Error("outer"); const inner = new Error("inner", { cause: cyclic }); cyclic.cause = inner; - const runtime = makeRuntime(); - const exitCode = await runEngine( - succeed(async (ctx) => { - const client = ctx.api as unknown as { call: () => Promise }; - await client.call(); - }), - runtime, - { - managementApi: { - createSdk: () => ({ - client: { - call: async () => { - throw cyclic; - }, - } as unknown as ManagementApiClient, - getLoginUrl: () => Promise.reject(new Error("unused")), - handleCallback: () => Promise.reject(new Error("unused")), - logout: () => Promise.reject(new Error("unused")), - }), - }, - }, - ); + const runtime = makeRuntime({ + credentialManager: throwingClientManager(cyclic), + }); + const exitCode = await runEngine(callApi, runtime); expect(exitCode).toBe(1); expect(runtime.stdoutText()).toContain('"code":"CLI.INTERNAL_ERROR"'); }); - - test("constructed once per run; credential refresh is picked up per request", async () => { - const tokens = ["token-1", "token-2"]; - const observed: string[] = []; - let constructions = 0; - const exitCode = await runEngine( - succeed(async (ctx) => { - const client = ctx.api as unknown as { call: () => Promise }; - await client.call(); - await client.call(); - }), - makeRuntime({ - getCredentials: async () => ({ token: tokens.shift() ?? "spent" }), - }), - { - managementApi: { - createSdk: (config) => { - constructions += 1; - const call = async (): Promise => { - const stored = await config.tokenStorage.getTokens(); - observed.push(stored?.accessToken ?? "none"); - }; - return { - client: { call } as unknown as ManagementApiClient, - getLoginUrl: () => Promise.reject(new Error("unused")), - handleCallback: () => Promise.reject(new Error("unused")), - logout: () => Promise.reject(new Error("unused")), - }; - }, - }, - }, - ); - expect(exitCode).toBe(0); - expect(constructions).toBe(1); - expect(observed).toEqual(["token-1", "token-2"]); - }); }); From e24d1d509567fbd7cd9252e429a4b18c305636f3 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 16:10:22 +0200 Subject: [PATCH 37/67] =?UTF-8?q?drive(prisma-cli-v8):=20credential-manage?= =?UTF-8?q?r=20design=20rev=205=20=E2=80=94=20the=20session=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-designed model replacing the grants model: sessions keyed by workspace id, one current; six-method SPI (no apiClient, no rememberWorkspaceName); engine constructs the API client with injected config; the manager's one internal seam is the SDK's TokenStorage contract; identity rule dropped (wallet identity-blind like legacy); legacy command names return. Refresh, migration, and locking mechanics carry from rev 4. Open ruling: use-X-not-held. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 1026 ++++++----------- 1 file changed, 372 insertions(+), 654 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index eb7ef2df..8f9bfafb 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -1,689 +1,407 @@ -# Credential manager — design, revision 4 (normative, final) - -Status: operator-adopted (2026-08-10). Revision 2 folded in the -pre-implementation design review (architect + principal engineer); -revision 3 adopted the GRANTS model by operator ruling: user-facing -workspace functionality is kept — "We have a credential manager now -literally to support managing multiple credentials for access to -different workspaces." Revision 4 folds the delta re-review -(architect + PE, both accept-with-changes) — notably the rotation -write (`setTokens`) semantics, the mixed-identity migration rule, -per-workspace API-client binding, and the §8 amendments. NORMATIVE -for the implementation, which lands on PR #130. Prior revisions are -in git history. - -## 1. Why this exists - -The engine gave commands two auth surfaces — `ctx.api` (consume the -management API) and `ctx.getCredentials` (read the resolved -credential) — and no modeled surface for the thing auth commands -operate ON: the credential machinery. The S2a auth-family port -therefore imported around the engine. The defect being corrected is -the ONTOLOGY and the PLUMBING, not credential plurality: the legacy -model had no active-identity concept (per-workspace "sessions" with -login/logout verbs — identity conflated with credential scope), a -split-brain context sidecar, read paths that write, and commands -bypassing the engine. Control-plane facts (operator-validated): user -OAuth tokens are workspace-bound at consent; refresh cannot -re-scope; the platform's multi-workspace primitive (`ActorUser`) -serves Console but not OAuth tokens today, and the platform is not -changing now — so multi-workspace access REQUIRES holding multiple -workspace-bound credentials. The 17-tool survey found no precedent -for exposing that plurality as per-workspace sessions; this design -KNOWINGLY keeps the plurality (operator ruling: functionality is not -dropped) while fixing the ontology: one identity, grants as -authorization artifacts, scalar view to every consumer. - -## 2. Ruled outcomes - -R1 The engine models the machinery as the **credential manager** - (user-centric surface over what is a dumb store today). -R2 Auth state: ONE identity, PLURAL workspace grants, ONE active - grant. The active-grant cursor is session state (satisfying the - draft premise: workspace selection is session state, not a - credential). Toward every engine consumer the view is SCALAR — - `session()`/`ctx.api`/`needs.credentials` see only - the active grant. The plurality lives entirely inside the - manager. No per-workspace login/logout vocabulary anywhere: you - log in as yourself; you hold, select, and forget GRANTS. -R3 Tree: `auth login`, `auth logout`, `auth whoami`, - `auth workspace list` (your held grants), `auth workspace use` - (activate a held grant, or acquire one via the consent flow when - not held), `auth workspace forget ` (drop one grant — - replaces the legacy `auth workspace logout`; nothing is a - per-workspace "logout"). `auth logout --workspace` does not - return (superseded by `forget`). Divergence entries accompany - every rename/semantic change. -R4 Migration from the legacy store per the decision table in §7. -R5 The fix lands on PR #130 (the shipped registry port is reworked, - not merged as-is). - -## 3. Entities - -No conditional properties (standing ruling): absent = `T | undefined`, -required key. All claim-derivable fields are derived by the manager, -never caller-supplied. +# Credential manager — design, revision 5 (normative) + +Status: operator-designed session model (2026-08-10), replacing the +rev-3/4 grants model wholesale. Revisions 1–4 and their review folds +are in git history; rev 4's REFRESH, MIGRATION, and LOCKING mechanics +carry forward with vocabulary mapped (grant → session) — those rules +were always about tokens. NORMATIVE for the implementation, which +lands on PR #130. A delta review (architect + PE) covers rev4→rev5. + +## 1. The reality this models + +Validated against pdp-control-plane source (2026-08-10): + +- `prisma auth login` can produce exactly ONE kind of thing: a + workspace-scoped OAuth token pair (access + refresh). The user + picks the workspace on the consent screen; the CLI CANNOT request + or pin a workspace — the authorize request carries no workspace + parameter (`AuthorizeSearchSchema`). The CLI learns which + workspace it got by decoding the returned token's `workspace_id` + claim. Refresh cannot re-scope. +- `PRISMA_SERVICE_TOKEN` supplies a workspace-scoped bearer token + from the environment. No refresh, never stored. +- Tokens carry identity claims (`sub`: a user for login tokens, a + workspace for service tokens) but the CLI's stored state records + and enforces NO identity. Identity surfaces only when `whoami` + decodes the current session's claims. A wallet MAY hold sessions + created by different accounts; the system does not care (operator + ruling — the rev-3/4 one-identity invariant is DROPPED). +- Legacy state: a JSON file of `{workspaceId, accessToken, + refreshToken}` entries plus a separate context file holding the + active workspace id. §7 migrates it. + +There are no grants and no separate credential registry concept. +The domain is: a set of per-workspace sessions, one current. + +## 2. Entities ```ts -type Identity = - | { readonly kind: "user"; readonly id: string; readonly email: string | undefined } - | { readonly kind: "service"; readonly id: string | undefined; readonly label: string | undefined }; - +/** The proof material. Only ever seen by the login flow (which + * mints it) and createSession (which stores it). */ interface Credential { readonly token: string; readonly refreshToken: string | undefined; readonly expiresAt: Date | undefined; - readonly method: "user-oauth" | "service-token"; } -interface Workspace { // resolved pair; NOT the id-or-name - readonly id: string; // string users type (that concept is - readonly name: string | undefined; // called a ref elsewhere) +interface Workspace { + readonly id: string; + readonly name: string | undefined; } +/** "Logged-in-edness", scoped to a workspace. Identified to users + * by its workspace. The token is INTERNAL: it lives in the stored + * record but is structurally absent from this public shape. */ interface Session { - readonly identity: Identity; - readonly method: "user-oauth" | "service-token"; - readonly origin: "stored" | "environment"; - readonly workspace: Workspace; // the ACTIVE grant's workspace + readonly workspaceId: string; + readonly workspace: Workspace | undefined; // loaded on session create (§4) readonly expiresAt: Date | undefined; + readonly source: "stored" | "environment"; + readonly active: boolean; } - -interface GrantSummary { // user-centric listing; NEVER - readonly workspace: Workspace; // carries credential material - readonly expiresAt: Date | undefined; - readonly active: boolean; // cursor position; when an env -} // token overrides, the LISTING - // command must say the env - // session is what's in force ``` -A GRANT is the pairing of a workspace with the credential the user's -consent minted for it. The manager stores grants internally -(credential material included); everything it EXPOSES about the set -is `GrantSummary` — token material structurally cannot leave. - -Notes (review-settled): -- `Session.origin` is the field that justifies the Session/Credential - split: it drives `endSession`'s refusal and lets `whoami` explain an - env override. `method` is a different axis (how authenticated) from - `identity.kind` (who) — perfectly correlated today, genuinely - distinct, will diverge (e.g. a personal access token). -- There is no `Scope` entity (dissolved in review: the one-element - array served neither timeline, and the word collides with OAuth's - `scope` claim). Future `ActorUser` routing adds - `reachable: readonly Workspace[]` beside `workspace` — additive. -- `Session` corresponds closely to the platform's own "principal" - (`/v1/me`: credential + user + workspace). The user-centric name - Session is deliberate (operator ruling); this note is the bridge - for readers of both codebases. -- Identity/workspace/expiry are decoded from JWT claims by the - manager (claims only — see §4 boundaries). `name` on Workspace is - `undefined` unless an explicit write recorded it; there is no - read-path caching. - -## 4. The CredentialManager interface +- Sessions are KEYED BY WORKSPACE ID: at most one session per + workspace. Logging in to the same workspace again upserts the + stored record (same key, new credential). Creating sessions for + several workspaces with the same account is fine — one record per + workspace, whatever credential is inside. +- `whoami`'s identity display comes from decoding the CURRENT + session's token claims at read time (user id/email for login + tokens, workspace for service tokens). Identity is a per-session + decoded fact, not system state. +- `source: "environment"` marks the ephemeral session composed from + `PRISMA_SERVICE_TOKEN` (§6). It never appears in `sessions()`. + +## 3. The CredentialManager interface (the SPI) + +Manages sessions. Nothing else. ```ts interface CredentialManager { - /** User-centric truth. Local-only: composes env + stored state, - * decodes claims, NEVER touches the network. An env service token - * wins over a stored credential; when both exist, the Session is - * the env one (origin: "environment") and whoami is responsible - * for surfacing the override (§6). */ - session(): Promise; - - /** Login's write. Derives identity, workspace, and expiry from - * the credential's claims (single-argument by review ruling). - * Same identity as the held grants: UPSERTS the grant for that - * workspace and makes it ACTIVE; other grants untouched. - * DIFFERENT identity: all existing grants are discarded and - * replaced by this one — the one-identity invariant is enforced - * here, and the stored state carries the identity ONCE beside - * the grants array so the invariant is structural. */ - beginSession(credential: Credential): Promise; - - /** Logout, whole-identity. Local (does not revoke server-side; - * other processes' in-memory access tokens stay valid until - * expiry — user-facing text says so). Clears ALL grants and the - * cursor (also reaps legacy orphan entries, §7). Rejects with a - * structured error when the active session is env-supplied; the - * error's why states whether stored grants also exist. */ - endSession(): Promise; - - /** The held grants, as summaries (no credential material). - * Local-only like session(): never touches the network. */ - grants(): Promise; - - /** Records a human-readable workspace name learned by a command - * (e.g. from the consent flow or an API response). The sanctioned - * name-write path — beginSession stays single-argument and - * claims-only. Explicit write; never a read-path side effect. - * Runs under the §8 lock like every mutation. No grant held for - * that workspace id → no-op, not an error. Only `login` and - * `workspace use` may call it (both already mutate) — a name - * write triggers §7 materialization, so read-only listing - * commands must not call it. */ - rememberWorkspaceName(workspaceId: string, name: string): Promise; - - /** Move the cursor to a HELD grant. Ref resolution is the - * manager's, against held grants only: exact id first, then - * case-insensitive name; ambiguity is a structured error; - * no match is a structured error the COMMAND catches to run the - * consent flow + beginSession (the manager never interacts with - * the user). Activating an expired grant succeeds locally (no - * network check) and fails at first use — grants() exposes - * expiresAt so commands can warn. */ - activateGrant(ref: string): Promise; - - /** Drop one grant. If it was active, the cursor clears (no - * auto-promotion of another grant). */ - forgetGrant(ref: string): Promise; - - /** The authenticated management API client, constructed and owned - * by the MANAGER (review blockers: the engine must not build a - * half-configured SDK — the real clientId lives with the auth - * module — and no SDK type may appear on this interface beyond - * the engine's existing ManagementApiClient alias). Clients are - * cached PER WORKSPACE ID, and any cursor-moving mutation - * (`beginSession`, `activateGrant`) invalidates the cached - * "active" client — a command that switches grants and then - * touches `ctx.api` (workspace use doing name enrichment, whoami - * after login) must get a client bound to the NEW grant, not a - * stale one that could read the old workspace's data and write - * it onto the new grant. Refresh single-flight is per client, - * which is correct because refresh is per grant. */ - apiClient(): Promise; + /** The session the engine is acting as RIGHT NOW — the one the + * management API authenticates with. Env token wins over the + * stored current (§6). Local-only: decodes claims, never + * touches the network. */ + currentSession(): Promise; + + /** The available sessions (auth workspace list). Local-only. */ + sessions(): Promise; + + /** Login's write. The caller names the workspace that identifies + * the session (for a workspace-bound credential the manager + * verifies the workspace_id claim matches and refuses on + * mismatch; for a multi-workspace credential the argument IS + * the choice). Upserts by workspaceId, becomes current. Fetches + * the workspace name via the management API once, best-effort + * (login is already online; failure leaves name undefined — + * never fails the login). */ + createSession(credential: Credential, workspaceId: string): Promise; + + /** Switch the current session. */ + useSession(session: Session): Promise; + + /** Log out of one workspace: remove that session. If it was + * current, there is no current (no auto-promotion). */ + endSession(session: Session): Promise; + + /** Log out entirely: remove all sessions and the current marker + * (also reaps legacy files, §7). */ + endAllSessions(): Promise; } ``` -Mutation rules under an env override: while a `PRISMA_SERVICE_TOKEN` -session is in force — -- `endSession`, `activateGrant`, `forgetGrant` REFUSE with one - structured error family: the why names the env var and states - whether stored grants exist underneath; the nextAction is the - literal `unset PRISMA_SERVICE_TOKEN` command. State the user - cannot observe as their session is never changed or destroyed. -- `beginSession` is ALLOWED (PE delta ruling): login is interactive, - additive in the same-identity case, and the user just performed a - browser consent — refusing it strands anyone with the var exported - in a shell profile or a CI image. Mandatory one-line notice: the - env token remains in force and the new grant takes effect only - once it is unset. -- `rememberWorkspaceName` is EXEMPT: it annotates an existing grant - and never changes which credential is in force; refusing would - break enrichment exactly in the CI case where names are cosmetic. -Reads (`session()`, `grants()`) work normally. - -Credential RESOLUTION is internal (ruled at rev-2 adoption — -architect recommendation: "let `credential()` be internal"; rev 3 -mistakenly restored it to the interface, corrected here). The -manager resolves the credential that authorizes a request inside -`apiClient()` and the needs check; no public method returns -credential material. Internal invariant: resolution yields null ⟺ -`session()` is null. The one known future consumer of raw -credential material — S2c's compute SDK client — comes back as an -operator question when that slice reaches it, not as a silent -re-add. - -State effects, at a glance: - -| Verb | Effect | -| --- | --- | -| `beginSession` | upsert one grant (same identity) or replace all (new identity); set cursor | -| `activateGrant` | cursor only | -| `forgetGrant` | remove one grant; clear cursor if it was active | -| `endSession` | remove ALL grants and cursor | - -(The single-workspace inverse of `beginSession` is `forgetGrant`, -not `endSession`.) - -Boundaries (review-settled): -- **Custody, not user interaction**: the manager never opens a - browser, never prompts, never talks to the user. (It DOES mint in - the narrow senses of refresh and future exchange — the earlier - "never creates" phrasing was wrong.) The login FLOW lives beside - it: `performLogin` changes shape to RETURN the minted credential - (today it persists internally and returns void); the login command - hands that credential to `beginSession`. -- **Claims only, never network**: `session()` on every command's - context (see §5) must be safe to call anywhere; enrichment (user - display name, workspace name) is `whoami`'s job through `ctx.api`. -- **Env is a construction input**: the manager receives `env` at - construction (like today's `makeGetCredentials(env)`); no library - below it may read `process.env` (this retires the - `PRISMA_PLATFORM_AUTH_FILE`-vs-`PRISMA_COMPUTE_AUTH_FILE` - split-brain: exactly one variable names the auth file, resolved - from the injected env; the other is accepted as a deprecated alias - with a one-time warning and never wins over an explicit path). -- **Error raising is single-sourced**: set-but-blank service token → - one structured error (the existing AUTH.CONFIG_INVALID content) - raised identically from `session()`, the needs check, and the - internal credential resolution behind `apiClient()`; unreadable store (EACCES/EPERM) → `CLI.CREDENTIALS_UNREADABLE`; - parse-corrupt store → signed out (self-heals on next login), never - an exception, never a write. - -## 5. Engine integration - -- `Runtime.credentialManager: CredentialManager` REPLACES - `Runtime.getCredentials`, staged (review ruling — not atomic): - 1. add `credentialManager` optional; engine prefers it, falls back; - bin wires the real manager; harness gains seeding; - 2. move the needs check + `ctx.api` onto the manager; rework the - auth family; - 3. delete `getCredentials` and fix remaining Runtime literals in - one mechanical commit. -- `ctx.session(): Promise` appears on EVERY context - (read-only, local-only — tested to perform no network I/O). -- `ctx.getCredentials` is DELETED (no handler consumes it; the - context ends with fewer auth surfaces than before: `api` + - `session`). -- Manager access is a CAPABILITY, not a need (a declaration never - fails a run): `managesCredentials: true` on the command definition - puts `ctx.credentialManager` on the context. Declared by exactly: - `auth login`, `auth logout`, `auth workspace list`, `auth - workspace use`, `auth workspace forget`. `whoami` uses - `ctx.session()` only. `grants()` lives ONLY on the manager — never - on the universal context (the plural view stays contained). The - doc is honest that this is documentation + testability, not - enforcement. -- `ctx.api` becomes a thin lazy proxy over `manager.apiClient()` - plus the engine-side error mapping (§6). The engine's placeholder - OAuth constants and its SDK construction are deleted. -- Harness: `createTestCli({ credential?: Credential; session?: Session })` - seeds a MUTABLE in-memory manager readable back by tests (login/ - logout tests observe state changes). Prefer seeding `credential` - and letting real derivation run; `session` is the escape hatch. - Grants-model extension: seeding accepts `{ identity, grants, - activeWorkspaceId }` with the WHOLE state readable back after the - run (grants, per-grant credentials, cursor); identity seedable - independently of grants so the invalid combination (a grant whose - `sub` disagrees with the recorded identity) is constructible. -- Fixture surface (review-required), beyond the rev-2 list (an - injectable refresh/token endpoint scripting 401 → rotated pair → - retry, `invalid_grant`, 5xx/network-throw with credential - untouched): - - a JWT minter (claims `sub`, `workspace_id`, `exp`, `email`) plus - an undecodable-token entry — migration, expiry, and - identity-guard tests all need it; - - a legacy-store builder writing real legacy auth + context files - (pointer valid / dangling / null / absent; one or many entries; - entries from two `sub`s; corrupt context; wrong shape); - - a deterministic clock for expiry, heartbeat, stale threshold; - - an interleaving hook — a pause point inside every mutation - between read-under-lock and write — so cursor-versus-rotation - orderings are testable in-process without sleeps, plus a way for - a real second process to hold the lock. -- Required tests (normative list): - - real-filesystem multi-process races, one each: `activateGrant` - vs rotation (final state = the cursor move + the rotated - tokens); `forgetGrant` vs rotation (no resurrection); different- - identity login vs rotation (write refused, no resurrection); - two refreshers (exactly one exchange); - - env-override matrix: every mutation × {unset, set, blank, - whitespace}, asserting the error family and that the auth - file's bytes are unchanged; - - forget-active / grants-held-none-active: one shared assertion - over `session()`, the needs check, and a bare `ctx.api` touch - (code, - why, next action); - - name persistence across refresh (the named legacy-regression - test): seed a named grant, script 401 → rotation, assert the - name survives and `expiresAt` was re-derived; - - a reads-never-write probe: a filesystem spy over the auth - directory asserting ZERO writes across every read path - including migration adoption; - - a token-material leak scan: run each command with a known - secret seeded, assert it appears in no stdout, stderr, debug - log, error meta, or envelope; - - lock-constant ordering (§8). -- Draft amendments land with the implementation: §4 (context: - session, api; getCredentials gone), §6 (`managesCredentials`), §10 - (`Runtime.credentialManager`), §11 (harness seeding + fixtures). +Interface rules: +- The manager never talks to the user (no prompts, no browser). + The login FLOW lives beside it; `performLogin` returns the minted + credential and the command calls `createSession`. +- The manager resolves NO user input. Commands resolve what the + user typed against `sessions()` themselves (exact id, then + case-insensitive name; ambiguity is the command's error) and pass + the matched `Session`. Manager errors are real state errors only — + e.g. the passed session no longer exists (another process ended + it) → structured error, nothing guessed. +- `useSession`/`endSession` identify the target by its + `workspaceId` against freshly-read state under the lock (§8) — + the Session object is a reference, not a snapshot to trust. +- Env is a construction input (injected `env`, never `process.env` + below the manager). Exactly one env var names the state file; the + legacy second variable is a deprecated, warned alias. +- Error single-sourcing: blank/whitespace service token → one + structured error raised identically from `currentSession()`, the + needs check, and the engine's token resolution; unreadable file → + `CLI.CREDENTIALS_UNREADABLE`; parse-corrupt file → signed out + (self-heals on next login), never an exception, never a write. + +Mutations under an env override (`PRISMA_SERVICE_TOKEN` set): +`useSession`, `endSession`, `endAllSessions` refuse with one +structured error family (why names the env var and whether stored +sessions exist; nextAction is the literal `unset` command). +`createSession` is ALLOWED with a mandatory one-line notice that the +env token remains in force until unset. Reads work normally, and +`auth workspace list` states that the env session is what is in +force (normative). + +## 4. Engine integration + +- `Runtime.credentialManager: CredentialManager` replaces + `Runtime.getCredentials`, staged as before (optional first, then + needs/api rework, then `getCredentials` deletion). +- `ctx.session(): Promise` on EVERY context — + read-only, local-only (tested: no network I/O). It serves + `currentSession()`. +- `managesCredentials: true` capability puts `ctx.credentialManager` + on the context for exactly: `auth login`, `auth logout`, + `auth workspace list`, `auth workspace use`, + `auth workspace logout`. `whoami` uses `ctx.session()` only. +- **The ENGINE constructs and owns the management API client** + (`ctx.api`), as it did in S2a. Construction config (real OAuth + client id, base URL) is injected by the bin beside the manager — + the engine's placeholder constants die. The client is cached per + workspace id and rebuilt when the current session changes (a + command that switches and then touches `ctx.api` must get a + client for the NEW session). Refresh single-flight is per client, + which is correct because refresh is per session. +- **The manager's one internal seam**: it implements the SDK's + `TokenStorage` contract (`getTokens` / `setTokens` / + `clearTokensIfCurrent` / `withRefreshLock`) so the SDK's 401 → + refresh → retry cycle reads and writes the session store under + the manager's rules (§6). The storage view handed to a client is + bound to THAT client's session's workspace id — bound to the ID, + never to a credential snapshot: `getTokens` re-reads the store on + every call. This seam is engine↔manager plumbing; it is not part + of the user-facing SPI and token material never crosses the + public interface. +- Harness: `createTestCli` seeds `{ sessions?: [...], currentWorkspaceId?, + credential? }` over a mutable in-memory manager with full state + read-back. Fixture surface and required tests: §5. + +## 5. Fixtures and required tests + +Fixture surface: injectable token endpoint (script 401 → rotated +pair → retry; `invalid_grant`; 5xx/network-throw), a JWT minter +(`sub`, `workspace_id`, `exp`, `email` + an undecodable token), a +legacy-store builder (pointer valid/dangling/null/absent; one/many +entries; corrupt context; wrong shape), a deterministic clock, an +interleaving hook (pause between read-under-lock and write), and a +way for a real second process to hold the lock. + +Required tests: +- multi-process races (real filesystem): `useSession` vs rotation + (final state = the switch + the rotated tokens); `endSession` vs + rotation (no resurrection); two refreshers (exactly one exchange); +- env-override matrix: every mutation × {unset, set, blank, + whitespace} — error family asserted, state file bytes unchanged; +- end-current / sessions-held-none-current: one shared assertion + over `ctx.session()`, the needs check, and a bare `ctx.api` touch; +- workspace-name persistence across refresh (the legacy regression: + rotation must not touch `workspace.name`), with `expiresAt` + re-derived; +- reads-never-write probe (filesystem spy: zero writes on every + read path including migration adoption); +- token-material leak scan (seed a known secret; assert absent from + stdout, stderr, debug logs, error meta, envelopes); +- lock-constant ordering (§8); +- `createSession` claim/argument mismatch refusal. ## 6. Runtime flows (normative) -**Unauthenticated.** `needs.credentials` → engine fails early with -`CLI.CREDENTIALS_REQUIRED` (exit 2, sign-in nextAction), handler -never loads. Bare `ctx.api` touch → the same error (single -constructor) at request time. `whoami` → completes "signed out", -exit 0. No auto-login (standing Q1 default). - -**Grants held, none active** (migration rows; forget-active): same -code `CLI.CREDENTIALS_REQUIRED`, distinct why ("you hold grants but -none is active") and nextAction `auth workspace use` alongside -sign-in. Single-sourced like the other credential errors: session(), the -needs check, and the internal resolution behind ctx.api produce it -identically. NOTHING -auto-promotes a grant. - -**Refresh.** Driven by the SDK on 401, with the manager as its -`TokenStorage` — the storage view the SDK sees is bound to THE GRANT -the client was constructed for (not the cursor: a concurrent -`workspace use` must not redirect a refresh mid-flight), under the -mandatory lock (§8). "Bound to the grant" means bound to the -WORKSPACE ID, never to a credential snapshot: the view's -`getTokens()` reads the current on-disk value for that workspace id -on every call, no in-memory caching. That is what makes the benign -race work — if another process already rotated the grant, the SDK's -compare fails and it returns the fresh tokens without a second -exchange; a cached snapshot would re-spend a spent refresh token. -The manager MUST implement `withRefreshLock` (the SDK silently -skips locking without it) and `clearTokensIfCurrent` scoped to that -grant: remove it if and only if its STORED credential still exactly -matches the one that failed; clear the cursor only if that grant is -still the active one. "Exactly matches" is defined over the SDK's -three compared fields ONLY — `workspaceId`, `accessToken`, -`refreshToken` — so a `rememberWorkspaceName` write or a re-derived -`expiresAt` between construction and failure cannot defeat the -compare and leave a dead grant in place. (The SDK's compare -including the ACCESS token is desired behavior, not a limitation: a -grant another process already rotated correctly declines to clear. -Do not "fix" it to compare refresh tokens only.) Blast radius of a -definitive refresh failure: one workspace, never the identity. -Preemptive refresh is PROHIBITED (a second refresher outside -the SDK's single-flight can spend a rotated refresh token and -convert an optimization into a false sign-out). Per-request token -resolution keeps long runs current. - -**The rotation write (`setTokens`).** The write that runs on every -successful refresh, specified as the inverse of compare-and-clear — -this is the path where the legacy store destroyed state (every -refresh overwrote the human workspace name with the raw id and -could move the active pointer). Normative rules: -- Updates IN PLACE the grant whose workspace id matches, and only - the fields `token`, `refreshToken`, `expiresAt`. The SDK's token - pair carries no expiry: `expiresAt` is always re-derived by the - manager from the new access token's claims. -- NEVER creates a grant. If the freshly-read state under the lock - holds no grant for that workspace id (another process ran - `forgetGrant`, `endSession`, or logged in as someone else), the - write is refused and `setTokens` throws — no resurrection of a - deliberately removed grant. Losing the rotated refresh token is - correct: the grant is gone on purpose. -- NEVER moves the cursor, and never touches `workspace.name` or any - other grant field. Name preservation across refresh gets its own - named regression test (the legacy defect). -- If the new access token's `workspace_id` claim disagrees with the - bound grant's id: refuse and throw. Refresh cannot re-scope; a - mismatch is a fault, not a new grant. -- Identity guard: if the fresh state's identity differs from the - identity the client was constructed under, refuse. Same guard on - `clearTokensIfCurrent` — two accounts can hold grants on the same - workspace, so matching by workspace id alone is not enough. -Side effect worth naming: the SDK's own `login()` also persists via -`tokenStorage.setTokens`. Under these rules that path fails loudly -instead of quietly minting an unowned grant — desired, since -`performLogin` returns the credential and `beginSession` owns the -write. - -**Refresh failure discrimination.** The SDK's -`AuthError.refreshTokenInvalid` is `true` ONLY for HTTP 4xx with -body error exactly `invalid_grant` — the reliable definitive -trigger. Engine-side mapping (replaces today's map-every-AuthError): -- `refreshTokenInvalid === true` → `CLI.CREDENTIALS_REQUIRED`, - expiry wording ("your session has expired — sign in again"). The - SDK has already cleared (compare-and-clear); the manager logs the - token-endpoint status + error value at debug level BEFORE the - clear so support can distinguish real expiry from a server bug. -- Any other `AuthError` (`refreshTokenInvalid !== true`) → the - manager RE-READS its own state under no lock: if the grant it was - bound to is gone (another process forgot it mid-flight — the SDK - throws its synthesized "No tokens available" here), - `CLI.CREDENTIALS_REQUIRED` with grant-removed wording; only - otherwise is it the transient auth-service error. This is a state - check, never message parsing — no string-matching of SDK errors - anywhere. -- No credential at all up front → `CLI.CREDENTIALS_REQUIRED`, - unauthenticated wording — raised by the manager's own structured - error, not the SDK's synthesized message. -- Non-auth failures (network, 5xx, other 4xx) → a transient - auth-service error, surfaced as such. NOT credentials-required. +**Unauthenticated.** `needs.credentials` → `CLI.CREDENTIALS_REQUIRED` +(exit 2, sign-in nextAction) before the handler loads; bare +`ctx.api` touch → same error at request time. `whoami` → "signed +out", exit 0. No auto-login. + +**Sessions held, none current** (migration rows; end-current): same +code, distinct why ("you have workspace sessions but none is +current") with nextActions `auth workspace use` and login. + +**Refresh.** Driven by the SDK on 401 through the manager's +`TokenStorage` view (§4), under the mandatory lock (§8): +- `setTokens` (the rotation write — the write that runs on every + successful refresh): updates IN PLACE only `token`, + `refreshToken`, `expiresAt` (re-derived from claims; the SDK's + pair carries no expiry) of its own session record. NEVER creates + a session, NEVER moves the current marker, NEVER touches + `workspace.name`. If the freshly-read state has no record for + that workspace id (another process ended it), refuse and throw — + no resurrection. If the new token's `workspace_id` claim + disagrees with the bound id, refuse (refresh cannot re-scope). +- `clearTokensIfCurrent`: remove the session iff its stored + credential still exactly matches the pair that failed — "exactly" + over the SDK's three compared fields (`workspaceId`, + `accessToken`, `refreshToken`) only, so a re-derived expiry can't + defeat the match. Clear the current marker only if it names that + session. (The SDK comparing ACCESS tokens too is desired: a pair + another process already rotated correctly declines to clear.) +- Preemptive refresh is PROHIBITED (outside the SDK's single-flight + it can spend a one-time refresh token and convert an optimization + into a false sign-out). + +**Refresh failure discrimination.** `AuthError.refreshTokenInvalid` +is `true` only for HTTP 4xx + body error exactly `invalid_grant` — +the definitive sign-out trigger: +- `true` → `CLI.CREDENTIALS_REQUIRED`, expiry wording. The SDK has + already cleared; the manager debug-logs endpoint status + error + value BEFORE the clear. +- any other `AuthError` → the manager re-reads its state: session + gone → `CLI.CREDENTIALS_REQUIRED` with session-ended wording; + otherwise a transient auth-service error. A state check, never + message parsing. +- non-auth failures (network, 5xx) → transient auth-service error; NOTHING cleared. -The sign-out decision is thereby the SDK's policy; the SDK version -is exact-pinned and a test asserts clearing happens on -`invalid_grant` and on nothing else. - -**Service token (env).** No refresh. 401 → structured error naming -`PRISMA_SERVICE_TOKEN` with a Console-pointing fix; nothing cleared. -Unset → fall through to stored; set-but-blank or whitespace → the -single blank-token error (§4). `session()` reports the env session -(origin "environment"); when a stored session ALSO exists, `whoami` -surfaces a one-line note that the env var is overriding it, and -`auth workspace list` must state that the env session is what is in -force (normative — this was previously only a type comment on -`GrantSummary`). - -**Lock contention.** A refresh-lock wait timeout is NOT an engine -bug: it gets its own structured code, a why naming the lock path -("another prisma process may be refreshing"), and a next action. - -**Debug valve.** Same shape as the telemetry sender's -(`PRISMA_NEXT_DEBUG`): prints source won (env/stored), resolved auth -file path, refresh attempted, token-endpoint status + error field, -lock acquire/release/steal with holder ids. Token material (access, -refresh, JWTs, even truncated) NEVER appears in any log, error -message, meta, or envelope — the session read model structurally -cannot carry it. - -## 6a. Switching grants - -`auth workspace use ` is the switch: it activates a held grant -(cursor move, §4), or — when no grant matches — announces in one line -that it is opening the browser to authorize that workspace, runs the -consent flow, and hands the minted credential to `beginSession`. Run -fully signed out, `use` is effectively a full login and announces -itself as such. `auth login` is for signing in (first grant) or -changing identity. To/from a service token: set/unset the env var -(wins at read time; also the scripts/parallel-terminal override). -Known trade-off (accepted): the ACTIVE cursor is one shared piece of -state on disk, so two terminals share it; simultaneous work against -two workspaces uses a service token in one terminal. A per-process -workspace override selecting among held grants without moving the -cursor is a recognized future affordance — explicitly deferred, not -designed here. - -## 7. Migration from the legacy store (R4) - -Governing rule: **the migration read writes nothing.** The store is -mutated only by `beginSession`, `endSession`, and refresh. +The SDK version is exact-pinned; a test asserts clearing happens on +`invalid_grant` and nothing else. + +**Service token (env).** Composes as an ephemeral current session +(`source: "environment"`), never stored, absent from `sessions()`. +No refresh; 401 → structured error naming the env var; nothing +cleared. `whoami` notes the override when stored sessions also +exist. Blank/whitespace → the single blank-token error (§3). + +**Lock contention.** A refresh-lock wait timeout is its own +structured code with a why naming the lock path and a next action. + +**Debug valve.** `PRISMA_NEXT_DEBUG` shape: source won, resolved +state-file path, refresh attempted, endpoint status + error field, +lock acquire/release/steal with holder ids. Token material NEVER +appears in any log, error, meta, or envelope. + +## 6a. The commands + +Legacy names return unchanged — the session model makes them honest +("log in to a workspace" = create a session for it): + +- `auth login` — browser consent flow; the user picks the + workspace; `createSession(credential, workspaceId-from-claims)`. +- `auth logout` — `endAllSessions()`. +- `auth whoami` — `ctx.session()` + claims decode; `ctx.api` + enrichment when online. +- `auth workspace list` — `sessions()`, current marked. +- `auth workspace use ` — resolve ref against `sessions()` + (command-side), `useSession(match)`. +- `auth workspace logout ` — resolve, `endSession(match)`. + (The rev-3 `workspace forget` rename is DEAD; legacy vocabulary + stands. The rev-3/4 grants vocabulary is dead everywhere.) + +**OPEN OPERATOR RULING — `use X` with no session for X.** The +consent flow cannot target a workspace (§1), so "use acquires X" is +unimplementable as promised in rev 3/4. Options: +(a) legacy-parity error: "no session for X — run `prisma auth +login` and pick X in the browser" (no browser launch from `use`); +(b) `use X` announces it is opening the browser, runs the generic +flow, then compares the returned `workspace_id` to X: match → +create + current; mismatch → create the session it actually got +(real consent, not wasted), leave the current marker unchanged, and +say "you logged in to Y, not X". Recommendation: (b). Built to (a) +until ruled — (a) is a subset of (b), so (b) adds on top without +rework. + +## 7. Migration from the legacy store + +Governing rule unchanged: **the migration read writes nothing.** | Legacy store state | Rule | | --- | --- | -| Context file exists, pointer targets an existing entry | All entries FOR THE ADOPTED IDENTITY (rule below) adopted as grants; that one is active | -| Context exists, pointer dangles | Adopted-identity entries adopted as grants; NO active grant (commands needing credentials fail with a why suggesting `auth workspace use` or login) | -| Context exists, `activeWorkspaceId: null` | Adopted-identity entries adopted; no active (legacy's explicit signed-out-of-active state preserved) | -| No context, exactly one entry | Adopted as the single grant, active | -| No context, multiple entries | Adopted-identity entries adopted; NO active (no coin-flip; the user activates or logs in) | -| Entries from more than one account | One identity adopted per the rule below; the rest are ignored and reported once | -| Auth file missing / unparseable / wrong shape | No grants. Never delete, never rewrite | - -**The mixed-identity rule** (the legacy store never recorded -identity, so entries from two accounts are a real state, not a -hypothetical). Adoption is still a pure read; nothing is written: - -1. Decode each entry's access token `sub` claim. Entries that will - not decode or carry no `sub` are NOT adopted — they cannot be - accounted to an identity. (Expired tokens still decode; expiry - is irrelevant here.) -2. Choose the identity to adopt: - - The legacy context pointer targets an existing entry → that - entry's identity wins (the credential the user was actually - using — adoption never looks like a silent account switch). - - Otherwise (no context, dangling pointer, `activeWorkspaceId: - null`, multiple entries) → the identity of the LAST valid - entry in store order wins — the same last-wins tie-break the - legacy CLI itself applies. - - Neither yields a decodable identity → adopt nothing (signed - out). Never a coin flip. -3. Adopt only entries whose `sub` equals the chosen identity's. The - rest are ignored, not deleted — they stay in the legacy files - until `endSession` (materialization writes the adopted set only). -4. Identity is keyed on `sub` ALONE; `email` may be absent from - claims and never participates in the comparison. -5. When entries were dropped for belonging to a different account, - `auth workspace list` and `whoami` say so once — the count, and - that signing in as that account brings its workspaces back. - Workspaces must never silently disappear. - -The migration read writes nothing; the adopted view is materialized -into the NEW single-file format only on the first mutation -(beginSession / activateGrant / forgetGrant / endSession / refresh -rotation / rememberWorkspaceName), and materialization writes the -FULL adopted set, not only the mutated grant. A name write is a -mutation, so it materializes and permanently cuts over from the -legacy files — which is why only `login` and `workspace use` may -call `rememberWorkspaceName` (§4): a read-only listing command must -never end legacy-CLI interop as a side effect. Once the new file -exists, the legacy files are ignored entirely (a legacy CLI writing -after materialization is invisible to v8 — accepted). Until then -the legacy files stay untouched, so a still-installed legacy CLI -keeps working. `endSession` clears everything including legacy -files. New writes use mode 0600 and tighten looser existing -permissions on first write. - -## 8. Locking and atomicity contract - -- **One file** holds the whole credential state. The shape is - normative: `{ version, identity, grants: [{ workspaceId, name?, - token, refreshToken?, expiresAt?, method }], activeWorkspaceId | - null }` — identity recorded ONCE beside the grants array, which - makes the one-identity invariant structural rather than upheld by - convention. No context sidecar — the split-brain class dies by - construction. Every write replaces the whole state, so grants and - cursor can never disagree. -- **One env var** names the auth file, resolved from injected env - (§4); the legacy second variable is a warned, deprecated alias. -- **Writes are atomic**: temp file in the same directory, fsync, - rename; mode 0600; whole-state replacement only. -- **Reads never write** (migration adoption is a pure read; the - legacy read-path-write bug class must not recur). Self-cleaning on - `invalid_grant` is a STATE TRANSITION (a write path), permitted; - opportunistic caching writes on reads are not. -- **Reads take no lock** (safe via atomic rename: old or new - complete state, never partial). -- **One advisory lock, every mutation**: `beginSession`, - `endSession`, `activateGrant`, `forgetGrant`, - `rememberWorkspaceName`, and refresh (`setTokens` / - `clearTokensIfCurrent`) all serialize on the same lock file. - `withRefreshLock` is implemented (mandatory), so the SDK's refresh - runs under it. -- **Re-entrant within a process**, scoped PER LOCK FILE PER PROCESS - via an owner token SHARED between the manager and the - `TokenStorage` adapter the SDK holds (a nested acquire is a - no-op). The SDK calls `setTokens`/`clearTokensIfCurrent` from - INSIDE the `withRefreshLock` the manager supplied — per-instance - re-entrancy would deadlock the adapter against the manager. This - is also why the legacy `lockSetTokens: false` escape hatch can be - retired: correct re-entrancy makes the nested write safe without - a bypass flag. No per-call-site "don't lock" flags survive. -- **Heartbeated**: holder touches the lock every ~5s; lock file - records pid/hostname/start; steals are debug-logged with both - identities. Constant ordering is normative and COMPLETE: - heartbeat < exchange timeout < stale threshold < wait timeout, - with stale ≥ 4× heartbeat. (A live refresh can never look stale, - and a waiter must outlive the stale threshold so it is entitled - to steal a dead holder's lock before giving up — the legacy - constants had wait 25s < stale 30s, turning every crashed holder - into a contention error instead of recovery.) A test asserts the - ordering of the constants themselves. +| Context file exists, pointer targets an existing entry | All entries adopted as sessions; that one current | +| Context exists, pointer dangles | All adopted; NO current | +| Context exists, `activeWorkspaceId: null` | All adopted; no current | +| No context, exactly one entry | Adopted, current | +| No context, multiple entries | All adopted; NO current (no coin flip) | +| Auth file missing / unparseable / wrong shape | No sessions. Never delete, never rewrite | + +The rev-4 mixed-identity rule is DELETED: the wallet is +identity-blind (operator ruling), so ALL decodable entries adopt +regardless of `sub`. Entries whose token does not decode to a +`workspace_id` are ignored (they cannot be keyed). + +The adopted view materializes into the new single-file format on +the first mutation (`createSession` / `useSession` / `endSession` / +`endAllSessions` / refresh rotation), writing the FULL adopted set. +After that the legacy files are ignored entirely; until then they +stay untouched so a still-installed legacy CLI keeps working. +`endAllSessions` clears everything including legacy files. New +writes use mode 0600 and tighten looser permissions on first write. +Names carried by legacy entries adopt onto the sessions. + +## 8. Locking and atomicity + +- **One file** holds the whole state, shape normative: + `{ version, sessions: [{ workspaceId, name?, token, + refreshToken?, expiresAt? }], currentWorkspaceId | null }`. No + context sidecar. Every write replaces the whole state. +- **Writes are atomic**: temp file, fsync, rename; 0600. +- **Reads never write; reads take no lock** (atomic rename + guarantees a complete state). +- **One advisory lock, every mutation** — the four SPI mutations + plus refresh's `setTokens`/`clearTokensIfCurrent`. + `withRefreshLock` is implemented (mandatory). +- **Re-entrant per lock file per process** via an owner token + SHARED between the manager and the `TokenStorage` view the SDK + holds (the SDK calls the token writes from INSIDE + `withRefreshLock`; per-instance re-entrancy would deadlock). The + legacy `lockSetTokens: false` bypass does not survive — correct + re-entrancy makes it unnecessary. +- **Heartbeated**: holder touches the lock ~5s; pid/hostname/start + recorded; steals debug-logged. Constant ordering normative and + complete: heartbeat < exchange timeout < stale threshold < wait + timeout, stale ≥ 4× heartbeat (legacy had wait 25s < stale 30s — + a crashed holder produced contention errors instead of recovery). + A test asserts the constants' ordering. - **Every mutation re-reads under the lock** and owns only its - slice of the freshly-read state: + slice: - | Mutation | May modify in the freshly-read state | + | Mutation | May modify | | --- | --- | - | `setTokens` (rotation) | `token`/`refreshToken`/`expiresAt` of its own grant | - | `clearTokensIfCurrent` | removes its own grant; clears cursor only if it names that grant | - | `activateGrant` | cursor only | - | `forgetGrant` | one grant; cursor if it named that grant | - | `rememberWorkspaceName` | `name` of one grant | - | `beginSession` | whole state, by definition | - | `endSession` | whole state | - - Standing rules: no mutation may write state it read BEFORE - acquiring the lock, and no mutation carrying an identity - assumption (`setTokens`, `clearTokensIfCurrent`) may write at all - if the fresh state's identity is not the one it assumed. Refresh - compares against the credential that failed (the SDK does this - given the lock); `clearTokensIfCurrent` clears only on exact - match over the three compared fields (§6). -- **Rotation durability**: the rotated pair is persisted (fsync + - rename) before the new access token is handed to any caller. The - unclosable client-side window (process killed between server - rotation and rename) is accepted; recovery is `prisma auth login`. - -## 9. Change surface on PR #130 (checklist) - -Engine (`packages/cli-engine`): Runtime staged swap (§5); -`ctx.session`; delete `ctx.getCredentials`; `managesCredentials` -capability; api-client.ts reduced to lazy proxy + §6 error mapping -(delete SDK construction + placeholder constants); draft amendments -(§4/§6/§10/§11); harness seeding + fixture surface; type-tests. + | `setTokens` (rotation) | `token`/`refreshToken`/`expiresAt` of its own record | + | `clearTokensIfCurrent` | removes its own record; current marker only if it names it | + | `useSession` | current marker only | + | `endSession` | one record; current marker if it named it | + | `createSession` | one record (upsert) + current marker | + | `endAllSessions` | whole state | + + No mutation writes state read before lock acquisition. +- **Rotation durability**: rotated pair persisted (fsync + rename) + before the new access token reaches any caller. + +## 9. Change surface on PR #130 + +Engine (`packages/cli-engine`): rename/reshape pass over the landed +engine-surface commit (a8ef3fb): the rev-4 entity trinity +(Session-as-read/Credential/GrantSummary/Identity union + method +axis) becomes §2's `Session`/`Credential`; the manager interface +becomes §3's six methods (`apiClient` and `rememberWorkspaceName` +deleted from the SPI); engine-side client construction returns +(with injected config); §6 error mapping kept; harness seeding +reshaped; draft amendments updated. Auth module (`packages/cli/src/auth`): the manager implementation -(persistence per §8, migration per §7, SDK construction with the -real CLIENT_ID, refresh integration); `performLogin` returns the -credential; the workspace OPERATIONS (`listAuthWorkspaces`, -`switchAuthWorkspace`, `logoutAuthWorkspace`) REMAIN — the legacy -shell consumes them until S2d; only their v8 exposure goes. - -v8 tree (`packages/cli/src/v8`): REWORK (not delete) the workspace -commands onto the manager: `workspace-list.ts` presents `grants()` -(help text says held grants, not memberships); `workspace-use.ts` -tries `activateGrant(ref)` and on grant-not-held runs the consent -flow then `beginSession`; `workspace-logout.ts` becomes -`workspace-forget.ts` (`auth workspace forget`, `forgetGrant`); -`logout.ts` drops `--workspace` (superseded). `login`/`logout` move -onto `ctx.credentialManager`; `whoami` onto `ctx.session()` + -`ctx.api` enrichment (and may show held-grant count). Runtime wiring -supplies the manager. The legacy operations in `src/auth` still -serve the legacy shell until S2d. - -Docs: rewrite (not append) the auth sections of -`assets/s2/parity-divergences.md` (subgroup reworked and renamed — -`workspace logout` → `workspace forget` is itself a divergence; -whoami json shape change — the legacy `provider` field has no -successor, held-grant count added; orphan-reaping logout; error-code -notes; commands that NEWLY fail under a set `PRISMA_SERVICE_TOKEN` -get their own entries — `auth logout` in a CI teardown and -`auth workspace forget` after a revoked grant used to succeed and -now exit non-zero); amend `specs/s2a-foundations.md` -§3/§4/acceptance (six commands, manager exports, erratum note); S2 +(§7 migration, §8 locking, TokenStorage seam, name fetch in +`createSession`); `performLogin` returns the credential. Legacy +operations remain for the legacy shell until S2d. + +v8 tree (`packages/cli/src/v8`): auth family onto the manager with +LEGACY names (`workspace-logout.ts` stays; no forget). `logout +--workspace`: does not return (superseded by `workspace logout`). + +Docs: parity-divergences auth sections rewritten AGAIN — now +smaller (rename class gone; remaining divergences: error-code map, +exit unifications, whoami shape, env-override mutation refusals, +orphan-reaping logout); s2a contract §3/§4/acceptance amended; S2 overview auth rows. -## 10. Review disposition record - -Rev 2: both reviews accept-with-changes; all recommendations adopted -(operator, 2026-08-10). Open ends resolved: begin/end verbs kept; -`ctx.credentialManager` kept as the context key with `managesCredentials` -as the declaration; `session()` on every context (local-only); -identity carries claim fields only; `beginSession(credential)` -single-argument; locking per §8. Naming: provenance field `origin`; -entity name Session kept with the principal-correspondence note. - -Rev 3 (grants model, operator-ruled): user-facing workspace -functionality kept; plurality contained in the manager; engine -consumer view stays scalar; vocabulary shifts from per-workspace -sessions to grants (list / use / forget). The product-team framing -updates accordingly: functionality kept, ontology fixed, the -multi-entry consistency surface now properly owned by one modeled -component. - -Rev 4 (delta re-review folded, 2026-08-10): architect delta and PE -delta both accept-with-changes at the grants model; all findings -adopted. PE blockers: `setTokens` rotation-write semantics (§6), -the mixed-identity migration rule (§7), per-workspace-id API-client -caching with cursor-move invalidation (§4). PE should-fixes: -grant-bound view binds a workspace id not a snapshot; exact-match -defined over the SDK's three compared fields; slice-ownership -mutation table + identity guard; env-override rule split -(`beginSession` allowed with notice — CHANGES the rev-3 uniform -refusal, operator veto point; `rememberWorkspaceName` exempt); -grant-removed-mid-flight discrimination by state re-read, never -message parsing; complete lock-constant ordering including the wait -timeout; re-entrancy per lock file per process with a shared owner -token; the env-override listing rule made normative; new-failure -divergence entries. Testability list expanded (§5). Operator veto -points at this revision: the env-override split above, and -`workspace use` announcing the browser-open in one line without a -confirm. +## 10. Disposition record + +Rev 5 (2026-08-10): operator-designed session model replaces the +grants model. Reversals, all operator-ruled: per-workspace session +vocabulary is CORRECT (a session per workspace, keyed by workspace +id) — the rev-2..4 "no per-workspace sessions" stance is dead; the +one-identity invariant is dropped (wallet is identity-blind, like +legacy); `workspace forget` rename dead, legacy command names +return; grants/GrantSummary/Identity-union deleted; `apiClient()` +and `rememberWorkspaceName` deleted from the SPI (engine constructs +the client with injected config; workspace names fetched once in +`createSession`). Carried from rev 4 unchanged: refresh/rotation +rules, failure discrimination, migration read-writes-nothing, +locking contract, fixture/test list, env-override split +(`createSession` allowed with notice). Open: §6a `use X` not-held +behavior (built to (a), recommendation (b)). From 015ae55c13003d9c6a4e020858c86da010c63cba Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 16:40:17 +0200 Subject: [PATCH 38/67] =?UTF-8?q?drive(prisma-cli-v8):=20rev=205=20final?= =?UTF-8?q?=20=E2=80=94=20session=20model=20with=20process=20pinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the delta review (architect + PE) and the operator's process-pinning concurrency ruling: a process's session is fixed at first read; racing refreshes are accepted (server-verified 10s reuse grace, sibling pairs stay valid); the heartbeat/epoch/cache- invalidation machinery is deleted. One client per process, short RMW-only file lock, no network under the lock. Cross-account disclosure rules not adopted. workspace use selects only. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 645 +++++++++++------- 1 file changed, 384 insertions(+), 261 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index 8f9bfafb..db4deeb5 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -1,11 +1,11 @@ -# Credential manager — design, revision 5 (normative) +# Credential manager — design, revision 5 (normative, final) -Status: operator-designed session model (2026-08-10), replacing the -rev-3/4 grants model wholesale. Revisions 1–4 and their review folds -are in git history; rev 4's REFRESH, MIGRATION, and LOCKING mechanics -carry forward with vocabulary mapped (grant → session) — those rules -were always about tokens. NORMATIVE for the implementation, which -lands on PR #130. A delta review (architect + PE) covers rev4→rev5. +Status: operator-designed session model (2026-08-10). Rev 5 replaced +the rev-3/4 grants model; this final text folds the delta review +(architect + PE) AND the operator's process-pinning concurrency +ruling, which deletes most of the reviewed locking machinery — §10 +records what was adopted and what that ruling made moot. Revisions +1–4 are in git history. NORMATIVE for the implementation on PR #130. ## 1. The reality this models @@ -16,22 +16,27 @@ Validated against pdp-control-plane source (2026-08-10): picks the workspace on the consent screen; the CLI CANNOT request or pin a workspace — the authorize request carries no workspace parameter (`AuthorizeSearchSchema`). The CLI learns which - workspace it got by decoding the returned token's `workspace_id` - claim. Refresh cannot re-scope. + workspace it got by decoding the token's `workspace_id` claim. + Refresh cannot re-scope. +- Refresh tokens are single-use WITH a 10-second reuse grace + (`StaticClientOAuthProvider`: rotation marks the token used; one + replay within 10s succeeds and issues its own pair; later replays + are `invalid_grant`). Rotation does not revoke sibling pairs — + any successfully issued pair remains valid on its own. Racing + refreshes are therefore SERVER-ABSORBED: whichever write lands + last, the file holds a working pair. Client-side coordination + beyond in-process dedup is unnecessary. - `PRISMA_SERVICE_TOKEN` supplies a workspace-scoped bearer token from the environment. No refresh, never stored. -- Tokens carry identity claims (`sub`: a user for login tokens, a - workspace for service tokens) but the CLI's stored state records - and enforces NO identity. Identity surfaces only when `whoami` - decodes the current session's claims. A wallet MAY hold sessions - created by different accounts; the system does not care (operator - ruling — the rev-3/4 one-identity invariant is DROPPED). +- Tokens carry identity claims (`sub`) but the stored state records + and enforces NO identity (operator ruling). A wallet MAY hold + sessions created by different accounts; identity surfaces only as + a read-time claim decode (`whoami`). - Legacy state: a JSON file of `{workspaceId, accessToken, - refreshToken}` entries plus a separate context file holding the - active workspace id. §7 migrates it. + refreshToken}` entries plus a context sidecar holding the active + workspace id. §7 migrates it. -There are no grants and no separate credential registry concept. -The domain is: a set of per-workspace sessions, one current. +The domain: a set of per-workspace sessions, one current. ## 2. Entities @@ -44,261 +49,340 @@ interface Credential { readonly expiresAt: Date | undefined; } -interface Workspace { - readonly id: string; - readonly name: string | undefined; -} - /** "Logged-in-edness", scoped to a workspace. Identified to users * by its workspace. The token is INTERNAL: it lives in the stored - * record but is structurally absent from this public shape. */ + * record, never on this public shape. */ interface Session { readonly workspaceId: string; - readonly workspace: Workspace | undefined; // loaded on session create (§4) + readonly workspaceName: string | undefined; // fetched once at creation (§3) readonly expiresAt: Date | undefined; readonly source: "stored" | "environment"; - readonly active: boolean; + readonly current: boolean; } ``` - Sessions are KEYED BY WORKSPACE ID: at most one session per workspace. Logging in to the same workspace again upserts the - stored record (same key, new credential). Creating sessions for - several workspaces with the same account is fine — one record per - workspace, whatever credential is inside. -- `whoami`'s identity display comes from decoding the CURRENT - session's token claims at read time (user id/email for login - tokens, workspace for service tokens). Identity is a per-session - decoded fact, not system state. + record (same key, new credential) — whichever account minted it; + the store cannot hold two credentials for one workspace and does + not try (accepted, matches legacy). A credential backs at most + one session (never store one refresh token under two keys). +- The marker is called CURRENT everywhere (state field + `currentWorkspaceId`, list flag `current`, read + `currentSession()`). - `source: "environment"` marks the ephemeral session composed from `PRISMA_SERVICE_TOKEN` (§6). It never appears in `sessions()`. +- `whoami` decodes the current session's claims at read time. ## 3. The CredentialManager interface (the SPI) -Manages sessions. Nothing else. +Manages sessions: six user-facing operations plus one engine-facing +accessor (flagged §10; it exposes a capability the SDK consumes, +never token material to engine code). ```ts interface CredentialManager { - /** The session the engine is acting as RIGHT NOW — the one the - * management API authenticates with. Env token wins over the - * stored current (§6). Local-only: decodes claims, never - * touches the network. */ + /** The session this PROCESS is acting as. Pinned at first read + * (§4): composed from the env token if set, else the file's + * current marker at that moment; later marker changes by other + * processes do not move it. This process's own mutations + * (createSession/useSession/endSession/endAllSessions) DO + * update it. Local-only: never touches the network. */ currentSession(): Promise; - /** The available sessions (auth workspace list). Local-only. */ + /** The available sessions (auth workspace list), read fresh from + * the file. Local-only. Under an env override the file's + * current marker is still shown as `current` and the listing + * command states the env session is what is in force. */ sessions(): Promise; /** Login's write. The caller names the workspace that identifies - * the session (for a workspace-bound credential the manager + * the session; for workspace-bound credentials the manager * verifies the workspace_id claim matches and refuses on - * mismatch; for a multi-workspace credential the argument IS - * the choice). Upserts by workspaceId, becomes current. Fetches - * the workspace name via the management API once, best-effort - * (login is already online; failure leaves name undefined — - * never fails the login). */ + * mismatch (a future multi-workspace credential makes the + * argument a real choice). Upserts by workspaceId, sets the + * file marker, becomes this process's current. The workspace + * name is fetched best-effort AFTER the write, outside the lock + * (§8), via the injected lookup — failure leaves it undefined, + * never fails login. */ createSession(credential: Credential, workspaceId: string): Promise; - /** Switch the current session. */ + /** Switch: sets the file's current marker AND this process's + * pinned session. */ useSession(session: Session): Promise; /** Log out of one workspace: remove that session. If it was - * current, there is no current (no auto-promotion). */ + * current (file marker or this process's pin), that current is + * cleared (no auto-promotion). */ endSession(session: Session): Promise; - /** Log out entirely: remove all sessions and the current marker - * (also reaps legacy files, §7). */ + /** Log out entirely: remove all sessions and the marker (also + * reaps legacy files, §7). Reports how many it ended. */ endAllSessions(): Promise; + + /** ENGINE-FACING, not a user operation: the SDK TokenStorage + * view for one workspace's session. The engine forwards it into + * SDK client config and never calls its methods itself. */ + tokenStorage(workspaceId: string): TokenStorage; } ``` Interface rules: -- The manager never talks to the user (no prompts, no browser). - The login FLOW lives beside it; `performLogin` returns the minted - credential and the command calls `createSession`. -- The manager resolves NO user input. Commands resolve what the - user typed against `sessions()` themselves (exact id, then - case-insensitive name; ambiguity is the command's error) and pass - the matched `Session`. Manager errors are real state errors only — - e.g. the passed session no longer exists (another process ended - it) → structured error, nothing guessed. -- `useSession`/`endSession` identify the target by its - `workspaceId` against freshly-read state under the lock (§8) — - the Session object is a reference, not a snapshot to trust. -- Env is a construction input (injected `env`, never `process.env` - below the manager). Exactly one env var names the state file; the - legacy second variable is a deprecated, warned alias. +- The manager never talks to the user and never opens a browser. + `performLogin` returns the minted credential; the login command + calls `createSession`. The login flow's own SDK instance uses a + THROWAWAY in-memory TokenStorage (the SDK persists tokens through + its storage at callback time; that write must never reach the + manager — minting and custody stay separate). The manager's + storage is reachable only through `tokenStorage()`. +- Construction dependencies (injected by the bin): `env` (no + library below the manager reads `process.env`; one env var names + the state file, the legacy second variable is a warned alias) and + `fetchWorkspaceName(credential, workspaceId)` (the manager + constructs no API client). +- The manager resolves NO user input. Commands resolve refs against + `sessions()` (exact id, then case-insensitive name; ambiguity is + the command's error) and pass the matched Session. +- `useSession`/`endSession` treat the argument as a WORKSPACE + reference: only `workspaceId` is read, re-validated against + freshly-read state under the lock. If another process replaced + that workspace's session in between, the operation applies to the + replacement (the intent — switch to or log out of the workspace — + is workspace-keyed). No session for that workspace → structured + error. Passing a `source: "environment"` session is a misuse → + the same error. `useSession` on the already-current session + succeeds and changes nothing. - Error single-sourcing: blank/whitespace service token → one structured error raised identically from `currentSession()`, the - needs check, and the engine's token resolution; unreadable file → + needs check, and the engine's request path; unreadable file → `CLI.CREDENTIALS_UNREADABLE`; parse-corrupt file → signed out (self-heals on next login), never an exception, never a write. Mutations under an env override (`PRISMA_SERVICE_TOKEN` set): -`useSession`, `endSession`, `endAllSessions` refuse with one -structured error family (why names the env var and whether stored -sessions exist; nextAction is the literal `unset` command). -`createSession` is ALLOWED with a mandatory one-line notice that the -env token remains in force until unset. Reads work normally, and -`auth workspace list` states that the env session is what is in -force (normative). - -## 4. Engine integration - +- `useSession`, `endSession` refuse with one structured error + family (why names the env var and whether stored sessions exist; + nextAction is the literal `unset` command). +- `endAllSessions` refuses when stored sessions exist and SUCCEEDS + AS A NO-OP when there are none (CI teardowns running `prisma + auth logout` with only the env token must not fail). Accepted, + stated: while the var is set, existing stored state cannot be + cleared. +- `createSession` is ALLOWED with a mandatory one-line notice that + the env token remains in force until unset. +- Reads work normally. + +## 4. Process pinning and engine integration + +**Process pinning (operator ruling).** A CLI process determines its +session ONCE: env token if set, else the file's current marker at +first read. That session is the process's identity for its entire +lifetime — another process switching the marker or replacing +records does NOT redirect a running process; new processes pick up +the new marker. The process's own auth mutations are the only thing +that move its pin. Consequences, normative: +- ONE stored-session API client per process, built lazily for the + pinned session and memoized for the run (no per-access + re-resolution, no cache invalidation machinery, no + "session-replaced" errors). The SDK's per-client refresh + single-flight therefore IS the per-process refresh dedup. +- Refresh writes are keyed by the pinned session's workspace id + ("by session identity"). Cross-process refresh races on the same + session need no client-side coordination (§1: server grace + + sibling-pair validity make either winner fine). The SDK's + compare-and-clear handles the stale-replay case benignly. +- A process whose pinned session is ended by another process + mid-run fails at its next request with the session-ended wording + (§6) — the honest outcome; nothing tries to re-pin. + +Engine integration: - `Runtime.credentialManager: CredentialManager` replaces - `Runtime.getCredentials`, staged as before (optional first, then - needs/api rework, then `getCredentials` deletion). + `Runtime.getCredentials`, staged as before. The bin also injects + the CLIENT CONFIG: `{clientId, redirectUri, apiBaseUrl, + authBaseUrl}` — all four (the SDK's refreshing fetch requires the + full config even though only login paths read redirectUri). The + same config feeds `performLogin`. The engine's placeholder + constants stay deleted; the construction test seam RETURNS via + this injected config (harness points it at a local server). - `ctx.session(): Promise` on EVERY context — - read-only, local-only (tested: no network I/O). It serves - `currentSession()`. -- `managesCredentials: true` capability puts `ctx.credentialManager` - on the context for exactly: `auth login`, `auth logout`, - `auth workspace list`, `auth workspace use`, - `auth workspace logout`. `whoami` uses `ctx.session()` only. + read-only, local-only (tested: no network I/O). Serves + `currentSession()` (the pin). +- `managesCredentials: true` puts `ctx.credentialManager` on the + context for exactly: `auth login`, `auth logout`, `auth workspace + list`, `auth workspace use`, `auth workspace logout`. `whoami` + uses `ctx.session()` only. - **The ENGINE constructs and owns the management API client** - (`ctx.api`), as it did in S2a. Construction config (real OAuth - client id, base URL) is injected by the bin beside the manager — - the engine's placeholder constants die. The client is cached per - workspace id and rebuilt when the current session changes (a - command that switches and then touches `ctx.api` must get a - client for the NEW session). Refresh single-flight is per client, - which is correct because refresh is per session. -- **The manager's one internal seam**: it implements the SDK's - `TokenStorage` contract (`getTokens` / `setTokens` / - `clearTokensIfCurrent` / `withRefreshLock`) so the SDK's 401 → - refresh → retry cycle reads and writes the session store under - the manager's rules (§6). The storage view handed to a client is - bound to THAT client's session's workspace id — bound to the ID, - never to a credential snapshot: `getTokens` re-reads the store on - every call. This seam is engine↔manager plumbing; it is not part - of the user-facing SPI and token material never crosses the - public interface. -- Harness: `createTestCli` seeds `{ sessions?: [...], currentWorkspaceId?, - credential? }` over a mutable in-memory manager with full state - read-back. Fixture surface and required tests: §5. + (`ctx.api`): the pinned session's client, once per process. + Stored session → the SDK's refreshing path with + `tokenStorage(workspaceId)` in the config. Env session → the + SDK's static-token path (`createManagementApiClient({baseUrl, + token})`) — no refresh machinery may exist for it; its error + mapping happens at the call site (the static path has no error + middleware). The auth commands that mutate state don't consume + `ctx.api` as the pinned session afterwards (whoami enrichment + runs in a fresh process). +- **The TokenStorage view**: bound to the workspace id, never to a + credential snapshot — `getTokens` re-reads the file on every call + and returns that workspace's current record. Write rules in §6. + All SDK methods including the required `clearTokens` are + implemented; the engine forwards the view and never calls it. +- Error unwrapping: the SDK's error middleware wraps non-SDK errors + into `FetchError(cause)`; the engine's mapping walks the cause + chain for BOTH `AuthError` and CLI structured errors, so + manager-raised errors surface as themselves. +- Names never refresh (accepted, stated): reads are offline, so a + renamed workspace keeps its stored name until the next login to + it. `list` renders a nameless session by its id. +- Harness: `createTestCli` seeds `{sessions?, currentWorkspaceId?, + credential?}` over a mutable in-memory manager with full state + read-back, plus the client config (local endpoint). ## 5. Fixtures and required tests -Fixture surface: injectable token endpoint (script 401 → rotated -pair → retry; `invalid_grant`; 5xx/network-throw), a JWT minter -(`sub`, `workspace_id`, `exp`, `email` + an undecodable token), a +Fixture surface: client config injection (all four fields) pointed +at a local HTTP server scripting 401 → rotated pair → retry, +`invalid_grant`, 5xx/network-throw; a JWT minter (`sub`, +`workspace_id`, `exp`, `email` + an undecodable token); a legacy-store builder (pointer valid/dangling/null/absent; one/many -entries; corrupt context; wrong shape), a deterministic clock, an -interleaving hook (pause between read-under-lock and write), and a -way for a real second process to hold the lock. +entries; duplicate entries for one workspace; placeholder names; +entries without refresh tokens; corrupt context; wrong shape); a +deterministic clock; a way for a second process to hold the lock. Required tests: -- multi-process races (real filesystem): `useSession` vs rotation - (final state = the switch + the rotated tokens); `endSession` vs - rotation (no resurrection); two refreshers (exactly one exchange); +- process pinning: marker moved by a second process mid-run → the + running process's requests still carry its pinned session's + tokens; a NEW process picks up the new marker; +- pinned session ended by a second process → next request fails + with session-ended wording (not the SDK's synthesized message, + not the transient error); +- two refreshers on one session across two processes: both + complete, the file ends with a valid pair (server-grace test via + the scripted endpoint); +- refresh rotation: only token fields written; name and marker + untouched; `expiresAt` re-derived; rotated pair persisted before + the new access token reaches the caller; +- `endSession` vs in-flight rotation on the same session: the + ended session stays gone (rotation must not resurrect it); +- login flow writes nothing through the manager (throwaway storage + observed; manager file untouched until `createSession`); +- `createSession` holds no lock during the name fetch (a second + process completes a mutation while the name request hangs); +- `createSession` claim/argument mismatch refusal; +- env session never refreshes (token endpoint not hit on 401); - env-override matrix: every mutation × {unset, set, blank, - whitespace} — error family asserted, state file bytes unchanged; + whitespace} — error family asserted, state-file bytes unchanged; + plus `endAllSessions` no-op success with zero stored sessions; - end-current / sessions-held-none-current: one shared assertion - over `ctx.session()`, the needs check, and a bare `ctx.api` touch; -- workspace-name persistence across refresh (the legacy regression: - rotation must not touch `workspace.name`), with `expiresAt` - re-derived; + over `ctx.session()`, the needs check, and a bare `ctx.api` + touch; - reads-never-write probe (filesystem spy: zero writes on every read path including migration adoption); - token-material leak scan (seed a known secret; assert absent from stdout, stderr, debug logs, error meta, envelopes); -- lock-constant ordering (§8); -- `createSession` claim/argument mismatch refusal. +- lock: two concurrent mutations in different processes both land + (no lost update); a crashed holder's lock is taken over after + the stale threshold. ## 6. Runtime flows (normative) -**Unauthenticated.** `needs.credentials` → `CLI.CREDENTIALS_REQUIRED` -(exit 2, sign-in nextAction) before the handler loads; bare -`ctx.api` touch → same error at request time. `whoami` → "signed -out", exit 0. No auto-login. +**Unauthenticated.** `needs.credentials` → +`CLI.CREDENTIALS_REQUIRED` (exit 2, sign-in nextAction) before the +handler loads; bare `ctx.api` touch → same error at request time. +`whoami` → "signed out", exit 0. No auto-login. **Sessions held, none current** (migration rows; end-current): same code, distinct why ("you have workspace sessions but none is current") with nextActions `auth workspace use` and login. -**Refresh.** Driven by the SDK on 401 through the manager's -`TokenStorage` view (§4), under the mandatory lock (§8): -- `setTokens` (the rotation write — the write that runs on every - successful refresh): updates IN PLACE only `token`, +**Refresh.** Driven by the SDK on 401 through the bound +TokenStorage view; the exchange itself runs OUTSIDE the file lock +(§8) — only the resulting write takes it: +- `setTokens` (the rotation write): updates IN PLACE only `token`, `refreshToken`, `expiresAt` (re-derived from claims; the SDK's - pair carries no expiry) of its own session record. NEVER creates - a session, NEVER moves the current marker, NEVER touches - `workspace.name`. If the freshly-read state has no record for - that workspace id (another process ended it), refuse and throw — - no resurrection. If the new token's `workspace_id` claim - disagrees with the bound id, refuse (refresh cannot re-scope). -- `clearTokensIfCurrent`: remove the session iff its stored - credential still exactly matches the pair that failed — "exactly" - over the SDK's three compared fields (`workspaceId`, - `accessToken`, `refreshToken`) only, so a re-derived expiry can't - defeat the match. Clear the current marker only if it names that - session. (The SDK comparing ACCESS tokens too is desired: a pair - another process already rotated correctly declines to clear.) -- Preemptive refresh is PROHIBITED (outside the SDK's single-flight - it can spend a one-time refresh token and convert an optimization - into a false sign-out). + pair carries no expiry) of its workspace's record. NEVER creates + a record, NEVER moves the marker, NEVER touches the name. If the + freshly-read state has no record for that workspace (ended by + another process), refuse and throw — no resurrection. If the new + token's `workspace_id` claim disagrees with the bound id, refuse + (refresh cannot re-scope). If the record's credential changed + since the refresh started (a newer login), the write still lands + — either pair is valid (§1); last write wins. +- `clearTokensIfCurrent`: remove the record iff its stored pair + still exactly matches the pair that failed — exact over the + SDK's three compared fields (`workspaceId`, `accessToken`, + `refreshToken`). Clear the marker only if it names that record. + This match is what makes a stale replay's `invalid_grant` benign + when a newer pair is already stored — do not "simplify" it. +- `clearTokens` (required by the SDK's TokenStorage type; reached + by its internal fallbacks): removes only the bound record — same + slice as `clearTokensIfCurrent` without the match. It never + means "end all sessions". The engine never calls `sdk.logout()`. +- `withRefreshLock` is implemented as IN-PROCESS single-flight + only (the SDK requires the hook to lock at all; cross-process + exchange races are server-absorbed, §1). +- Preemptive refresh stays PROHIBITED (per-request resolution + keeps long runs current; a background refresher adds nothing). **Refresh failure discrimination.** `AuthError.refreshTokenInvalid` -is `true` only for HTTP 4xx + body error exactly `invalid_grant` — -the definitive sign-out trigger: -- `true` → `CLI.CREDENTIALS_REQUIRED`, expiry wording. The SDK has - already cleared; the manager debug-logs endpoint status + error - value BEFORE the clear. -- any other `AuthError` → the manager re-reads its state: session - gone → `CLI.CREDENTIALS_REQUIRED` with session-ended wording; - otherwise a transient auth-service error. A state check, never - message parsing. -- non-auth failures (network, 5xx) → transient auth-service error; - NOTHING cleared. +is `true` only for HTTP 4xx + body error exactly `invalid_grant`: +- `true` → the SDK has run compare-and-clear; if the session + survived (newer pair stored), the retry proceeds — nothing + surfaced. If it cleared, `CLI.CREDENTIALS_REQUIRED`, expiry + wording; the manager debug-logs endpoint status + error value + BEFORE the clear. +- any other `AuthError` → the manager re-reads state FOR THE + WORKSPACE THE CLIENT IS BOUND TO: record gone → + `CLI.CREDENTIALS_REQUIRED`, session-ended wording; otherwise a + transient auth-service error. A state check, never message + parsing. +- any non-`AuthError` from the refresh path (e.g. the SDK's + undecodable-token plain `Error`) → transient auth-service error; + nothing cleared; debug valve records it. +- non-auth failures (network, 5xx) → transient; NOTHING cleared. The SDK version is exact-pinned; a test asserts clearing happens on `invalid_grant` and nothing else. -**Service token (env).** Composes as an ephemeral current session -(`source: "environment"`), never stored, absent from `sessions()`. -No refresh; 401 → structured error naming the env var; nothing -cleared. `whoami` notes the override when stored sessions also -exist. Blank/whitespace → the single blank-token error (§3). - -**Lock contention.** A refresh-lock wait timeout is its own -structured code with a why naming the lock path and a next action. +**Service token (env).** Composes as the process's pinned session +(`source: "environment"`), never stored, absent from `sessions()` +(the file's marked current stays shown; the listing states the +override). Static-token client, no refresh; 401 → structured error +naming the env var; nothing cleared. `whoami` notes the override +when stored sessions exist. Blank/whitespace → the single +blank-token error. **Debug valve.** `PRISMA_NEXT_DEBUG` shape: source won, resolved -state-file path, refresh attempted, endpoint status + error field, -lock acquire/release/steal with holder ids. Token material NEVER +state-file path, pin decision, refresh attempted, endpoint status + +error field, lock acquire/release/takeover. Token material NEVER appears in any log, error, meta, or envelope. ## 6a. The commands -Legacy names return unchanged — the session model makes them honest +Legacy names, unchanged — the session model makes them honest ("log in to a workspace" = create a session for it): -- `auth login` — browser consent flow; the user picks the - workspace; `createSession(credential, workspaceId-from-claims)`. -- `auth logout` — `endAllSessions()`. +- `auth login` — browser consent; user picks the workspace; + `createSession(credential, workspaceId-from-claims)`. +- `auth logout` — `endAllSessions()`; reports the count ended. - `auth whoami` — `ctx.session()` + claims decode; `ctx.api` enrichment when online. -- `auth workspace list` — `sessions()`, current marked. -- `auth workspace use ` — resolve ref against `sessions()` +- `auth workspace list` — `sessions()`, current marked, nameless + rows rendered by id. Under an env override the listing states + the env session is in force. +- `auth workspace use ` — resolve against `sessions()` (command-side), `useSession(match)`. -- `auth workspace logout ` — resolve, `endSession(match)`. - (The rev-3 `workspace forget` rename is DEAD; legacy vocabulary - stands. The rev-3/4 grants vocabulary is dead everywhere.) - -**OPEN OPERATOR RULING — `use X` with no session for X.** The -consent flow cannot target a workspace (§1), so "use acquires X" is -unimplementable as promised in rev 3/4. Options: -(a) legacy-parity error: "no session for X — run `prisma auth -login` and pick X in the browser" (no browser launch from `use`); -(b) `use X` announces it is opening the browser, runs the generic -flow, then compares the returned `workspace_id` to X: match → -create + current; mismatch → create the session it actually got -(real consent, not wasted), leave the current marker unchanged, and -say "you logged in to Y, not X". Recommendation: (b). Built to (a) -until ruled — (a) is a subset of (b), so (b) adds on top without -rework. +- `auth workspace logout ` — resolve, `endSession(match)`; + prints the workspace it ended. + +**RULED (operator, 2026-08-10): `workspace use` SELECTS among your +sessions; it never creates one.** No session for X → structured +error: "no session for workspace X — run `prisma auth login` and +pick X in the browser" (nextAction: the literal `prisma auth +login`). No browser ever opens from `use`; session creation belongs +to `auth login` alone. (Matches §1: the consent flow cannot target +a workspace. Both reviewers independently concurred.) ## 7. Migration from the legacy store -Governing rule unchanged: **the migration read writes nothing.** +Governing rule: **the migration read writes nothing.** | Legacy store state | Rule | | --- | --- | @@ -309,99 +393,138 @@ Governing rule unchanged: **the migration read writes nothing.** | No context, multiple entries | All adopted; NO current (no coin flip) | | Auth file missing / unparseable / wrong shape | No sessions. Never delete, never rewrite | -The rev-4 mixed-identity rule is DELETED: the wallet is -identity-blind (operator ruling), so ALL decodable entries adopt -regardless of `sub`. Entries whose token does not decode to a -`workspace_id` are ignored (they cannot be keyed). - -The adopted view materializes into the new single-file format on -the first mutation (`createSession` / `useSession` / `endSession` / -`endAllSessions` / refresh rotation), writing the FULL adopted set. -After that the legacy files are ignored entirely; until then they -stay untouched so a still-installed legacy CLI keeps working. +Adoption rules (identity-blind — entries from any account adopt): +- Key and pointer-resolve on the token's `workspace_id` claim (the + legacy `credentialWorkspaceId`), not the hydrated display id. +- Entries whose token does not decode to a `workspace_id` are + ignored (unkeyable). +- Duplicate legacy entries for one workspace: the LAST wins + (matches legacy's latest-wins reads). +- Legacy placeholder names do not adopt: a name equal to + "Unknown workspace" or to the workspace id adopts as undefined. +- Entries without a refresh token adopt (reads until expiry, then + fail cleanly). +- `lastSeenAt` does not carry over; list order is store order. + +Materialization: the adopted view is written into the new +single-file format on the first mutation, writing the FULL adopted +set. The adoption decision is re-made INSIDE the lock beside the +mutation's re-read: if a new-format file exists at that point it +wins outright and no adoption occurs (a naive full-set write could +resurrect tokens another process already rotated). After +materialization the legacy files are ignored entirely; until then +they stay untouched so a still-installed legacy CLI keeps working. `endAllSessions` clears everything including legacy files. New writes use mode 0600 and tighten looser permissions on first write. -Names carried by legacy entries adopt onto the sessions. -## 8. Locking and atomicity +## 8. File, lock, and atomicity -- **One file** holds the whole state, shape normative: - `{ version, sessions: [{ workspaceId, name?, token, - refreshToken?, expiresAt? }], currentWorkspaceId | null }`. No - context sidecar. Every write replaces the whole state. -- **Writes are atomic**: temp file, fsync, rename; 0600. +- **One file**, shape normative: `{ version, sessions: [{ + workspaceId, name?, token, refreshToken?, expiresAt? }], + currentWorkspaceId | null }`. No context sidecar. Every write + replaces the whole state. +- **Writes are atomic**: temp file in the same directory, fsync, + rename; mode 0600. - **Reads never write; reads take no lock** (atomic rename guarantees a complete state). -- **One advisory lock, every mutation** — the four SPI mutations - plus refresh's `setTokens`/`clearTokensIfCurrent`. - `withRefreshLock` is implemented (mandatory). -- **Re-entrant per lock file per process** via an owner token - SHARED between the manager and the `TokenStorage` view the SDK - holds (the SDK calls the token writes from INSIDE - `withRefreshLock`; per-instance re-entrancy would deadlock). The - legacy `lockSetTokens: false` bypass does not survive — correct - re-entrancy makes it unnecessary. -- **Heartbeated**: holder touches the lock ~5s; pid/hostname/start - recorded; steals debug-logged. Constant ordering normative and - complete: heartbeat < exchange timeout < stale threshold < wait - timeout, stale ≥ 4× heartbeat (legacy had wait 25s < stale 30s — - a crashed holder produced contention errors instead of recovery). - A test asserts the constants' ordering. -- **Every mutation re-reads under the lock** and owns only its - slice: +- **One short advisory lock for read-modify-write.** Every + mutation acquires it, re-reads, applies its slice, writes, + releases. Its ONLY job is lost-update prevention between + processes (two mutations touching different records must both + land). **No network I/O ever runs under it** — the token + exchange happens outside (§6), and `createSession`'s name fetch + happens after release, with a second minimal locked write that + sets `name` iff the record still exists. Holds are + milliseconds, so: no heartbeat, a small fixed stale threshold + (crashed-holder takeover), takeovers debug-logged. The rev-4 + heartbeat/exchange-timeout/steal apparatus existed to survive + network calls under the lock; with none, it is deleted. +- **Slices** (each mutation re-reads under the lock and modifies + only): | Mutation | May modify | | --- | --- | - | `setTokens` (rotation) | `token`/`refreshToken`/`expiresAt` of its own record | - | `clearTokensIfCurrent` | removes its own record; current marker only if it names it | - | `useSession` | current marker only | - | `endSession` | one record; current marker if it named it | - | `createSession` | one record (upsert) + current marker | + | `setTokens` (rotation) | token fields of its workspace's record | + | `clearTokensIfCurrent` | removes its record (three-field match); marker only if it names it | + | `clearTokens` | removes its record; marker only if it names it | + | `useSession` | marker only | + | `endSession` | one record; marker if it named it | + | `createSession` | one record (upsert) + marker | + | `createSession` name backfill | `name` of its record | | `endAllSessions` | whole state | No mutation writes state read before lock acquisition. -- **Rotation durability**: rotated pair persisted (fsync + rename) - before the new access token reaches any caller. +- **Rotation durability**: the rotated pair is persisted (fsync + + rename) before the new access token reaches any caller. ## 9. Change surface on PR #130 -Engine (`packages/cli-engine`): rename/reshape pass over the landed -engine-surface commit (a8ef3fb): the rev-4 entity trinity -(Session-as-read/Credential/GrantSummary/Identity union + method -axis) becomes §2's `Session`/`Credential`; the manager interface -becomes §3's six methods (`apiClient` and `rememberWorkspaceName` -deleted from the SPI); engine-side client construction returns -(with injected config); §6 error mapping kept; harness seeding -reshaped; draft amendments updated. +Engine (`packages/cli-engine`) — a rename/reshape pass over the +landed a8ef3fb plus two behavior corrections in `api-client.ts`: +1. client construction returns to the engine (revert to the + pre-a8ef3fb shape, then re-apply the §6 error mapping; config + injection replaces the deleted `createSdk` seam). The run-long + client memoization STAYS (process pinning makes it correct); +2. the failure mapping re-reads state for the BOUND workspace, not + `currentSession()`. +Renames/reshapes: entity types per §2 (Identity/GrantSummary/ +method axis deleted; Session is one-of-many with `current`); SPI +per §3 (`tokenStorage()` added; `apiClient`/`rememberWorkspaceName` +gone); error wording (sessions-held-none-current etc.); ref +resolution moves command-side (codes renamed to session +vocabulary); harness seeding per §4. The `managesCredentials` +capability and `defineCommand` overloads survive unchanged. Auth module (`packages/cli/src/auth`): the manager implementation -(§7 migration, §8 locking, TokenStorage seam, name fetch in -`createSession`); `performLogin` returns the credential. Legacy -operations remain for the legacy shell until S2d. +(§7 migration, §8 file+lock, the TokenStorage views, process +pinning); `performLogin` returns the credential and uses a +throwaway storage; `fetchWorkspaceName` injected. Legacy operations +remain for the legacy shell until S2d. v8 tree (`packages/cli/src/v8`): auth family onto the manager with -LEGACY names (`workspace-logout.ts` stays; no forget). `logout ---workspace`: does not return (superseded by `workspace logout`). - -Docs: parity-divergences auth sections rewritten AGAIN — now -smaller (rename class gone; remaining divergences: error-code map, -exit unifications, whoami shape, env-override mutation refusals, -orphan-reaping logout); s2a contract §3/§4/acceptance amended; S2 +LEGACY names (`workspace-logout.ts` stays; no forget; `logout +--workspace` still does not return — superseded by `workspace +logout`). Command-side ref resolution. + +Docs: parity-divergences auth sections rewritten — remaining +divergences: error-code map, exit unifications, whoami shape, +env-override mutation refusals (exact error family and exit code, +incl. the `auth logout` no-op rule), the list JSON shape when an +env session is in force, orphan-reaping logout, names no longer +refreshed on read. Amend s2a contract §3/§4/acceptance and S2 overview auth rows. ## 10. Disposition record -Rev 5 (2026-08-10): operator-designed session model replaces the -grants model. Reversals, all operator-ruled: per-workspace session -vocabulary is CORRECT (a session per workspace, keyed by workspace -id) — the rev-2..4 "no per-workspace sessions" stance is dead; the -one-identity invariant is dropped (wallet is identity-blind, like -legacy); `workspace forget` rename dead, legacy command names -return; grants/GrantSummary/Identity-union deleted; `apiClient()` -and `rememberWorkspaceName` deleted from the SPI (engine constructs -the client with injected config; workspace names fetched once in -`createSession`). Carried from rev 4 unchanged: refresh/rotation -rules, failure discrimination, migration read-writes-nothing, -locking contract, fixture/test list, env-override split -(`createSession` allowed with notice). Open: §6a `use X` not-held -behavior (built to (a), recommendation (b)). +Rev 5 (2026-08-10): operator-designed session model. Operator +rulings: per-workspace sessions keyed by workspace id; identity +rule dropped (wallet identity-blind; reviewer-proposed +cross-account disclosure rules NOT adopted — "no different to +today"); legacy command names return; grants vocabulary dead; +`apiClient()`/`rememberWorkspaceName` off the SPI; +`createSession(credential, workspaceId)`; `useSession`/`endSession` +take `Session`; `workspace use` selects only; **process pinning** — +a process's session is fixed at first read, other processes' +switches never redirect it, and racing refreshes are accepted +(server-verified: 10s reuse grace + sibling-pair validity). + +Delta review folded where the pinning ruling left it standing: +throwaway login storage (PE); `clearTokens` bounded to its record +(PE); name fetch outside the lock via injected +`fetchWorkspaceName` (architect + PE); env `endAllSessions` no-op +rule (PE); client config four-field list + env static-token +construction path + cause-chain unwrapping (PE); non-AuthError +refresh throw → transient (PE); migration additions: claim keying, +last-wins duplicates, placeholder names, refresh-token-less +entries, lock-held adoption decision (architect + PE); bound- +workspace failure mapping (architect); `Session` flattened to +`workspaceName` + uniform `current` (architect — VETO-ABLE +deviation 1); `tokenStorage(workspaceId)` as the seventh +engine-facing member (architect — VETO-ABLE deviation 2). + +Made MOOT by process pinning (not adopted): sessionEpoch binding +and session-replaced errors; per-access client cache with +keys/eviction; call-chain-scoped lock re-entrancy (no nested +locking remains — `withRefreshLock` is in-process single-flight, +mutations take the short file lock directly); heartbeat/exchange- +timeout/stale ordering apparatus; cross-account race guards. From 9384a9545433e6765db81c2d2d4f62adbf703011 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 17:01:17 +0200 Subject: [PATCH 39/67] feat(cli-engine): rework the credential manager surface to the session model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design revision 5 (the session model, normative) replaces the rev-4 grants model landed in a8ef3fb: - Entities per section 2: Identity, GrantSummary, Workspace, and the method axis are deleted; Credential is {token, refreshToken?, expiresAt?} and Session is {workspaceId, workspaceName?, expiresAt?, source, current}, undefined-style with keys required. - SPI per section 3, exactly seven members: currentSession(), sessions(), createSession(credential, workspaceId), useSession(session), endSession(session), endAllSessions(), and the engine-facing tokenStorage(workspaceId). apiClient() and rememberWorkspaceName are gone; ref resolution moves command-side. - Client construction returns to the ENGINE: ctx.api builds the pinned session's client once per run from the Runtime-injected managementApiClientConfig ({clientId, redirectUri, apiBaseUrl, authBaseUrl}) — a stored session gets the SDK's refreshing path over manager.tokenStorage(workspaceId); an env session gets the SDK's static-token constructor with its 401 mapping at the call site (AUTH.SERVICE_TOKEN_REJECTED). The section-6 failure mapping re-reads state for the workspace the client is BOUND to (not currentSession()) and walks the cause chain for both AuthError and CLI structured errors. - Error vocabulary: sessions-held-none-current and session-ended replace grants-held-none-active and grant-removed; AUTH.NO_SESSION_FOR_WORKSPACE replaces AUTH.GRANT_NOT_HELD; AUTH.ENV_SESSION_IN_FORCE keeps the env-override semantics including the endAllSessions no-op rule. - Harness: the in-memory manager reshapes to {sessions?, currentWorkspaceId?, credential?, environmentToken?} seeding with full state read-back, implements the process-pinning semantics (currentSession fixed at first read; own mutations move it; overwriteStoredState simulates another process), and the harness seeds the client config pointed at test endpoints. mintTestJwt stays. - Tests rework: session-model surface tests, engine-layer pinning tests, and real-SDK integration tests over a scripted fetch (rotation persists through the TokenStorage view; invalid_grant maps to the expired wording with compare-and-clear observed; transient failures map to CLI.AUTH_SERVICE_ERROR; the env static path never touches the token endpoint). - engine-interface-draft.ts sections 4/4b/6/10/11 and the header note amended to rev 5. Codes the design left unnamed, chosen here: AUTH.NO_SESSION_FOR_WORKSPACE (workspace-reference misses), AUTH.SERVICE_TOKEN_REJECTED (env static-path 401). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/engine-interface-draft.ts | 234 ++++---- packages/cli-engine/src/context.ts | 9 +- packages/cli-engine/src/credential-errors.ts | 81 ++- packages/cli-engine/src/credential-manager.ts | 152 ++--- .../cli-engine/src/execution/api-client.ts | 172 ++++-- .../src/execution/command-context.ts | 2 +- packages/cli-engine/src/execution/needs.ts | 12 +- packages/cli-engine/src/exports/index.ts | 11 +- packages/cli-engine/src/exports/testing.ts | 2 +- packages/cli-engine/src/management-api.ts | 25 +- packages/cli-engine/src/runtime.ts | 7 + .../src/testing-credential-manager.ts | 519 +++++++++--------- packages/cli-engine/src/testing.ts | 75 +-- .../tests/credential-manager.test.ts | 514 +++++++++-------- packages/cli-engine/tests/engine.test.ts | 2 + packages/cli-engine/tests/engine.type-test.ts | 40 +- .../cli-engine/tests/management-api.test.ts | 397 ++++++++++---- 17 files changed, 1324 insertions(+), 930 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index 97a60da5..5b3703c3 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -17,12 +17,14 @@ * family-supplied base; committed versions for releases; auth library * lives in the CLI repo, distinct from Prisma Cloud. Prior versions * preserved as -v1…-v7.ts; reviews in ./reviews/. - * Amended 2026-08-10 for the credential-manager design rev 4 - * (credential-manager-design.md, normative): §4 gains ctx.session and - * the CredentialManager entity surface (ctx.getCredentials removal is + * Amended 2026-08-10 for the credential-manager design rev 5 — the + * SESSION MODEL (credential-manager-design.md, normative; rev 5 + * replaced rev 4's grants model): §4 gains ctx.session and the + * CredentialManager entity surface (ctx.getCredentials removal is * STAGED — the engine still carries it until the swap's final stage); * §6 gains the managesCredentials capability; §10 gains - * Runtime.credentialManager; §11 gains manager seeding + fixtures. + * Runtime.credentialManager and the injected client config; §11 gains + * manager seeding + fixtures. * * THE MODEL, in one analogy (operator, 2026-08-09): commands settle like * promises. A command can COMPLETE — and its completion can be @@ -373,11 +375,12 @@ export interface CommandContext PresentedResult - /** The current auth session, or null when signed out — on EVERY + /** The session this process is acting as (the manager's + * currentSession() pin), or null when signed out — on EVERY * context. Read-only and local-only: safe to call anywhere; never * touches the network. Raises the same single-sourced structured * errors as the needs check for broken-but-not-signed-out states - * (grants held none active; blank env token). + * (sessions held none current; blank env token). * ctx.getCredentials is DELETED (staged: the engine carries it * until the swap's final stage) — the context ends with fewer auth * surfaces than before: `api` + `session`, plus @@ -385,16 +388,22 @@ export interface CommandContext Promise - /** The Management API client: a thin lazy proxy over the credential - * manager's apiClient(), constructed on first method CALL, once - * per run. Request failures pass through the engine-side mapping - * (design §6): refreshTokenInvalid === true → the expired + /** The Management API client, constructed and owned by the ENGINE: + * the pinned session's client, built from the injected client + * config on first method CALL, once per run (process pinning makes + * the memoization correct). A stored session gets the SDK's + * refreshing path over manager.tokenStorage(workspaceId); an env + * session gets the SDK's static-token path with its error mapping + * at the call site. Request failures pass through the engine-side + * mapping (design §6): refreshTokenInvalid === true → the expired * CLI.CREDENTIALS_REQUIRED; any other SDK AuthError → a state - * re-read (grant gone → grant-removed CLI.CREDENTIALS_REQUIRED, - * otherwise the transient auth-service error) — state checks, - * never message parsing. A request made while signed out throws - * the structured CLI.CREDENTIALS_REQUIRED error (the same - * constructor the needs.credentials check uses). */ + * re-read for the workspace the client is BOUND to (session gone → + * session-ended CLI.CREDENTIALS_REQUIRED, otherwise the transient + * auth-service error) — state checks, never message parsing; the + * cause chain is walked for both AuthError and CLI structured + * errors. A request made while signed out throws the structured + * CLI.CREDENTIALS_REQUIRED error (the same constructor the + * needs.credentials check uses). */ readonly api: ManagementApiClient /** The one way to emit while running (§1). */ @@ -435,76 +444,84 @@ export interface Credentials { readonly token: string } -// —— §4b The credential manager (design rev 4 §3/§4, normative) —— -// One identity, plural workspace grants, one active grant; every -// consumer surface is scalar. No conditional properties: absent = -// `T | undefined` with the key required. - -export type Identity = - | { readonly kind: 'user'; readonly id: string; readonly email: string | undefined } - | { readonly kind: 'service'; readonly id: string | undefined; readonly label: string | undefined } +// —— §4b The credential manager (design rev 5 §2/§3, normative) —— +// A set of per-workspace sessions, one current. Sessions are keyed +// by workspace id: at most one session per workspace. No conditional +// properties: absent = `T | undefined` with the key required. +/** The proof material. Only ever seen by the login flow (which mints + * it) and createSession (which stores it). */ export interface Credential { readonly token: string readonly refreshToken: string | undefined readonly expiresAt: Date | undefined - readonly method: 'user-oauth' | 'service-token' -} - -/** A resolved pair — NOT the id-or-name string users type (a ref). */ -export interface Workspace { - readonly id: string - readonly name: string | undefined } +/** "Logged-in-edness", scoped to a workspace. Identified to users by + * its workspace. The token is INTERNAL: it lives in the stored + * record, never on this public shape. `source: "environment"` marks + * the ephemeral session composed from PRISMA_SERVICE_TOKEN; it never + * appears in sessions(). */ export interface Session { - readonly identity: Identity - readonly method: 'user-oauth' | 'service-token' - readonly origin: 'stored' | 'environment' - /** The ACTIVE grant's workspace. */ - readonly workspace: Workspace + readonly workspaceId: string + readonly workspaceName: string | undefined readonly expiresAt: Date | undefined + readonly source: 'stored' | 'environment' + readonly current: boolean } -/** User-centric grant listing; structurally NEVER carries credential - * material. `active` is the cursor position. */ -export interface GrantSummary { - readonly workspace: Workspace - readonly expiresAt: Date | undefined - readonly active: boolean -} - -/** Custody, not user interaction: never opens a browser, never - * prompts. Env is a construction input. Credential RESOLUTION is - * internal — it happens inside apiClient() and the needs check; no - * public method returns credential material. Full semantics - * (env-override mutation rules, error single-sourcing, locking) are - * normative in credential-manager-design.md §4/§6/§8. */ +/** Manages sessions: six user-facing operations plus one + * engine-facing accessor. Custody, not user interaction: never opens + * a browser, never prompts. Env is a construction input. The manager + * resolves NO user input: commands resolve refs against sessions() + * and pass the matched Session. Full semantics (process pinning, + * env-override mutation rules, error single-sourcing, locking) are + * normative in credential-manager-design.md §3/§4/§6/§8. */ export interface CredentialManager { - /** Local-only truth; never touches the network. */ - session(): Promise - /** Login's write: claims-derived; upserts (same identity) or - * replaces all (different identity); sets the cursor. */ - beginSession(credential: Credential): Promise - /** Logout, whole-identity; refuses under an env-supplied session. */ - endSession(): Promise - /** Held grants as summaries; local-only. */ - grants(): Promise - /** The sanctioned name-write path; no grant held → no-op. */ - rememberWorkspaceName(workspaceId: string, name: string): Promise - /** Cursor move to a HELD grant; ref = exact id, then - * case-insensitive name; ambiguity/no-match are structured errors. */ - activateGrant(ref: string): Promise - /** Drop one grant; clears the cursor if it was active. */ - forgetGrant(ref: string): Promise - /** The authenticated client, constructed and owned by the MANAGER; - * cached per workspace id, invalidated by cursor moves. */ - apiClient(): Promise + /** The session this PROCESS is acting as: pinned at first read (env + * token if set, else the file's current marker at that moment); + * other processes' marker moves never redirect it, this process's + * own mutations do. Local-only. */ + currentSession(): Promise + /** The available sessions, read fresh from the file. Local-only. + * Under an env override the file's marker is still shown as + * `current`. */ + sessions(): Promise + /** Login's write: verifies the workspace_id claim matches, upserts + * by workspaceId, sets the marker and this process's pin. The name + * is fetched best-effort after the write. */ + createSession(credential: Credential, workspaceId: string): Promise + /** Switch: sets the file's marker AND this process's pin. The + * argument is a workspace reference — only workspaceId is read, + * re-validated against freshly-read state. */ + useSession(session: Session): Promise + /** Log out of one workspace. If it was current (marker or pin), + * that current is cleared — no auto-promotion. */ + endSession(session: Session): Promise + /** Log out entirely: remove all sessions and the marker. */ + endAllSessions(): Promise + /** ENGINE-FACING, not a user operation: the SDK TokenStorage view + * for one workspace's session. The engine forwards it into SDK + * client config and never calls its methods itself. */ + tokenStorage(workspaceId: string): TokenStorage } -/** The SDK's typed client, re-exported by the engine so consumers - * never import @prisma/management-api-sdk directly. */ +/** The SDK's typed client and token-storage contract, re-exported by + * the engine so consumers never import @prisma/management-api-sdk + * directly. */ export type ManagementApiClient = import('@prisma/management-api-sdk').ManagementApiClient +export type TokenStorage = import('@prisma/management-api-sdk').TokenStorage + +/** SDK client construction config, injected by the bin beside the + * manager (§10). All four fields: the SDK's refreshing fetch + * requires the full config even though only login paths read + * redirectUri. */ +export interface ManagementApiClientConfig { + readonly clientId: string + readonly redirectUri: string + readonly apiBaseUrl: string + readonly authBaseUrl: string +} /** * §4a Prompts (operator ruling, 2026-08-09: prompts return their answer @@ -764,13 +781,13 @@ export interface CommandDefinition< readonly exitCodes: Readonly> /** - * A CAPABILITY, not a need (design rev 4 §5): when true, + * A CAPABILITY, not a need (design rev 5 §4): when true, * ctx.credentialManager appears on the context. Declaring it never * fails a run — documentation and testability, not enforcement. * Declared by exactly: auth login, auth logout, auth workspace - * list, auth workspace use, auth workspace forget. whoami uses - * ctx.session() only; grants() lives ONLY on the manager, never on - * the universal context. + * list, auth workspace use, auth workspace logout. whoami uses + * ctx.session() only; sessions() lives ONLY on the manager, never + * on the universal context. */ readonly managesCredentials: TManagesCredentials @@ -1137,12 +1154,17 @@ export interface Runtime { /** Loaded config + file-level diagnostics; the shell builds this via * the unified loader (R10). Tests hand in fixtures. */ readonly config: LoadedConfig - /** The credential manager the bin wires (design rev 4 §5). The + /** The credential manager the bin wires (design rev 5 §4). The * engine prefers it for the needs check, ctx.session, and ctx.api. * Optional only during the staged swap; getCredentials below is * the fallback and is deleted — with the optionality — in the * swap's final mechanical stage. */ readonly credentialManager?: CredentialManager + /** SDK client construction config the bin injects beside the + * manager; the engine builds ctx.api from it (the same config + * feeds performLogin). Required whenever a credentialManager is + * wired; optional only during the staged swap. */ + readonly managementApiClientConfig?: ManagementApiClientConfig readonly getCredentials: () => Promise /** Management API endpoint config; the bin derives baseUrl from env * (getApiBaseUrl). */ @@ -1199,20 +1221,24 @@ export declare function createTestCli(spec: { * a manager-less runtime. Mutually exclusive with the manager * seeds below; deleted with the swap's final stage. */ readonly credentials?: Credentials - /** Preferred manager seed: beginSession runs its real claims + /** Convenience manager seed: createSession runs its real claims * derivation on this credential (mint the token with mintTestJwt). */ readonly credential?: Credential - /** Escape hatch: ctx.session() resolves exactly this. */ - readonly session?: Session - /** Grants-model seeding; identity is seedable independently of - * grants, so a grant whose token disagrees with the recorded - * identity is constructible. */ - readonly identity?: Identity - readonly grants?: ReadonlyArray<{ - readonly workspace: Workspace + /** Session-model seeding: stored sessions mirroring the state + * file's records, and the file's current marker. */ + readonly sessions?: ReadonlyArray<{ + readonly workspaceId: string + readonly workspaceName: string | undefined readonly credential: Credential }> - readonly activeWorkspaceId?: string + readonly currentWorkspaceId?: string + /** Composes the ephemeral env session; also exported to each run's + * env as PRISMA_SERVICE_TOKEN. */ + readonly environmentToken?: string + /** The SDK client construction config; defaults point every + * endpoint at test.invalid hosts (the design's local-endpoint + * fixture surface). */ + readonly managementApiClientConfig?: ManagementApiClientConfig /** baseUrl defaults to "https://test.invalid"; when `client` is * supplied, ctx.api IS that object (the uniform mock seam). */ readonly managementApi?: { @@ -1226,30 +1252,40 @@ export declare function createTestCli(spec: { /** Mints an unsigned JWT whose payload is exactly `claims` — the * harness's claim source (`sub`, `workspace_id`, `exp`, `email`) for - * beginSession derivation, migration, expiry, and identity-guard - * tests. The rest of the design's fixture surface (the injectable - * refresh/token endpoint scripting, legacy-store builder, - * deterministic clock, mutation interleaving hook) lands with the - * real manager implementation, whose behavior it exercises. */ + * createSession derivation, migration, and expiry tests. The rest of + * the design's fixture surface (the token-endpoint scripting, + * legacy-store builder, deterministic clock, second-process lock + * holder) lands with the real manager implementation, whose behavior + * it exercises. */ export declare function mintTestJwt(claims: Readonly>): string export interface TestCli { /** The MUTABLE in-memory credential manager backing the runs: the - * full CredentialManager interface plus state(), which reads the - * whole state back after a run — grants with their per-grant - * credentials, identity, and cursor (login/logout tests observe - * state changes). Undefined only when the legacy `credentials` - * seed selected the manager-less fallback runtime. */ + * full CredentialManager interface — with the design's + * process-pinning semantics (currentSession fixed at first read; + * its own mutations move it) — plus state(), which reads the whole + * stored state back after a run, and overwriteStoredState(), which + * applies a write as ANOTHER process would (never moves the pin). + * Undefined only when the legacy `credentials` seed selected the + * manager-less fallback runtime. */ readonly credentialManager: | (CredentialManager & { state(): { - readonly identity: Identity | undefined - readonly grants: ReadonlyArray<{ - readonly workspace: Workspace + readonly sessions: ReadonlyArray<{ + readonly workspaceId: string + readonly workspaceName: string | undefined readonly credential: Credential }> - readonly activeWorkspaceId: string | null + readonly currentWorkspaceId: string | null } + overwriteStoredState(state: { + readonly sessions?: ReadonlyArray<{ + readonly workspaceId: string + readonly workspaceName: string | undefined + readonly credential: Credential + }> + readonly currentWorkspaceId?: string | null + }): void }) | undefined run( diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index f3f0c7a3..67bed2bc 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -31,10 +31,11 @@ export interface CommandContext< ) => PresentedResult; /** - * The current auth session, or null when signed out. Read-only and + * The session this process is acting as (the manager's + * currentSession() pin), or null when signed out. Read-only and * local-only — safe to call anywhere; never touches the network. * Throws the same structured errors the needs check raises for - * broken-but-not-signed-out states (grants held, none active). + * broken-but-not-signed-out states (sessions held, none current). */ readonly session: () => Promise; @@ -47,8 +48,8 @@ export interface CommandContext< readonly getCredentials: () => Promise; /** - * The Management API client: a thin lazy proxy over the credential - * manager's apiClient(), constructed on first method call, once per + * The Management API client, constructed and owned by the ENGINE: + * the pinned session's client, built on first method call, once per * run. A request made while signed out throws the structured * CLI.CREDENTIALS_REQUIRED error (the same constructor the * needs.credentials check uses). diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts index a851ec39..01ff2d1d 100644 --- a/packages/cli-engine/src/credential-errors.ts +++ b/packages/cli-engine/src/credential-errors.ts @@ -5,23 +5,22 @@ const signInAction: NextAction = { label: "Sign in, then run the command again.", }; -const activateGrantAction: NextAction = { +const useSessionAction: NextAction = { kind: "run-command", - label: "Activate one of your held workspace grants", + label: "Make one of your workspace sessions current", command: "prisma auth workspace use", }; export type CredentialsRequiredReason = | "unauthenticated" | "expired" - | "grant-removed" - | "grants-held-none-active"; + | "session-ended" + | "sessions-held-none-current"; /** - * The single constructor of CLI.CREDENTIALS_REQUIRED. Raised by the - * needs check, by an unauthenticated ctx.api request, and by the - * credential manager's session()/credential() in the - * grants-held-none-active state — identically from all of them. + * The single constructor of CLI.CREDENTIALS_REQUIRED. Raised + * identically by the needs check, ctx.session, and the engine's + * request path. */ export function credentialsRequiredError( reason: CredentialsRequiredReason = "unauthenticated", @@ -39,22 +38,22 @@ export function credentialsRequiredError( "Your session has expired — sign in again.", { nextActions: [signInAction] }, ); - case "grant-removed": + case "session-ended": return new CliStructuredError( "CLI.CREDENTIALS_REQUIRED", - "The workspace grant this command was using is no longer held.", + "The workspace session this command was using has ended.", { - why: "It was removed while the command was running (for example by another prisma process).", - nextActions: [activateGrantAction, signInAction], + why: "It was ended while the command was running (for example by another prisma process).", + nextActions: [useSessionAction, signInAction], }, ); - case "grants-held-none-active": + case "sessions-held-none-current": return new CliStructuredError( "CLI.CREDENTIALS_REQUIRED", - "No workspace is active.", + "No workspace session is current.", { - why: "You hold workspace grants, but none is active.", - nextActions: [activateGrantAction, signInAction], + why: "You have workspace sessions but none is current.", + nextActions: [useSessionAction, signInAction], }, ); } @@ -88,14 +87,14 @@ export function authServiceError(): CliStructuredError { */ export function environmentSessionMutationError(spec: { readonly envVar: string; - readonly storedGrantsExist: boolean; + readonly storedSessionsExist: boolean; }): CliStructuredError { return new CliStructuredError( "AUTH.ENV_SESSION_IN_FORCE", `The current session comes from ${spec.envVar}, which this command cannot change.`, { - why: spec.storedGrantsExist - ? `${spec.envVar} overrides your stored workspace grants; unsetting it restores them.` + why: spec.storedSessionsExist + ? `${spec.envVar} overrides your stored workspace sessions; unsetting it restores them.` : `${spec.envVar} supplies the only session; there is no stored state to change.`, nextActions: [ { @@ -107,3 +106,47 @@ export function environmentSessionMutationError(spec: { }, ); } + +/** + * No session exists for the named workspace. Sessions are created by + * `prisma auth login` alone — `workspace use` selects among the ones + * you have. + */ +export function noSessionForWorkspaceError( + workspaceRef: string, +): CliStructuredError { + return new CliStructuredError( + "AUTH.NO_SESSION_FOR_WORKSPACE", + `You have no session for workspace '${workspaceRef}'.`, + { + nextActions: [ + { + kind: "run-command", + label: `Sign in and pick '${workspaceRef}' in the browser`, + command: "prisma auth login", + }, + ], + }, + ); +} + +/** + * The management API rejected the env-supplied service token (401). + * There is no refresh for it and nothing stored is cleared. + */ +export function serviceTokenRejectedError(spec: { + readonly envVar: string; +}): CliStructuredError { + return new CliStructuredError( + "AUTH.SERVICE_TOKEN_REJECTED", + `The management API rejected the service token from ${spec.envVar}.`, + { + nextActions: [ + { + kind: "user-choice", + label: `Replace ${spec.envVar} with a valid service token, or unset it to use your stored sessions.`, + }, + ], + }, + ); +} diff --git a/packages/cli-engine/src/credential-manager.ts b/packages/cli-engine/src/credential-manager.ts index f4176f57..e598c33e 100644 --- a/packages/cli-engine/src/credential-manager.ts +++ b/packages/cli-engine/src/credential-manager.ts @@ -1,142 +1,86 @@ -import type { ManagementApiClient } from "./management-api"; +import type { TokenStorage } from "./management-api"; /** - * Who is signed in. One identity per login; every held grant belongs to - * it. Claim-derived fields only. + * The proof material. Only ever seen by the login flow (which mints + * it) and createSession (which stores it). */ -export type Identity = - | { - readonly kind: "user"; - readonly id: string; - readonly email: string | undefined; - } - | { - readonly kind: "service"; - readonly id: string | undefined; - readonly label: string | undefined; - }; - -/** Credential material. Leaves the manager only through credential(). */ export interface Credential { readonly token: string; readonly refreshToken: string | undefined; readonly expiresAt: Date | undefined; - readonly method: "user-oauth" | "service-token"; -} - -/** - * A resolved workspace pair — NOT the id-or-name string users type - * (that concept is called a ref). `name` is undefined unless an - * explicit write recorded it; there is no read-path caching. - */ -export interface Workspace { - readonly id: string; - readonly name: string | undefined; } /** - * The scalar view of the auth state every engine consumer sees. - * `origin` distinguishes an env-supplied session from stored state (it - * drives endSession's refusal and lets whoami explain an override); - * `method` is how authenticated, a different axis from who - * (identity.kind). `workspace` is the ACTIVE grant's workspace. + * "Logged-in-edness", scoped to a workspace. Identified to users by + * its workspace. The token is INTERNAL: it lives in the stored + * record, never on this public shape. `source: "environment"` marks + * the ephemeral session composed from PRISMA_SERVICE_TOKEN; it never + * appears in sessions(). */ export interface Session { - readonly identity: Identity; - readonly method: "user-oauth" | "service-token"; - readonly origin: "stored" | "environment"; - readonly workspace: Workspace; + readonly workspaceId: string; + readonly workspaceName: string | undefined; readonly expiresAt: Date | undefined; + readonly source: "stored" | "environment"; + readonly current: boolean; } /** - * A held grant, as listed to the user. Structurally carries no - * credential material. `active` is the cursor position; when an env - * token overrides, the listing command must say the env session is - * what is in force. - */ -export interface GrantSummary { - readonly workspace: Workspace; - readonly expiresAt: Date | undefined; - readonly active: boolean; -} - -/** - * The credential machinery, modeled: one identity, plural workspace - * grants (workspace + the credential the user's consent minted for - * it), one active grant. The plurality lives entirely inside the - * manager; every consumer surface is scalar. The manager holds custody - * only: it never opens a browser, never prompts, never talks to the + * Manages sessions — a set of per-workspace sessions, one current. + * Six user-facing operations plus one engine-facing accessor. Custody + * only: never opens a browser, never prompts, never talks to the * user. Env is a construction input — nothing below the manager reads - * process.env. + * process.env. The manager resolves no user input: commands resolve + * refs against sessions() and pass the matched Session. */ export interface CredentialManager { /** - * User-centric truth. Local-only: composes env + stored state, - * decodes claims, NEVER touches the network. An env service token - * wins over a stored credential (origin: "environment"); whoami is - * responsible for surfacing the override. + * The session this PROCESS is acting as. Pinned at first read: + * composed from the env token if set, else the file's current + * marker at that moment; later marker changes by other processes do + * not move it. This process's own mutations DO update it. + * Local-only: never touches the network. */ - session(): Promise; + currentSession(): Promise; /** - * Login's write. Derives identity, workspace, and expiry from the - * credential's claims. Same identity as the held grants: UPSERTS - * the grant for that workspace and makes it ACTIVE; other grants - * untouched. Different identity: all existing grants are discarded - * and replaced by this one — the one-identity invariant is enforced - * here. + * The available sessions, read fresh from the file. Local-only. + * Under an env override the file's current marker is still shown as + * `current`. */ - beginSession(credential: Credential): Promise; + sessions(): Promise; /** - * Logout, whole-identity. Local (does not revoke server-side). - * Clears ALL grants and the cursor. Rejects with a structured error - * when the active session is env-supplied; the error's why states - * whether stored grants also exist. + * Login's write. The caller names the workspace that identifies the + * session; for workspace-bound credentials the manager verifies the + * workspace_id claim matches and refuses on mismatch. Upserts by + * workspaceId, sets the file marker, becomes this process's + * current. The workspace name is fetched best-effort after the + * write — failure leaves it undefined, never fails login. */ - endSession(): Promise; + createSession(credential: Credential, workspaceId: string): Promise; /** - * The held grants, as summaries (no credential material). - * Local-only like session(): never touches the network. + * Switch: sets the file's current marker AND this process's pinned + * session. The argument is a workspace reference — only workspaceId + * is read, re-validated against freshly-read state. */ - grants(): Promise; + useSession(session: Session): Promise; /** - * Records a human-readable workspace name learned by a command. The - * sanctioned name-write path — beginSession stays single-argument - * and claims-only. Explicit write; never a read-path side effect. - * No grant held for that workspace id → no-op, not an error. Only - * `login` and `workspace use` may call it (both already mutate). + * Log out of one workspace: remove that session. If it was current + * (file marker or this process's pin), that current is cleared (no + * auto-promotion). */ - rememberWorkspaceName(workspaceId: string, name: string): Promise; + endSession(session: Session): Promise; - /** - * Move the cursor to a HELD grant. Ref resolution is the manager's, - * against held grants only: exact id first, then case-insensitive - * name; ambiguity is a structured error; no match is a structured - * error the COMMAND catches to run the consent flow + beginSession. - * Activating an expired grant succeeds locally and fails at first - * use. - */ - activateGrant(ref: string): Promise; - - /** - * Drop one grant. If it was active, the cursor clears (no - * auto-promotion of another grant). - */ - forgetGrant(ref: string): Promise; + /** Log out entirely: remove all sessions and the marker. */ + endAllSessions(): Promise; /** - * The authenticated management API client, constructed and owned by - * the MANAGER. Credential RESOLUTION is internal: the manager - * resolves the credential that authorizes a request inside this - * method and the needs check — no public method returns credential - * material (internal invariant: resolution yields null ⟺ session() - * is null). Clients are cached per workspace id; any cursor-moving - * mutation (beginSession, activateGrant) invalidates the cached - * active client. + * ENGINE-FACING, not a user operation: the SDK TokenStorage view + * for one workspace's session. The engine forwards it into SDK + * client config and never calls its methods itself. */ - apiClient(): Promise; + tokenStorage(workspaceId: string): TokenStorage; } diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 2319198e..2b052842 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -1,27 +1,42 @@ import { authServiceError, credentialsRequiredError, + serviceTokenRejectedError, } from "../credential-errors"; -import type { Session } from "../credential-manager"; +import type { CredentialManager, Session } from "../credential-manager"; import type { ManagementApiClient } from "../management-api"; import { CliStructuredError } from "../protocol"; import type { Invocation } from "./engine"; +const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; + +type ClientBinding = + | { readonly source: "stored"; readonly workspaceId: string } + | { readonly source: "environment" }; + /** - * ctx.api: a thin lazy proxy over the credential manager's - * apiClient(). Nothing resolves until the first method CALL, so a run - * that never issues a request never touches the manager. Every request - * failure passes through the engine-side error mapping below; the - * returned client is a Proxy whose method wrappers await the lazy - * resolution before applying the call — every client method is async, - * so the deferral is invisible to callers. + * ctx.api: the ENGINE constructs and owns the management API client — + * the pinned session's client, once per run (process pinning makes + * the memoization correct). Nothing resolves until the first method + * CALL, so a run that never issues a request never pays for — or + * depends on — the SDK module load. A stored session gets the SDK's + * refreshing path over the manager's TokenStorage view; an env + * session gets the SDK's static-token path with its error mapping at + * the call site. Every request failure passes through the engine-side + * mapping below. The returned client is a Proxy whose method wrappers + * await the lazy construction before applying the call — every client + * method is async, so the deferral is invisible to callers. */ export function buildManagementApiClient( invocation: Invocation, ): ManagementApiClient { + let binding: ClientBinding | undefined; let clientPromise: Promise | undefined; const resolveClient = (): Promise => { - clientPromise ??= resolveManagerClient(invocation); + clientPromise ??= constructClient(invocation).then((constructed) => { + binding = constructed.binding; + return constructed.client; + }); return clientPromise; }; @@ -32,47 +47,113 @@ export function buildManagementApiClient( if (typeof property === "symbol" || property === "then") { return undefined; } - return (...args: unknown[]): Promise => - resolveClient() - .then((client) => { - const value: unknown = Reflect.get(client, property); - if (typeof value !== "function") { - throw new TypeError( - `@prisma/cli-engine: ctx.api.${property} is not a function`, - ); - } - return Reflect.apply(value, client, args) as Promise; - }) - .catch(async (cause: unknown) => { - throw await mapRequestFailure(invocation, cause); - }); + return async (...args: unknown[]): Promise => { + try { + const client = await resolveClient(); + const value: unknown = Reflect.get(client, property); + if (typeof value !== "function") { + throw new TypeError( + `@prisma/cli-engine: ctx.api.${property} is not a function`, + ); + } + const result: unknown = await (Reflect.apply( + value, + client, + args, + ) as Promise); + if (binding?.source === "environment" && responseWas401(result)) { + throw serviceTokenRejectedError({ envVar: SERVICE_TOKEN_ENV_VAR }); + } + return result; + } catch (cause) { + throw await mapRequestFailure(invocation, binding, cause); + } + }; }, }); } -async function resolveManagerClient( - invocation: Invocation, -): Promise { +async function constructClient(invocation: Invocation): Promise<{ + readonly client: ManagementApiClient; + readonly binding: ClientBinding; +}> { const manager = invocation.runtime.credentialManager; if (manager === undefined) { throw credentialsRequiredError(); } - return manager.apiClient(); + const session = await manager.currentSession(); + if (session === null) { + throw credentialsRequiredError(); + } + const config = invocation.runtime.managementApiClientConfig; + if (config === undefined) { + throw new Error( + "@prisma/cli-engine: ctx.api requires Runtime.managementApiClientConfig when a credentialManager is wired", + ); + } + if (session.source === "environment") { + const token = invocation.runtime.env[SERVICE_TOKEN_ENV_VAR]; + if (token === undefined || token.trim() === "") { + throw credentialsRequiredError(); + } + const { createManagementApiClient } = await import( + "@prisma/management-api-sdk" + ); + return { + client: createManagementApiClient({ + baseUrl: config.apiBaseUrl, + token, + }), + binding: { source: "environment" }, + }; + } + const { createManagementApiSdk } = await import( + "@prisma/management-api-sdk" + ); + const sdk = createManagementApiSdk({ + clientId: config.clientId, + redirectUri: config.redirectUri, + apiBaseUrl: config.apiBaseUrl, + authBaseUrl: config.authBaseUrl, + tokenStorage: manager.tokenStorage(session.workspaceId), + }); + return { + client: sdk.client, + binding: { source: "stored", workspaceId: session.workspaceId }, + }; +} + +/** The static-token path has no error middleware, so a 401 arrives as + * a resolved openapi-fetch result; the call site inspects it. */ +function responseWas401(result: unknown): boolean { + if (typeof result !== "object" || result === null) { + return false; + } + const response = (result as { readonly response?: unknown }).response; + return ( + typeof response === "object" && + response !== null && + (response as { readonly status?: unknown }).status === 401 + ); } /** * The engine-side request-failure mapping. A structured error raised - * inside the pipeline is rethrown unwrapped so it settles as itself. - * An SDK AuthError is discriminated by STATE, never by message - * parsing: refreshTokenInvalid === true (the SDK's definitive + * inside the pipeline (the SDK wraps non-SDK errors into + * FetchError(cause), so the cause chain is walked for BOTH AuthError + * and CLI structured errors) is rethrown unwrapped so it settles as + * itself. An SDK AuthError is discriminated by STATE, never by + * message parsing: refreshTokenInvalid === true (the SDK's definitive * invalid_grant signal, already cleared by compare-and-clear) maps to - * the expired CLI.CREDENTIALS_REQUIRED; any other AuthError triggers a - * re-read of the manager's state — the bound grant gone means the - * grant-removed CLI.CREDENTIALS_REQUIRED, otherwise the failure was - * the auth service's and nothing was cleared. + * the expired CLI.CREDENTIALS_REQUIRED; any other AuthError triggers + * a re-read of the manager's state for the workspace the client is + * BOUND to — that session gone means the session-ended + * CLI.CREDENTIALS_REQUIRED, otherwise the failure was the auth + * service's and nothing was cleared. */ async function mapRequestFailure( invocation: Invocation, + binding: ClientBinding | undefined, cause: unknown, ): Promise { const structured = structuredCause(cause); @@ -87,17 +168,28 @@ async function mapRequestFailure( return credentialsRequiredError("expired"); } const manager = invocation.runtime.credentialManager; - if (manager === undefined) { - return credentialsRequiredError(); + if (manager === undefined || binding?.source !== "stored") { + return authServiceError(); } - let session: Session | null; + return mapStoredSessionAuthFailure(manager, binding.workspaceId, cause); +} + +async function mapStoredSessionAuthFailure( + manager: CredentialManager, + boundWorkspaceId: string, + cause: unknown, +): Promise { + let sessions: readonly Session[]; try { - session = await manager.session(); + sessions = await manager.sessions(); } catch (stateCause) { return CliStructuredError.is(stateCause) ? stateCause : cause; } - if (session === null) { - return credentialsRequiredError("grant-removed"); + const boundSessionGone = !sessions.some( + (session) => session.workspaceId === boundWorkspaceId, + ); + if (boundSessionGone) { + return credentialsRequiredError("session-ended"); } return authServiceError(); } diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 8be06846..856f51e5 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -90,7 +90,7 @@ export function makeContext( config, present: present as CommandContext["present"], session: (): Promise => - invocation.runtime.credentialManager?.session() ?? + invocation.runtime.credentialManager?.currentSession() ?? Promise.resolve(null), getCredentials: (): Promise => invocation.runtime.getCredentials(), diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index d6e927cb..8fa42f5b 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -104,11 +104,11 @@ function checkDependencies( /** * The credentials need, single-sourced from the credential manager - * when one is wired: session() is the local-only truth, its structured - * errors (grants held none active, blank env token) pass through - * verbatim so the needs check, ctx.session, and ctx.api raise - * identically. The getCredentials path below is the staged-swap - * fallback. + * when one is wired: currentSession() is the local-only truth (the + * process pin), its structured errors (sessions held none current, + * blank env token) pass through verbatim so the needs check, + * ctx.session, and ctx.api raise identically. The getCredentials path + * below is the staged-swap fallback. */ async function checkCredentials( needs: AnyCommand["needs"], @@ -121,7 +121,7 @@ async function checkCredentials( invocation.runtime.credentialManager; if (manager !== undefined) { try { - if ((await manager.session()) === null) { + if ((await manager.currentSession()) === null) { return needsErrored(credentialsRequiredError()); } return undefined; diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index f771ac7b..b65b282b 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -57,14 +57,13 @@ export { credentialsRequiredError, type CredentialsRequiredReason, environmentSessionMutationError, + noSessionForWorkspaceError, + serviceTokenRejectedError, } from "../credential-errors"; export type { Credential, CredentialManager, - GrantSummary, - Identity, Session, - Workspace, } from "../credential-manager"; export type { EngineEvent, @@ -72,7 +71,11 @@ export type { StreamEvent, StreamMeta, } from "../events"; -export type { ManagementApiClient } from "../management-api"; +export type { + ManagementApiClient, + ManagementApiClientConfig, + TokenStorage, +} from "../management-api"; export { type Block, type Format, diff --git a/packages/cli-engine/src/exports/testing.ts b/packages/cli-engine/src/exports/testing.ts index b85e3de3..6d775f26 100644 --- a/packages/cli-engine/src/exports/testing.ts +++ b/packages/cli-engine/src/exports/testing.ts @@ -8,5 +8,5 @@ export { TestCredentialManager, type TestCredentialManagerSeed, type TestCredentialManagerState, - type TestGrant, + type TestSessionRecord, } from "../testing-credential-manager"; diff --git a/packages/cli-engine/src/management-api.ts b/packages/cli-engine/src/management-api.ts index 1c755249..bee4d449 100644 --- a/packages/cli-engine/src/management-api.ts +++ b/packages/cli-engine/src/management-api.ts @@ -1,7 +1,30 @@ -import type { ManagementApiClient as SdkClient } from "@prisma/management-api-sdk"; +import type { + ManagementApiClient as SdkClient, + TokenStorage as SdkTokenStorage, +} from "@prisma/management-api-sdk"; /** * The SDK's typed client, re-exported so consumers never import * @prisma/management-api-sdk directly. */ export type ManagementApiClient = SdkClient; + +/** + * The SDK's token-storage contract, re-exported for the same reason. + * CredentialManager.tokenStorage returns one; the engine forwards it + * into SDK client config and never calls its methods itself. + */ +export type TokenStorage = SdkTokenStorage; + +/** + * SDK client construction config, injected by the bin beside the + * credential manager. All four fields: the SDK's refreshing fetch + * requires the full config even though only login paths read + * redirectUri. + */ +export interface ManagementApiClientConfig { + readonly clientId: string; + readonly redirectUri: string; + readonly apiBaseUrl: string; + readonly authBaseUrl: string; +} diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index 66211e8d..7b393bdd 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -1,5 +1,6 @@ import type { Credentials } from "./context"; import type { CredentialManager } from "./credential-manager"; +import type { ManagementApiClientConfig } from "./management-api"; import type { Diagnostic } from "./protocol"; /** Minimal structural stream types; no NodeJS.* in the public surface. */ @@ -52,6 +53,12 @@ export interface Runtime { * deleted with the swap's final stage. */ readonly credentialManager?: CredentialManager; + /** + * SDK client construction config the bin injects beside the + * manager; the engine builds ctx.api from it. Required whenever a + * credentialManager is wired; optional only during the staged swap. + */ + readonly managementApiClientConfig?: ManagementApiClientConfig; readonly getCredentials: () => Promise; /** Management API endpoint config; the bin derives baseUrl from env. */ readonly managementApi: { readonly baseUrl: string }; diff --git a/packages/cli-engine/src/testing-credential-manager.ts b/packages/cli-engine/src/testing-credential-manager.ts index 743d8943..932430c8 100644 --- a/packages/cli-engine/src/testing-credential-manager.ts +++ b/packages/cli-engine/src/testing-credential-manager.ts @@ -2,53 +2,46 @@ import { Buffer } from "node:buffer"; import { credentialsRequiredError, environmentSessionMutationError, + noSessionForWorkspaceError, } from "./credential-errors"; import type { Credential, CredentialManager, - GrantSummary, - Identity, Session, - Workspace, } from "./credential-manager"; -import type { ManagementApiClient } from "./management-api"; -import { CliStructuredError } from "./protocol"; +import type { TokenStorage } from "./management-api"; const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; -/** A held grant with its credential material, as seeded into and read - * back from the test credential manager. */ -export interface TestGrant { - readonly workspace: Workspace; +/** A stored session with its credential material, as seeded into and + * read back from the test credential manager. */ +export interface TestSessionRecord { + readonly workspaceId: string; + readonly workspaceName: string | undefined; readonly credential: Credential; } export interface TestCredentialManagerSeed { - /** Preferred seed: runs beginSession's real claims derivation. The - * token must be a JWT (use mintTestJwt). */ + /** Stored sessions, mirroring the state file's records. */ + readonly sessions?: readonly TestSessionRecord[]; + /** The file's current marker. */ + readonly currentWorkspaceId?: string; + /** Convenience seed: runs createSession's real claims derivation. + * The token must be a JWT with `workspace_id` (use mintTestJwt). */ readonly credential?: Credential; - /** Escape hatch: session() returns exactly this. origin - * "environment" seeds an env-override session; origin "stored" - * materializes one active grant with a synthesized credential. */ - readonly session?: Session; - /** Grants-model seeding. `identity` is seedable independently of - * `grants`, so a grant whose token disagrees with the recorded - * identity is constructible. */ - readonly identity?: Identity; - readonly grants?: readonly TestGrant[]; - readonly activeWorkspaceId?: string; + /** Composes the ephemeral env session (PRISMA_SERVICE_TOKEN). The + * token must be a JWT with `workspace_id` (use mintTestJwt). */ + readonly environmentToken?: string; } -/** The whole manager state, readable back after a run. */ +/** The whole stored state, readable back after a run. */ export interface TestCredentialManagerState { - readonly identity: Identity | undefined; - readonly grants: readonly TestGrant[]; - readonly activeWorkspaceId: string | null; + readonly sessions: readonly TestSessionRecord[]; + readonly currentWorkspaceId: string | null; } /** Mints an unsigned JWT whose payload is exactly `claims` — the - * harness's claim source for beginSession derivation (`sub`, - * `workspace_id`, `exp`, `email`). */ + * harness's claim source (`sub`, `workspace_id`, `exp`, `email`). */ export function mintTestJwt(claims: Readonly>): string { const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url"); @@ -74,300 +67,328 @@ function decodeJwtClaims( } } -function stringClaim( - claims: Record, - key: string, -): string | undefined { - const value = claims[key]; - return typeof value === "string" ? value : undefined; +function claimedWorkspaceId(token: string): string | undefined { + const claims = decodeJwtClaims(token); + const workspaceId = claims?.workspace_id; + return typeof workspaceId === "string" ? workspaceId : undefined; } -interface DerivedClaims { - readonly identity: Identity; - readonly workspaceId: string; - readonly expiresAt: Date | undefined; +function claimedExpiresAt(token: string): Date | undefined { + const exp = decodeJwtClaims(token)?.exp; + return typeof exp === "number" ? new Date(exp * 1000) : undefined; } -function deriveFromClaims(credential: Credential): DerivedClaims { - const claims = decodeJwtClaims(credential.token); - const sub = claims === undefined ? undefined : stringClaim(claims, "sub"); - const workspaceId = - claims === undefined ? undefined : stringClaim(claims, "workspace_id"); - if (claims === undefined || sub === undefined || workspaceId === undefined) { - throw new Error( - "@prisma/cli-engine/testing: beginSession derives identity and workspace from the credential's claims — the token must be a JWT with `sub` and `workspace_id` (use mintTestJwt)", - ); - } - const exp = claims.exp; - const expiresAt = typeof exp === "number" ? new Date(exp * 1000) : undefined; - const identity: Identity = - credential.method === "service-token" - ? { kind: "service", id: sub, label: undefined } - : { kind: "user", id: sub, email: stringClaim(claims, "email") }; - return { identity, workspaceId, expiresAt }; -} - -function identityFromGrants( - grants: readonly TestGrant[], -): Identity | undefined { - for (const grant of grants) { - const claims = decodeJwtClaims(grant.credential.token); - const sub = claims === undefined ? undefined : stringClaim(claims, "sub"); - if (sub !== undefined) { - return grant.credential.method === "service-token" - ? { kind: "service", id: sub, label: undefined } - : { - kind: "user", - id: sub, - email: claims === undefined ? undefined : stringClaim(claims, "email"), - }; - } - } - return undefined; -} +type Pin = + | { readonly kind: "unpinned" } + | { readonly kind: "environment" } + | { readonly kind: "marker"; readonly workspaceId: string | null }; /** * The harness's mutable in-memory CredentialManager: the same - * interface commands see, with the whole state readable back after a - * run. No persistence, no locking — those belong to the real manager - * and its own tests. + * interface commands see, with the whole stored state readable back + * after a run, and the design's process-pinning semantics — + * currentSession() is fixed at its first read; only this manager's + * own mutations move it. No persistence, no locking — those belong to + * the real manager and its own tests. */ export class TestCredentialManager implements CredentialManager { - private identity: Identity | undefined; - private heldGrants: TestGrant[]; - private activeWorkspaceId: string | null; - private environmentSession: Session | undefined; - private readonly client: ManagementApiClient | undefined; + private storedSessions: TestSessionRecord[]; + private markedWorkspaceId: string | null; + private readonly environmentToken: string | undefined; + private pin: Pin = { kind: "unpinned" }; - constructor( - seed: TestCredentialManagerSeed, - client?: ManagementApiClient, - ) { - this.client = client; - this.heldGrants = [...(seed.grants ?? [])]; - this.identity = seed.identity ?? identityFromGrants(this.heldGrants); - this.activeWorkspaceId = seed.activeWorkspaceId ?? null; - this.environmentSession = - seed.session?.origin === "environment" ? seed.session : undefined; - if (seed.session !== undefined && seed.session.origin === "stored") { - this.materializeStoredSession(seed.session); - } + constructor(seed: TestCredentialManagerSeed) { + this.storedSessions = [...(seed.sessions ?? [])]; + this.markedWorkspaceId = seed.currentWorkspaceId ?? null; + this.environmentToken = seed.environmentToken; if (seed.credential !== undefined) { - this.applyBeginSession(seed.credential); + const workspaceId = claimedWorkspaceId(seed.credential.token); + if (workspaceId === undefined) { + throw new Error( + "@prisma/cli-engine/testing: the `credential` seed runs createSession's claims derivation — the token must be a JWT with `workspace_id` (use mintTestJwt)", + ); + } + this.applyCreateSession(seed.credential, workspaceId); } } state(): TestCredentialManagerState { return { - identity: this.identity, - grants: [...this.heldGrants], - activeWorkspaceId: this.activeWorkspaceId, + sessions: [...this.storedSessions], + currentWorkspaceId: this.markedWorkspaceId, }; } - async session(): Promise { - if (this.environmentSession !== undefined) { - return this.environmentSession; + /** Applies a write as ANOTHER process would: the stored state + * changes, but this process's pinned session does not move. */ + overwriteStoredState(state: { + readonly sessions?: readonly TestSessionRecord[]; + readonly currentWorkspaceId?: string | null; + }): void { + if (state.sessions !== undefined) { + this.storedSessions = [...state.sessions]; + } + if (state.currentWorkspaceId !== undefined) { + this.markedWorkspaceId = state.currentWorkspaceId; } - return this.storedSession(); } - async beginSession(credential: Credential): Promise { - return this.applyBeginSession(credential); + async currentSession(): Promise { + if (this.pin.kind === "unpinned") { + this.pin = + this.environmentToken !== undefined + ? { kind: "environment" } + : { kind: "marker", workspaceId: this.resolvedMarker() }; + return this.pinnedSession(true); + } + return this.pinnedSession(false); } - async endSession(): Promise { - this.refuseUnderEnvironmentSession(); - this.identity = undefined; - this.heldGrants = []; - this.activeWorkspaceId = null; + async sessions(): Promise { + return this.storedSessions.map((record) => this.asSession(record)); } - async grants(): Promise { - return this.heldGrants.map((grant) => ({ - workspace: grant.workspace, - expiresAt: grant.credential.expiresAt, - active: grant.workspace.id === this.activeWorkspaceId, - })); + async createSession( + credential: Credential, + workspaceId: string, + ): Promise { + return this.applyCreateSession(credential, workspaceId); } - async rememberWorkspaceName(workspaceId: string, name: string): Promise { - this.heldGrants = this.heldGrants.map((grant) => - grant.workspace.id === workspaceId - ? { ...grant, workspace: { id: workspaceId, name } } - : grant, - ); + async useSession(session: Session): Promise { + this.refuseUnderEnvironmentSession(); + const record = this.validatedWorkspaceReference(session); + this.markedWorkspaceId = record.workspaceId; + this.pin = { kind: "marker", workspaceId: record.workspaceId }; + return this.asSession(record); } - async activateGrant(ref: string): Promise { + async endSession(session: Session): Promise { this.refuseUnderEnvironmentSession(); - this.activeWorkspaceId = this.resolveRef(ref).workspace.id; - const session = this.storedSession(); - if (session === null) { - throw credentialsRequiredError("grants-held-none-active"); + const record = this.validatedWorkspaceReference(session); + this.storedSessions = this.storedSessions.filter( + (stored) => stored.workspaceId !== record.workspaceId, + ); + if (this.markedWorkspaceId === record.workspaceId) { + this.markedWorkspaceId = null; + } + if ( + this.pin.kind === "marker" && + this.pin.workspaceId === record.workspaceId + ) { + this.pin = { kind: "marker", workspaceId: null }; } - return session; } - async forgetGrant(ref: string): Promise { - this.refuseUnderEnvironmentSession(); - const grant = this.resolveRef(ref); - this.heldGrants = this.heldGrants.filter((held) => held !== grant); - if (this.activeWorkspaceId === grant.workspace.id) { - this.activeWorkspaceId = null; + async endAllSessions(): Promise { + if (this.environmentToken !== undefined) { + if (this.storedSessions.length === 0) { + return; + } + throw environmentSessionMutationError({ + envVar: SERVICE_TOKEN_ENV_VAR, + storedSessionsExist: true, + }); } + this.storedSessions = []; + this.markedWorkspaceId = null; + this.pin = { kind: "marker", workspaceId: null }; } - /** Credential resolution is internal per the interface ruling: only - * apiClient() consumes it. */ - private resolveCredential(): Credential | null { - if (this.environmentSession !== undefined) { - return { - token: "test-environment-token", - refreshToken: undefined, - expiresAt: this.environmentSession.expiresAt, - method: this.environmentSession.method, - }; - } - const session = this.storedSession(); - if (session === null) { - return null; - } - const active = this.heldGrants.find( - (grant) => grant.workspace.id === this.activeWorkspaceId, - ); - return active === undefined ? null : active.credential; + tokenStorage(workspaceId: string): TokenStorage { + const boundRecord = (): TestSessionRecord | undefined => + this.storedSessions.find((record) => record.workspaceId === workspaceId); + return { + getTokens: async () => { + const record = boundRecord(); + if (record === undefined) { + return null; + } + return { + workspaceId, + accessToken: record.credential.token, + refreshToken: record.credential.refreshToken, + }; + }, + setTokens: async (tokens) => { + const record = boundRecord(); + if (record === undefined) { + throw new Error( + "@prisma/cli-engine/testing: the session this rotation belongs to has ended — a refresh write must not resurrect it", + ); + } + const claimed = claimedWorkspaceId(tokens.accessToken); + if (claimed !== undefined && claimed !== workspaceId) { + throw new Error( + "@prisma/cli-engine/testing: a refreshed token's workspace_id claim disagrees with the bound workspace — refresh cannot re-scope", + ); + } + this.storedSessions = this.storedSessions.map((stored) => + stored.workspaceId === workspaceId + ? { + ...stored, + credential: { + token: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: claimedExpiresAt(tokens.accessToken), + }, + } + : stored, + ); + }, + clearTokens: async () => { + this.removeRecordAndMarker(workspaceId); + }, + clearTokensIfCurrent: async (tokens) => { + const record = boundRecord(); + if ( + record === undefined || + tokens.workspaceId !== workspaceId || + tokens.accessToken !== record.credential.token || + tokens.refreshToken !== record.credential.refreshToken + ) { + return; + } + this.removeRecordAndMarker(workspaceId); + }, + withRefreshLock: (fn) => fn(), + }; } - async apiClient(): Promise { - if (this.resolveCredential() === null) { - throw credentialsRequiredError(); + private removeRecordAndMarker(workspaceId: string): void { + this.storedSessions = this.storedSessions.filter( + (stored) => stored.workspaceId !== workspaceId, + ); + if (this.markedWorkspaceId === workspaceId) { + this.markedWorkspaceId = null; } - if (this.client === undefined) { - throw new Error( - "@prisma/cli-engine/testing: supply managementApi.client to createTestCli before using apiClient()", - ); + } + + /** The marker the first read pins: a marker naming no record (the + * migration none-current case) pins as none. */ + private resolvedMarker(): string | null { + if ( + this.markedWorkspaceId !== null && + this.storedSessions.some( + (record) => record.workspaceId === this.markedWorkspaceId, + ) + ) { + return this.markedWorkspaceId; } - return this.client; + return null; } - /** Shared throw semantics with session(): null when signed out; the - * structured grants-held-none-active error when grants are held but - * no cursor names one. */ - private storedSession(): Session | null { - if (this.identity === undefined || this.heldGrants.length === 0) { + private pinnedSession(justPinned: boolean): Session | null { + if (this.pin.kind === "environment") { + return this.environmentSession(); + } + if (this.pin.kind === "unpinned" || this.pin.workspaceId === null) { + if (this.storedSessions.length > 0) { + throw credentialsRequiredError("sessions-held-none-current"); + } return null; } - const active = this.heldGrants.find( - (grant) => grant.workspace.id === this.activeWorkspaceId, + const pinnedWorkspaceId = this.pin.workspaceId; + const record = this.storedSessions.find( + (stored) => stored.workspaceId === pinnedWorkspaceId, ); - if (active === undefined) { - throw credentialsRequiredError("grants-held-none-active"); + if (record === undefined) { + if (justPinned) { + throw new Error( + "@prisma/cli-engine/testing: the pin resolved to a workspace with no record", + ); + } + throw credentialsRequiredError("session-ended"); + } + return this.asSession(record); + } + + private environmentSession(): Session { + const token = this.environmentToken; + if (token === undefined) { + throw new Error( + "@prisma/cli-engine/testing: no environment token is seeded", + ); + } + const workspaceId = claimedWorkspaceId(token); + if (workspaceId === undefined) { + throw new Error( + "@prisma/cli-engine/testing: the `environmentToken` seed must be a JWT with `workspace_id` (use mintTestJwt)", + ); } return { - identity: this.identity, - method: active.credential.method, - origin: "stored", - workspace: active.workspace, - expiresAt: active.credential.expiresAt, + workspaceId, + workspaceName: undefined, + expiresAt: claimedExpiresAt(token), + source: "environment", + current: true, }; } - private materializeStoredSession(session: Session): void { - this.identity = session.identity; - this.heldGrants = [ - { - workspace: session.workspace, - credential: { - token: "test-session-token", - refreshToken: undefined, - expiresAt: session.expiresAt, - method: session.method, - }, - }, - ]; - this.activeWorkspaceId = session.workspace.id; + private asSession(record: TestSessionRecord): Session { + return { + workspaceId: record.workspaceId, + workspaceName: record.workspaceName, + expiresAt: record.credential.expiresAt, + source: "stored", + current: record.workspaceId === this.markedWorkspaceId, + }; } - private applyBeginSession(credential: Credential): Session { - const derived = deriveFromClaims(credential); - const sameIdentity = - this.identity !== undefined && this.identity.id === derived.identity.id; - if (!sameIdentity) { - this.heldGrants = []; + private applyCreateSession( + credential: Credential, + workspaceId: string, + ): Session { + const claimed = claimedWorkspaceId(credential.token); + if (claimed !== undefined && claimed !== workspaceId) { + throw new Error( + "@prisma/cli-engine/testing: createSession's workspaceId argument disagrees with the credential's workspace_id claim", + ); } - this.identity = derived.identity; - const existing = this.heldGrants.find( - (grant) => grant.workspace.id === derived.workspaceId, + const existing = this.storedSessions.find( + (stored) => stored.workspaceId === workspaceId, ); - const stored: TestGrant = { - workspace: { id: derived.workspaceId, name: existing?.workspace.name }, + const record: TestSessionRecord = { + workspaceId, + workspaceName: existing?.workspaceName, credential: { token: credential.token, refreshToken: credential.refreshToken, - expiresAt: derived.expiresAt, - method: credential.method, + expiresAt: claimedExpiresAt(credential.token) ?? credential.expiresAt, }, }; - this.heldGrants = [ - ...this.heldGrants.filter((grant) => grant.workspace.id !== derived.workspaceId), - stored, + this.storedSessions = [ + ...this.storedSessions.filter( + (stored) => stored.workspaceId !== workspaceId, + ), + record, ]; - this.activeWorkspaceId = derived.workspaceId; - return { - identity: derived.identity, - method: credential.method, - origin: "stored", - workspace: stored.workspace, - expiresAt: derived.expiresAt, - }; + this.markedWorkspaceId = workspaceId; + if (this.environmentToken === undefined) { + this.pin = { kind: "marker", workspaceId }; + } + return this.asSession(record); } private refuseUnderEnvironmentSession(): void { - if (this.environmentSession !== undefined) { + if (this.environmentToken !== undefined) { throw environmentSessionMutationError({ envVar: SERVICE_TOKEN_ENV_VAR, - storedGrantsExist: this.heldGrants.length > 0, + storedSessionsExist: this.storedSessions.length > 0, }); } } - private resolveRef(ref: string): TestGrant { - const byId = this.heldGrants.find((grant) => grant.workspace.id === ref); - if (byId !== undefined) { - return byId; + private validatedWorkspaceReference(session: Session): TestSessionRecord { + if (session.source === "environment") { + throw noSessionForWorkspaceError(session.workspaceId); } - const byName = this.heldGrants.filter( - (grant) => grant.workspace.name?.toLowerCase() === ref.toLowerCase(), + const record = this.storedSessions.find( + (stored) => stored.workspaceId === session.workspaceId, ); - if (byName.length > 1) { - throw new CliStructuredError( - "AUTH.WORKSPACE_REF_AMBIGUOUS", - `'${ref}' names more than one held workspace grant.`, - { - nextActions: [ - { - kind: "user-choice", - label: "Refer to the workspace by its id instead.", - }, - ], - }, - ); - } - if (byName.length === 0) { - throw new CliStructuredError( - "AUTH.GRANT_NOT_HELD", - `You hold no workspace grant matching '${ref}'.`, - { - nextActions: [ - { - kind: "user-choice", - label: "Sign in to that workspace to acquire a grant for it.", - }, - ], - }, - ); + if (record === undefined) { + throw noSessionForWorkspaceError(session.workspaceId); } - return byName[0]; + return record; } } diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index 37559796..92befd83 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -1,27 +1,27 @@ import type { CommandFamily, MountedTree } from "./command-family"; import type { Credentials } from "./context"; -import type { - Credential, - Identity, - Session, -} from "./credential-manager"; +import type { Credential } from "./credential-manager"; import type { EngineEvent, StreamEvent } from "./events"; import { buildEngine } from "./execution/engine"; -import type { ManagementApiClient } from "./management-api"; +import type { + ManagementApiClient, + ManagementApiClientConfig, +} from "./management-api"; import type { PresentedResult } from "./presentation"; import type { RunSummary } from "./run-summary"; import type { Runtime } from "./runtime"; import { TestCredentialManager, - type TestGrant, + type TestSessionRecord, } from "./testing-credential-manager"; export interface TestCli { /** * The mutable in-memory credential manager backing the runs — the - * whole state (grants, per-grant credentials, cursor) is readable - * back after a run via state(). Undefined only when the legacy - * `credentials` seed selected the getCredentials fallback path. + * whole stored state (sessions with their credentials, the current + * marker) is readable back after a run via state(). Undefined only + * when the legacy `credentials` seed selected the getCredentials + * fallback path. */ readonly credentialManager: TestCredentialManager | undefined; run( @@ -93,16 +93,19 @@ export function createTestCli(spec: { * seeds below; deleted with the swap's final stage. */ readonly credentials?: Credentials; - /** Preferred manager seed: beginSession runs its real claims + /** Convenience manager seed: createSession runs its real claims * derivation on this credential (mint the token with mintTestJwt). */ readonly credential?: Credential; - /** Escape hatch: ctx.session() resolves exactly this. */ - readonly session?: Session; - /** Grants-model seeding; identity is independent of grants so - * mismatched states are constructible. */ - readonly identity?: Identity; - readonly grants?: readonly TestGrant[]; - readonly activeWorkspaceId?: string; + /** Stored sessions, mirroring the state file's records. */ + readonly sessions?: readonly TestSessionRecord[]; + /** The file's current marker. */ + readonly currentWorkspaceId?: string; + /** Composes the ephemeral env session; also exported to each run's + * env as PRISMA_SERVICE_TOKEN (overridable per run). */ + readonly environmentToken?: string; + /** The SDK client construction config; defaults point every + * endpoint at test.invalid hosts. */ + readonly managementApiClientConfig?: ManagementApiClientConfig; /** baseUrl defaults to "https://test.invalid"; when `client` is * supplied, ctx.api IS that object. */ readonly managementApi?: { @@ -115,10 +118,9 @@ export function createTestCli(spec: { }): TestCli { const managerSeeded = spec.credential !== undefined || - spec.session !== undefined || - spec.identity !== undefined || - spec.grants !== undefined || - spec.activeWorkspaceId !== undefined; + spec.sessions !== undefined || + spec.currentWorkspaceId !== undefined || + spec.environmentToken !== undefined; if (spec.credentials !== undefined && managerSeeded) { throw new Error( "@prisma/cli-engine/testing: the legacy `credentials` seed selects the manager-less fallback runtime and cannot be combined with credential-manager seeds", @@ -127,16 +129,19 @@ export function createTestCli(spec: { const credentialManager = spec.credentials !== undefined ? undefined - : new TestCredentialManager( - { - credential: spec.credential, - session: spec.session, - identity: spec.identity, - grants: spec.grants, - activeWorkspaceId: spec.activeWorkspaceId, - }, - spec.managementApi?.client, - ); + : new TestCredentialManager({ + sessions: spec.sessions, + currentWorkspaceId: spec.currentWorkspaceId, + credential: spec.credential, + environmentToken: spec.environmentToken, + }); + const managementApiClientConfig: ManagementApiClientConfig = + spec.managementApiClientConfig ?? { + clientId: "test-client-id", + redirectUri: "https://test.invalid/auth/callback", + apiBaseUrl: spec.managementApi?.baseUrl ?? "https://test.invalid", + authBaseUrl: "https://auth.test.invalid", + }; const engine = buildEngine( { name: "prisma-test", @@ -175,7 +180,10 @@ export function createTestCli(spec: { }, stdin: inputStreamFromString(opts?.stdin ?? ""), cwd: opts?.cwd ?? "/", - env: opts?.env ?? {}, + env: + spec.environmentToken === undefined + ? (opts?.env ?? {}) + : { PRISMA_SERVICE_TOKEN: spec.environmentToken, ...opts?.env }, isTty: { stdin: opts?.isTty?.stdin ?? false, stdout: opts?.isTty?.stdout ?? false, @@ -194,6 +202,7 @@ export function createTestCli(spec: { }, config: { sections: spec.config ?? {}, diagnostics: [] }, credentialManager, + managementApiClientConfig, getCredentials: async () => spec.credentials, managementApi: { baseUrl: spec.managementApi?.baseUrl ?? "https://test.invalid", diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 38754775..315e937f 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -1,14 +1,15 @@ /** - * The credential-manager engine surface: ctx.session on every context, - * the managesCredentials capability, the manager-backed needs check - * with its single-sourced errors, harness seeding with state - * read-back, and the no-token-material guarantees. + * The credential-manager engine surface (design rev 5, the session + * model): ctx.session on every context serving the process pin, the + * managesCredentials capability, the manager-backed needs check with + * its single-sourced errors, session mutations with state read-back, + * process-pinning semantics, harness seeding, and the + * no-token-material guarantees. */ import { type Credential, defineCommand, - type GrantSummary, type Session, } from "@prisma/cli-engine"; import { @@ -19,39 +20,44 @@ import { import { createTestCli, mintTestJwt, - type TestGrant, + TestCredentialManager, + type TestSessionRecord, } from "@prisma/cli-engine/testing"; import { afterEach, describe, expect, test, vi } from "vitest"; const userCredential = (overrides?: { readonly sub?: string; readonly workspaceId?: string; - readonly email?: string; readonly exp?: number; - readonly token?: string; }): Credential => ({ - token: - overrides?.token ?? - mintTestJwt({ - sub: overrides?.sub ?? "user-1", - workspace_id: overrides?.workspaceId ?? "workspace-1", - email: overrides?.email ?? "someone@example.com", - exp: overrides?.exp ?? 1_900_000_000, - }), + token: mintTestJwt({ + sub: overrides?.sub ?? "user-1", + workspace_id: overrides?.workspaceId ?? "workspace-1", + exp: overrides?.exp ?? 1_900_000_000, + }), refreshToken: undefined, expiresAt: undefined, - method: "user-oauth", }); -const grantFor = ( +const sessionRecordFor = ( workspaceId: string, - opts?: { readonly sub?: string; readonly name?: string }, -): TestGrant => ({ - workspace: { id: workspaceId, name: opts?.name }, - credential: userCredential({ - sub: opts?.sub ?? "user-1", - workspaceId, - }), + opts?: { readonly name?: string }, +): TestSessionRecord => ({ + workspaceId, + workspaceName: opts?.name, + credential: { + token: mintTestJwt({ sub: "user-1", workspace_id: workspaceId }), + refreshToken: undefined, + expiresAt: undefined, + }, +}); + +const storedSessionRef = (workspaceId: string): Session => ({ + workspaceId, + workspaceName: undefined, + expiresAt: undefined, + source: "stored", + current: false, }); const sessionReader = () => { @@ -75,44 +81,64 @@ describe("ctx.session", () => { expect(reader.seen()).toBeNull(); }); - test("a seeded credential runs real claims derivation: identity, workspace, and expiry come from the token", async () => { + test("a seeded credential runs real createSession derivation: workspace and expiry come from the token's claims", async () => { const reader = sessionReader(); const cli = createTestCli({ commands: { toy: reader.command }, credential: userCredential({ - sub: "user-42", workspaceId: "workspace-9", - email: "user42@example.com", exp: 1_900_000_000, }), }); const { exitCode } = await cli.run(["toy"]); expect(exitCode).toBe(0); expect(reader.seen()).toEqual({ - identity: { kind: "user", id: "user-42", email: "user42@example.com" }, - method: "user-oauth", - origin: "stored", - workspace: { id: "workspace-9", name: undefined }, + workspaceId: "workspace-9", + workspaceName: undefined, expiresAt: new Date(1_900_000_000 * 1000), + source: "stored", + current: true, }); }); - test("the seeded session escape hatch is returned verbatim", async () => { - const seeded: Session = { - identity: { kind: "service", id: "svc-1", label: undefined }, - method: "service-token", - origin: "environment", - workspace: { id: "workspace-env", name: undefined }, - expiresAt: undefined, - }; + test("seeded sessions with a current marker serve the marked session", async () => { + const reader = sessionReader(); + const cli = createTestCli({ + commands: { toy: reader.command }, + sessions: [ + sessionRecordFor("workspace-1", { name: "Acme Prod" }), + sessionRecordFor("workspace-2"), + ], + currentWorkspaceId: "workspace-1", + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(reader.seen()).toMatchObject({ + workspaceId: "workspace-1", + workspaceName: "Acme Prod", + source: "stored", + current: true, + }); + }); + + test("a seeded environment token composes the env session", async () => { const reader = sessionReader(); const cli = createTestCli({ commands: { toy: reader.command }, - session: seeded, + environmentToken: mintTestJwt({ + sub: "svc-1", + workspace_id: "workspace-env", + }), }); const { exitCode } = await cli.run(["toy"]); expect(exitCode).toBe(0); - expect(reader.seen()).toEqual(seeded); + expect(reader.seen()).toEqual({ + workspaceId: "workspace-env", + workspaceName: undefined, + expiresAt: undefined, + source: "environment", + current: true, + }); }); afterEach(() => { @@ -192,9 +218,9 @@ describe("the manager-backed needs check", () => { }); }); - test("grants held, none active: the identical single-sourced error from the needs check, ctx.session, and a bare ctx.api touch", async () => { + test("sessions held, none current: the identical single-sourced error from the needs check, ctx.session, and a bare ctx.api touch", async () => { const seeds = { - grants: [grantFor("workspace-1"), grantFor("workspace-2")], + sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], }; const fromNeedsCheck = await (async () => { @@ -255,7 +281,7 @@ describe("the manager-backed needs check", () => { expect(fromNeedsCheck).toMatchObject({ code: "CLI.CREDENTIALS_REQUIRED", - why: "You hold workspace grants, but none is active.", + why: "You have workspace sessions but none is current.", nextActions: [ { kind: "run-command", @@ -278,263 +304,267 @@ describe("the manager-backed needs check", () => { }); }); -const managed = ( - body: (manager: { - readonly beginSession: (credential: Credential) => Promise; - readonly endSession: () => Promise; - readonly activateGrant: (ref: string) => Promise; - readonly forgetGrant: (ref: string) => Promise; - readonly rememberWorkspaceName: ( - workspaceId: string, - name: string, - ) => Promise; - readonly grants: () => Promise; - }) => Promise, -) => - defineCommand({ - help: { summary: "Mutates through the manager" }, - managesCredentials: true, - handler: async (_args, ctx) => { - try { - await body(ctx.credentialManager); - } catch (cause) { - return notOk(cause as CliStructuredError); - } - return ok(ctx.present({ data: null }, { human: () => [] })); - }, - }); +const codeOf = (thrown: unknown): string => + (thrown as CliStructuredError).code; -describe("mutations and state read-back", () => { - test("beginSession records identity, one grant, and the cursor", async () => { - const cli = createTestCli({ - commands: { - toy: managed(async (manager) => { - await manager.beginSession( - userCredential({ sub: "user-1", workspaceId: "workspace-1" }), - ); - }), - }, +describe("session mutations and state read-back", () => { + test("createSession upserts by workspaceId, preserves a recorded name, and sets the marker", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1", { name: "Acme Prod" })], + currentWorkspaceId: "workspace-1", }); - const { exitCode } = await cli.run(["toy"]); - expect(exitCode).toBe(0); - const state = cli.credentialManager?.state(); - expect(state?.identity).toEqual({ - kind: "user", - id: "user-1", - email: "someone@example.com", - }); - expect(state?.grants.map((grant) => grant.workspace.id)).toEqual([ + await manager.createSession( + userCredential({ workspaceId: "workspace-2" }), + "workspace-2", + ); + await manager.createSession( + userCredential({ workspaceId: "workspace-1", sub: "user-9" }), "workspace-1", - ]); - expect(state?.activeWorkspaceId).toBe("workspace-1"); - }); - - test("same identity upserts and preserves a recorded workspace name; a different identity replaces every grant", async () => { - const sameIdentity = userCredential({ - sub: "user-1", - workspaceId: "workspace-1", - }); - const otherWorkspace = userCredential({ - sub: "user-1", - workspaceId: "workspace-2", - }); - const otherIdentity = userCredential({ - sub: "user-9", - workspaceId: "workspace-3", - }); - const cli = createTestCli({ - commands: { - toy: managed(async (manager) => { - await manager.beginSession(sameIdentity); - await manager.rememberWorkspaceName("workspace-1", "Acme Prod"); - await manager.beginSession(otherWorkspace); - await manager.beginSession(sameIdentity); - }), - replace: managed(async (manager) => { - await manager.beginSession(otherIdentity); - }), - }, - }); - expect((await cli.run(["toy"])).exitCode).toBe(0); - const upserted = cli.credentialManager?.state(); + ); + const state = manager.state(); expect( - upserted?.grants.map((grant) => [ - grant.workspace.id, - grant.workspace.name, + state.sessions.map((record) => [ + record.workspaceId, + record.workspaceName, ]), ).toEqual([ ["workspace-2", undefined], ["workspace-1", "Acme Prod"], ]); - expect(upserted?.activeWorkspaceId).toBe("workspace-1"); + expect(state.currentWorkspaceId).toBe("workspace-1"); + expect(await manager.currentSession()).toMatchObject({ + workspaceId: "workspace-1", + current: true, + }); + }); - expect((await cli.run(["replace"])).exitCode).toBe(0); - const replaced = cli.credentialManager?.state(); - expect(replaced?.identity).toMatchObject({ id: "user-9" }); - expect(replaced?.grants.map((grant) => grant.workspace.id)).toEqual([ - "workspace-3", - ]); - expect(replaced?.activeWorkspaceId).toBe("workspace-3"); + test("createSession refuses a workspaceId argument that disagrees with the workspace_id claim", async () => { + const manager = new TestCredentialManager({}); + await expect( + manager.createSession( + userCredential({ workspaceId: "workspace-1" }), + "workspace-2", + ), + ).rejects.toThrow(/disagrees with the credential's workspace_id claim/); }); - test("forgetGrant drops one grant and clears the cursor only when it named that grant", async () => { - const cli = createTestCli({ - commands: { - toy: managed(async (manager) => { - await manager.forgetGrant("workspace-1"); - }), - }, - grants: [grantFor("workspace-1"), grantFor("workspace-2")], - activeWorkspaceId: "workspace-1", + test("useSession switches the marker; an unknown workspace and an environment-source argument raise AUTH.NO_SESSION_FOR_WORKSPACE", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + currentWorkspaceId: "workspace-1", + }); + const switched = await manager.useSession(storedSessionRef("workspace-2")); + expect(switched).toMatchObject({ workspaceId: "workspace-2", current: true }); + expect(manager.state().currentWorkspaceId).toBe("workspace-2"); + + await expect( + manager.useSession(storedSessionRef("workspace-9")).catch(codeOf), + ).resolves.toBe("AUTH.NO_SESSION_FOR_WORKSPACE"); + await expect( + manager + .useSession({ + ...storedSessionRef("workspace-2"), + source: "environment", + }) + .catch(codeOf), + ).resolves.toBe("AUTH.NO_SESSION_FOR_WORKSPACE"); + + const unchanged = await manager.useSession(storedSessionRef("workspace-2")); + expect(unchanged).toMatchObject({ workspaceId: "workspace-2" }); + }); + + test("endSession removes one session and clears the current only when it named it — no auto-promotion", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + currentWorkspaceId: "workspace-1", }); - expect((await cli.run(["toy"])).exitCode).toBe(0); - const state = cli.credentialManager?.state(); - expect(state?.grants.map((grant) => grant.workspace.id)).toEqual([ + await manager.endSession(storedSessionRef("workspace-1")); + const state = manager.state(); + expect(state.sessions.map((record) => record.workspaceId)).toEqual([ "workspace-2", ]); - expect(state?.activeWorkspaceId).toBeNull(); + expect(state.currentWorkspaceId).toBeNull(); + await expect(manager.currentSession().catch(codeOf)).resolves.toBe( + "CLI.CREDENTIALS_REQUIRED", + ); }); - test("activateGrant resolves an exact id first, then a case-insensitive name; ambiguity and no-match are structured errors", async () => { - const outcomes: Record = {}; - const cli = createTestCli({ - commands: { - toy: managed(async (manager) => { - outcomes.byId = (await manager.activateGrant("workspace-2")).workspace - .id; - outcomes.byName = (await manager.activateGrant("ACME staging")) - .workspace.id; - outcomes.ambiguous = await manager - .activateGrant("twin") - .then(() => "resolved") - .catch((cause: CliStructuredError) => cause.code); - outcomes.notHeld = await manager - .activateGrant("nowhere") - .then(() => "resolved") - .catch((cause: CliStructuredError) => cause.code); - }), - }, - grants: [ - grantFor("workspace-1", { name: "Acme Staging" }), - grantFor("workspace-2"), - grantFor("workspace-3", { name: "Twin" }), - grantFor("workspace-4", { name: "twin" }), - ], - activeWorkspaceId: "workspace-1", + test("endAllSessions clears every session and the marker", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + currentWorkspaceId: "workspace-1", + }); + await manager.endAllSessions(); + expect(manager.state()).toEqual({ + sessions: [], + currentWorkspaceId: null, }); - expect((await cli.run(["toy"])).exitCode).toBe(0); - expect(outcomes).toEqual({ - byId: "workspace-2", - byName: "workspace-1", - ambiguous: "AUTH.WORKSPACE_REF_AMBIGUOUS", - notHeld: "AUTH.GRANT_NOT_HELD", + expect(await manager.currentSession()).toBeNull(); + }); +}); + +describe("mutations under an env-supplied session", () => { + const environmentToken = mintTestJwt({ + sub: "svc-1", + workspace_id: "workspace-env", + }); + + test("useSession and endSession refuse, naming the variable and the unset command; state is untouched", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1")], + currentWorkspaceId: "workspace-1", + environmentToken, + }); + for (const mutate of [ + () => manager.useSession(storedSessionRef("workspace-1")), + () => manager.endSession(storedSessionRef("workspace-1")), + ]) { + const thrown = (await mutate().catch( + (cause: unknown) => cause, + )) as CliStructuredError; + expect(thrown.code).toBe("AUTH.ENV_SESSION_IN_FORCE"); + expect(thrown.nextActions).toMatchObject([ + { kind: "run-command", command: "unset PRISMA_SERVICE_TOKEN" }, + ]); + } + expect(manager.state().sessions).toHaveLength(1); + expect(manager.state().currentWorkspaceId).toBe("workspace-1"); + }); + + test("endAllSessions refuses when stored sessions exist and succeeds as a no-op when there are none", async () => { + const withStored = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1")], + environmentToken, }); - expect(cli.credentialManager?.state().activeWorkspaceId).toBe( + await expect(withStored.endAllSessions().catch(codeOf)).resolves.toBe( + "AUTH.ENV_SESSION_IN_FORCE", + ); + expect(withStored.state().sessions).toHaveLength(1); + + const withoutStored = new TestCredentialManager({ environmentToken }); + await expect(withoutStored.endAllSessions()).resolves.toBeUndefined(); + }); + + test("createSession is allowed; the env token remains in force", async () => { + const manager = new TestCredentialManager({ environmentToken }); + await manager.createSession( + userCredential({ workspaceId: "workspace-1" }), "workspace-1", ); + expect(manager.state().sessions.map((record) => record.workspaceId)).toEqual( + ["workspace-1"], + ); + expect(await manager.currentSession()).toMatchObject({ + source: "environment", + workspaceId: "workspace-env", + }); }); - test("rememberWorkspaceName records on a held grant and no-ops on an unheld id", async () => { - const cli = createTestCli({ - commands: { - toy: managed(async (manager) => { - await manager.rememberWorkspaceName("workspace-1", "Named"); - await manager.rememberWorkspaceName("workspace-unheld", "Ghost"); - }), - }, - grants: [grantFor("workspace-1")], - activeWorkspaceId: "workspace-1", + test("sessions() still lists stored sessions with the file's marked current", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + currentWorkspaceId: "workspace-2", + environmentToken, }); - expect((await cli.run(["toy"])).exitCode).toBe(0); - const state = cli.credentialManager?.state(); + const listed = await manager.sessions(); expect( - state?.grants.map((grant) => [grant.workspace.id, grant.workspace.name]), - ).toEqual([["workspace-1", "Named"]]); + listed.map((session) => [session.workspaceId, session.current]), + ).toEqual([ + ["workspace-1", false], + ["workspace-2", true], + ]); + expect(listed.every((session) => session.source === "stored")).toBe(true); }); +}); - test("endSession clears identity, every grant, and the cursor", async () => { - const cli = createTestCli({ - commands: { - toy: managed(async (manager) => { - await manager.endSession(); - }), - }, - grants: [grantFor("workspace-1"), grantFor("workspace-2")], - activeWorkspaceId: "workspace-1", +describe("process pinning", () => { + test("the marker moved by another process between reads does not re-pin; a new manager picks up the new marker", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + currentWorkspaceId: "workspace-1", }); - expect((await cli.run(["toy"])).exitCode).toBe(0); - expect(cli.credentialManager?.state()).toEqual({ - identity: undefined, - grants: [], - activeWorkspaceId: null, + expect(await manager.currentSession()).toMatchObject({ + workspaceId: "workspace-1", + }); + + manager.overwriteStoredState({ currentWorkspaceId: "workspace-2" }); + expect(await manager.currentSession()).toMatchObject({ + workspaceId: "workspace-1", + }); + + const movedState = manager.state(); + const newProcess = new TestCredentialManager({ + sessions: movedState.sessions, + currentWorkspaceId: movedState.currentWorkspaceId ?? undefined, + }); + expect(await newProcess.currentSession()).toMatchObject({ + workspaceId: "workspace-2", }); }); - test("mutations refuse under an env-supplied session, naming the variable and the unset command; state is untouched", async () => { - const envSession: Session = { - identity: { kind: "service", id: "svc-1", label: undefined }, - method: "service-token", - origin: "environment", - workspace: { id: "workspace-env", name: undefined }, - expiresAt: undefined, - }; - const cli = createTestCli({ - commands: { - toy: managed(async (manager) => { - await manager.endSession(); - }), - }, - session: envSession, + test("this manager's own useSession moves the pin", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + currentWorkspaceId: "workspace-1", }); - const { exitCode, json } = await cli.run(["toy", "--json"]); - expect(exitCode).toBe(2); - const result = json.find((frame) => frame.kind === "result"); - expect(result).toMatchObject({ - envelope: { - ok: false, - error: { - code: "AUTH.ENV_SESSION_IN_FORCE", - nextActions: [ - { kind: "run-command", command: "unset PRISMA_SERVICE_TOKEN" }, - ], - }, - }, + expect(await manager.currentSession()).toMatchObject({ + workspaceId: "workspace-1", + }); + await manager.useSession(storedSessionRef("workspace-2")); + expect(await manager.currentSession()).toMatchObject({ + workspaceId: "workspace-2", + }); + }); + + test("a pinned session ended by another process raises the session-ended wording on the next read", async () => { + const manager = new TestCredentialManager({ + sessions: [sessionRecordFor("workspace-1")], + currentWorkspaceId: "workspace-1", + }); + expect(await manager.currentSession()).toMatchObject({ + workspaceId: "workspace-1", }); + manager.overwriteStoredState({ sessions: [], currentWorkspaceId: null }); + const thrown = (await manager + .currentSession() + .catch((cause: unknown) => cause)) as CliStructuredError; + expect(thrown.code).toBe("CLI.CREDENTIALS_REQUIRED"); + expect(thrown.message).toContain("has ended"); }); }); describe("token material never leaves", () => { - test("grants() summaries and the session expose no seeded token through any output channel", async () => { + test("sessions() and the session expose no seeded token through any output channel", async () => { const secret = mintTestJwt({ sub: "user-1", workspace_id: "workspace-1", secret_marker: "SECRET-TOKEN-MATERIAL", }); const toy = defineCommand({ - help: { summary: "Lists grants" }, + help: { summary: "Lists sessions" }, managesCredentials: true, handler: async (_args, ctx) => { - const grants = await ctx.credentialManager.grants(); + const sessions = await ctx.credentialManager.sessions(); const session = await ctx.session(); return ok( - ctx.present({ data: { grants, session } }, { human: () => [] }), + ctx.present({ data: { sessions, session } }, { human: () => [] }), ); }, }); const cli = createTestCli({ commands: { toy }, - grants: [ + sessions: [ { - workspace: { id: "workspace-1", name: "Acme" }, + workspaceId: "workspace-1", + workspaceName: "Acme", credential: { token: secret, refreshToken: "SECRET-REFRESH-TOKEN", expiresAt: undefined, - method: "user-oauth", }, }, ], - activeWorkspaceId: "workspace-1", + currentWorkspaceId: "workspace-1", }); const { exitCode, stdout, stderr, json } = await cli.run(["toy", "--json"]); expect(exitCode).toBe(0); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 328b76af..90a274cf 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -29,7 +29,9 @@ describe("main export", () => { "environmentSessionMutationError", "flag", "loadConfig", + "noSessionForWorkspaceError", "positional", + "serviceTokenRejectedError", ]); }); diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index 3f6a2274..66b91dc7 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -10,7 +10,6 @@ import type { CommandContext, CommandFamily, CredentialManager, - GrantSummary, Session, CommandHandler, CompletedEnvelope, @@ -394,10 +393,10 @@ export const runtimeShape: Runtime = { }; // ————————————————————————————————————————————————————————————————————— -// The credential manager surface (design rev 4): managesCredentials is -// a capability — ctx.credentialManager exists exactly when declared; -// ctx.session exists on every context; the harness seeds a mutable -// in-memory manager. +// The credential manager surface (design rev 5, the session model): +// managesCredentials is a capability — ctx.credentialManager exists +// exactly when declared; ctx.session exists on every context; the +// harness seeds a mutable in-memory manager. // ————————————————————————————————————————————————————————————————————— export const managedCommand = defineCommand({ @@ -425,8 +424,12 @@ export const unmanagedCommand = defineCommand({ }); export const unmanagedIsUndeclared: false = unmanagedCommand.managesCredentials; -export const grantSummaryHasNoTokenMaterial: "workspace" | "expiresAt" | "active" = - undefined as unknown as keyof GrantSummary; +export const sessionHasNoTokenMaterial: + | "workspaceId" + | "workspaceName" + | "expiresAt" + | "source" + | "current" = undefined as unknown as keyof Session; export const seededHarnessSpec: Parameters[0] = { commands: tree, @@ -434,24 +437,35 @@ export const seededHarnessSpec: Parameters[0] = { token: "jwt", refreshToken: undefined, expiresAt: undefined, - method: "user-oauth", }, - identity: { kind: "user", id: "user-1", email: undefined }, - grants: [ + sessions: [ { - workspace: { id: "workspace-1", name: "Acme" }, + workspaceId: "workspace-1", + workspaceName: "Acme", credential: { token: "jwt", refreshToken: undefined, expiresAt: undefined, - method: "user-oauth", }, }, ], - activeWorkspaceId: "workspace-1", + currentWorkspaceId: "workspace-1", + environmentToken: "jwt", + managementApiClientConfig: { + clientId: "client", + redirectUri: "https://test.invalid/cb", + apiBaseUrl: "https://api.test.invalid", + authBaseUrl: "https://auth.test.invalid", + }, }; export const runtimeWithManager: Runtime = { ...runtimeShape, credentialManager: undefined as unknown as CredentialManager, + managementApiClientConfig: { + clientId: "client", + redirectUri: "https://test.invalid/cb", + apiBaseUrl: "https://api.test.invalid", + authBaseUrl: "https://auth.test.invalid", + }, }; diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 93dc3d5e..dcfd5edf 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -1,24 +1,37 @@ /** - * ctx.api: injected fakes, the lazy proxy over the credential - * manager's apiClient(), the unauthenticated throw path, and the - * engine-side request-failure mapping (refresh-invalid → expired; - * other AuthError → state re-read). + * ctx.api under design rev 5: the ENGINE constructs the pinned + * session's client from the injected config — the SDK's refreshing + * path over the manager's TokenStorage view for stored sessions, the + * static-token path for env sessions — plus the engine-side + * request-failure mapping (refresh-invalid → expired; other AuthError + * → bound-workspace state re-read; cause-chain unwrapping). Requests + * run against the real SDK over a scripted global fetch. */ import { type CredentialManager, defineCommand, type ManagementApiClient, + type ManagementApiClientConfig, type Runtime, + type Session, + type TokenStorage, credentialsRequiredError, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { createTestCli } from "@prisma/cli-engine/testing"; -import { AuthError } from "@prisma/management-api-sdk"; -import { describe, expect, test } from "vitest"; +import { createTestCli, mintTestJwt } from "@prisma/cli-engine/testing"; +import { afterEach, describe, expect, test, vi } from "vitest"; import type { AnyCommand } from "../src/commands"; import { buildEngine, type RunHooks } from "../src/execution/engine"; +const CLIENT_CONFIG: ManagementApiClientConfig = { + clientId: "test-client-id", + redirectUri: "https://test.invalid/auth/callback", + apiBaseUrl: "https://api.test.invalid", + authBaseUrl: "https://auth.test.invalid", +}; +const TOKEN_ENDPOINT = "https://auth.test.invalid/token"; + function unusedManagerMethod(name: string): () => never { return () => { throw new Error(`unexpected CredentialManager.${name} call`); @@ -29,18 +42,25 @@ function fakeCredentialManager( overrides: Partial, ): CredentialManager { return { - session: unusedManagerMethod("session"), - beginSession: unusedManagerMethod("beginSession"), + currentSession: unusedManagerMethod("currentSession"), + sessions: unusedManagerMethod("sessions"), + createSession: unusedManagerMethod("createSession"), + useSession: unusedManagerMethod("useSession"), endSession: unusedManagerMethod("endSession"), - grants: unusedManagerMethod("grants"), - rememberWorkspaceName: unusedManagerMethod("rememberWorkspaceName"), - activateGrant: unusedManagerMethod("activateGrant"), - forgetGrant: unusedManagerMethod("forgetGrant"), - apiClient: unusedManagerMethod("apiClient"), + endAllSessions: unusedManagerMethod("endAllSessions"), + tokenStorage: unusedManagerMethod("tokenStorage"), ...overrides, }; } +const storedSession = (workspaceId: string): Session => ({ + workspaceId, + workspaceName: undefined, + expiresAt: undefined, + source: "stored", + current: true, +}); + function makeRuntime(overrides?: { readonly credentialManager?: CredentialManager; }): Runtime & { @@ -72,6 +92,7 @@ function makeRuntime(overrides?: { onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, credentialManager: overrides?.credentialManager, + managementApiClientConfig: CLIENT_CONFIG, getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", @@ -107,22 +128,57 @@ const succeed = ( }); const callApi = succeed(async (ctx) => { - const client = ctx.api as unknown as { call: () => Promise }; - await client.call(); + await ctx.api.GET("/v1/workspaces", {}); }); -function throwingClientManager(failure: unknown): CredentialManager { - return fakeCredentialManager({ - apiClient: async () => - ({ - call: async () => { - throw failure; - }, - }) as unknown as ManagementApiClient, - }); +interface RecordedFetch { + readonly url: string; + readonly authorization: string | null; +} + +function scriptFetch( + script: (url: string, request: Request) => Response | Promise, +): RecordedFetch[] { + const calls: RecordedFetch[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: Request | string | URL, init?: RequestInit) => { + const request = + input instanceof Request ? input : new Request(input, init); + calls.push({ + url: request.url, + authorization: request.headers.get("authorization"), + }); + return script(request.url, request); + }), + ); + return calls; } -describe("ctx.api", () => { +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const accessTokenFor = (workspaceId: string, marker: string): string => + mintTestJwt({ sub: "user-1", workspace_id: workspaceId, token: marker }); + +const sessionSeed = (workspaceId: string, refreshToken?: string) => ({ + workspaceId, + workspaceName: undefined, + credential: { + token: accessTokenFor(workspaceId, "initial"), + refreshToken, + expiresAt: undefined, + }, +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ctx.api construction", () => { test("the harness client override IS ctx.api", async () => { const fake = { GET: async () => ({}) } as unknown as ManagementApiClient; let seen: ManagementApiClient | undefined; @@ -139,7 +195,7 @@ describe("ctx.api", () => { expect(seen).toBe(fake); }); - test("the manager's apiClient is never resolved for a run that never issues a request", async () => { + test("nothing is constructed for a run that never issues a request", async () => { const runtime = makeRuntime({ credentialManager: fakeCredentialManager({}), }); @@ -155,13 +211,7 @@ describe("ctx.api", () => { test("unauthenticated api use throws CLI.CREDENTIALS_REQUIRED and settles errored, exit 2", async () => { const cli = createTestCli({ - commands: { - toy: succeed(async (ctx) => { - await ctx.api.GET("/v1/workspaces/{id}", { - params: { path: { id: "w1" } }, - }); - }), - }, + commands: { toy: callApi }, }); const { exitCode, stderr, json } = await cli.run(["toy", "--json"]); expect(exitCode).toBe(2); @@ -178,135 +228,254 @@ describe("ctx.api", () => { }); }); - test("apiClient resolves on first method call and once per run; calls are proxied with their arguments", async () => { - let resolutions = 0; - const observed: unknown[] = []; + test("the client is constructed once per run over the pinned session's TokenStorage view; calls are proxied", async () => { + const calls = scriptFetch(() => jsonResponse(200, { workspaces: [] })); + let tokenStorageResolutions = 0; + const storage: TokenStorage = { + getTokens: async () => ({ + workspaceId: "workspace-1", + accessToken: accessTokenFor("workspace-1", "initial"), + }), + setTokens: async () => {}, + clearTokens: async () => {}, + }; const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - apiClient: async () => { - resolutions += 1; - return { - call: async (argument: unknown) => { - observed.push(argument); - }, - } as unknown as ManagementApiClient; + currentSession: async () => storedSession("workspace-1"), + tokenStorage: (workspaceId) => { + expect(workspaceId).toBe("workspace-1"); + tokenStorageResolutions += 1; + return storage; }, }), }); const exitCode = await runEngine( succeed(async (ctx) => { - const client = ctx.api as unknown as { - call: (argument: unknown) => Promise; - }; - await client.call("first"); - await client.call("second"); + await ctx.api.GET("/v1/workspaces", {}); + await ctx.api.GET("/v1/workspaces", {}); }), runtime, ); expect(exitCode).toBe(0); - expect(resolutions).toBe(1); - expect(observed).toEqual(["first", "second"]); + expect(tokenStorageResolutions).toBe(1); + expect(calls).toHaveLength(2); + expect(calls[0].url).toBe("https://api.test.invalid/v1/workspaces"); + expect(calls[0].authorization).toBe( + `Bearer ${accessTokenFor("workspace-1", "initial")}`, + ); }); +}); - test("refreshTokenInvalid === true maps to CLI.CREDENTIALS_REQUIRED with the expiry wording, exit 2", async () => { - const runtime = makeRuntime({ - credentialManager: throwingClientManager( - new AuthError("401 Unauthorized", true), - ), +describe("the stored-session refresh path", () => { + test("a 401 refreshes through the manager's TokenStorage view and retries; the rotated pair lands in the store", async () => { + const rotatedAccessToken = accessTokenFor("workspace-1", "rotated"); + const calls = scriptFetch((url) => { + if (url === TOKEN_ENDPOINT) { + return jsonResponse(200, { + access_token: rotatedAccessToken, + refresh_token: "refresh-2", + }); + } + const bearer = calls[calls.length - 1].authorization; + return bearer === `Bearer ${rotatedAccessToken}` + ? jsonResponse(200, { workspaces: ["fresh"] }) + : jsonResponse(401, { message: "unauthorized" }); }); - const exitCode = await runEngine(callApi, runtime); - expect(exitCode).toBe(2); - expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); - expect(runtime.stdoutText()).toContain( - "Your session has expired — sign in again.", + let seen: unknown; + const cli = createTestCli({ + commands: { + toy: succeed(async (ctx) => { + seen = await ctx.api.GET("/v1/workspaces", {}); + }), + }, + sessions: [sessionSeed("workspace-1", "refresh-1")], + currentWorkspaceId: "workspace-1", + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(seen).toMatchObject({ data: { workspaces: ["fresh"] } }); + const state = cli.credentialManager?.state(); + expect(state?.sessions).toMatchObject([ + { + workspaceId: "workspace-1", + credential: { token: rotatedAccessToken, refreshToken: "refresh-2" }, + }, + ]); + expect(state?.currentWorkspaceId).toBe("workspace-1"); + }); + + test("invalid_grant on refresh maps to CLI.CREDENTIALS_REQUIRED with the expiry wording; compare-and-clear ended the session", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(400, { error: "invalid_grant" }) + : jsonResponse(401, { message: "unauthorized" }), ); + const cli = createTestCli({ + commands: { toy: callApi }, + sessions: [sessionSeed("workspace-1", "refresh-1")], + currentWorkspaceId: "workspace-1", + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + const result = json.find((frame) => frame.kind === "result"); + expect(result).toMatchObject({ + envelope: { + ok: false, + error: { + code: "CLI.CREDENTIALS_REQUIRED", + summary: "Your session has expired — sign in again.", + }, + }, + }); + expect(cli.credentialManager?.state()).toEqual({ + sessions: [], + currentWorkspaceId: null, + }); }); - test("another AuthError with the grant gone maps to the grant-removed CLI.CREDENTIALS_REQUIRED", async () => { - const runtime = makeRuntime({ - credentialManager: fakeCredentialManager({ - session: async () => null, - apiClient: async () => - ({ - call: async () => { - throw new AuthError("No tokens available", false); - }, - }) as unknown as ManagementApiClient, - }), + test("a transient refresh failure with the bound session still stored maps to CLI.AUTH_SERVICE_ERROR; nothing cleared", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(500, { message: "boom" }) + : jsonResponse(401, { message: "unauthorized" }), + ); + const cli = createTestCli({ + commands: { toy: callApi }, + sessions: [sessionSeed("workspace-1", "refresh-1")], + currentWorkspaceId: "workspace-1", + managementApiClientConfig: CLIENT_CONFIG, }); - const exitCode = await runEngine(callApi, runtime); + const { exitCode, json } = await cli.run(["toy", "--json"]); expect(exitCode).toBe(2); - expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); - expect(runtime.stdoutText()).toContain("no longer held"); + const result = json.find((frame) => frame.kind === "result"); + expect(result).toMatchObject({ + envelope: { + ok: false, + error: { code: "CLI.AUTH_SERVICE_ERROR" }, + }, + }); + expect(cli.credentialManager?.state().sessions).toHaveLength(1); }); - test("another AuthError with a session still present maps to the transient auth-service error, exit 2", async () => { + test("the failure mapping re-reads the workspace the client is BOUND to, not currentSession()", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(500, { message: "boom" }) + : jsonResponse(401, { message: "unauthorized" }), + ); const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - session: async () => ({ - identity: { kind: "user", id: "u1", email: undefined }, - method: "user-oauth", - origin: "stored", - workspace: { id: "w1", name: undefined }, - expiresAt: undefined, + // The pin still reports workspace-1; the stored state only + // holds workspace-2 — the bound session is gone. + currentSession: async () => storedSession("workspace-1"), + sessions: async () => [storedSession("workspace-2")], + tokenStorage: () => ({ + getTokens: async () => ({ + workspaceId: "workspace-1", + accessToken: accessTokenFor("workspace-1", "initial"), + refreshToken: "refresh-1", + }), + setTokens: async () => {}, + clearTokens: async () => {}, }), - apiClient: async () => - ({ - call: async () => { - throw new AuthError("token endpoint returned 500", false); - }, - }) as unknown as ManagementApiClient, }), }); const exitCode = await runEngine(callApi, runtime); expect(exitCode).toBe(2); - expect(runtime.stdoutText()).toContain('"code":"CLI.AUTH_SERVICE_ERROR"'); - expect(runtime.stdoutText()).not.toContain("CLI.CREDENTIALS_REQUIRED"); + expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); + expect(runtime.stdoutText()).toContain("has ended"); }); - test("another AuthError while the state re-read raises a structured error surfaces that error", async () => { + test("a structured error thrown inside the request pipeline is unwrapped from the SDK's FetchError and settles as itself", async () => { + scriptFetch(() => jsonResponse(200, {})); const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - session: async () => { - throw credentialsRequiredError("grants-held-none-active"); - }, - apiClient: async () => - ({ - call: async () => { - throw new AuthError("refresh raced a forget", false); - }, - }) as unknown as ManagementApiClient, + currentSession: async () => storedSession("workspace-1"), + tokenStorage: () => ({ + getTokens: async () => { + throw credentialsRequiredError("sessions-held-none-current"); + }, + setTokens: async () => {}, + clearTokens: async () => {}, + }), }), }); const exitCode = await runEngine(callApi, runtime); expect(exitCode).toBe(2); expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); expect(runtime.stdoutText()).toContain( - "You hold workspace grants, but none is active.", + "You have workspace sessions but none is current.", ); }); - test("a structured error raised inside the request pipeline settles as itself", async () => { - const runtime = makeRuntime({ - credentialManager: throwingClientManager( - credentialsRequiredError("grants-held-none-active"), - ), - }); - const exitCode = await runEngine(callApi, runtime); - expect(exitCode).toBe(2); - expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); - expect(runtime.stdoutText()).toContain("No workspace is active."); - }); - test("a cyclic cause chain on a request failure terminates and settles as a bug", async () => { + scriptFetch(() => jsonResponse(200, {})); const cyclic = new Error("outer"); const inner = new Error("inner", { cause: cyclic }); cyclic.cause = inner; const runtime = makeRuntime({ - credentialManager: throwingClientManager(cyclic), + credentialManager: fakeCredentialManager({ + currentSession: async () => storedSession("workspace-1"), + tokenStorage: () => ({ + getTokens: async () => { + throw cyclic; + }, + setTokens: async () => {}, + clearTokens: async () => {}, + }), + }), }); const exitCode = await runEngine(callApi, runtime); expect(exitCode).toBe(1); expect(runtime.stdoutText()).toContain('"code":"CLI.INTERNAL_ERROR"'); }); }); + +describe("the environment-session static path", () => { + const environmentToken = mintTestJwt({ + sub: "svc-1", + workspace_id: "workspace-env", + }); + + test("requests carry the env token; a 401 maps to AUTH.SERVICE_TOKEN_REJECTED without touching the token endpoint", async () => { + const calls = scriptFetch(() => + jsonResponse(401, { message: "unauthorized" }), + ); + const cli = createTestCli({ + commands: { toy: callApi }, + environmentToken, + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + const result = json.find((frame) => frame.kind === "result"); + expect(result).toMatchObject({ + envelope: { + ok: false, + error: { code: "AUTH.SERVICE_TOKEN_REJECTED" }, + }, + }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("https://api.test.invalid/v1/workspaces"); + expect(calls[0].authorization).toBe(`Bearer ${environmentToken}`); + }); + + test("a successful env-session request passes its data through", async () => { + scriptFetch(() => jsonResponse(200, { workspaces: ["env"] })); + let seen: unknown; + const cli = createTestCli({ + commands: { + toy: succeed(async (ctx) => { + seen = await ctx.api.GET("/v1/workspaces", {}); + }), + }, + environmentToken, + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(seen).toMatchObject({ data: { workspaces: ["env"] } }); + }); +}); From 6bb8452a09c4bcdda8b6fd39fb696f50bea5979c Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 17:16:16 +0200 Subject: [PATCH 40/67] feat(cli-engine): consent tokens, openUrl, and browser-wait affordances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three engine-owned interaction affordances, so command code never reads TTY or CI state and never invents its own consent-skipping flag. prompt.consent takes an optional token — the natural noun of the action. Interactively it becomes type-to-confirm (clack re-prompts a wrong answer; the line renderer, which cannot offer a retry, fails structurally). Non-interactively, and under --yes, the new shared --confirm flag grants it: one supplied value must match the token exactly, and each value is consumed once per run. Consents without a token keep their yes/no rendering and stay non-interactively ungrantable; their error now says the command should declare a token instead of pointing at the abandoned per-command consent flags. ctx.openUrl announces a URL as an endpoint event — a stderr line in human mode, a frame in json mode — and opens the browser through an opener the runtime injects. A non-interactive run opens nothing and reports opened: false; nothing here is ever an error. prompt.browserWait adds the waiting half: announce, open, then poll on the engine's injectable clock until the caller says the user finished, the timeout elapses, or Ctrl-C cancels. Outside an interactive terminal it raises the interaction-required error with the URL in it, without opening or polling. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 23 +- .../assets/engine/engine-interface-draft.ts | 105 ++++- packages/cli-engine/src/context.ts | 69 +++- .../src/execution/clack-renderer.ts | 14 + .../src/execution/command-context.ts | 2 + packages/cli-engine/src/execution/engine.ts | 42 +- packages/cli-engine/src/execution/open-url.ts | 33 ++ packages/cli-engine/src/execution/prompts.ts | 167 +++++++- .../cli-engine/src/execution/shared-flags.ts | 12 + packages/cli-engine/src/exports/index.ts | 5 +- packages/cli-engine/src/runtime.ts | 8 + packages/cli-engine/src/testing.ts | 13 +- .../cli-engine/tests/clack-prompts.test.ts | 42 ++ .../tests/interaction-affordances.test.ts | 391 ++++++++++++++++++ packages/cli-engine/tests/prompts.test.ts | 2 +- packages/cli-engine/tests/run-hooks.test.ts | 1 + 16 files changed, 893 insertions(+), 36 deletions(-) create mode 100644 packages/cli-engine/src/execution/open-url.ts create mode 100644 packages/cli-engine/tests/interaction-affordances.test.ts diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index db4deeb5..9880f3a7 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -117,7 +117,8 @@ interface CredentialManager { endSession(session: Session): Promise; /** Log out entirely: remove all sessions and the marker (also - * reaps legacy files, §7). Reports how many it ended. */ + * reaps legacy files, §7). Returns nothing: the COMMAND reports + * how many it ended, by calling `sessions()` before this. */ endAllSessions(): Promise; /** ENGINE-FACING, not a user operation: the SDK TokenStorage @@ -215,7 +216,10 @@ Engine integration: Stored session → the SDK's refreshing path with `tokenStorage(workspaceId)` in the config. Env session → the SDK's static-token path (`createManagementApiClient({baseUrl, - token})`) — no refresh machinery may exist for it; its error + token})`), where the ENGINE reads `PRISMA_SERVICE_TOKEN` from the + injected `Runtime.env` for that token — the manager never exposes + token material — so nothing has to hand a credential back out of + the manager; no refresh machinery may exist for it; its error mapping happens at the call site (the static path has no error middleware). The auth commands that mutate state don't consume `ctx.api` as the pinned session afterwards (whoami enrichment @@ -233,8 +237,11 @@ Engine integration: renamed workspace keeps its stored name until the next login to it. `list` renders a nameless session by its id. - Harness: `createTestCli` seeds `{sessions?, currentWorkspaceId?, - credential?}` over a mutable in-memory manager with full state - read-back, plus the client config (local endpoint). + credential?, environmentToken?}` over a mutable in-memory manager + with full state read-back, plus the client config (local + endpoint). `environmentToken` composes the env session and is + exported to each run's env as `PRISMA_SERVICE_TOKEN`, which is + where the engine reads it. ## 5. Fixtures and required tests @@ -344,7 +351,10 @@ The SDK version is exact-pinned; a test asserts clearing happens on **Service token (env).** Composes as the process's pinned session (`source: "environment"`), never stored, absent from `sessions()` (the file's marked current stays shown; the listing states the -override). Static-token client, no refresh; 401 → structured error +override). The ENGINE builds that session's client by reading +`PRISMA_SERVICE_TOKEN` from the injected `Runtime.env` itself: the +manager composes the session but never hands out the token. +Static-token client, no refresh; 401 → structured error naming the env var; nothing cleared. `whoami` notes the override when stored sessions exist. Blank/whitespace → the single blank-token error. @@ -361,7 +371,8 @@ Legacy names, unchanged — the session model makes them honest - `auth login` — browser consent; user picks the workspace; `createSession(credential, workspaceId-from-claims)`. -- `auth logout` — `endAllSessions()`; reports the count ended. +- `auth logout` — `sessions()` for the count, then + `endAllSessions()`; the command reports the count it ended. - `auth whoami` — `ctx.session()` + claims decode; `ctx.api` enrichment when online. - `auth workspace list` — `sessions()`, current marked, nameless diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index 5b3703c3..bdcd6f24 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -25,6 +25,11 @@ * §6 gains the managesCredentials capability; §10 gains * Runtime.credentialManager and the injected client config; §11 gains * manager seeding + fixtures. + * Amended 2026-08-10 for the ENGINE INTERACTION AFFORDANCES (operator + * rulings): consent tokens with the shared --confirm flag (§4a), + * ctx.openUrl (§4), and prompt.browserWait (§4a). All three are + * engine-owned so command code never reads TTY or CI state and never + * invents its own consent-skipping flag. * * THE MODEL, in one analogy (operator, 2026-08-09): commands settle like * promises. A command can COMPLETE — and its completion can be @@ -98,7 +103,8 @@ * pipe-clean. json mode is unchanged: the frame stream owns stdout. * The engine injects the shared flag family on every non-server * command: --format/--json, --log-level/-v/--verbose, -q/--quiet, - * -y/--yes, --interactive/--no-interactive, --color/--no-color. + * -y/--yes, --confirm (repeatable), --interactive/--no-interactive, + * --color/--no-color. * Commands cannot declare flags with those names. Declared flag keys are * camelCase and transliterate to --kebab-case. * @@ -412,6 +418,22 @@ export interface CommandContext Promise<{ readonly opened: boolean }> + /** Fires on Ctrl-C/SIGTERM (engine-owned; a second signal * force-exits through the runtime's exit proxy). Session commands * run until it fires; everything else aborts in-flight work with @@ -548,8 +570,32 @@ export interface ManagementApiClientConfig { * Runtime.stdin.setRawMode present, no scripted answers — render * through @clack/prompts, loaded by dynamic import only on that path. * Both tiers write prompt UI to stderr and share the same structural - * rules: --yes resolution and structural failures are decided before - * the tier branch. Clack's cancel symbol (including the \x03 byte + * rules: --yes resolution, `--confirm` matching, and structural + * failures are decided before the tier branch. + * + * CONSENT TOKENS AND --confirm (operator ruling, 2026-08-10). A + * consent may declare a `token`: the natural noun of the action being + * consented to — an app name, a hostname — not a yes/no word. The + * token changes both halves of the prompt. + * Interactively it becomes TYPE-TO-CONFIRM: the user must type the + * token exactly. On the clack tier a wrong answer re-prompts (the + * only exits are the exact token and Ctrl-C); on the plain line + * tier — scripted answers, piped stdin — a wrong answer cannot be + * corrected, so it fails structurally (exit 2). + * Non-interactively (and under --yes, which is the same skip + * condition) the consent is satisfied iff one of the run's + * `--confirm ` values matches the token EXACTLY. Each value + * is consumed once per run, so two consents on one token need two + * --confirms. Otherwise the existing structural consent error + * (CLI.CONSENT_REQUIRED, exit 2), whose message now names the + * expected value and the literal `--confirm ` usage. + * A consent WITHOUT a token keeps today's yes/no rendering and stays + * non-interactively unsatisfiable — its error says the command + * should declare a token (the old "pass the command's explicit + * consent flag" wording described the abandoned per-command-flag + * doctrine: commands do not invent consent flags any more). + * --yes is unchanged by all of this: it accepts declared defaults and + * never grants consent, with or without a token. Clack's cancel symbol (including the \x03 byte * path) maps to the same CLI.PROMPT_CANCELLED exit-3 settlement. * Clack's spinner/log helpers are forbidden (process-global handlers); * progress remains engine events. @@ -565,11 +611,15 @@ export interface PromptSurface { /** * A question requiring EXPLICIT consent — never inferable, not * necessarily destructive. Structurally undefaultable: no default - * parameter exists, so --yes, Enter-through, and non-interactive - * contexts can never satisfy it; the command documents the explicit - * flag that grants consent non-interactively. + * parameter exists, so --yes and Enter-through can never satisfy it. + * `token` is the natural noun of the action; supplying one makes the + * interactive prompt type-to-confirm and makes `--confirm ` + * the one non-interactive way to grant it (§4a header). */ - readonly consent: (question: string) => Promise + readonly consent: ( + question: string, + opts?: { readonly token?: string }, + ) => Promise /** * On the clack tier, Enter picks the HIGHLIGHTED option — the * declared default when present, else the first option — so moving @@ -585,6 +635,32 @@ export interface PromptSurface { question: string, opts?: { readonly placeholder?: string; readonly default?: string }, ) => Promise + /** + * Sends the user to a URL and waits for them to finish there. It + * announces the URL (the same one-line `endpoint` announcement + * ctx.openUrl makes), opens the browser through the runtime's + * injected opener, then calls `poll` on the ENGINE's injectable + * clock at the engine's declared interval until it returns true. + * Settles three ways: resolved (poll true); the structured timeout + * error when `timeout` elapses first; the standard prompt-cancel + * exit-3 settlement on Ctrl-C. + * + * Non-interactively it throws the structured interaction-required + * error (exit 2) WITHOUT opening or polling anything, carrying the + * URL so the user can finish by hand. A command that can do nothing + * at all without an interactive terminal should say so declaratively + * with `needs.interaction` (§6) and fail before it starts, rather + * than reaching this error mid-run. + */ + readonly browserWait: (request: { + readonly url: string + readonly message: string + /** Has the user finished? Receives ctx.signal, so a polling + * request aborts with the command. */ + readonly poll: (signal: AbortSignal) => Promise + /** Milliseconds to keep polling before giving up. */ + readonly timeout: number + }) => Promise } // ———————————————————————————————————————————————————————————————————————— @@ -1166,6 +1242,12 @@ export interface Runtime { * wired; optional only during the staged swap. */ readonly managementApiClientConfig?: ManagementApiClientConfig readonly getCredentials: () => Promise + /** Opens a URL in the user's browser — the login flow's opener, + * wired by the bin so the engine never depends on it. Called only + * for interactive sessions; a throw means "did not open" and never + * fails a command; absent means this host cannot open a browser, + * and the URL is announced instead. */ + readonly openUrl?: (url: string) => Promise | void /** Management API endpoint config; the bin derives baseUrl from env * (getApiBaseUrl). */ readonly managementApi: { readonly baseUrl: string } @@ -1246,8 +1328,15 @@ export declare function createTestCli(spec: { readonly client?: ManagementApiClient } readonly packageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' - /** Fixed clock for deterministic stream timestamps. */ + /** Fixed clock for deterministic stream timestamps; a clock that + * advances also drives prompt.browserWait's timeout, whose waiting + * is instant under the harness. */ readonly now?: () => Date + /** The browser opener behind ctx.openUrl and prompt.browserWait. + * Defaults to one that succeeds without doing anything — a real + * browser is never a test dependency; pass a spy to assert what was + * opened, or a thrower for the could-not-open path. */ + readonly openUrl?: (url: string) => Promise | void }): TestCli /** Mints an unsigned JWT whose payload is exactly `claims` — the diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 67bed2bc..0d84fd74 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -62,6 +62,17 @@ export interface CommandContext< /** Interactive input. */ readonly prompt: PromptSurface; + /** + * Shows the user a URL and, in an interactive session, opens it in + * their browser. Always announces the URL on the commentary channel + * (an `endpoint` event: a stderr line in human mode, a frame in json + * mode), so a non-interactive run — which never opens anything — can + * still be completed by hand. Never fails: a browser that could not + * be opened reports `opened: false`. To wait for the user to finish + * something in that browser, use prompt.browserWait. + */ + readonly openUrl: (request: OpenUrlRequest) => Promise; + /** * Fires on Ctrl-C/SIGTERM (engine-owned; a second signal force-exits * through the runtime's exit proxy). Session commands run until it @@ -89,6 +100,33 @@ export interface CommandContext< ) => Promise>; } +export interface OpenUrlRequest { + readonly url: string; + /** The announcement label — what the URL is for, in the user's + * terms ("Finish signing in"). */ + readonly message: string; +} + +export interface OpenUrlOutcome { + /** False whenever the browser was not launched: a non-interactive + * session, a host with no opener wired, or an opener that failed. */ + readonly opened: boolean; +} + +export interface BrowserWaitRequest { + readonly url: string; + /** The announcement label — what the user is being sent to do. */ + readonly message: string; + /** + * Asks whether the user has finished. The engine calls it on its own + * interval and passes ctx.signal, so a poll that makes a request can + * abort with the command. + */ + readonly poll: (signal: AbortSignal) => Promise; + /** Milliseconds to keep polling before giving up. */ + readonly timeout: number; +} + /** * Prompts. Every prompt resolves to its answered value directly. * Failures THROW engine-internal structured errors the engine catches @@ -111,10 +149,21 @@ export interface PromptSurface { ) => Promise; /** * A question requiring explicit consent — never inferable. - * Structurally undefaultable: no default parameter exists, so --yes, - * Enter-through, and non-interactive contexts can never satisfy it. + * Structurally undefaultable: no default parameter exists, so --yes + * and Enter-through can never satisfy it. + * + * `token` is the natural noun of what is being consented to — an app + * name, a hostname. Supplying one changes both halves of the prompt: + * interactively the user must type the token exactly instead of + * answering yes/no, and non-interactively the consent is granted by + * `--confirm ` on the command line (one `--confirm` value per + * consent). Without a token there is no non-interactive way to + * consent at all. */ - readonly consent: (question: string) => Promise; + readonly consent: ( + question: string, + opts?: { readonly token?: string }, + ) => Promise; readonly select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, @@ -124,4 +173,18 @@ export interface PromptSurface { question: string, opts?: { readonly placeholder?: string; readonly default?: string }, ) => Promise; + /** + * Sends the user to a URL and waits for them to finish there: + * announces the URL, opens the browser, then polls until `poll` + * returns true. Resolves when it does; throws the structured timeout + * error when `timeout` elapses first, and the usual prompt-cancelled + * error (exit 3) on Ctrl-C. + * + * Non-interactively it throws the interaction-required error before + * opening or polling anything, with the URL in the message so the + * user can finish by hand. A command that cannot do anything useful + * without an interactive terminal should declare `needs.interaction` + * instead and fail before it starts. + */ + readonly browserWait: (request: BrowserWaitRequest) => Promise; } diff --git a/packages/cli-engine/src/execution/clack-renderer.ts b/packages/cli-engine/src/execution/clack-renderer.ts index 079d48a6..2c4ff225 100644 --- a/packages/cli-engine/src/execution/clack-renderer.ts +++ b/packages/cli-engine/src/execution/clack-renderer.ts @@ -52,6 +52,9 @@ export interface ClackRenderer { ): Promise; /** Starts on No: Enter-through returns false; only explicit Yes grants. */ consent(question: string): Promise; + /** Type-to-confirm: anything but the token re-prompts, so the only + * ways out are the exact token and cancelling. */ + confirmToken(question: string, token: string): Promise; select( question: string, options: ReadonlyArray<{ value: T; label: string }>, @@ -88,6 +91,17 @@ export async function makeClackRenderer( message: question, initialValue: false, }), + confirmToken: (question, token) => + clack.text({ + input, + output, + message: `${question} Type ${token} to confirm.`, + placeholder: token, + validate: (value) => + value === token + ? undefined + : `Type ${token} exactly, or press Ctrl-C.`, + }), select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 856f51e5..fe558d26 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -11,6 +11,7 @@ import { type Diagnostic, notOk, okVoid } from "../protocol"; import { buildManagementApiClient } from "./api-client"; import type { Invocation, RunState } from "./engine"; import { dependencyResolvable, missingDependencyError } from "./needs"; +import { announceUrl } from "./open-url"; import { makePromptSurface } from "./prompts"; import { reportEvent } from "./reporting"; @@ -102,6 +103,7 @@ export function makeContext( }, report: (event) => reportEvent(invocation, event), prompt: makePromptSurface(invocation), + openUrl: (request) => announceUrl(invocation, request), signal: invocation.signal, cwd: invocation.runtime.cwd, env: invocation.runtime.env, diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 1ea95ff0..83302d3c 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -79,6 +79,10 @@ export interface RunState { format: Format; logLevel: Severity; yes: boolean; + /** The run's unconsumed `--confirm` values. A consent prompt with a + * token removes the value it matched, so one `--confirm` grants one + * consent. */ + confirmValues: string[]; interactive: boolean; colorEnabled: boolean; resolved: boolean; @@ -101,6 +105,10 @@ export interface Invocation { readonly runtime: Runtime; readonly hooks: RunHooks; readonly now: () => Date; + /** Waits, or returns early when the signal fires. The engine's only + * timer, injectable so waiting paths (prompt.browserWait) run + * instantly under test. */ + readonly delay: (ms: number, signal: AbortSignal) => Promise; readonly state: RunState; /** The engine-owned abort signal behind ctx.signal, fed by the * runtime's signal subscription. */ @@ -109,9 +117,29 @@ export interface Invocation { export function buildEngine( spec: EngineSpec, - options?: { readonly now?: () => Date }, + options?: { + readonly now?: () => Date; + readonly delay?: (ms: number, signal: AbortSignal) => Promise; + }, ): Engine { - return new EngineImpl(spec, options?.now); + return new EngineImpl(spec, options?.now, options?.delay); +} + +/** Resolves on the timer OR on the signal, whichever comes first — the + * caller decides what an abort means, and no timer outlives the run. */ +function waitFor(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, ms); + signal.addEventListener("abort", finish, { once: true }); + }); } type ErasedHandler = ( @@ -150,10 +178,16 @@ export class EngineImpl implements Engine { private readonly spec: EngineSpec; private readonly root: StricliRouteMap; private readonly now: () => Date; + private readonly delay: (ms: number, signal: AbortSignal) => Promise; - constructor(spec: EngineSpec, now: () => Date = () => new Date()) { + constructor( + spec: EngineSpec, + now: () => Date = () => new Date(), + delay: (ms: number, signal: AbortSignal) => Promise = waitFor, + ) { this.spec = spec; this.now = now; + this.delay = delay; this.root = buildRoutes( spec, buildCommandTree(spec), @@ -176,6 +210,7 @@ export class EngineImpl implements Engine { format, logLevel: "info", yes: false, + confirmValues: [], interactive: defaultInteractive(runtime), colorEnabled: false, resolved: false, @@ -202,6 +237,7 @@ export class EngineImpl implements Engine { runtime, hooks: { ...hooks }, now: this.now, + delay: this.delay, state, signal: controller.signal, }; diff --git a/packages/cli-engine/src/execution/open-url.ts b/packages/cli-engine/src/execution/open-url.ts new file mode 100644 index 00000000..51951f26 --- /dev/null +++ b/packages/cli-engine/src/execution/open-url.ts @@ -0,0 +1,33 @@ +/** + * The engine's browser-opening effect, behind ctx.openUrl and + * prompt.browserWait. One announcement — an `endpoint` event, which is + * a stderr line in human mode and a frame in json mode, so the URL + * reaches a machine consumer through the existing event vocabulary — + * and, when the session is interactive and the runtime wired an opener, + * the open itself. Never an error: a session that cannot open a browser + * is told the URL and reports opened: false. + */ +import type { OpenUrlOutcome, OpenUrlRequest } from "../context"; +import type { Invocation } from "./engine"; +import { reportEvent } from "./reporting"; + +export async function announceUrl( + invocation: Invocation, + request: OpenUrlRequest, +): Promise { + reportEvent(invocation, { + kind: "endpoint", + name: request.message, + url: request.url, + }); + const open = invocation.runtime.openUrl; + if (!invocation.state.interactive || open === undefined) { + return { opened: false }; + } + try { + await open(request.url); + return { opened: true }; + } catch { + return { opened: false }; + } +} diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index d683c65c..24c160d2 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -4,15 +4,21 @@ * Under --yes and in non-interactive contexts a prompt with a declared * default resolves to it without displaying; one without a default * HALTS the invocation with a structured error (the engine renders the - * errored envelope, exit 2). consent is structurally undefaultable and - * always halts in those contexts. Cancellation (EOF at the prompt) is a - * distinct structured error mapped to exit 3. + * errored envelope, exit 2). consent is structurally undefaultable: --yes + * never grants it, and outside an interactive terminal the only thing + * that can is a matching `--confirm ` when the consent declares a + * token. Cancellation (EOF at the prompt) is a distinct structured error + * mapped to exit 3. * * Rendering is two-tier: real TTYs (isTty.stdin AND stdin.setRawMode * present, no scripted answers) render through @clack/prompts via * clack-renderer.ts; everything else uses the plain line renderer - * below. --yes resolution and structural failures are decided before - * the tier branch, so both tiers share identical semantics. + * below. --yes resolution, `--confirm` matching, and structural failures + * are decided before the tier branch, so both tiers share identical + * semantics. A consent WITH a token renders as type-to-confirm on both + * tiers; the tiers differ only in what a wrong answer does — clack + * re-prompts, the line renderer fails structurally, because a scripted + * or piped answer cannot be corrected. */ import type { PromptSurface } from "../context"; import { CliStructuredError } from "../protocol"; @@ -23,6 +29,21 @@ import { makeClackRenderer, } from "./clack-renderer"; import type { Invocation, RunState } from "./engine"; +import { announceUrl } from "./open-url"; + +/** How often browserWait asks whether the user has finished. */ +const BROWSER_WAIT_POLL_INTERVAL_MS = 1000; + +/** A `--confirm` value grants at most one consent: the matched value is + * removed, so two consents on the same token need two --confirms. */ +function consumeConfirmValue(state: RunState, token: string): boolean { + const index = state.confirmValues.indexOf(token); + if (index === -1) { + return false; + } + state.confirmValues.splice(index, 1); + return true; +} function makeLineReader( stdin: InputStream, @@ -90,20 +111,33 @@ function promptUnanswerable( function consentUnavailable( question: string, state: RunState, + token: string | undefined, ): CliStructuredError { + const situation = state.yes + ? `"${question}" requires explicit consent, which --yes cannot grant.` + : `"${question}" requires explicit consent, and the session is not interactive.`; + if (token === undefined) { + return new CliStructuredError("CLI.CONSENT_REQUIRED", situation, { + nextActions: [ + { + kind: "user-choice", + label: + "Run the command interactively. Consent can only be granted outside an interactive terminal when the command declares a consent token.", + }, + ], + }); + } return new CliStructuredError( "CLI.CONSENT_REQUIRED", - state.yes - ? `"${question}" requires explicit consent, which --yes cannot grant.` - : `"${question}" requires explicit consent, and the session is not interactive.`, + `${situation} Grant it by passing --confirm ${token}.`, { nextActions: [ { kind: "user-choice", - label: - "Run the command interactively, or pass the command's explicit consent flag if it documents one.", + label: `Run the command interactively and type ${token}, or pass --confirm ${token}.`, }, ], + meta: { consentToken: token }, }, ); } @@ -115,6 +149,63 @@ function promptInvalid(question: string, raw: string): CliStructuredError { ); } +/** A consent whose token was typed wrong where re-prompting is not + * possible (scripted answers, piped stdin). */ +function consentTokenMismatch( + question: string, + token: string, + raw: string, +): CliStructuredError { + return new CliStructuredError( + "CLI.PROMPT_INVALID", + `"${raw}" does not confirm "${question}": the answer must be exactly ${token}.`, + { meta: { consentToken: token } }, + ); +} + +/** browserWait outside an interactive terminal: nothing is opened and + * nothing is polled, so the URL travels in the error instead. */ +function browserWaitUnavailable( + message: string, + url: string, +): CliStructuredError { + return new CliStructuredError( + "CLI.INTERACTION_REQUIRED", + `${message} requires an interactive terminal: it waits for you to finish at ${url}.`, + { + why: "The session is not interactive (no TTY stdin, CI, or --no-interactive), so the browser cannot be opened and the wait would never end.", + nextActions: [ + { + kind: "user-choice", + label: `Open ${url} and finish there, then run the command again from an interactive terminal (or pass --interactive).`, + }, + ], + meta: { url }, + }, + ); +} + +function browserWaitTimedOut( + message: string, + url: string, + timeout: number, +): CliStructuredError { + return new CliStructuredError( + "CLI.BROWSER_WAIT_TIMEOUT", + `${message} was not finished within ${Math.round(timeout / 1000)}s.`, + { + why: `The command waited for ${url} and stopped waiting before it completed.`, + nextActions: [ + { + kind: "user-choice", + label: "Run the command again and finish in the browser.", + }, + ], + meta: { url, timeoutMs: timeout }, + }, + ); +} + function isExplicitYes(raw: string | boolean): boolean { if (typeof raw === "boolean") { return raw; @@ -197,6 +288,29 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { return line; }; + /** The interactive rendering of a consent that declares a token: the + * user types the token itself. Clack lets them try again; the line + * renderer cannot, so a wrong answer there is structural. */ + const confirmByTyping = async ( + question: string, + token: string, + ): Promise => { + if (useClack()) { + await renderWithClack(question, (r) => + r.confirmToken(question, token), + ); + return true; + } + const typed = await ask( + question, + `? ${question} (type ${token} to confirm) `, + ); + if (typeof typed !== "string" || typed.trim() !== token) { + throw consentTokenMismatch(question, token, String(typed)); + } + return true; + }; + return { confirm: async (question, opts) => { const fallback = opts?.default; @@ -218,9 +332,16 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { const raw = await ask(question, `? ${question} ${hint} `); return parseBooleanAnswer(raw, fallback, question); }, - consent: async (question) => { + consent: async (question, opts) => { + const token = opts?.token; if (state.yes || !state.interactive) { - throw consentUnavailable(question, state); + if (token !== undefined && consumeConfirmValue(state, token)) { + return true; + } + throw consentUnavailable(question, state, token); + } + if (token !== undefined) { + return confirmByTyping(question, token); } if (useClack()) { return renderWithClack(question, (r) => r.consent(question)); @@ -290,5 +411,27 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { } return raw; }, + browserWait: async ({ url, message, poll, timeout }) => { + if (!state.interactive) { + throw browserWaitUnavailable(message, url); + } + await announceUrl(invocation, { url, message }); + const deadline = invocation.now().getTime() + timeout; + for (;;) { + if (invocation.signal.aborted) { + throw promptCancelled(message); + } + if (await poll(invocation.signal)) { + return; + } + if (invocation.now().getTime() >= deadline) { + throw browserWaitTimedOut(message, url, timeout); + } + await invocation.delay( + BROWSER_WAIT_POLL_INTERVAL_MS, + invocation.signal, + ); + } + }, }; } diff --git a/packages/cli-engine/src/execution/shared-flags.ts b/packages/cli-engine/src/execution/shared-flags.ts index a025f496..66f7b475 100644 --- a/packages/cli-engine/src/execution/shared-flags.ts +++ b/packages/cli-engine/src/execution/shared-flags.ts @@ -12,6 +12,7 @@ export const RESERVED_FLAG_NAMES: ReadonlySet = new Set([ "verbose", "quiet", "yes", + "confirm", "interactive", "color", "help", @@ -59,6 +60,15 @@ export const SHARED_FLAG_PARAMETERS = { default: false, brief: "Accept prompt defaults without asking", }, + confirm: { + kind: "parsed", + parse: (input: string) => input, + placeholder: "value", + variadic: true, + optional: true, + brief: + "Grant a consent prompt non-interactively by typing its token (repeatable)", + }, interactive: { kind: "boolean", optional: true, @@ -82,6 +92,7 @@ export interface SharedFlags { readonly verbose?: boolean; readonly quiet?: boolean; readonly yes?: boolean; + readonly confirm?: readonly string[]; readonly interactive?: boolean; readonly color?: boolean; } @@ -123,6 +134,7 @@ export function applySharedFlags( ): void { state.format = shared.format ?? resolveAutoFormat(shared, runtime); state.yes = shared.yes === true; + state.confirmValues = [...(shared.confirm ?? [])]; state.interactive = shared.interactive ?? defaultInteractive(runtime); state.logLevel = resolveLogLevel(shared); state.colorEnabled = diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index b65b282b..5462eae7 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -48,14 +48,17 @@ export { type SectionValidation, } from "../config-section"; export type { + BrowserWaitRequest, CommandContext, Credentials, + OpenUrlOutcome, + OpenUrlRequest, PromptSurface, } from "../context"; export { authServiceError, - credentialsRequiredError, type CredentialsRequiredReason, + credentialsRequiredError, environmentSessionMutationError, noSessionForWorkspaceError, serviceTokenRejectedError, diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index 7b393bdd..36627b48 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -60,6 +60,14 @@ export interface Runtime { */ readonly managementApiClientConfig?: ManagementApiClientConfig; readonly getCredentials: () => Promise; + /** + * Opens a URL in the user's browser, wired by the bin (the login + * flow's opener). The engine calls it only for interactive sessions, + * treats a throw as "did not open", and never fails a command over + * it. Absent means this host cannot open a browser: the engine + * announces the URL instead. + */ + readonly openUrl?: (url: string) => Promise | void; /** Management API endpoint config; the bin derives baseUrl from env. */ readonly managementApi: { readonly baseUrl: string }; /** diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index 92befd83..faea7f3c 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -113,8 +113,14 @@ export function createTestCli(spec: { readonly client?: ManagementApiClient; }; readonly packageManager?: "npm" | "pnpm" | "yarn" | "bun" | "unknown"; - /** Fixed clock for deterministic stream timestamps. */ + /** Fixed clock for deterministic stream timestamps; a clock that + * advances also drives prompt.browserWait's timeout. */ readonly now?: () => Date; + /** The browser opener behind ctx.openUrl and prompt.browserWait. + * Defaults to one that succeeds without doing anything; pass a spy + * to assert what was opened, or a thrower to exercise the + * could-not-open path. */ + readonly openUrl?: (url: string) => Promise | void; }): TestCli { const managerSeeded = spec.credential !== undefined || @@ -150,7 +156,9 @@ export function createTestCli(spec: { groups: spec.groups ?? {}, commands: spec.commands, }, - { now: spec.now }, + /** Waiting is instant under test: browserWait's polling is driven + * by the seeded clock, never by real time. */ + { now: spec.now, delay: async () => {} }, ); return { credentialManager, @@ -204,6 +212,7 @@ export function createTestCli(spec: { credentialManager, managementApiClientConfig, getCredentials: async () => spec.credentials, + openUrl: spec.openUrl ?? ((): void => {}), managementApi: { baseUrl: spec.managementApi?.baseUrl ?? "https://test.invalid", }, diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index f6ca73cf..c12d7a93 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -17,6 +17,7 @@ import { describe, expect, test } from "vitest"; const DOWN = "\x1b[B"; const ENTER = "\r"; const CTRL_C = "\x03"; +const BACKSPACE = "\x7f"; function keystrokeStdin(keys: readonly string[]) { let cursor = 0; @@ -216,6 +217,47 @@ describe("the clack tier resolves prompt values", () => { expect(answerIn(result.plainStderr)).toBe("true"); }); + test("consent with a token: typing it exactly grants", async () => { + const result = await runInteractive( + (prompt) => prompt.consent("Delete it?", { token: "prod-db" }), + [..."prod-db", ENTER], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe("true"); + expect(result.plainStderr).toContain("Type prod-db to confirm."); + }); + + test("consent with a token: a wrong answer re-prompts instead of failing", async () => { + const result = await runInteractive( + (prompt) => prompt.consent("Delete it?", { token: "prod-db" }), + // The rejected text stays in the field, so the retry erases it first. + [ + ..."nope", + ENTER, + BACKSPACE, + BACKSPACE, + BACKSPACE, + BACKSPACE, + ..."prod-db", + ENTER, + ], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe("true"); + expect(result.plainStderr).toContain("Type prod-db exactly"); + }); + + test("consent with a token: Ctrl-C cancels, exit 3", async () => { + const result = await runInteractive( + (prompt) => prompt.consent("Delete it?", { token: "prod-db" }), + [..."nope", ENTER, CTRL_C], + ); + + expect(result.exitCode).toBe(3); + }); + test("a multi-step wizard reuses the one renderer and stdin iterator", async () => { const result = await runInteractive( async (prompt) => { diff --git a/packages/cli-engine/tests/interaction-affordances.test.ts b/packages/cli-engine/tests/interaction-affordances.test.ts new file mode 100644 index 00000000..c3f700b1 --- /dev/null +++ b/packages/cli-engine/tests/interaction-affordances.test.ts @@ -0,0 +1,391 @@ +/** + * The three engine-owned interaction affordances: consent tokens with + * the shared `--confirm` flag, ctx.openUrl, and prompt.browserWait. + * Every one of them is exercised in both an interactive and a + * non-interactive session, because the whole point is that a handler + * never reads TTY or CI state itself. + */ +import { + type Block, + type CommandContext, + defineCommand, + type PromptSurface, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +const EPOCH = () => new Date(0); + +/** A clock that moves a second every time it is read — what makes the + * browser-wait timeout reachable without real waiting. */ +function tickingClock(stepMs = 1000): () => Date { + let reads = 0; + return () => { + reads += 1; + return new Date(reads * stepMs); + }; +} + +const INTERACTIVE = { isTty: { stdin: true, stdout: true } }; + +function probeCommand(run: (ctx: CommandContext) => Promise) { + return defineCommand({ + help: { summary: "Interaction probe" }, + handler: async (_args, ctx) => { + const answer = await run(ctx); + return ok( + ctx.present( + { data: { answer } }, + { + human: (): readonly Block[] => [ + { kind: "summary", tone: "ok", text: `answer=${answer}` }, + ], + }, + ), + ); + }, + }); +} + +function promptProbe(run: (prompt: PromptSurface) => Promise) { + return probeCommand((ctx) => run(ctx.prompt)); +} + +function errorOf(result: { readonly json: readonly unknown[] }) { + const last = result.json[result.json.length - 1] as { + kind: string; + envelope: { ok: boolean; error: { code: string; summary: string } }; + }; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +describe("consent tokens", () => { + const dropDatabase = (prompt: PromptSurface) => + prompt.consent("Delete the production database?", { token: "prod-db" }); + + test("an interactive session must type the token exactly", async () => { + const cli = createTestCli({ + commands: { probe: promptProbe(dropDatabase) }, + now: EPOCH, + }); + const result = await cli.run(["probe"], { + ...INTERACTIVE, + stdin: "prod-db\n", + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: true }); + expect(result.stderr).toContain("type prod-db to confirm"); + }); + + test("a wrong answer fails structurally where it cannot be retyped", async () => { + const cli = createTestCli({ + commands: { probe: promptProbe(dropDatabase) }, + now: EPOCH, + }); + const result = await cli.run(["probe", "--json"], { + ...INTERACTIVE, + answers: ["yes"], + }); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.PROMPT_INVALID"); + expect(errorOf(result)?.summary).toContain("exactly prod-db"); + }); + + test("--confirm with the token grants the consent non-interactively", async () => { + const cli = createTestCli({ + commands: { probe: promptProbe(dropDatabase) }, + now: EPOCH, + }); + const result = await cli.run(["probe", "--confirm", "prod-db"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: true }); + }); + + test("--confirm with the wrong value halts and names the expected one", async () => { + const cli = createTestCli({ + commands: { probe: promptProbe(dropDatabase) }, + now: EPOCH, + }); + const result = await cli.run(["probe", "--confirm", "staging-db"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.CONSENT_REQUIRED"); + expect(errorOf(result)?.summary).toContain("--confirm prod-db"); + }); + + test("no --confirm at all halts the same way", async () => { + const cli = createTestCli({ + commands: { probe: promptProbe(dropDatabase) }, + now: EPOCH, + }); + const result = await cli.run(["probe"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.CONSENT_REQUIRED"); + }); + + test("--yes still cannot grant it, but --confirm alongside --yes can", async () => { + const cli = createTestCli({ + commands: { probe: promptProbe(dropDatabase) }, + now: EPOCH, + }); + + const withoutConfirm = await cli.run( + ["probe", "--yes", "--json"], + INTERACTIVE, + ); + expect(withoutConfirm.exitCode).toBe(2); + expect(errorOf(withoutConfirm)?.summary).toContain("--yes cannot grant"); + + const withConfirm = await cli.run( + ["probe", "--yes", "--confirm", "prod-db"], + INTERACTIVE, + ); + expect(withConfirm.exitCode).toBe(0); + expect(withConfirm.presented?.data).toEqual({ answer: true }); + }); + + test("each --confirm value grants one consent: repeat it to grant two", async () => { + const twice = promptProbe(async (prompt) => { + const first = await prompt.consent("Delete it?", { token: "prod-db" }); + const second = await prompt.consent("Really delete it?", { + token: "prod-db", + }); + return first && second; + }); + + const once = await createTestCli({ + commands: { probe: twice }, + now: EPOCH, + }).run(["probe", "--confirm", "prod-db"]); + expect(once.exitCode).toBe(2); + expect(errorOf(once)?.code).toBe("CLI.CONSENT_REQUIRED"); + + const repeated = await createTestCli({ + commands: { probe: twice }, + now: EPOCH, + }).run(["probe", "--confirm", "prod-db", "--confirm", "prod-db"]); + expect(repeated.exitCode).toBe(0); + expect(repeated.presented?.data).toEqual({ answer: true }); + }); + + test("a consent without a token keeps its yes/no rendering and stays ungrantable", async () => { + const tokenless = promptProbe((prompt) => + prompt.consent("Delete everything?"), + ); + const cli = createTestCli({ commands: { probe: tokenless }, now: EPOCH }); + + const interactive = await cli.run(["probe"], { + ...INTERACTIVE, + stdin: "yes\n", + }); + expect(interactive.exitCode).toBe(0); + expect(interactive.presented?.data).toEqual({ answer: true }); + expect(interactive.stderr).toContain("(y/n)"); + + const confirmed = await cli.run([ + "probe", + "--json", + "--confirm", + "Delete everything?", + ]); + expect(confirmed.exitCode).toBe(2); + expect(errorOf(confirmed)?.code).toBe("CLI.CONSENT_REQUIRED"); + expect(errorOf(confirmed)?.summary).not.toContain("--confirm"); + }); + + test("--confirm shows up in help as part of the shared flag family", async () => { + const cli = createTestCli({ + commands: { probe: promptProbe(dropDatabase) }, + now: EPOCH, + }); + const result = await cli.run(["probe", "--help"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--confirm"); + expect(result.stdout).toContain("--yes"); + }); +}); + +describe("ctx.openUrl", () => { + const dashboard = probeCommand((ctx) => + ctx.openUrl({ + url: "https://console.test.invalid/projects", + message: "Open your dashboard", + }), + ); + + test("an interactive session opens the browser and announces the URL", async () => { + const opened: string[] = []; + const cli = createTestCli({ + commands: { probe: dashboard }, + now: EPOCH, + openUrl: (url) => { + opened.push(url); + }, + }); + const result = await cli.run(["probe"], INTERACTIVE); + + expect(result.exitCode).toBe(0); + expect(opened).toEqual(["https://console.test.invalid/projects"]); + expect(result.presented?.data).toEqual({ answer: { opened: true } }); + expect(result.stderr).toContain( + "Open your dashboard: https://console.test.invalid/projects", + ); + }); + + test("a non-interactive session prints the URL and opens nothing", async () => { + const opened: string[] = []; + const cli = createTestCli({ + commands: { probe: dashboard }, + now: EPOCH, + openUrl: (url) => { + opened.push(url); + }, + }); + const result = await cli.run(["probe"], { isTty: { stdout: true } }); + + expect(result.exitCode).toBe(0); + expect(opened).toEqual([]); + expect(result.presented?.data).toEqual({ answer: { opened: false } }); + expect(result.stderr).toContain("https://console.test.invalid/projects"); + }); + + test("json mode surfaces the URL as an endpoint frame", async () => { + const cli = createTestCli({ commands: { probe: dashboard }, now: EPOCH }); + const result = await cli.run(["probe", "--json"], INTERACTIVE); + + expect(result.exitCode).toBe(0); + expect(result.json[0]).toEqual({ + kind: "endpoint", + name: "Open your dashboard", + url: "https://console.test.invalid/projects", + commandId: "probe", + timestamp: new Date(0).toISOString(), + }); + expect(result.stdout).toContain("endpoint"); + }); + + test("an opener that fails is never an error: it reports opened false", async () => { + const cli = createTestCli({ + commands: { probe: dashboard }, + now: EPOCH, + openUrl: () => { + throw new Error("no browser on this host"); + }, + }); + const result = await cli.run(["probe"], INTERACTIVE); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: { opened: false } }); + }); +}); + +describe("prompt.browserWait", () => { + function waitCommand( + poll: (signal: AbortSignal) => Promise, + timeout = 60_000, + ) { + return promptProbe(async (prompt) => { + await prompt.browserWait({ + url: "https://auth.test.invalid/device", + message: "Finish signing in", + poll, + timeout, + }); + return "done"; + }); + } + + test("it opens the browser and resolves when polling says so", async () => { + const opened: string[] = []; + let polls = 0; + const cli = createTestCli({ + commands: { + probe: waitCommand(async () => { + polls += 1; + return polls > 2; + }), + }, + now: EPOCH, + openUrl: (url) => { + opened.push(url); + }, + }); + const result = await cli.run(["probe"], INTERACTIVE); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: "done" }); + expect(opened).toEqual(["https://auth.test.invalid/device"]); + expect(polls).toBe(3); + expect(result.stderr).toContain( + "Finish signing in: https://auth.test.invalid/device", + ); + }); + + test("it gives up with a structured timeout error", async () => { + const cli = createTestCli({ + commands: { probe: waitCommand(async () => false, 5000) }, + now: tickingClock(), + }); + const result = await cli.run(["probe", "--json"], INTERACTIVE); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.BROWSER_WAIT_TIMEOUT"); + expect(errorOf(result)?.summary).toContain("Finish signing in"); + }); + + test("Ctrl-C while waiting settles as a cancelled prompt, exit 3", async () => { + const controller = new AbortController(); + const cli = createTestCli({ + commands: { + probe: waitCommand(async () => { + controller.abort("SIGINT"); + return false; + }), + }, + now: EPOCH, + }); + const result = await cli.run(["probe", "--json"], { + ...INTERACTIVE, + abort: controller.signal, + }); + + expect(result.exitCode).toBe(3); + expect(errorOf(result)?.code).toBe("CLI.PROMPT_CANCELLED"); + }); + + test("a non-interactive session neither opens nor polls: exit 2 with the URL", async () => { + const opened: string[] = []; + let polls = 0; + const cli = createTestCli({ + commands: { + probe: waitCommand(async () => { + polls += 1; + return true; + }), + }, + now: EPOCH, + openUrl: (url) => { + opened.push(url); + }, + }); + const result = await cli.run(["probe", "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.INTERACTION_REQUIRED"); + expect(errorOf(result)?.summary).toContain( + "https://auth.test.invalid/device", + ); + expect(opened).toEqual([]); + expect(polls).toBe(0); + }); +}); diff --git a/packages/cli-engine/tests/prompts.test.ts b/packages/cli-engine/tests/prompts.test.ts index 49d01ae8..0391db53 100644 --- a/packages/cli-engine/tests/prompts.test.ts +++ b/packages/cli-engine/tests/prompts.test.ts @@ -246,7 +246,7 @@ describe("consent", () => { { kind: "user-choice", label: - "Run the command interactively, or pass the command's explicit consent flag if it documents one.", + "Run the command interactively. Consent can only be granted outside an interactive terminal when the command declares a consent token.", }, ], }); diff --git a/packages/cli-engine/tests/run-hooks.test.ts b/packages/cli-engine/tests/run-hooks.test.ts index 954c7ed5..51fca6b6 100644 --- a/packages/cli-engine/tests/run-hooks.test.ts +++ b/packages/cli-engine/tests/run-hooks.test.ts @@ -79,6 +79,7 @@ describe("RunHooks.onSettled", () => { { name: "verbose", source: "default" }, { name: "quiet", source: "default" }, { name: "yes", source: "default" }, + { name: "confirm", source: "default" }, { name: "interactive", source: "default" }, { name: "color", source: "default" }, { name: "dry-run", source: "cli" }, From 9ffbb01484b741450415b6101cdb12f04a7dbdd9 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 17:35:06 +0200 Subject: [PATCH 41/67] =?UTF-8?q?feat(cli):=20the=20credential=20manager?= =?UTF-8?q?=20=E2=80=94=20persistence,=20pinning,=20and=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the CredentialManager SPI over one state file: atomic 0600 writes, a short advisory file lock around every read-modify-write, and per-process session pinning. The TokenStorage views are bound to a workspace id and re-read on every call; rotation writes token fields only, never resurrects an ended session, and never moves the marker. The legacy store is adopted as a pure read and materializes in the new format on the first mutation. performLogin now returns the minted credential through a throwaway in-memory storage, and the v8 bin wires the manager, the SDK client config, and the browser opener. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/credential-errors.ts | 28 + .../cli-engine/src/execution/api-client.ts | 4 +- packages/cli-engine/src/exports/index.ts | 1 + .../src/testing-credential-manager.ts | 4 +- .../tests/credential-manager.test.ts | 57 +- packages/cli-engine/tests/engine.test.ts | 1 + packages/cli-engine/tests/engine.type-test.ts | 4 +- .../cli-engine/tests/management-api.test.ts | 2 +- packages/cli/src/auth/claims.ts | 47 ++ packages/cli/src/auth/client.ts | 18 + packages/cli/src/auth/credential-manager.ts | 459 +++++++++++++++ packages/cli/src/auth/index.ts | 19 +- packages/cli/src/auth/legacy-state.ts | 141 +++++ packages/cli/src/auth/operations.ts | 69 ++- packages/cli/src/auth/state-file.ts | 292 +++++++++ packages/cli/src/auth/workspace-name.ts | 21 + packages/cli/src/controllers/auth.ts | 21 +- packages/cli/src/v8/auth/login.ts | 4 +- packages/cli/src/v8/runtime.ts | 40 +- packages/cli/tests/auth-real-mode.test.ts | 12 +- .../tests/credential-manager-login.test.ts | 71 +++ .../credential-manager-migration.test.ts | 282 +++++++++ .../credential-manager-processes.test.ts | 175 ++++++ packages/cli/tests/credential-manager.test.ts | 557 ++++++++++++++++++ .../helpers/credential-manager-worker.ts | 75 +++ packages/cli/tests/v8-auth.test.ts | 18 +- packages/cli/tests/v8-bin.test.ts | 36 +- 27 files changed, 2412 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/auth/claims.ts create mode 100644 packages/cli/src/auth/credential-manager.ts create mode 100644 packages/cli/src/auth/legacy-state.ts create mode 100644 packages/cli/src/auth/state-file.ts create mode 100644 packages/cli/src/auth/workspace-name.ts create mode 100644 packages/cli/tests/credential-manager-login.test.ts create mode 100644 packages/cli/tests/credential-manager-migration.test.ts create mode 100644 packages/cli/tests/credential-manager-processes.test.ts create mode 100644 packages/cli/tests/credential-manager.test.ts create mode 100644 packages/cli/tests/helpers/credential-manager-worker.ts diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts index 01ff2d1d..bb77b1b8 100644 --- a/packages/cli-engine/src/credential-errors.ts +++ b/packages/cli-engine/src/credential-errors.ts @@ -107,6 +107,34 @@ export function environmentSessionMutationError(spec: { ); } +/** + * The env var that supplies a session is set to a blank value. The one + * structured error for it, raised identically by currentSession(), the + * needs check, and the engine's request path. + */ +export function emptyServiceTokenError(spec: { + readonly envVar: string; +}): CliStructuredError { + return new CliStructuredError( + "AUTH.SERVICE_TOKEN_EMPTY", + `${spec.envVar} is set but empty.`, + { + why: `A blank token authenticates nothing, and ${spec.envVar} overrides your stored workspace sessions while it is set.`, + nextActions: [ + { + kind: "run-command", + label: `Unset ${spec.envVar}`, + command: `unset ${spec.envVar}`, + }, + { + kind: "user-choice", + label: `Or set ${spec.envVar} to a valid service token.`, + }, + ], + }, + ); +} + /** * No session exists for the named workspace. Sessions are created by * `prisma auth login` alone — `workspace use` selects among the ones diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 2b052842..89654ed2 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -107,9 +107,7 @@ async function constructClient(invocation: Invocation): Promise<{ binding: { source: "environment" }, }; } - const { createManagementApiSdk } = await import( - "@prisma/management-api-sdk" - ); + const { createManagementApiSdk } = await import("@prisma/management-api-sdk"); const sdk = createManagementApiSdk({ clientId: config.clientId, redirectUri: config.redirectUri, diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 5462eae7..cc39e1a2 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -59,6 +59,7 @@ export { authServiceError, type CredentialsRequiredReason, credentialsRequiredError, + emptyServiceTokenError, environmentSessionMutationError, noSessionForWorkspaceError, serviceTokenRejectedError, diff --git a/packages/cli-engine/src/testing-credential-manager.ts b/packages/cli-engine/src/testing-credential-manager.ts index 932430c8..c5446cf7 100644 --- a/packages/cli-engine/src/testing-credential-manager.ts +++ b/packages/cli-engine/src/testing-credential-manager.ts @@ -48,9 +48,7 @@ export function mintTestJwt(claims: Readonly>): string { return `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.test-signature`; } -function decodeJwtClaims( - token: string, -): Record | undefined { +function decodeJwtClaims(token: string): Record | undefined { const parts = token.split("."); if (parts.length < 2) { return undefined; diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 315e937f..8162d8f5 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -220,7 +220,10 @@ describe("the manager-backed needs check", () => { test("sessions held, none current: the identical single-sourced error from the needs check, ctx.session, and a bare ctx.api touch", async () => { const seeds = { - sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], }; const fromNeedsCheck = await (async () => { @@ -239,16 +242,16 @@ describe("the manager-backed needs check", () => { const caughtBy = ( body: (ctx: { readonly session: () => Promise; - readonly api: { GET: (path: string, opts: unknown) => Promise }; + readonly api: { + GET: (path: string, opts: unknown) => Promise; + }; }) => Promise, ) => defineCommand({ help: { summary: "Catches the structured error" }, handler: async (_args, ctx) => { try { - await body( - ctx as unknown as Parameters[0], - ); + await body(ctx as unknown as Parameters[0]); } catch (cause) { return notOk(cause as CliStructuredError); } @@ -304,8 +307,7 @@ describe("the manager-backed needs check", () => { }); }); -const codeOf = (thrown: unknown): string => - (thrown as CliStructuredError).code; +const codeOf = (thrown: unknown): string => (thrown as CliStructuredError).code; describe("session mutations and state read-back", () => { test("createSession upserts by workspaceId, preserves a recorded name, and sets the marker", async () => { @@ -350,11 +352,17 @@ describe("session mutations and state read-back", () => { test("useSession switches the marker; an unknown workspace and an environment-source argument raise AUTH.NO_SESSION_FOR_WORKSPACE", async () => { const manager = new TestCredentialManager({ - sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], currentWorkspaceId: "workspace-1", }); const switched = await manager.useSession(storedSessionRef("workspace-2")); - expect(switched).toMatchObject({ workspaceId: "workspace-2", current: true }); + expect(switched).toMatchObject({ + workspaceId: "workspace-2", + current: true, + }); expect(manager.state().currentWorkspaceId).toBe("workspace-2"); await expect( @@ -375,7 +383,10 @@ describe("session mutations and state read-back", () => { test("endSession removes one session and clears the current only when it named it — no auto-promotion", async () => { const manager = new TestCredentialManager({ - sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], currentWorkspaceId: "workspace-1", }); await manager.endSession(storedSessionRef("workspace-1")); @@ -391,7 +402,10 @@ describe("session mutations and state read-back", () => { test("endAllSessions clears every session and the marker", async () => { const manager = new TestCredentialManager({ - sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], currentWorkspaceId: "workspace-1", }); await manager.endAllSessions(); @@ -451,9 +465,9 @@ describe("mutations under an env-supplied session", () => { userCredential({ workspaceId: "workspace-1" }), "workspace-1", ); - expect(manager.state().sessions.map((record) => record.workspaceId)).toEqual( - ["workspace-1"], - ); + expect( + manager.state().sessions.map((record) => record.workspaceId), + ).toEqual(["workspace-1"]); expect(await manager.currentSession()).toMatchObject({ source: "environment", workspaceId: "workspace-env", @@ -462,7 +476,10 @@ describe("mutations under an env-supplied session", () => { test("sessions() still lists stored sessions with the file's marked current", async () => { const manager = new TestCredentialManager({ - sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], currentWorkspaceId: "workspace-2", environmentToken, }); @@ -480,7 +497,10 @@ describe("mutations under an env-supplied session", () => { describe("process pinning", () => { test("the marker moved by another process between reads does not re-pin; a new manager picks up the new marker", async () => { const manager = new TestCredentialManager({ - sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], currentWorkspaceId: "workspace-1", }); expect(await manager.currentSession()).toMatchObject({ @@ -504,7 +524,10 @@ describe("process pinning", () => { test("this manager's own useSession moves the pin", async () => { const manager = new TestCredentialManager({ - sessions: [sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2")], + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], currentWorkspaceId: "workspace-1", }); expect(await manager.currentSession()).toMatchObject({ diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 90a274cf..66447087 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -26,6 +26,7 @@ describe("main export", () => { "defineConfigSection", "defineServerCommand", "defineSessionCommand", + "emptyServiceTokenError", "environmentSessionMutationError", "flag", "loadConfig", diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index 66b91dc7..64f7d87e 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -9,11 +9,10 @@ import type { Char, CommandContext, CommandFamily, - CredentialManager, - Session, CommandHandler, CompletedEnvelope, ConfigSection, + CredentialManager, EngineEvent, ErroredEnvelope, FlagSpec, @@ -24,6 +23,7 @@ import type { PresentedResult, Runtime, SectionValidation, + Session, StreamEvent, } from "@prisma/cli-engine"; import { diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index dcfd5edf..5db0afee 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -10,13 +10,13 @@ import { type CredentialManager, + credentialsRequiredError, defineCommand, type ManagementApiClient, type ManagementApiClientConfig, type Runtime, type Session, type TokenStorage, - credentialsRequiredError, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt } from "@prisma/cli-engine/testing"; diff --git a/packages/cli/src/auth/claims.ts b/packages/cli/src/auth/claims.ts new file mode 100644 index 00000000..aaafa8ce --- /dev/null +++ b/packages/cli/src/auth/claims.ts @@ -0,0 +1,47 @@ +import { Buffer } from "node:buffer"; + +const WORKSPACE_SUB_PREFIX = "workspace:"; + +export function decodeClaims( + token: string, +): Record | undefined { + const payload = token.split(".")[1]; + if (payload === undefined) return undefined; + try { + const parsed: unknown = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** The `workspace_id` claim, which is what sessions are keyed by. */ +export function claimedWorkspaceId(token: string): string | undefined { + const workspaceId = decodeClaims(token)?.workspace_id; + return typeof workspaceId === "string" && workspaceId.length > 0 + ? workspaceId + : undefined; +} + +/** The workspace a service token names, for display only: its + * `workspace_id` claim, or the workspace its `sub` names. */ +export function serviceTokenWorkspaceId(token: string): string | undefined { + const fromWorkspaceClaim = claimedWorkspaceId(token); + if (fromWorkspaceClaim !== undefined) return fromWorkspaceClaim; + + const sub = decodeClaims(token)?.sub; + if (typeof sub !== "string" || !sub.startsWith(WORKSPACE_SUB_PREFIX)) { + return undefined; + } + const derived = sub.slice(WORKSPACE_SUB_PREFIX.length).trim(); + return derived.length > 0 ? derived : undefined; +} + +export function claimedExpiresAt(token: string): Date | undefined { + const exp = decodeClaims(token)?.exp; + return typeof exp === "number" ? new Date(exp * 1000) : undefined; +} diff --git a/packages/cli/src/auth/client.ts b/packages/cli/src/auth/client.ts index c6f66137..79ec7a1b 100644 --- a/packages/cli/src/auth/client.ts +++ b/packages/cli/src/auth/client.ts @@ -3,19 +3,37 @@ import path from "node:path"; export const CLIENT_ID = "cmm3lndn701oo0uefvxzo0ivw"; export const DEFAULT_API_BASE_URL = "https://api.prisma.io"; +export const DEFAULT_AUTH_BASE_URL = "https://auth.prisma.io"; export const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; export const AUTH_FILE_ENV_VAR = "PRISMA_COMPUTE_AUTH_FILE"; +/** + * The redirect the OAuth client is registered with. `performLogin` + * replaces it with its own ephemeral callback server's port; the + * refreshing client never reads it. + */ +export const DEFAULT_REDIRECT_URI = "http://localhost/auth/callback"; + export function getApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string { return env.PRISMA_MANAGEMENT_API_URL?.trim() || DEFAULT_API_BASE_URL; } +export function getAuthBaseUrl(env: NodeJS.ProcessEnv = process.env): string { + return env.PRISMA_AUTH_BASE_URL?.trim() || DEFAULT_AUTH_BASE_URL; +} + export function getAuthFilePath(env: NodeJS.ProcessEnv = process.env): string { const configured = env[AUTH_FILE_ENV_VAR]; if (configured?.trim()) { return path.resolve(configured); } + return defaultAuthFilePath(env); +} + +export function defaultAuthFilePath( + env: Readonly> = process.env, +): string { if (process.platform === "darwin") { return path.join( os.homedir(), diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts new file mode 100644 index 00000000..3f4570ed --- /dev/null +++ b/packages/cli/src/auth/credential-manager.ts @@ -0,0 +1,459 @@ +import fs from "node:fs/promises"; +import type { + Credential, + CredentialManager, + Session, + TokenStorage, +} from "@prisma/cli-engine"; +import { + credentialsRequiredError, + emptyServiceTokenError, + environmentSessionMutationError, + noSessionForWorkspaceError, +} from "@prisma/cli-engine"; +import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import { + claimedExpiresAt, + claimedWorkspaceId, + serviceTokenWorkspaceId, +} from "./claims"; +import { SERVICE_TOKEN_ENV_VAR } from "./client"; +import { + type CredentialState, + type DebugLog, + EMPTY_STATE, + makeDebugLog, + readCredentialState, + resolveStateFilePath, + type StoredSession, + withStateLock, + writeCredentialState, +} from "./state-file"; +import { getAuthContextFilePath } from "./token-storage"; + +/** Looks the workspace's name up with the credential that was just + * minted. Best-effort: the manager treats any failure as "no name". */ +export type FetchWorkspaceName = ( + credential: Credential, + workspaceId: string, +) => Promise; + +export interface FileCredentialManagerOptions { + readonly env: Readonly>; + readonly fetchWorkspaceName?: FetchWorkspaceName; + readonly debugWrite?: (text: string) => void; +} + +type Pin = + | { readonly kind: "unpinned" } + | { readonly kind: "environment" } + | { readonly kind: "marker"; readonly workspaceId: string | null }; + +function credentialWorkspaceMismatchError( + workspaceId: string, +): CliStructuredError { + return new CliStructuredError( + "AUTH.CREDENTIAL_WORKSPACE_MISMATCH", + "That credential belongs to a different workspace.", + { + why: `The token's workspace_id claim does not name workspace '${workspaceId}'.`, + nextActions: [ + { + kind: "run-command", + label: "Sign in again and pick the workspace you want", + command: "prisma auth login", + }, + ], + }, + ); +} + +/** + * The credential manager over one state file. Sessions are keyed by + * workspace id; the current session is pinned once per process; every + * mutation takes a short file lock, re-reads, applies its slice, and + * writes atomically. Reads never write and take no lock. + */ +export class FileCredentialManager implements CredentialManager { + readonly #env: Readonly>; + readonly #filePath: string; + readonly #debug: DebugLog; + readonly #fetchWorkspaceName: FetchWorkspaceName | undefined; + #pin: Pin = { kind: "unpinned" }; + #refreshLock: Promise = Promise.resolve(); + + constructor(options: FileCredentialManagerOptions) { + this.#env = options.env; + this.#filePath = resolveStateFilePath(options.env).filePath; + this.#debug = makeDebugLog(options.env, options.debugWrite); + this.#fetchWorkspaceName = options.fetchWorkspaceName; + this.#debug(`state file ${this.#filePath}`); + } + + get stateFilePath(): string { + return this.#filePath; + } + + async currentSession(): Promise { + const environmentToken = this.#environmentToken(); + if (this.#pin.kind === "unpinned") { + if (environmentToken !== undefined) { + this.#pin = { kind: "environment" }; + this.#debug("pinned to the environment session"); + } else { + const state = await readCredentialState(this.#filePath); + this.#pin = { kind: "marker", workspaceId: resolvedMarker(state) }; + this.#debug(`pinned to session ${this.#pin.workspaceId ?? "(none)"}`); + } + } + + if (this.#pin.kind === "environment") { + return this.#environmentSession(); + } + const pinnedWorkspaceId = this.#pin.workspaceId; + const state = await readCredentialState(this.#filePath); + if (pinnedWorkspaceId === null) { + if (state.sessions.length > 0) { + throw credentialsRequiredError("sessions-held-none-current"); + } + return null; + } + const record = state.sessions.find( + (session) => session.workspaceId === pinnedWorkspaceId, + ); + if (record === undefined) { + throw credentialsRequiredError("session-ended"); + } + return toSession(record, state); + } + + async sessions(): Promise { + const state = await readCredentialState(this.#filePath); + return state.sessions.map((record) => toSession(record, state)); + } + + async createSession( + credential: Credential, + workspaceId: string, + ): Promise { + const claimed = claimedWorkspaceId(credential.token); + if (claimed !== undefined && claimed !== workspaceId) { + throw credentialWorkspaceMismatchError(workspaceId); + } + + const created = await this.#mutate((state) => { + const existing = state.sessions.find( + (session) => session.workspaceId === workspaceId, + ); + const record: StoredSession = { + workspaceId, + ...(existing?.name === undefined ? {} : { name: existing.name }), + token: credential.token, + ...(credential.refreshToken === undefined + ? {} + : { refreshToken: credential.refreshToken }), + ...expiresAtSlice(credential.token, credential.expiresAt), + }; + const next: CredentialState = { + ...state, + sessions: [ + ...state.sessions.filter( + (session) => session.workspaceId !== workspaceId, + ), + record, + ], + currentWorkspaceId: workspaceId, + }; + return { state: next, result: toSession(record, next) }; + }); + + if (this.#pin.kind !== "environment") { + this.#pin = { kind: "marker", workspaceId }; + } + + const name = await this.#lookUpWorkspaceName(credential, workspaceId); + if (name === undefined) return created; + + return this.#mutate((state) => { + const record = state.sessions.find( + (session) => session.workspaceId === workspaceId, + ); + if (record === undefined) return { result: created }; + const named: StoredSession = { ...record, name }; + const next: CredentialState = { + ...state, + sessions: state.sessions.map((session) => + session.workspaceId === workspaceId ? named : session, + ), + }; + return { state: next, result: toSession(named, next) }; + }); + } + + async useSession(session: Session): Promise { + await this.#refuseUnderEnvironmentSession(); + const workspaceId = referencedWorkspaceId(session); + + const selected = await this.#mutate((state) => { + const record = requireRecord(state, workspaceId); + const next: CredentialState = { + ...state, + currentWorkspaceId: workspaceId, + }; + return { state: next, result: toSession(record, next) }; + }); + this.#pin = { kind: "marker", workspaceId }; + return selected; + } + + async endSession(session: Session): Promise { + await this.#refuseUnderEnvironmentSession(); + const workspaceId = referencedWorkspaceId(session); + + await this.#mutate((state) => { + requireRecord(state, workspaceId); + return { state: withoutRecord(state, workspaceId), result: undefined }; + }); + + if (this.#pin.kind === "marker" && this.#pin.workspaceId === workspaceId) { + this.#pin = { kind: "marker", workspaceId: null }; + } + } + + async endAllSessions(): Promise { + if (this.#environmentToken() !== undefined) { + const stored = await readCredentialState(this.#filePath); + if (stored.sessions.length === 0) return; + throw environmentSessionMutationError({ + envVar: SERVICE_TOKEN_ENV_VAR, + storedSessionsExist: true, + }); + } + + await this.#mutate(() => ({ state: EMPTY_STATE, result: undefined })); + await fs.unlink(getAuthContextFilePath(this.#filePath)).catch(() => {}); + this.#pin = { kind: "marker", workspaceId: null }; + } + + tokenStorage(workspaceId: string): TokenStorage { + return { + getTokens: async () => { + const state = await readCredentialState(this.#filePath); + const record = state.sessions.find( + (session) => session.workspaceId === workspaceId, + ); + if (record === undefined) return null; + return { + workspaceId, + accessToken: record.token, + ...(record.refreshToken === undefined + ? {} + : { refreshToken: record.refreshToken }), + }; + }, + + setTokens: async (tokens) => { + this.#debug(`rotation write for session ${workspaceId}`); + const claimed = claimedWorkspaceId(tokens.accessToken); + if (claimed !== undefined && claimed !== workspaceId) { + throw credentialWorkspaceMismatchError(workspaceId); + } + await this.#mutate((state) => { + const record = state.sessions.find( + (session) => session.workspaceId === workspaceId, + ); + if (record === undefined) { + throw credentialsRequiredError("session-ended"); + } + const rotated: StoredSession = { + workspaceId: record.workspaceId, + ...(record.name === undefined ? {} : { name: record.name }), + token: tokens.accessToken, + ...(tokens.refreshToken === undefined + ? {} + : { refreshToken: tokens.refreshToken }), + ...expiresAtSlice(tokens.accessToken, undefined), + }; + return { + state: { + ...state, + sessions: state.sessions.map((session) => + session.workspaceId === workspaceId ? rotated : session, + ), + }, + result: undefined, + }; + }); + }, + + clearTokens: async () => { + this.#debug(`clearing session ${workspaceId}`); + await this.#mutate((state) => ({ + state: withoutRecord(state, workspaceId), + result: undefined, + })); + }, + + clearTokensIfCurrent: async (tokens) => { + this.#debug( + `clearing session ${workspaceId} if its pair still matches`, + ); + await this.#mutate((state) => { + const record = state.sessions.find( + (session) => session.workspaceId === workspaceId, + ); + const matches = + record !== undefined && + tokens.workspaceId === workspaceId && + tokens.accessToken === record.token && + tokens.refreshToken === record.refreshToken; + if (!matches) return { result: undefined }; + return { + state: withoutRecord(state, workspaceId), + result: undefined, + }; + }); + }, + + withRefreshLock: (fn: () => Promise): Promise => { + const run = this.#refreshLock.then(fn, fn); + this.#refreshLock = run.then( + () => undefined, + () => undefined, + ); + return run; + }, + }; + } + + #environmentToken(): string | undefined { + const raw = this.#env[SERVICE_TOKEN_ENV_VAR]; + if (raw === undefined) return undefined; + if (raw.trim().length === 0) { + throw emptyServiceTokenError({ envVar: SERVICE_TOKEN_ENV_VAR }); + } + return raw.trim(); + } + + #environmentSession(): Session { + const token = this.#environmentToken(); + if (token === undefined) { + throw credentialsRequiredError(); + } + return { + workspaceId: serviceTokenWorkspaceId(token) ?? "", + workspaceName: undefined, + expiresAt: claimedExpiresAt(token), + source: "environment", + current: true, + }; + } + + async #refuseUnderEnvironmentSession(): Promise { + if (this.#environmentToken() === undefined) return; + const state = await readCredentialState(this.#filePath); + throw environmentSessionMutationError({ + envVar: SERVICE_TOKEN_ENV_VAR, + storedSessionsExist: state.sessions.length > 0, + }); + } + + async #lookUpWorkspaceName( + credential: Credential, + workspaceId: string, + ): Promise { + if (this.#fetchWorkspaceName === undefined) return undefined; + try { + const name = await this.#fetchWorkspaceName(credential, workspaceId); + return name?.trim() ? name.trim() : undefined; + } catch { + return undefined; + } + } + + /** One mutation: the short lock, a fresh read, one slice, one atomic + * write. A slice that returns no state writes nothing. */ + async #mutate( + apply: (state: CredentialState) => { + readonly state?: CredentialState; + readonly result: T; + }, + ): Promise { + return withStateLock(this.#filePath, this.#debug, async () => { + const state = await readCredentialState(this.#filePath); + const applied = apply(state); + if (applied.state !== undefined) { + await writeCredentialState(this.#filePath, applied.state); + } + return applied.result; + }); + } +} + +function referencedWorkspaceId(session: Session): string { + if (session.source === "environment") { + throw noSessionForWorkspaceError(session.workspaceId); + } + return session.workspaceId; +} + +function requireRecord( + state: CredentialState, + workspaceId: string, +): StoredSession { + const record = state.sessions.find( + (session) => session.workspaceId === workspaceId, + ); + if (record === undefined) { + throw noSessionForWorkspaceError(workspaceId); + } + return record; +} + +function withoutRecord( + state: CredentialState, + workspaceId: string, +): CredentialState { + return { + ...state, + sessions: state.sessions.filter( + (session) => session.workspaceId !== workspaceId, + ), + currentWorkspaceId: + state.currentWorkspaceId === workspaceId + ? null + : state.currentWorkspaceId, + }; +} + +function expiresAtSlice( + token: string, + fallback: Date | undefined, +): { expiresAt?: string } { + const expiresAt = claimedExpiresAt(token) ?? fallback; + return expiresAt === undefined ? {} : { expiresAt: expiresAt.toISOString() }; +} + +/** The marker a first read pins: a marker naming no record pins as + * none. */ +function resolvedMarker(state: CredentialState): string | null { + const marked = state.currentWorkspaceId; + if ( + marked !== null && + state.sessions.some((session) => session.workspaceId === marked) + ) { + return marked; + } + return null; +} + +function toSession(record: StoredSession, state: CredentialState): Session { + return { + workspaceId: record.workspaceId, + workspaceName: record.name, + expiresAt: + record.expiresAt === undefined ? undefined : new Date(record.expiresAt), + source: "stored", + current: state.currentWorkspaceId === record.workspaceId, + }; +} diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 92a50905..c5560e1c 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -1,4 +1,14 @@ -export { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +export { + CLIENT_ID, + DEFAULT_REDIRECT_URI, + getApiBaseUrl, + getAuthBaseUrl, + SERVICE_TOKEN_ENV_VAR, +} from "./client"; +export { + type FetchWorkspaceName, + FileCredentialManager, +} from "./credential-manager"; export { makeGetCredentials } from "./credentials"; export { workspaceAmbiguousError, @@ -12,17 +22,24 @@ export { performLogin, performLogout, readAuthState, + storeLegacyCredential, } from "./operations"; export { RecipientSessionInvalidError, type RecipientWorkspaceSession, resolveRecipientWorkspaceSession, } from "./recipient"; +export { + DEPRECATED_STATE_FILE_ENV_VAR, + resolveStateFilePath, + STATE_FILE_ENV_VAR, +} from "./state-file"; export { FileTokenStorage, type StoredAuthWorkspace, WorkspaceSelectionError, } from "./token-storage"; +export { fetchWorkspaceName } from "./workspace-name"; export { listAuthWorkspaces, logoutAuthWorkspace, diff --git a/packages/cli/src/auth/legacy-state.ts b/packages/cli/src/auth/legacy-state.ts new file mode 100644 index 00000000..19203f39 --- /dev/null +++ b/packages/cli/src/auth/legacy-state.ts @@ -0,0 +1,141 @@ +import fs from "node:fs/promises"; +import { claimedExpiresAt, claimedWorkspaceId } from "./claims"; +import type { CredentialState, StoredSession } from "./state-file"; +import { getAuthContextFilePath } from "./token-storage"; + +const LEGACY_PLACEHOLDER_NAME = "Unknown workspace"; + +interface LegacyContext { + readonly exists: boolean; + readonly activeWorkspaceId: string | null; + readonly names: Readonly>; +} + +/** + * The legacy store read as sessions. Pure: adoption never writes, and + * the legacy files stay untouched until a mutation materializes the + * adopted set in the new format. + */ +export async function adoptLegacyState( + parsedAuthFile: unknown, + authFilePath: string, +): Promise { + const entries = (parsedAuthFile as { tokens?: unknown }).tokens; + if (!Array.isArray(entries)) { + return { version: 1, sessions: [], currentWorkspaceId: null }; + } + + const context = await readLegacyContext(getAuthContextFilePath(authFilePath)); + const adopted = new Map(); + for (const entry of entries) { + const session = adoptLegacyEntry(entry, context); + if (session) adopted.set(session.workspaceId, session); + } + + const sessions = [...adopted.values()]; + return { + version: 1, + sessions, + currentWorkspaceId: adoptedCurrent(sessions, context), + }; +} + +function adoptLegacyEntry( + entry: unknown, + context: LegacyContext, +): StoredSession | undefined { + if (typeof entry !== "object" || entry === null) return undefined; + const { token, refreshToken } = entry as { + token?: unknown; + refreshToken?: unknown; + }; + if (typeof token !== "string" || token.length === 0) return undefined; + + const workspaceId = claimedWorkspaceId(token); + if (workspaceId === undefined) return undefined; + + const name = adoptedName(context.names[workspaceId], workspaceId); + const expiresAt = claimedExpiresAt(token); + return { + workspaceId, + ...(name === undefined ? {} : { name }), + token, + ...(typeof refreshToken === "string" && refreshToken.length > 0 + ? { refreshToken } + : {}), + ...(expiresAt === undefined ? {} : { expiresAt: expiresAt.toISOString() }), + }; +} + +/** Legacy placeholders do not adopt: a name equal to "Unknown + * workspace" or to the workspace id adopts as no name at all. */ +function adoptedName( + name: string | undefined, + workspaceId: string, +): string | undefined { + const trimmed = name?.trim(); + if (!trimmed) return undefined; + if (trimmed === LEGACY_PLACEHOLDER_NAME) return undefined; + if (trimmed === workspaceId) return undefined; + return trimmed; +} + +function adoptedCurrent( + sessions: readonly StoredSession[], + context: LegacyContext, +): string | null { + if (context.exists) { + const pointed = context.activeWorkspaceId; + return pointed !== null && + sessions.some((session) => session.workspaceId === pointed) + ? pointed + : null; + } + return sessions.length === 1 ? sessions[0].workspaceId : null; +} + +async function readLegacyContext( + contextFilePath: string, +): Promise { + const absent: LegacyContext = { + exists: false, + activeWorkspaceId: null, + names: {}, + }; + + const raw = await fs.readFile(contextFilePath, "utf8").catch(() => null); + if (raw === null) return absent; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return absent; + } + if (typeof parsed !== "object" || parsed === null) return absent; + + const { activeWorkspaceId, workspaces } = parsed as { + activeWorkspaceId?: unknown; + workspaces?: unknown; + }; + const names: Record = {}; + if ( + typeof workspaces === "object" && + workspaces !== null && + !Array.isArray(workspaces) + ) { + for (const [workspaceId, value] of Object.entries(workspaces)) { + const name = (value as { name?: unknown } | null)?.name; + if (typeof name === "string") names[workspaceId] = name; + } + } + + return { + exists: true, + activeWorkspaceId: + typeof activeWorkspaceId === "string" && activeWorkspaceId.trim() + ? activeWorkspaceId.trim() + : null, + names, + }; +} diff --git a/packages/cli/src/auth/operations.ts b/packages/cli/src/auth/operations.ts index 0d3e93e8..a4fb2f83 100644 --- a/packages/cli/src/auth/operations.ts +++ b/packages/cli/src/auth/operations.ts @@ -1,8 +1,14 @@ -import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import type { Credential } from "@prisma/cli-engine"; +import type { + ManagementApiClient, + TokenStorage, + Tokens, +} from "@prisma/management-api-sdk"; import type { AuthStateResult } from "../types/auth"; +import { claimedExpiresAt, claimedWorkspaceId } from "./claims"; import { SERVICE_TOKEN_ENV_VAR } from "./client"; import { authenticatedManagementApiClient } from "./guard"; -import { login } from "./login"; +import { AuthError, login } from "./login"; import { FileTokenStorage } from "./token-storage"; const WORKSPACE_SUB_PREFIX = "workspace:"; @@ -58,19 +64,70 @@ function workspaceIdFromClaims(claims: Record): string | null { return id.length > 0 ? id : null; } +/** Holds the tokens the SDK writes at callback time and nothing else: + * minting and custody stay separate, so a login never writes through + * the credential manager. */ +class ThrowawayTokenStorage implements TokenStorage { + tokens: Tokens | null = null; + + async getTokens(): Promise { + return this.tokens; + } + + async setTokens(tokens: Tokens): Promise { + this.tokens = tokens; + } + + async clearTokens(): Promise { + this.tokens = null; + } +} + +/** Runs the browser consent flow and RETURNS the minted credential. + * Storing it is the caller's job. */ export async function performLogin( env: NodeJS.ProcessEnv, signal?: AbortSignal, options?: { onVerificationUrl?: (url: string) => void }, -): Promise { +): Promise { + const tokenStorage = new ThrowawayTokenStorage(); await login({ - tokenStorage: new FileTokenStorage(env, signal, { - activateOnSetTokens: true, - }), + tokenStorage, env, signal, onVerificationUrl: options?.onVerificationUrl, }); + + const tokens = tokenStorage.tokens; + if (!tokens) { + throw new AuthError("Sign-in finished without producing a credential."); + } + return { + token: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: claimedExpiresAt(tokens.accessToken), + }; +} + +/** Writes a minted credential into the legacy store, which the legacy + * shell still reads. Dies with the legacy shell. */ +export async function storeLegacyCredential( + env: NodeJS.ProcessEnv, + credential: Credential, + signal?: AbortSignal, +): Promise { + const workspaceId = claimedWorkspaceId(credential.token); + if (workspaceId === undefined) return; + + await new FileTokenStorage(env, signal, { + activateOnSetTokens: true, + }).setTokens({ + workspaceId, + accessToken: credential.token, + ...(credential.refreshToken === undefined + ? {} + : { refreshToken: credential.refreshToken }), + }); } export async function readAuthState( diff --git a/packages/cli/src/auth/state-file.ts b/packages/cli/src/auth/state-file.ts new file mode 100644 index 00000000..3fddd3ad --- /dev/null +++ b/packages/cli/src/auth/state-file.ts @@ -0,0 +1,292 @@ +// biome-ignore-all lint/performance/noAwaitInLoops: Lock acquisition retries must run sequentially. +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import { defaultAuthFilePath } from "./client"; +import { adoptLegacyState } from "./legacy-state"; + +export const STATE_FILE_ENV_VAR = "PRISMA_AUTH_FILE"; +export const DEPRECATED_STATE_FILE_ENV_VAR = "PRISMA_COMPUTE_AUTH_FILE"; +export const STATE_VERSION = 1; + +const FILE_MODE = 0o600; +const LOCK_STALE_MS = 5_000; +const LOCK_RETRY_MS = 10; +const LOCK_WAIT_TIMEOUT_MS = 10_000; + +export interface StoredSession { + readonly workspaceId: string; + readonly name?: string; + readonly token: string; + readonly refreshToken?: string; + readonly expiresAt?: string; +} + +export interface CredentialState { + readonly version: number; + readonly sessions: readonly StoredSession[]; + readonly currentWorkspaceId: string | null; +} + +export const EMPTY_STATE: CredentialState = { + version: STATE_VERSION, + sessions: [], + currentWorkspaceId: null, +}; + +export type DebugLog = (message: string) => void; + +export function makeDebugLog( + env: Readonly>, + write: (text: string) => void = (text) => { + process.stderr.write(text); + }, +): DebugLog { + if (env.PRISMA_NEXT_DEBUG !== "1") return () => {}; + return (message) => { + write(`prisma auth: ${message}\n`); + }; +} + +export interface ResolvedStateFile { + readonly filePath: string; + readonly fromDeprecatedEnvVar: boolean; +} + +export function resolveStateFilePath( + env: Readonly>, +): ResolvedStateFile { + const configured = env[STATE_FILE_ENV_VAR]; + if (configured?.trim()) { + return { filePath: path.resolve(configured), fromDeprecatedEnvVar: false }; + } + + const deprecated = env[DEPRECATED_STATE_FILE_ENV_VAR]; + if (deprecated?.trim()) { + return { filePath: path.resolve(deprecated), fromDeprecatedEnvVar: true }; + } + + return { filePath: defaultAuthFilePath(env), fromDeprecatedEnvVar: false }; +} + +export function credentialsUnreadableError( + filePath: string, + cause: unknown, +): CliStructuredError { + return new CliStructuredError( + "CLI.CREDENTIALS_UNREADABLE", + "Your stored credentials could not be read.", + { + why: `The credentials file at ${filePath} exists but could not be read.`, + nextActions: [ + { + kind: "user-choice", + label: "Check the file's permissions, then run the command again.", + }, + ], + cause, + }, + ); +} + +/** + * The stored state: the new format as written, the legacy store adopted + * (§7 — a pure read that writes nothing), or empty. Reads take no lock: + * writes rename a complete file into place. + */ +export async function readCredentialState( + filePath: string, +): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return EMPTY_STATE; + throw credentialsUnreadableError(filePath, error); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return EMPTY_STATE; + } + + if (typeof parsed !== "object" || parsed === null) return EMPTY_STATE; + + const shape = parsed as { + sessions?: unknown; + currentWorkspaceId?: unknown; + version?: unknown; + }; + if (Array.isArray(shape.sessions)) { + return { + version: + typeof shape.version === "number" ? shape.version : STATE_VERSION, + sessions: shape.sessions.filter(isStoredSession).map(normalizeSession), + currentWorkspaceId: + typeof shape.currentWorkspaceId === "string" && + shape.currentWorkspaceId.length > 0 + ? shape.currentWorkspaceId + : null, + }; + } + + return adoptLegacyState(parsed, filePath); +} + +function isStoredSession(value: unknown): value is StoredSession { + if (typeof value !== "object" || value === null) return false; + const candidate = value as StoredSession; + return ( + typeof candidate.workspaceId === "string" && + candidate.workspaceId.length > 0 && + typeof candidate.token === "string" && + candidate.token.length > 0 + ); +} + +function normalizeSession(session: StoredSession): StoredSession { + return { + workspaceId: session.workspaceId, + ...(typeof session.name === "string" && session.name.length > 0 + ? { name: session.name } + : {}), + token: session.token, + ...(typeof session.refreshToken === "string" && + session.refreshToken.length > 0 + ? { refreshToken: session.refreshToken } + : {}), + ...(typeof session.expiresAt === "string" && session.expiresAt.length > 0 + ? { expiresAt: session.expiresAt } + : {}), + }; +} + +/** Temp file in the same directory, fsync, rename, mode 0600 — a reader + * only ever sees a complete state. */ +export async function writeCredentialState( + filePath: string, + state: CredentialState, +): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${randomUUID()}.tmp`; + const handle = await fs.open(tempPath, "wx", FILE_MODE); + try { + await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + + try { + await fs.rename(tempPath, filePath); + } catch (error) { + await fs.unlink(tempPath).catch(() => {}); + throw error; + } + await fs.chmod(filePath, FILE_MODE).catch(() => {}); +} + +class StateLockTimeoutError extends CliStructuredError { + constructor(lockPath: string) { + super( + "CLI.CREDENTIALS_LOCKED", + "Another prisma process is still updating your credentials.", + { + why: `The credentials lock at ${lockPath} was held for longer than ${LOCK_WAIT_TIMEOUT_MS}ms.`, + nextActions: [ + { + kind: "user-choice", + label: "Wait for the other command to finish, then try again.", + }, + ], + }, + ); + } +} + +/** + * The short advisory lock every mutation takes: acquire, re-read, apply + * one slice, write, release. Its only job is lost-update prevention + * between processes. No network I/O ever runs under it, so holds are + * milliseconds and a crashed holder's lock is simply taken over after a + * small fixed staleness threshold. + */ +export async function withStateLock( + filePath: string, + debug: DebugLog, + run: () => Promise, +): Promise { + const lockPath = `${filePath}.lock`; + const lockId = await acquireStateLock(lockPath, debug); + debug(`lock acquired ${lockPath}`); + try { + return await run(); + } finally { + await releaseStateLock(lockPath, lockId); + debug(`lock released ${lockPath}`); + } +} + +async function acquireStateLock( + lockPath: string, + debug: DebugLog, +): Promise { + const lockId = randomUUID(); + const startedAt = Date.now(); + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + + while (true) { + if (await tryCreateStateLock(lockPath, lockId)) return lockId; + + if (await takeOverStaleStateLock(lockPath, debug)) continue; + + if (Date.now() - startedAt >= LOCK_WAIT_TIMEOUT_MS) { + throw new StateLockTimeoutError(lockPath); + } + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); + } +} + +async function tryCreateStateLock( + lockPath: string, + lockId: string, +): Promise { + let handle: fs.FileHandle; + try { + handle = await fs.open(lockPath, "wx", FILE_MODE); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + try { + await handle.writeFile(lockId, "utf8"); + } finally { + await handle.close(); + } + return true; +} + +async function takeOverStaleStateLock( + lockPath: string, + debug: DebugLog, +): Promise { + const stats = await fs.stat(lockPath).catch(() => null); + if (!stats) return true; + if (Date.now() - stats.mtimeMs <= LOCK_STALE_MS) return false; + + debug(`lock taken over from a crashed holder ${lockPath}`); + await fs.unlink(lockPath).catch(() => {}); + return true; +} + +async function releaseStateLock( + lockPath: string, + lockId: string, +): Promise { + const holder = await fs.readFile(lockPath, "utf8").catch(() => null); + if (holder !== lockId) return; + await fs.unlink(lockPath).catch(() => {}); +} diff --git a/packages/cli/src/auth/workspace-name.ts b/packages/cli/src/auth/workspace-name.ts new file mode 100644 index 00000000..5e0aff0b --- /dev/null +++ b/packages/cli/src/auth/workspace-name.ts @@ -0,0 +1,21 @@ +import { createManagementApiClient } from "@prisma/management-api-sdk"; +import type { FetchWorkspaceName } from "./credential-manager"; + +/** The manager's injected name lookup: a static-token client over the + * credential just minted. The manager constructs no API client and + * treats any failure here as "no name". */ +export function fetchWorkspaceName(apiBaseUrl: string): FetchWorkspaceName { + return async (credential, workspaceId) => { + const client = createManagementApiClient({ + baseUrl: apiBaseUrl, + token: credential.token, + }); + const { data } = await client.GET("/v1/workspaces/{id}", { + params: { path: { id: workspaceId } }, + }); + const name = data?.data?.name; + return typeof name === "string" && name.trim().length > 0 + ? name.trim() + : undefined; + }; +} diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index 3fb4aa2e..629df36a 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -5,6 +5,7 @@ import { performLogout, readAuthState, SERVICE_TOKEN_ENV_VAR, + storeLegacyCredential, switchAuthWorkspace, type WorkspaceOperationContext, } from "../auth"; @@ -64,7 +65,15 @@ export async function runAuthLogin( let result: AuthStateResult; if (isRealMode(context)) { - await performLogin(context.runtime.env, context.runtime.signal); + const credential = await performLogin( + context.runtime.env, + context.runtime.signal, + ); + await storeLegacyCredential( + context.runtime.env, + credential, + context.runtime.signal, + ); result = await readAuthState(context.runtime.env, context.runtime.signal); } else { const useCases = createAuthUseCases(createCliUseCaseGateways(context)); @@ -218,7 +227,15 @@ export async function requireAuthenticatedAuthState( throw authRequiredError(); } - await performLogin(context.runtime.env, context.runtime.signal); + const credential = await performLogin( + context.runtime.env, + context.runtime.signal, + ); + await storeLegacyCredential( + context.runtime.env, + credential, + context.runtime.signal, + ); return readAuthState(context.runtime.env, context.runtime.signal); } diff --git a/packages/cli/src/v8/auth/login.ts b/packages/cli/src/v8/auth/login.ts index 5f0ed16f..8b09f20c 100644 --- a/packages/cli/src/v8/auth/login.ts +++ b/packages/cli/src/v8/auth/login.ts @@ -4,6 +4,7 @@ import { isEmptyServiceTokenError, performLogin, readAuthState, + storeLegacyCredential, } from "../../auth"; import { CLI_NAME } from "../../cli-name"; import type { AuthStateResult } from "../../types/auth"; @@ -67,10 +68,11 @@ export const authLoginCommand = defineCommand({ handler: async (_args, ctx) => { ctx.report({ kind: "step-started", step: LOGIN_STEP }); try { - await performLogin(ctx.env, ctx.signal, { + const credential = await performLogin(ctx.env, ctx.signal, { onVerificationUrl: (url) => ctx.report({ kind: "endpoint", name: "verification", url }), }); + await storeLegacyCredential(ctx.env, credential, ctx.signal); } catch (error) { ctx.report({ kind: "step-finished", diff --git a/packages/cli/src/v8/runtime.ts b/packages/cli/src/v8/runtime.ts index 2f2877ef..b75e48c9 100644 --- a/packages/cli/src/v8/runtime.ts +++ b/packages/cli/src/v8/runtime.ts @@ -4,7 +4,19 @@ import { loadConfig, type Runtime, } from "@prisma/cli-engine"; -import { getApiBaseUrl, makeGetCredentials } from "../auth"; +import open from "open"; +import { + CLIENT_ID, + DEFAULT_REDIRECT_URI, + DEPRECATED_STATE_FILE_ENV_VAR, + FileCredentialManager, + fetchWorkspaceName, + getApiBaseUrl, + getAuthBaseUrl, + makeGetCredentials, + resolveStateFilePath, + STATE_FILE_ENV_VAR, +} from "../auth"; export type SignalProcess = Pick; @@ -37,6 +49,15 @@ export function detectPackageManager( return "unknown"; } +/** PRISMA_COMPUTE_AUTH_FILE still names the credentials file, but + * PRISMA_AUTH_FILE is the supported name. Warned once per process. */ +function warnOnDeprecatedStateFileEnvVar(proc: HostProcess): void { + if (!resolveStateFilePath(proc.env).fromDeprecatedEnvVar) return; + proc.stderr.write( + `${DEPRECATED_STATE_FILE_ENV_VAR} is deprecated; use ${STATE_FILE_ENV_VAR} instead.\n`, + ); +} + export async function assembleRuntime(proc: HostProcess): Promise { const stdin: InputStream = { setRawMode: @@ -47,6 +68,8 @@ export async function assembleRuntime(proc: HostProcess): Promise { : undefined, [Symbol.asyncIterator]: () => proc.stdin[Symbol.asyncIterator](), }; + warnOnDeprecatedStateFileEnvVar(proc); + const apiBaseUrl = getApiBaseUrl(proc.env); return { stdout: { write: (text) => { @@ -70,7 +93,20 @@ export async function assembleRuntime(proc: HostProcess): Promise { onSignal: makeOnSignal(proc), config: await loadConfig(proc.cwd()), getCredentials: makeGetCredentials(proc.env), - managementApi: { baseUrl: getApiBaseUrl(proc.env) }, + credentialManager: new FileCredentialManager({ + env: proc.env, + fetchWorkspaceName: fetchWorkspaceName(apiBaseUrl), + }), + managementApiClientConfig: { + clientId: CLIENT_ID, + redirectUri: DEFAULT_REDIRECT_URI, + apiBaseUrl, + authBaseUrl: getAuthBaseUrl(proc.env), + }, + openUrl: async (url) => { + await open(url); + }, + managementApi: { baseUrl: apiBaseUrl }, packageManager: detectPackageManager(proc.env), }; } diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index 0069cb20..4a51f055 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -21,7 +21,11 @@ afterEach(() => { describe("real auth mode", () => { it("uses real auth operations when fixture mode is not enabled", async () => { - const performLogin = vi.fn().mockResolvedValue(undefined); + const performLogin = vi.fn().mockResolvedValue({ + token: "real-mode-access-token", + refreshToken: undefined, + expiresAt: undefined, + }); const readAuthState = vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -77,7 +81,11 @@ describe("real auth mode", () => { }); it("stays in mock mode when fixture mode is enabled", async () => { - const performLogin = vi.fn().mockResolvedValue(undefined); + const performLogin = vi.fn().mockResolvedValue({ + token: "real-mode-access-token", + refreshToken: undefined, + expiresAt: undefined, + }); const readAuthState = vi.fn().mockResolvedValue(null); const performLogout = vi.fn().mockResolvedValue(undefined); diff --git a/packages/cli/tests/credential-manager-login.test.ts b/packages/cli/tests/credential-manager-login.test.ts new file mode 100644 index 00000000..7374d65e --- /dev/null +++ b/packages/cli/tests/credential-manager-login.test.ts @@ -0,0 +1,71 @@ +/** + * Minting and custody stay separate: performLogin returns the minted + * credential and the SDK's callback-time write lands in a throwaway + * storage, never in the credential manager's state file. + */ +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { mintTestJwt } from "@prisma/cli-engine/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FileCredentialManager } from "../src/auth/credential-manager"; +import { login } from "../src/auth/login"; +import { performLogin } from "../src/auth/operations"; + +vi.mock("../src/auth/login", async (importOriginal) => ({ + ...(await importOriginal()), + login: vi.fn(), +})); + +const WORKSPACE_A = "wksp_a"; +const MINTED_ACCESS_TOKEN = mintTestJwt({ + workspace_id: WORKSPACE_A, + exp: 2_000_000_000, +}); + +let stateFilePath: string; + +beforeEach(async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "prisma-credential-login-")); + stateFilePath = path.join(dir, "auth.json"); + vi.mocked(login).mockReset(); +}); + +describe("performLogin", () => { + it("returns the minted credential and writes nothing to the state file", async () => { + vi.mocked(login).mockImplementation(async (options) => { + await options?.tokenStorage?.setTokens({ + workspaceId: WORKSPACE_A, + accessToken: MINTED_ACCESS_TOKEN, + refreshToken: "minted-refresh-token", + }); + }); + + const env = { PRISMA_AUTH_FILE: stateFilePath }; + const credential = await performLogin(env); + + expect(credential).toEqual({ + token: MINTED_ACCESS_TOKEN, + refreshToken: "minted-refresh-token", + expiresAt: new Date(2_000_000_000 * 1000), + }); + expect(await readFile(stateFilePath, "utf8").catch(() => null)).toBeNull(); + + const manager = new FileCredentialManager({ env }); + expect(await manager.sessions()).toEqual([]); + + await manager.createSession(credential, WORKSPACE_A); + expect( + (await manager.sessions()).map((session) => session.workspaceId), + ).toEqual([WORKSPACE_A]); + }); + + it("fails when the flow finishes without a credential", async () => { + vi.mocked(login).mockResolvedValue(undefined); + + await expect( + performLogin({ PRISMA_AUTH_FILE: stateFilePath }), + ).rejects.toThrow("Sign-in finished without producing a credential."); + }); +}); diff --git a/packages/cli/tests/credential-manager-migration.test.ts b/packages/cli/tests/credential-manager-migration.test.ts new file mode 100644 index 00000000..28ab2fb7 --- /dev/null +++ b/packages/cli/tests/credential-manager-migration.test.ts @@ -0,0 +1,282 @@ +/** + * Migration from the legacy store: adoption is a pure read, and the + * adopted set materializes in the new format on the first mutation. + */ +import { mkdtemp, readFile, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { mintTestJwt } from "@prisma/cli-engine/testing"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { FileCredentialManager } from "../src/auth/credential-manager"; +import { readCredentialState } from "../src/auth/state-file"; +import { getAuthContextFilePath } from "../src/auth/token-storage"; + +const WORKSPACE_A = "wksp_a"; +const WORKSPACE_B = "wksp_b"; + +let authFilePath: string; +let contextFilePath: string; + +function mintToken( + workspaceId: string, + overrides: Record = {}, +) { + return mintTestJwt({ workspace_id: workspaceId, ...overrides }); +} + +function legacyEntry(workspaceId: string, refreshToken?: string) { + return { + workspaceId, + token: mintToken(workspaceId), + ...(refreshToken === undefined ? {} : { refreshToken }), + }; +} + +async function writeLegacyStore(entries: unknown[]): Promise { + await writeFile(authFilePath, JSON.stringify({ tokens: entries }), "utf8"); +} + +async function writeLegacyContext(context: unknown): Promise { + await writeFile(contextFilePath, JSON.stringify(context), "utf8"); +} + +function makeManager() { + return new FileCredentialManager({ env: { PRISMA_AUTH_FILE: authFilePath } }); +} + +beforeEach(async () => { + const dir = await mkdtemp( + path.join(os.tmpdir(), "prisma-credential-migration-"), + ); + authFilePath = path.join(dir, "auth.json"); + contextFilePath = getAuthContextFilePath(authFilePath); +}); + +describe("adopting the legacy store", () => { + it("adopts every entry and marks the one the pointer targets", async () => { + await writeLegacyStore([ + legacyEntry(WORKSPACE_A), + legacyEntry(WORKSPACE_B), + ]); + await writeLegacyContext({ + activeWorkspaceId: WORKSPACE_B, + workspaces: { [WORKSPACE_B]: { name: "Bravo" } }, + }); + + const sessions = await makeManager().sessions(); + expect(sessions.map((session) => session.workspaceId)).toEqual([ + WORKSPACE_A, + WORKSPACE_B, + ]); + expect(sessions.find((session) => session.current)?.workspaceId).toBe( + WORKSPACE_B, + ); + expect(sessions[1].workspaceName).toBe("Bravo"); + }); + + it("adopts with no current session when the pointer dangles", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A)]); + await writeLegacyContext({ + activeWorkspaceId: "wksp_gone", + workspaces: {}, + }); + + const sessions = await makeManager().sessions(); + expect(sessions).toHaveLength(1); + expect(sessions.every((session) => !session.current)).toBe(true); + }); + + it("adopts with no current session when the pointer is null", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A)]); + await writeLegacyContext({ activeWorkspaceId: null, workspaces: {} }); + + expect( + (await makeManager().sessions()).every((session) => !session.current), + ).toBe(true); + }); + + it("makes the only entry current when there is no context file", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A)]); + + const sessions = await makeManager().sessions(); + expect(sessions[0].current).toBe(true); + }); + + it("picks no current session when several entries have no context file", async () => { + await writeLegacyStore([ + legacyEntry(WORKSPACE_A), + legacyEntry(WORKSPACE_B), + ]); + + expect( + (await makeManager().sessions()).every((session) => !session.current), + ).toBe(true); + }); + + it("adopts nothing from a missing, unparseable, or wrong-shaped file", async () => { + expect(await makeManager().sessions()).toEqual([]); + + await writeFile(authFilePath, "not json at all", "utf8"); + expect(await makeManager().sessions()).toEqual([]); + + await writeFile(authFilePath, JSON.stringify({ nope: true }), "utf8"); + expect(await makeManager().sessions()).toEqual([]); + }); + + it("keys on the workspace_id claim, keeps the last duplicate, and ignores undecodable entries", async () => { + await writeLegacyStore([ + { + workspaceId: "stale-key", + token: mintToken(WORKSPACE_A), + refreshToken: "first", + }, + { + workspaceId: WORKSPACE_A, + token: mintToken(WORKSPACE_A), + refreshToken: "second", + }, + { workspaceId: "wksp_broken", token: "not-a-jwt", refreshToken: "third" }, + ]); + + const state = await readCredentialState(authFilePath); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0]).toMatchObject({ + workspaceId: WORKSPACE_A, + refreshToken: "second", + }); + }); + + it("adopts placeholder names as no name and entries without a refresh token", async () => { + await writeLegacyStore([ + legacyEntry(WORKSPACE_A), + legacyEntry(WORKSPACE_B), + ]); + await writeLegacyContext({ + activeWorkspaceId: null, + workspaces: { + [WORKSPACE_A]: { name: "Unknown workspace" }, + [WORKSPACE_B]: { name: WORKSPACE_B }, + }, + }); + + const state = await readCredentialState(authFilePath); + expect(state.sessions.map((session) => session.name)).toEqual([ + undefined, + undefined, + ]); + expect( + state.sessions.every((session) => session.refreshToken === undefined), + ).toBe(true); + }); + + it("leaves the legacy files untouched until a mutation materializes the adopted set", async () => { + await writeLegacyStore([ + legacyEntry(WORKSPACE_A, "r1"), + legacyEntry(WORKSPACE_B, "r2"), + ]); + await writeLegacyContext({ + activeWorkspaceId: WORKSPACE_A, + workspaces: {}, + }); + const legacyBytes = await readFile(authFilePath, "utf8"); + + const manager = makeManager(); + await manager.currentSession(); + expect(await readFile(authFilePath, "utf8")).toBe(legacyBytes); + + const sessionB = (await manager.sessions()).find( + (session) => session.workspaceId === WORKSPACE_B, + ); + await manager.useSession(sessionB as never); + + const state = await readCredentialState(authFilePath); + expect(state.sessions.map((session) => session.workspaceId)).toEqual([ + WORKSPACE_A, + WORKSPACE_B, + ]); + expect(state.currentWorkspaceId).toBe(WORKSPACE_B); + expect(await readFile(authFilePath, "utf8")).not.toBe(legacyBytes); + }); + + it("lets an existing new-format file win over adoption", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A)]); + const manager = makeManager(); + await manager.sessions(); + + await writeFile( + authFilePath, + JSON.stringify({ + version: 1, + sessions: [{ workspaceId: WORKSPACE_B, token: mintToken(WORKSPACE_B) }], + currentWorkspaceId: WORKSPACE_B, + }), + "utf8", + ); + + await manager.createSession( + { + token: mintToken(WORKSPACE_A), + refreshToken: "r", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + + const state = await readCredentialState(authFilePath); + expect(state.sessions.map((session) => session.workspaceId)).toEqual([ + WORKSPACE_B, + WORKSPACE_A, + ]); + }); + + it("re-decides adoption inside the lock", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A, "legacy-refresh")]); + const manager = makeManager(); + + await writeFile(`${authFilePath}.lock`, "another-process", "utf8"); + const mutation = manager.createSession( + { + token: mintToken(WORKSPACE_B), + refreshToken: "r", + expiresAt: undefined, + }, + WORKSPACE_B, + ); + + await writeFile( + authFilePath, + JSON.stringify({ + version: 1, + sessions: [], + currentWorkspaceId: null, + }), + "utf8", + ); + await unlink(`${authFilePath}.lock`); + await mutation; + + const state = await readCredentialState(authFilePath); + expect(state.sessions.map((session) => session.workspaceId)).toEqual([ + WORKSPACE_B, + ]); + }); + + it("reaps the legacy context file on endAllSessions", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A)]); + await writeLegacyContext({ + activeWorkspaceId: WORKSPACE_A, + workspaces: {}, + }); + + await makeManager().endAllSessions(); + + expect( + await readFile(contextFilePath, "utf8").catch(() => null), + ).toBeNull(); + const state = await readCredentialState(authFilePath); + expect(state.sessions).toEqual([]); + expect(state.currentWorkspaceId).toBeNull(); + await unlink(authFilePath); + }); +}); diff --git a/packages/cli/tests/credential-manager-processes.test.ts b/packages/cli/tests/credential-manager-processes.test.ts new file mode 100644 index 00000000..cfbf0212 --- /dev/null +++ b/packages/cli/tests/credential-manager-processes.test.ts @@ -0,0 +1,175 @@ +/** + * The credential manager across real processes on a real filesystem: + * the short lock prevents lost updates, a crashed holder's lock is + * taken over, and a new process picks up the marker this one pinned + * away from. + */ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, stat, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mintTestJwt } from "@prisma/cli-engine/testing"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { FileCredentialManager } from "../src/auth/credential-manager"; +import { readCredentialState } from "../src/auth/state-file"; + +const WORKSPACE_A = "wksp_a"; +const WORKSPACE_B = "wksp_b"; +const WORKSPACE_C = "wksp_c"; + +const workerPath = fileURLToPath( + new URL("./helpers/credential-manager-worker.ts", import.meta.url), +); +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); + +let stateFilePath: string; + +function mintToken(workspaceId: string) { + return mintTestJwt({ workspace_id: workspaceId }); +} + +function runWorker(command: string, ...args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx", workerPath, stateFilePath, command, ...args], + { cwd: packageRoot }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`worker ${command} failed: ${stderr}`)); + }); + }); +} + +function makeManager() { + return new FileCredentialManager({ + env: { PRISMA_AUTH_FILE: stateFilePath }, + }); +} + +beforeEach(async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "prisma-credential-procs-")); + stateFilePath = path.join(dir, "auth.json"); +}); + +describe("across processes", () => { + it("lands both concurrent mutations from separate processes", async () => { + await Promise.all([ + runWorker("create", WORKSPACE_A, mintToken(WORKSPACE_A), "refresh-a"), + runWorker("create", WORKSPACE_B, mintToken(WORKSPACE_B), "refresh-b"), + runWorker("create", WORKSPACE_C, mintToken(WORKSPACE_C), "refresh-c"), + ]); + + const state = await readCredentialState(stateFilePath); + expect( + [...state.sessions.map((session) => session.workspaceId)].sort(), + ).toEqual([WORKSPACE_A, WORKSPACE_B, WORKSPACE_C]); + }, 30_000); + + it("leaves a valid pair in the file when two processes rotate the same session", async () => { + await runWorker("create", WORKSPACE_A, mintToken(WORKSPACE_A), "refresh-0"); + const first = mintToken(WORKSPACE_A); + const second = mintToken(WORKSPACE_A); + + await Promise.all([ + runWorker("rotate", WORKSPACE_A, first, "refresh-1"), + runWorker("rotate", WORKSPACE_A, second, "refresh-2"), + ]); + + const state = await readCredentialState(stateFilePath); + expect(state.sessions).toHaveLength(1); + const record = state.sessions[0]; + expect([`${first}|refresh-1`, `${second}|refresh-2`]).toContain( + `${record.token}|${record.refreshToken}`, + ); + }, 30_000); + + it("takes over a crashed holder's lock after the stale threshold", async () => { + await runWorker("create", WORKSPACE_A, mintToken(WORKSPACE_A), "refresh-a"); + await runWorker("crash-holding-the-lock"); + const lockPath = `${stateFilePath}.lock`; + expect(await stat(lockPath)).toBeTruthy(); + + const debugLines: string[] = []; + const manager = new FileCredentialManager({ + env: { PRISMA_AUTH_FILE: stateFilePath, PRISMA_NEXT_DEBUG: "1" }, + debugWrite: (text) => debugLines.push(text), + }); + await manager.createSession( + { + token: mintToken(WORKSPACE_B), + refreshToken: "refresh-b", + expiresAt: undefined, + }, + WORKSPACE_B, + ); + + const state = await readCredentialState(stateFilePath); + expect(state.sessions.map((session) => session.workspaceId)).toEqual([ + WORKSPACE_A, + WORKSPACE_B, + ]); + expect(debugLines.join("")).toContain("taken over"); + }, 30_000); + + it("waits for a lock another process still holds", async () => { + await runWorker("create", WORKSPACE_A, mintToken(WORKSPACE_A), "refresh-a"); + const lockPath = `${stateFilePath}.lock`; + await writeFile(lockPath, "another-process", "utf8"); + + const mutation = makeManager().createSession( + { + token: mintToken(WORKSPACE_B), + refreshToken: "refresh-b", + expiresAt: undefined, + }, + WORKSPACE_B, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + const during = await readCredentialState(stateFilePath); + expect(during.sessions).toHaveLength(1); + + await unlink(lockPath); + await mutation; + + expect((await readCredentialState(stateFilePath)).sessions).toHaveLength(2); + }, 30_000); + + it("gives a new process the marker this process pinned away from", async () => { + await runWorker("create", WORKSPACE_A, mintToken(WORKSPACE_A), "refresh-a"); + await runWorker("create", WORKSPACE_B, mintToken(WORKSPACE_B), "refresh-b"); + + const manager = makeManager(); + expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_B); + + await runWorker("use", WORKSPACE_A); + expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_B); + + const fromNewProcess = JSON.parse(await runWorker("current")) as { + workspaceId: string; + }; + expect(fromNewProcess.workspaceId).toBe(WORKSPACE_A); + }, 30_000); + + it("never leaves token material in a worker's output", async () => { + const token = mintToken(WORKSPACE_A); + await runWorker("create", WORKSPACE_A, token, "s3cret-refresh"); + const printed = await runWorker("sessions"); + + expect(printed).not.toContain("s3cret-refresh"); + expect(printed).not.toContain(token); + expect(await readFile(stateFilePath, "utf8")).toContain("s3cret-refresh"); + }, 30_000); +}); diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts new file mode 100644 index 00000000..8f58448f --- /dev/null +++ b/packages/cli/tests/credential-manager.test.ts @@ -0,0 +1,557 @@ +/** + * The credential manager over its state file: the file format and its + * atomicity, process pinning, the env override rules, the TokenStorage + * write slices, and the legacy migration. + */ + +/** + * The credential manager over its state file: the file format and its + * atomicity, process pinning, the env override rules, the TokenStorage + * write slices, and the legacy migration. + */ +import fsPromises, { + mkdtemp, + readFile, + stat, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { mintTestJwt } from "@prisma/cli-engine/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FileCredentialManager } from "../src/auth/credential-manager"; +import { readCredentialState } from "../src/auth/state-file"; + +const WORKSPACE_A = "wksp_a"; +const WORKSPACE_B = "wksp_b"; + +let stateFilePath: string; + +function mintToken( + workspaceId: string, + overrides: Record = {}, +) { + return mintTestJwt({ + workspace_id: workspaceId, + sub: "user_1", + ...overrides, + }); +} + +function credentialFor(workspaceId: string, refreshToken = "refresh-1") { + return { + token: mintToken(workspaceId), + refreshToken, + expiresAt: undefined, + }; +} + +function makeManager( + options: { + env?: Record; + fetchWorkspaceName?: ( + credential: { token: string }, + workspaceId: string, + ) => Promise; + debugWrite?: (text: string) => void; + } = {}, +) { + return new FileCredentialManager({ + env: { PRISMA_AUTH_FILE: stateFilePath, ...options.env }, + fetchWorkspaceName: options.fetchWorkspaceName, + debugWrite: options.debugWrite, + }); +} + +async function readRawState(): Promise { + return readFile(stateFilePath, "utf8").catch(() => null); +} + +async function seedTwoSessions(): Promise { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + await manager.createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); +} + +beforeEach(async () => { + const dir = await mkdtemp( + path.join(os.tmpdir(), "prisma-credential-manager-"), + ); + stateFilePath = path.join(dir, "auth.json"); +}); + +describe("the state file", () => { + it("writes the normative shape with mode 0600", async () => { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + + const state = JSON.parse((await readRawState()) ?? ""); + expect(state).toMatchObject({ + version: 1, + currentWorkspaceId: WORKSPACE_A, + sessions: [{ workspaceId: WORKSPACE_A, refreshToken: "refresh-1" }], + }); + expect((await stat(stateFilePath)).mode & 0o777).toBe(0o600); + }); + + it("tightens permissions looser than 0600", async () => { + await writeFile(stateFilePath, JSON.stringify({ tokens: [] }), { + mode: 0o644, + }); + await makeManager().createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + + expect((await stat(stateFilePath)).mode & 0o777).toBe(0o600); + }); + + it("reads never write", async () => { + await seedTwoSessions(); + const legacyPath = path.join(path.dirname(stateFilePath), "legacy.json"); + await writeFile( + legacyPath, + JSON.stringify({ + tokens: [ + { + workspaceId: WORKSPACE_A, + token: mintToken(WORKSPACE_A), + refreshToken: "legacy-refresh", + }, + ], + }), + ); + + const writes = vi.spyOn(fsPromises, "writeFile"); + const renames = vi.spyOn(fsPromises, "rename"); + const opens = vi.spyOn(fsPromises, "open"); + try { + const manager = makeManager(); + await manager.currentSession(); + await manager.sessions(); + await manager.tokenStorage(WORKSPACE_A).getTokens(); + + const adopting = new FileCredentialManager({ + env: { PRISMA_AUTH_FILE: legacyPath }, + }); + await adopting.currentSession(); + await adopting.sessions(); + + expect(writes).not.toHaveBeenCalled(); + expect(renames).not.toHaveBeenCalled(); + expect(opens).not.toHaveBeenCalled(); + + await manager.endAllSessions(); + expect(renames).toHaveBeenCalled(); + } finally { + writes.mockRestore(); + renames.mockRestore(); + opens.mockRestore(); + } + }); + + it("treats a corrupt file as signed out and never rewrites it", async () => { + await writeFile(stateFilePath, "{ not json", "utf8"); + const before = await readRawState(); + + const manager = makeManager(); + expect(await manager.currentSession()).toBeNull(); + expect(await manager.sessions()).toEqual([]); + expect(await readRawState()).toBe(before); + }); +}); + +describe("process pinning", () => { + it("pins the current session at the first read and keeps it when another process moves the marker", async () => { + await seedTwoSessions(); + const manager = makeManager(); + await makeManager().useSession( + (await manager.sessions()).find( + (session) => session.workspaceId === WORKSPACE_A, + ) as never, + ); + + const pinned = await manager.currentSession(); + expect(pinned?.workspaceId).toBe(WORKSPACE_A); + + const otherProcess = makeManager(); + await otherProcess.useSession( + (await otherProcess.sessions()).find( + (session) => session.workspaceId === WORKSPACE_B, + ) as never, + ); + + expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_A); + expect((await makeManager().currentSession())?.workspaceId).toBe( + WORKSPACE_B, + ); + }); + + it("moves the pin on this process's own mutations", async () => { + await seedTwoSessions(); + const manager = makeManager(); + expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_B); + + const sessionA = (await manager.sessions()).find( + (session) => session.workspaceId === WORKSPACE_A, + ); + await manager.useSession(sessionA as never); + expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_A); + + await manager.endSession(sessionA as never); + await expect(manager.currentSession()).rejects.toMatchObject({ + code: "CLI.CREDENTIALS_REQUIRED", + }); + }); + + it("fails with the session-ended error when another process ends the pinned session", async () => { + await seedTwoSessions(); + const manager = makeManager(); + await manager.currentSession(); + + const otherProcess = makeManager(); + await otherProcess.endSession( + (await otherProcess.sessions()).find( + (session) => session.workspaceId === WORKSPACE_B, + ) as never, + ); + + await expect(manager.currentSession()).rejects.toMatchObject({ + code: "CLI.CREDENTIALS_REQUIRED", + message: "The workspace session this command was using has ended.", + }); + }); + + it("reports sessions held but none current", async () => { + await seedTwoSessions(); + const manager = makeManager(); + const sessions = await manager.sessions(); + await manager.endSession( + sessions.find((session) => session.workspaceId === WORKSPACE_B) as never, + ); + + await expect(makeManager().currentSession()).rejects.toMatchObject({ + code: "CLI.CREDENTIALS_REQUIRED", + why: "You have workspace sessions but none is current.", + }); + }); +}); + +describe("mutations under an environment session", () => { + const environments = { + set: mintToken(WORKSPACE_B), + blank: "", + whitespace: " ", + } as const; + + for (const [name, token] of Object.entries(environments)) { + it(`refuses useSession, endSession and endAllSessions with the env token ${name}`, async () => { + await seedTwoSessions(); + const stored = await makeManager().sessions(); + const before = await readRawState(); + const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: token } }); + const expectedCode = + name === "set" + ? "AUTH.ENV_SESSION_IN_FORCE" + : "AUTH.SERVICE_TOKEN_EMPTY"; + + await expect( + manager.useSession(stored[0] as never), + ).rejects.toMatchObject({ code: expectedCode }); + await expect( + manager.endSession(stored[0] as never), + ).rejects.toMatchObject({ code: expectedCode }); + await expect(manager.endAllSessions()).rejects.toMatchObject({ + code: expectedCode, + }); + expect(await readRawState()).toBe(before); + }); + } + + it("succeeds as a no-op when endAllSessions runs with no stored sessions", async () => { + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_B) }, + }); + await expect(manager.endAllSessions()).resolves.toBeUndefined(); + expect(await readRawState()).toBeNull(); + }); + + it("allows createSession while the env token is in force and leaves the pin on the env session", async () => { + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_B) }, + }); + expect((await manager.currentSession())?.source).toBe("environment"); + + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + + expect((await manager.currentSession())?.source).toBe("environment"); + const state = await readCredentialState(stateFilePath); + expect(state.currentWorkspaceId).toBe(WORKSPACE_A); + }); + + it("raises the blank-token error from currentSession", async () => { + const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: " " } }); + await expect(manager.currentSession()).rejects.toMatchObject({ + code: "AUTH.SERVICE_TOKEN_EMPTY", + }); + }); +}); + +describe("createSession", () => { + it("refuses a credential whose workspace_id claim names another workspace", async () => { + const manager = makeManager(); + await expect( + manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_B), + ).rejects.toMatchObject({ code: "AUTH.CREDENTIAL_WORKSPACE_MISMATCH" }); + expect(await readRawState()).toBeNull(); + }); + + it("holds no lock while the workspace name is fetched", async () => { + let releaseFetch: () => void = () => {}; + const fetchStarted = new Promise((resolve) => { + const manager = makeManager({ + fetchWorkspaceName: async () => { + resolve(); + await new Promise((done) => { + releaseFetch = done; + }); + return "Workspace A"; + }, + }); + void manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + }); + + await fetchStarted; + await makeManager().createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); + releaseFetch(); + + await vi.waitFor(async () => { + const state = await readCredentialState(stateFilePath); + expect( + state.sessions.find((session) => session.workspaceId === WORKSPACE_A) + ?.name, + ).toBe("Workspace A"); + }); + const state = await readCredentialState(stateFilePath); + expect(state.sessions.map((session) => session.workspaceId)).toEqual([ + WORKSPACE_A, + WORKSPACE_B, + ]); + }); + + it("keeps login working when the name lookup fails", async () => { + const manager = makeManager({ + fetchWorkspaceName: async () => { + throw new Error("offline"); + }, + }); + const session = await manager.createSession( + credentialFor(WORKSPACE_A), + WORKSPACE_A, + ); + expect(session.workspaceName).toBeUndefined(); + }); + + it("does not resurrect a record ended while the name was fetched", async () => { + let releaseFetch: () => void = () => {}; + const fetchStarted = new Promise((resolve) => { + const manager = makeManager({ + fetchWorkspaceName: async () => { + resolve(); + await new Promise((done) => { + releaseFetch = done; + }); + return "Workspace A"; + }, + }); + void manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + }); + + await fetchStarted; + const otherProcess = makeManager(); + await otherProcess.endSession( + (await otherProcess.sessions()).find( + (session) => session.workspaceId === WORKSPACE_A, + ) as never, + ); + releaseFetch(); + + await vi.waitFor(async () => { + expect((await readCredentialState(stateFilePath)).sessions).toEqual([]); + }); + }); + + it("upserts by workspace id, keeping the stored name and moving the marker", async () => { + const manager = makeManager({ + fetchWorkspaceName: async () => "Workspace A", + }); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + await manager.createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); + + const plain = makeManager(); + await plain.createSession( + credentialFor(WORKSPACE_A, "refresh-2"), + WORKSPACE_A, + ); + + const state = await readCredentialState(stateFilePath); + expect(state.currentWorkspaceId).toBe(WORKSPACE_A); + expect(state.sessions).toHaveLength(2); + expect( + state.sessions.find((session) => session.workspaceId === WORKSPACE_A), + ).toMatchObject({ name: "Workspace A", refreshToken: "refresh-2" }); + }); +}); + +describe("the TokenStorage view", () => { + it("writes only the token fields on rotation and re-derives the expiry", async () => { + const manager = makeManager({ + fetchWorkspaceName: async () => "Workspace A", + }); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + await makeManager().createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); + + const rotated = mintToken(WORKSPACE_A, { exp: 2_000_000_000 }); + await manager.tokenStorage(WORKSPACE_A).setTokens({ + workspaceId: WORKSPACE_A, + accessToken: rotated, + refreshToken: "refresh-2", + }); + + const state = await readCredentialState(stateFilePath); + const record = state.sessions.find( + (session) => session.workspaceId === WORKSPACE_A, + ); + expect(record).toMatchObject({ + name: "Workspace A", + token: rotated, + refreshToken: "refresh-2", + expiresAt: new Date(2_000_000_000 * 1000).toISOString(), + }); + expect(state.currentWorkspaceId).toBe(WORKSPACE_B); + }); + + it("refuses to resurrect a session ended during the rotation", async () => { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + + const otherProcess = makeManager(); + await otherProcess.endSession( + (await otherProcess.sessions()).find( + (session) => session.workspaceId === WORKSPACE_A, + ) as never, + ); + + await expect( + manager.tokenStorage(WORKSPACE_A).setTokens({ + workspaceId: WORKSPACE_A, + accessToken: mintToken(WORKSPACE_A), + refreshToken: "refresh-2", + }), + ).rejects.toMatchObject({ code: "CLI.CREDENTIALS_REQUIRED" }); + expect((await readCredentialState(stateFilePath)).sessions).toEqual([]); + }); + + it("refuses a rotated token that re-scopes to another workspace", async () => { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + + await expect( + manager.tokenStorage(WORKSPACE_A).setTokens({ + workspaceId: WORKSPACE_A, + accessToken: mintToken(WORKSPACE_B), + refreshToken: "refresh-2", + }), + ).rejects.toMatchObject({ code: "AUTH.CREDENTIAL_WORKSPACE_MISMATCH" }); + }); + + it("clears on an exact three-field match and leaves a newer pair alone", async () => { + const manager = makeManager(); + const credential = credentialFor(WORKSPACE_A); + await manager.createSession(credential, WORKSPACE_A); + const storage = manager.tokenStorage(WORKSPACE_A); + + const stale = { + workspaceId: WORKSPACE_A, + accessToken: "stale-access-token", + refreshToken: "refresh-1", + }; + const before = await readRawState(); + await storage.clearTokensIfCurrent?.(stale); + expect(await readRawState()).toBe(before); + + await storage.clearTokensIfCurrent?.({ + workspaceId: WORKSPACE_A, + accessToken: credential.token, + refreshToken: credential.refreshToken, + }); + const state = await readCredentialState(stateFilePath); + expect(state.sessions).toEqual([]); + expect(state.currentWorkspaceId).toBeNull(); + }); + + it("clearTokens removes only the bound record", async () => { + await seedTwoSessions(); + await makeManager().tokenStorage(WORKSPACE_A).clearTokens(); + + const state = await readCredentialState(stateFilePath); + expect(state.sessions.map((session) => session.workspaceId)).toEqual([ + WORKSPACE_B, + ]); + expect(state.currentWorkspaceId).toBe(WORKSPACE_B); + }); + + it("serializes refreshes in this process", async () => { + const manager = makeManager(); + const storage = manager.tokenStorage(WORKSPACE_A); + const order: string[] = []; + const first = storage.withRefreshLock?.(async () => { + order.push("first-start"); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push("first-end"); + }); + const second = storage.withRefreshLock?.(async () => { + order.push("second-start"); + }); + await Promise.all([first, second]); + + expect(order).toEqual(["first-start", "first-end", "second-start"]); + }); + + it("re-reads the file on every getTokens", async () => { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const storage = manager.tokenStorage(WORKSPACE_A); + expect((await storage.getTokens())?.refreshToken).toBe("refresh-1"); + + await makeManager().createSession( + credentialFor(WORKSPACE_A, "refresh-2"), + WORKSPACE_A, + ); + expect((await storage.getTokens())?.refreshToken).toBe("refresh-2"); + }); +}); + +describe("token material never leaks", () => { + it("keeps the secret out of debug output and errors", async () => { + const secret = "s3cret-refresh-token"; + const debugLines: string[] = []; + const manager = makeManager({ + env: { PRISMA_NEXT_DEBUG: "1" }, + debugWrite: (text) => debugLines.push(text), + }); + const credential = { + token: mintToken(WORKSPACE_A), + refreshToken: secret, + expiresAt: undefined, + }; + await manager.createSession(credential, WORKSPACE_A); + await manager.currentSession(); + + const mismatch = await manager + .createSession(credential, WORKSPACE_B) + .catch((error: unknown) => error); + + const rendered = `${debugLines.join("")}${JSON.stringify(mismatch, Object.getOwnPropertyNames(mismatch))}`; + expect(rendered).not.toContain(secret); + expect(rendered).not.toContain(credential.token); + }); +}); diff --git a/packages/cli/tests/helpers/credential-manager-worker.ts b/packages/cli/tests/helpers/credential-manager-worker.ts new file mode 100644 index 00000000..d49e65c4 --- /dev/null +++ b/packages/cli/tests/helpers/credential-manager-worker.ts @@ -0,0 +1,75 @@ +/** + * A second real process for the credential manager's cross-process + * tests: it runs one manager operation (or holds the lock) against a + * state file and prints the result as JSON. + */ +import fs from "node:fs/promises"; +import { FileCredentialManager } from "../../src/auth/credential-manager"; + +const [stateFilePath, command, ...args] = process.argv.slice(2); + +function makeManager(env: Record = {}) { + return new FileCredentialManager({ + env: { PRISMA_AUTH_FILE: stateFilePath, ...env }, + }); +} + +async function run(): Promise { + switch (command) { + case "create": { + const [workspaceId, token, refreshToken] = args; + return makeManager().createSession( + { token, refreshToken, expiresAt: undefined }, + workspaceId, + ); + } + case "use": { + const [workspaceId] = args; + const manager = makeManager(); + const session = (await manager.sessions()).find( + (candidate) => candidate.workspaceId === workspaceId, + ); + if (session === undefined) throw new Error(`no session ${workspaceId}`); + return manager.useSession(session); + } + case "end": { + const [workspaceId] = args; + const manager = makeManager(); + const session = (await manager.sessions()).find( + (candidate) => candidate.workspaceId === workspaceId, + ); + if (session === undefined) throw new Error(`no session ${workspaceId}`); + await manager.endSession(session); + return null; + } + case "rotate": { + const [workspaceId, accessToken, refreshToken] = args; + await makeManager() + .tokenStorage(workspaceId) + .setTokens({ workspaceId, accessToken, refreshToken }); + return null; + } + case "current": + return makeManager().currentSession(); + case "sessions": + return makeManager().sessions(); + case "crash-holding-the-lock": { + await fs.writeFile(`${stateFilePath}.lock`, "crashed-holder", "utf8"); + return null; + } + default: + throw new Error(`unknown command ${command}`); + } +} + +run().then( + (result) => { + process.stdout.write(JSON.stringify(result ?? null)); + }, + (error: unknown) => { + process.stderr.write( + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; + }, +); diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index 829adaea..e4f9ce24 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -150,6 +150,12 @@ beforeEach(() => { vi.mocked(logoutAuthWorkspace).mockReset(); }); +const MINTED_CREDENTIAL = { + token: "minted-access-token", + refreshToken: "minted-refresh-token", + expiresAt: undefined, +}; + describe("prisma-v8 auth login", () => { it("runs the browser flow, emits step and endpoint events, and renders the signed-in card", async () => { vi.mocked(performLogin).mockImplementation( @@ -157,6 +163,7 @@ describe("prisma-v8 auth login", () => { options?.onVerificationUrl?.( "https://auth.prisma.io/activate?code=XYZ", ); + return MINTED_CREDENTIAL; }, ); vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); @@ -199,7 +206,7 @@ describe("prisma-v8 auth login", () => { }); it("appends the agent-setup tip line and next action from a project directory", async () => { - vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); const cwd = await emptyTempCwd(); await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); @@ -225,7 +232,7 @@ describe("prisma-v8 auth login", () => { }); it("suppresses the agent-setup tip in CI", async () => { - vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); const cwd = await emptyTempCwd(); await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); @@ -241,7 +248,7 @@ describe("prisma-v8 auth login", () => { }); it("suppresses the agent-setup tip when Prisma skills are already installed", async () => { - vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); const cwd = await emptyTempCwd(); await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); @@ -267,7 +274,7 @@ describe("prisma-v8 auth login", () => { }); it("carries the agent-setup tip in the json envelope (result field + nextAction)", async () => { - vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); const cwd = await emptyTempCwd(); await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); @@ -293,7 +300,7 @@ describe("prisma-v8 auth login", () => { }); it("maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID, exit 2", async () => { - vi.mocked(performLogin).mockResolvedValue(undefined); + vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); vi.mocked(readAuthState).mockRejectedValue(new EmptyServiceTokenError()); const result = await makeCli().run(["auth", "login", "--json"], { @@ -318,6 +325,7 @@ describe("prisma-v8 auth login", () => { options?.onVerificationUrl?.( "https://auth.prisma.io/activate?code=XYZ", ); + return MINTED_CREDENTIAL; }, ); vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); diff --git a/packages/cli/tests/v8-bin.test.ts b/packages/cli/tests/v8-bin.test.ts index 55b4d46a..45ca6ce6 100644 --- a/packages/cli/tests/v8-bin.test.ts +++ b/packages/cli/tests/v8-bin.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { makeGetCredentials } from "../src/auth"; +import { + CLIENT_ID, + DEFAULT_REDIRECT_URI, + makeGetCredentials, +} from "../src/auth"; import { buildCli } from "../src/v8/cli"; import { main } from "../src/v8/main"; import { @@ -182,6 +186,36 @@ describe("assembleRuntime", () => { expect(proc.stderrText).toBe("err"); }); + it("wires the credential manager, the SDK client config, and the browser opener", async () => { + const proc = makeProcess({ + env: { + PRISMA_AUTH_FILE: "/tmp/v8-bin-test-auth.json", + PRISMA_MANAGEMENT_API_URL: "https://api.example.test", + }, + }); + const runtime = await assembleRuntime(proc); + + expect(runtime.credentialManager).toBeDefined(); + expect(runtime.managementApiClientConfig).toEqual({ + clientId: CLIENT_ID, + redirectUri: DEFAULT_REDIRECT_URI, + apiBaseUrl: "https://api.example.test", + authBaseUrl: "https://auth.prisma.io", + }); + expect(typeof runtime.openUrl).toBe("function"); + expect(proc.stderrText).toBe(""); + }); + + it("warns once when the credentials file is named by the deprecated variable", async () => { + const proc = makeProcess({ + env: { PRISMA_COMPUTE_AUTH_FILE: "/tmp/v8-bin-test-legacy-auth.json" }, + }); + await assembleRuntime(proc); + + expect(proc.stderrText).toContain("PRISMA_COMPUTE_AUTH_FILE is deprecated"); + expect(proc.stderrText).toContain("PRISMA_AUTH_FILE"); + }); + it("derives managementApi.baseUrl from PRISMA_MANAGEMENT_API_URL", async () => { const proc = makeProcess({ env: { PRISMA_MANAGEMENT_API_URL: "https://api.example.test" }, From d7e8df9ee0b455ca8c5e78d59788fc79c29b63d5 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 17:47:26 +0200 Subject: [PATCH 42/67] drive(prisma-cli-v8): record the same-path state-file ruling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same path as the legacy auth file: the first v8 mutation converts it and a still-installed legacy CLI reads signed-out — ruled over two silently diverging auth worlds. Also records the state-file env var naming and the debug-log placement as implemented. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 29 ++++--- packages/cli/src/v8/auth/errors.ts | 58 ------------- .../cli/src/v8/auth/run-workspace-logout.ts | 81 ------------------- packages/cli/src/v8/auth/state-card.ts | 52 ------------ packages/cli/src/v8/auth/workspace-shared.ts | 36 --------- 5 files changed, 20 insertions(+), 236 deletions(-) delete mode 100644 packages/cli/src/v8/auth/errors.ts delete mode 100644 packages/cli/src/v8/auth/run-workspace-logout.ts delete mode 100644 packages/cli/src/v8/auth/state-card.ts delete mode 100644 packages/cli/src/v8/auth/workspace-shared.ts diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index 9880f3a7..f326c6c9 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -137,8 +137,9 @@ Interface rules: manager — minting and custody stay separate). The manager's storage is reachable only through `tokenStorage()`. - Construction dependencies (injected by the bin): `env` (no - library below the manager reads `process.env`; one env var names - the state file, the legacy second variable is a warned alias) and + library below the manager reads `process.env`; `PRISMA_AUTH_FILE` + names the state file, `PRISMA_COMPUTE_AUTH_FILE` is the warned + deprecated alias) and `fetchWorkspaceName(credential, workspaceId)` (the manager constructs no API client). - The manager resolves NO user input. Commands resolve refs against @@ -334,7 +335,9 @@ is `true` only for HTTP 4xx + body error exactly `invalid_grant`: - `true` → the SDK has run compare-and-clear; if the session survived (newer pair stored), the retry proceeds — nothing surfaced. If it cleared, `CLI.CREDENTIALS_REQUIRED`, expiry - wording; the manager debug-logs endpoint status + error value + wording; the ENGINE's mapping debug-logs endpoint status + + error value (the SDK hands the manager no status — the manager + debug-logs the clear attempt itself) BEFORE the clear. - any other `AuthError` → the manager re-reads state FOR THE WORKSPACE THE CLIENT IS BOUND TO: record gone → @@ -420,13 +423,21 @@ Adoption rules (identity-blind — entries from any account adopt): Materialization: the adopted view is written into the new single-file format on the first mutation, writing the FULL adopted set. The adoption decision is re-made INSIDE the lock beside the -mutation's re-read: if a new-format file exists at that point it +mutation's re-read: if a new-format state exists at that point it wins outright and no adoption occurs (a naive full-set write could -resurrect tokens another process already rotated). After -materialization the legacy files are ignored entirely; until then -they stay untouched so a still-installed legacy CLI keeps working. -`endAllSessions` clears everything including legacy files. New -writes use mode 0600 and tighten looser permissions on first write. +resurrect tokens another process already rotated). The new format +lives at the SAME PATH as the legacy auth file (ruled 2026-08-10: +one file, one world), so the first v8 mutation rewrites it in the +new shape and a still-installed legacy CLI reads signed-out from +then on — a loud, `prisma auth login`-fixable state, preferred over +two silently diverging auth worlds. Until that first mutation the +file stays untouched and the legacy CLI keeps working. The context +sidecar is reaped by `endAllSessions`, which clears everything. +New writes use mode 0600 and tighten looser permissions on first +write. Env naming (ruled with the implementation): +`PRISMA_AUTH_FILE` names the state file; `PRISMA_COMPUTE_AUTH_FILE` +is the warned deprecated alias (`PRISMA_PLATFORM_AUTH_FILE` never +existed in the repo). ## 8. File, lock, and atomicity diff --git a/packages/cli/src/v8/auth/errors.ts b/packages/cli/src/v8/auth/errors.ts deleted file mode 100644 index da36489d..00000000 --- a/packages/cli/src/v8/auth/errors.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Mapping from the auth operations layer's legacy CliError shapes to - * the v8 protocol's dotted AUTH.* structured errors. The mapping is - * mechanical (flat code -> AUTH., fix prose -> - * one user-choice nextAction, meta preserved); every mapped code is - * enumerated in the S2 parity divergence list. - */ -import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { CliError } from "../../shell/errors"; - -const AUTH_CODE_MAP: Readonly> = { - AUTH_CONFIG_INVALID: "AUTH.CONFIG_INVALID", - WORKSPACE_SWITCH_UNAVAILABLE: "AUTH.WORKSPACE_SWITCH_UNAVAILABLE", - WORKSPACE_NOT_AUTHENTICATED: "AUTH.WORKSPACE_NOT_AUTHENTICATED", - WORKSPACE_AMBIGUOUS: "AUTH.WORKSPACE_AMBIGUOUS", - USAGE_ERROR: "AUTH.USAGE_ERROR", -}; - -/** The structured error `auth whoami` established for an empty - * PRISMA_SERVICE_TOKEN; shared verbatim by login and logout. */ -export function authConfigInvalidError(why: string): CliStructuredError { - return new CliStructuredError( - "AUTH.CONFIG_INVALID", - "Authentication configuration is invalid", - { - why, - nextActions: [ - { - kind: "user-choice", - label: - "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", - }, - ], - }, - ); -} - -/** - * Maps a legacy CliError thrown by the auth operations to its dotted - * AUTH.* structured form; returns null for anything else so the caller - * rethrows and the engine settles it as a bug. - */ -export function mapAuthOperationError( - error: unknown, -): CliStructuredError | null { - if (!(error instanceof CliError)) { - return null; - } - const code = AUTH_CODE_MAP[error.code]; - if (code === undefined) { - return null; - } - return new CliStructuredError(code, error.summary, { - why: error.why ?? undefined, - meta: Object.keys(error.meta).length > 0 ? error.meta : undefined, - nextActions: error.fix ? [{ kind: "user-choice", label: error.fix }] : [], - }); -} diff --git a/packages/cli/src/v8/auth/run-workspace-logout.ts b/packages/cli/src/v8/auth/run-workspace-logout.ts deleted file mode 100644 index 9cee3a40..00000000 --- a/packages/cli/src/v8/auth/run-workspace-logout.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * The shared workspace-logout operation + presentation, called by both - * `auth workspace logout ` and `auth logout --workspace ` — - * the same operation, the same presentation. - */ -import type { CommandContext, Presentations } from "@prisma/cli-engine"; -import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; -import { logoutAuthWorkspace } from "../../auth"; -import { CLI_NAME } from "../../cli-name"; -import type { AuthWorkspaceLogoutResult } from "../../types/auth"; -import { mapAuthOperationError } from "./errors"; -import { LIST_NEXT_ACTION, operationContext } from "./workspace-shared"; - -function logoutPresentations(result: AuthWorkspaceLogoutResult): Presentations { - const rows = [ - { label: "workspace", value: result.workspace.name }, - { label: "active", value: result.activeWorkspace?.name ?? "none" }, - ]; - return { - human: () => [ - { - kind: "summary", - tone: "info", - text: "Removing a local OAuth workspace session.", - }, - { kind: "fields", rows }, - { - kind: "summary", - tone: "ok", - text: result.wasActive - ? "Removed active workspace session; no replacement workspace was selected." - : "Removed workspace session.", - }, - ], - stdout: () => rows.map((row) => `${row.label}: ${row.value}`), - next: () => - result.activeWorkspace - ? [LIST_NEXT_ACTION] - : [ - LIST_NEXT_ACTION, - { - kind: "run-command", - label: "Select a replacement workspace", - command: `${CLI_NAME} auth workspace use `, - }, - ], - }; -} - -function workspaceRequiredError(): CliStructuredError { - return new CliStructuredError("AUTH.USAGE_ERROR", "Workspace required", { - why: "auth workspace logout needs a workspace id or cached workspace name.", - nextActions: [ - { - kind: "user-choice", - label: `Pass a workspace from ${CLI_NAME} auth workspace list.`, - }, - ], - }); -} - -export async function runWorkspaceLogout( - ctx: CommandContext, - workspaceRef: string, -) { - if (!workspaceRef.trim()) { - return notOk(workspaceRequiredError()); - } - - let result: AuthWorkspaceLogoutResult; - try { - result = await logoutAuthWorkspace(operationContext(ctx), workspaceRef); - } catch (error) { - const mapped = mapAuthOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } - return ok(ctx.present({ data: result }, logoutPresentations(result))); -} diff --git a/packages/cli/src/v8/auth/state-card.ts b/packages/cli/src/v8/auth/state-card.ts deleted file mode 100644 index 628aaae8..00000000 --- a/packages/cli/src/v8/auth/state-card.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { AuthProviderId, AuthStateResult } from "../../types/auth"; - -export interface FieldRow { - readonly label: string; - readonly value: string; -} - -export function providerLabel(provider: AuthProviderId): string { - return provider === "github" ? "GitHub" : "Google"; -} - -export function userLabel(state: AuthStateResult): string | null { - if (state.user?.email) { - return state.user.email; - } - - if (state.credential?.type === "service_token") { - return state.credential.name - ? `` - : ""; - } - - if (state.credential?.type === "management_token") { - return state.credential.name - ? `` - : ""; - } - - return null; -} - -/** The whoami-style card rows for an auth state. */ -export function authStateFieldRows( - state: AuthStateResult, -): readonly FieldRow[] { - if (!state.authenticated) { - return [{ label: "status", value: "signed out" }]; - } - - const rows: FieldRow[] = [{ label: "status", value: "signed in" }]; - const user = userLabel(state); - if (user) { - rows.push({ label: "user", value: user }); - } - if (state.provider) { - rows.push({ label: "provider", value: providerLabel(state.provider) }); - } - if (state.workspace?.name) { - rows.push({ label: "workspace", value: state.workspace.name }); - } - return rows; -} diff --git a/packages/cli/src/v8/auth/workspace-shared.ts b/packages/cli/src/v8/auth/workspace-shared.ts deleted file mode 100644 index b2659e25..00000000 --- a/packages/cli/src/v8/auth/workspace-shared.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Helpers shared by the `auth workspace *` commands. Operations come - * from the auth module (`src/auth/index.ts`); legacy CliError shapes - * map to dotted AUTH.* structured errors via `mapAuthOperationError`. - */ -import type { CommandContext } from "@prisma/cli-engine"; -import type { NextAction } from "@prisma/cli-engine/protocol"; -import type { WorkspaceOperationContext } from "../../auth"; -import { CLI_NAME } from "../../cli-name"; -import { mapAuthOperationError } from "./errors"; - -export const LIST_NEXT_ACTION: NextAction = { - kind: "run-command", - label: "List authenticated workspaces", - command: `${CLI_NAME} auth workspace list`, -}; - -export const LOGIN_NEXT_ACTION: NextAction = { - kind: "run-command", - label: "Sign in", - command: `${CLI_NAME} auth login`, -}; - -export function operationContext( - ctx: CommandContext, -): WorkspaceOperationContext { - return { env: ctx.env, signal: ctx.signal }; -} - -export function rethrowMapped(error: unknown): never { - const mapped = mapAuthOperationError(error); - if (mapped) { - throw mapped; - } - throw error; -} From ddbb816d921e3df12f9cd2595e1ac2494ffb73a5 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 17:51:12 +0200 Subject: [PATCH 43/67] feat(cli): rework the v8 auth family onto the credential manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six auth commands now speak the session model instead of the legacy auth-state operations. `auth login` calls createSession with the workspace its credential names; `auth logout` ends every session and reports the count; `auth whoami` reads ctx.session() and enriches from the API when online; the three workspace commands resolve a user-typed ref command-side (exact id, then case-insensitive name) and hand the matched Session to the manager. `auth workspace use` selects only: a ref you hold no session for is a structured error pointing at `prisma auth login`, and no browser ever opens from it. `auth logout --workspace` is gone — `auth workspace logout ` is the one way to end a single session. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/s2/parity-divergences.md | 240 ++-- .../prisma-cli-v8/specs/s2-overview.md | 10 +- .../prisma-cli-v8/specs/s2a-foundations.md | 75 +- packages/cli/src/auth/index.ts | 1 + packages/cli/src/v8/auth/login.ts | 131 +- packages/cli/src/v8/auth/logout.ts | 77 +- packages/cli/src/v8/auth/session-card.ts | 39 + packages/cli/src/v8/auth/session-ref.ts | 82 ++ packages/cli/src/v8/auth/whoami.ts | 128 +- packages/cli/src/v8/auth/workspace-list.ts | 141 +- packages/cli/src/v8/auth/workspace-logout.ts | 86 +- packages/cli/src/v8/auth/workspace-use.ts | 182 +-- packages/cli/tests/v8-auth.test.ts | 1265 +++++++---------- .../cli/tests/v8-golden-rendering.test.ts | 128 +- packages/cli/tests/v8-whoami.test.ts | 209 +-- 15 files changed, 1414 insertions(+), 1380 deletions(-) create mode 100644 packages/cli/src/v8/auth/session-card.ts create mode 100644 packages/cli/src/v8/auth/session-ref.ts diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index 7a0d647b..6475e2f8 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -15,102 +15,166 @@ ported command and are not repeated per command below. ## S2a — auth family + update check (this PR) -### Error-code mapping (flat → dotted `AUTH.*`) +The auth family is implemented ON the credential manager (the session +model: a set of per-workspace sessions, one current). The COMMAND NAMES +are the legacy ones and do not change — there is no rename class in +this list. What follows is what a user can still observe as different. + +### Error-code mapping (flat → dotted, session vocabulary) -Pattern set by S1 (`AUTH_CONFIG_INVALID` → `AUTH.CONFIG_INVALID`). Every errored settlement exits 2 in v8, regardless of the legacy -per-error exit code; `fix` prose maps to one `user-choice` nextAction -and the legacy `nextSteps` string list is folded into it (the S1 -whoami precedent), while `meta` is preserved verbatim. +per-error exit code. Legacy `fix` prose maps to one `user-choice` +nextAction; `meta` is preserved. -| Legacy flat code (exit) | v8 dotted code (exit) | Commands | +| Legacy flat code (exit) | v8 code (exit) | Raised by | | --- | --- | --- | -| `AUTH_CONFIG_INVALID` (1) | `AUTH.CONFIG_INVALID` (2) | whoami, login, logout, workspace list | -| `WORKSPACE_SWITCH_UNAVAILABLE` (1) | `AUTH.WORKSPACE_SWITCH_UNAVAILABLE` (2) | workspace use | -| `WORKSPACE_NOT_AUTHENTICATED` (1) | `AUTH.WORKSPACE_NOT_AUTHENTICATED` (2) | workspace use, workspace logout, logout --workspace | -| `WORKSPACE_AMBIGUOUS` (2) | `AUTH.WORKSPACE_AMBIGUOUS` (2) | workspace use, workspace logout, logout --workspace | -| `USAGE_ERROR` (2) — "No authenticated workspaces" | `AUTH.USAGE_ERROR` (2) | workspace use | -| `USAGE_ERROR` (2) — "Workspace required" (blank ref) | `AUTH.USAGE_ERROR` (2) | workspace logout | +| `AUTH_CONFIG_INVALID` (1) — blank `PRISMA_SERVICE_TOKEN` | `AUTH.SERVICE_TOKEN_EMPTY` (2) | every command, single-sourced from `currentSession()` | +| `WORKSPACE_NOT_AUTHENTICATED` (1) | `AUTH.NO_SESSION_FOR_WORKSPACE` (2) | `workspace use`, `workspace logout` | +| `WORKSPACE_AMBIGUOUS` (2) | `AUTH.WORKSPACE_AMBIGUOUS` (2) | `workspace use`, `workspace logout` | +| `WORKSPACE_SWITCH_UNAVAILABLE` (1) | `AUTH.ENV_SESSION_IN_FORCE` (2) | `workspace use`, `workspace logout`, `logout` | +| `USAGE_ERROR` (2) — "No authenticated workspaces" | `AUTH.NO_WORKSPACE_SESSIONS` (2) | `workspace use` | +| `USAGE_ERROR` (2) — "Workspace required" (blank ref) | `AUTH.NO_SESSION_FOR_WORKSPACE` (2) | `workspace logout` — a blank/whitespace ref matches no session rather than being its own usage error | +| (none — legacy could not happen) | `AUTH.LOGIN_WORKSPACE_UNKNOWN` (2) | `login`, when the minted credential carries no `workspace_id` claim | +| (none) | `CLI.CREDENTIALS_REQUIRED` (2) | the engine, for signed-out and sessions-held-none-current | No documented 4–99 codes exist in this family. +### Exit unifications + +Legacy exit 1 for `AUTH_CONFIG_INVALID`, `WORKSPACE_NOT_AUTHENTICATED` +and `WORKSPACE_SWITCH_UNAVAILABLE` becomes exit 2 (could-not-complete) +in v8. A failed login (browser launch, callback, token exchange) was an +unstructured crash at exit 1 in legacy and still settles at exit 1, now +as a structured `CLI.INTERNAL_ERROR`. + +### `auth whoami` — json shape + +The legacy result was `AuthStateResult` +(`authenticated`/`provider`/`user`/`workspace`/`credential`). The v8 +result is the session: + +```json +{ "authenticated": true, "workspace": { "id": "…", "name": "…" }, + "user": { "id": "…", "email": "…", "name": "…" }, + "source": "stored", "expiresAt": null } +``` + +- **`provider` has NO successor.** Nothing in the session model records + which identity provider minted a credential, and the stored state + records no identity at all, so the field is gone rather than renamed. +- `credential` is gone: the type/id/name of the credential is not a + user-facing concept in the session model. +- `source` is new (`"stored"` | `"environment"`), and `expiresAt` is + the session's expiry. +- Identity display: for an environment session it is decoded from the + token's own claims; for a stored session it comes from `/v1/me` when + online. Offline, a stored session shows its workspace and no user — + legacy showed the claim-derived user in that case. (The Session shape + carries no token by design, so the command cannot decode a stored + credential's claims itself.) +- Signed out still exits 0. + +### Env-override mutation refusals (`PRISMA_SERVICE_TOKEN` set) + +One error family, `AUTH.ENV_SESSION_IN_FORCE`, exit 2, whose `why` +names the variable and states whether stored sessions exist, with the +literal `unset PRISMA_SERVICE_TOKEN` as its nextAction: + +| Command | Behavior under the override | +| --- | --- | +| `auth workspace use` | refuses — `AUTH.ENV_SESSION_IN_FORCE`, exit 2 | +| `auth workspace logout` | refuses — same error, exit 2 | +| `auth logout`, stored sessions exist | refuses — same error, exit 2; nothing is cleared | +| `auth logout`, no stored sessions | **succeeds as a no-op**, exit 0, `endedCount: 0` (CI teardowns must not fail) | +| `auth login` | **succeeds**, and prints the mandatory one-line notice that the env token remains in force until unset | +| every read (`whoami`, `workspace list`) | works normally | + +Legacy refused workspace switching with `WORKSPACE_SWITCH_UNAVAILABLE` +and let `auth logout` clear stored state even while the variable was +set. Stated consequence: while the variable is set, existing stored +state cannot be cleared. + +### `auth workspace list` + +- Rows are the sessions the manager holds: `name`, `id`, `status`, + where status is `current` (legacy: `active`). The legacy `source` + column and the `auth source` line are gone — the environment session + never appears as a row. +- Under an env override the listing STATES that the env session is in + force; the file's own current marker is still shown as current. The + json context carries `environmentSessionInForce: true` alongside + `currentWorkspaceId`, which keeps naming the stored marker, not the + env session's workspace. +- The json shape is new (`context`/`items`/`count` with + `workspaceId`/`workspaceName`/`current`/`expiresAt`); the legacy + fields `credentialWorkspaceId`, `switchable`, `lastSeenAt` and + `source` have no successor. +- A session whose name was never fetched renders by its workspace id in + both columns. +- Human mode also writes the data rows to stdout; legacy wrote nothing. + +### `auth logout` — orphan reaping and the count + +- `auth logout` ends EVERY workspace session, not just the active one, + and reports how many it ended (`endedCount`). Legacy cleared the + active credential and could leave orphaned per-workspace entries + behind; those are now reaped, together with the legacy files. +- The presentation reports the count; the json result is + `{ endedCount, workspaceIds }`, replacing the raw post-logout + `AuthStateResult`. +- **`auth logout --workspace ` no longer exists.** `auth workspace + logout ` is the one way to end a single session. + +### `auth workspace use` — selects only + +Ruled: `workspace use` SELECTS among the sessions you have and never +creates one. A ref naming a workspace you hold no session for is +`AUTH.NO_SESSION_FOR_WORKSPACE`, exit 2, whose nextAction is the +literal `prisma auth login` ("sign in and pick it in the browser"). No +browser ever opens from `use`. Legacy behaved the same way in effect +(it could not create a session either) but said +`WORKSPACE_NOT_AUTHENTICATED` at exit 1. + +- Ref resolution is command-side: exact workspace id first, then + case-insensitive workspace NAME (legacy matched names exactly). + Several sessions sharing a name is `AUTH.WORKSPACE_AMBIGUOUS`, which + lists the matching workspace ids in `meta.workspaceIds`. +- Absent positional + several sessions + non-interactive: legacy threw + its own `USAGE_ERROR`; v8 lets the engine's structural prompt failure + speak — `CLI.PROMPT_REQUIRED` (exit 2), `CLI.PROMPT_INVALID` (exit 2) + for an invalid scripted answer, `CLI.PROMPT_CANCELLED` (exit 3) on + cancellation. Single-session auto-select is unchanged. + +### Workspace names are never refreshed on read + +A workspace name is fetched once, best-effort, when the session is +created. Reads are entirely offline, so a workspace renamed in the +console keeps its stored name locally until the next login to it (and a +session whose name fetch failed renders by id). Legacy re-fetched names +on every `whoami`/`list` and wrote them back. Accepted and stated. + ### `auth login` - The fixture-only flags `--provider`, `--user`, `--workspace` do NOT port (hidden mock-selection surface; fixture machinery dies in S2d). - The `--workspace` name is reused by `auth logout` only. -- The flow now speaks engine events: `step-started`/`step-finished` - around the browser flow, and an `endpoint` event named `verification` - carrying the OAuth authorize URL (surfaced via a new optional - `onVerificationUrl` hook on `performLogin`; legacy callers are - unaffected). Legacy printed the URL only inside the interactive - instruction prose. +- The flow speaks engine events: `step-started`/`step-finished` around + the browser flow, and an `endpoint` event named `verification` + carrying the OAuth authorize URL (via the optional + `onVerificationUrl` hook on `performLogin`). Legacy printed the URL + only inside the interactive instruction prose. - The interactive paste-fallback prompt and instruction prose inside - `performLogin` still write to the process's own stdin/stderr (the - reference implementation owns that flow); unchanged from legacy. -- Presentation is the whoami-style card (summary + `label: value` - rows) titled with the legacy copy "Starting an authenticated CLI - session.", not the legacy mutate-card layout. -- Agent-setup tip: the legacy helper suppressed the tip under - `--json`, `--quiet`, CI (unless `--interactive`), and non-TTY - stderr. In v8: CI suppression is kept (`ctx.env.CI`; the engine does - not expose the `--interactive` flag to handlers, so its override is - dropped); the tip LINE renders only in the human presentation, so - json output never shows it; but the tip nextAction and the - `agentSetupTip` result field DO appear in json envelopes (the - contract's nextActions row), where legacy omitted the tip from - `--json` entirely. `--quiet` no longer suppresses it (log-level - alias ruling). There is no stderr-TTY check; format auto-selection - covers the piped case. -- A failed login (browser launch, callback, token exchange) was an - unstructured crash (exit 1) in legacy; in v8 it settles as - `CLI.INTERNAL_ERROR`, exit 1 — same class, structured envelope. + `performLogin` still write to the process's own stdin/stderr; + unchanged from legacy. +- The json result is `{ workspace: { id, name }, + environmentSessionInForce }` — the workspace the session was created + for, not an auth-state snapshot. +- Agent-setup tip: legacy suppressed it under `--json`, `--quiet`, CI + (unless `--interactive`), and non-TTY stderr. In v8 CI suppression is + kept (`ctx.env.CI`); the tip LINE renders only in the human + presentation; the tip nextAction appears in json envelopes, where + legacy omitted it entirely. `--quiet` no longer suppresses it. - nextActions: `prisma-cli auth whoami`, `prisma-cli project list`, - plus the tip command when present (legacy: same strings as - `nextSteps`). - -### `auth logout` - -- `--workspace ` no longer re-dispatches at the argv level: the - handler calls the shared workspace-logout operation directly. The - envelope therefore reports commandId `auth.logout` where legacy - reported `auth.workspace.logout` for the same invocation. Semantics - and presentation are those of `auth workspace logout`. -- Plain logout keeps the legacy copy ("Clearing the current CLI - session." / "Session removed from local CLI state.") in the block - vocabulary; the json result stays the raw post-logout - `AuthStateResult` (legacy had no serializer either). - -### `auth workspace list` - -- Human table ports the exact legacy column rule: name, id, status, - with a source column only when sources are mixed; the `auth source` - line is kept. Rail/padding/color styling goes the way of all v8 - rendering (S1 doc §3). -- The json serializer is `serializeAuthWorkspaceList`, ported - verbatim (context/items/count shape). -- Human mode now also writes the table's data rows to stdout (the - machine payload surface); legacy wrote nothing to stdout. - -### `auth workspace use` - -- Absent positional + multiple workspaces + non-interactive: legacy - threw its own `USAGE_ERROR` ("Interactive workspace selection - unavailable", exit 2); v8 lets the engine's structural prompt - failure speak — `CLI.PROMPT_REQUIRED`, exit 2. An invalid scripted - answer is `CLI.PROMPT_INVALID` (exit 2); cancellation is - `CLI.PROMPT_CANCELLED` (exit 3). -- Single-workspace auto-select and the zero-workspace usage error - port unchanged; the picker itself is `ctx.prompt.select` (clack on - real TTYs) with the legacy label shape - `name (id)[ active]`. - -### `auth workspace logout` - -- Ported unchanged, including was-active handling (never - auto-falls-through; suggests `auth workspace use ` when the - active workspace was removed). Raw result shape in json (legacy - serializer was the identity). + plus the tip command when present. ### Update check (§5) @@ -129,12 +193,6 @@ No documented 4–99 codes exist in this family. alias). Same for `--version`, CI, non-TTY stderr, and `NO_UPDATE_NOTIFIER`. -### `auth workspace list` — empty service token - -- An empty/blank `PRISMA_SERVICE_TOKEN` now errors as - `AUTH.CONFIG_INVALID` (exit 2), matching whoami/login/logout. Legacy - let the raw `EmptyServiceTokenError` crash unstructured. - ### Telemetry (§6) - **Config enrichment dropped.** The ORM CLI's detached sender loaded @@ -158,4 +216,6 @@ No documented 4–99 codes exist in this family. commands are deleted; the file keeps its real-mode storage cases and the legacy-shell presentation cases (help text, TTY header) until S2d. The v8 side is pinned semantically in `tests/v8-auth.test.ts` - and `tests/v8-update-check.test.ts`. + (over the harness's in-memory credential manager, with manager state + read-back) and `tests/v8-update-check.test.ts`; the byte pins live in + `tests/v8-golden-rendering.test.ts` and `tests/v8-whoami.test.ts`. diff --git a/.drive/projects/prisma-cli-v8/specs/s2-overview.md b/.drive/projects/prisma-cli-v8/specs/s2-overview.md index 1bfcccd5..c42ae6e7 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2-overview.md +++ b/.drive/projects/prisma-cli-v8/specs/s2-overview.md @@ -7,7 +7,7 @@ the port PRs; infrastructure PRs may be smaller where inherently so): | PR | Contract | Content | | --- | --- | --- | -| S2a | `s2a-foundations.md` | Engine publishable + production dep; `ctx.api`; auth module extraction; `auth *` family; update check; telemetry package move + wiring; clack prompt renderer | +| S2a | `s2a-foundations.md` | Engine publishable + production dep; `ctx.api`; auth module extraction; the credential manager + the `auth *` family on it; update check; telemetry package move + wiring; clack prompt renderer | | S2b | `s2b-resources.md` | `project *`, `postgres *` (database), `bucket *` (incl. keys), `branch list` | | S2c | `s2c-services.md` | `service *` (renamed from `app`, incl. env + domain subgroups), `build *`, `git *`, `agent *`, `feedback` | | S2d | `s2d-init-and-retirement.md` | `init` wizard; commander-shell deletion; fixture-mode machinery deletion; final parity review | @@ -32,7 +32,8 @@ All operator-ruled; none are open to implementer judgment. undefined` or a natural empty). 4. **Testing**: semantic-first. Commands are tested through `createTestCli` (`@prisma/cli-engine/testing`) with the management - API faked at `ctx.api` and auth stubbed at the auth-module seam. + API faked at `ctx.api` and sessions seeded into the harness's + in-memory credential manager (state read back after the run). Assertions target the envelope, presented data, events, and exit codes — NOT output bytes. A single small golden suite per output surface pins human rendering and channel discipline globally. @@ -42,7 +43,10 @@ All operator-ruled; none are open to implementer judgment. `CommandContext` (operator: no extension mechanisms — this is Prisma's engine). Spec in S2a. 6. **Auth**: an internal module (`packages/cli/src/auth/`), not a - workspace package. Spec in S2a. + workspace package, holding the credential manager behind the + engine's `CredentialManager` SPI. Sessions are per-workspace, one + current; the `auth *` commands keep their legacy names. Spec in + S2a, design in `../assets/engine/credential-manager-design.md`. 7. **Telemetry is essential**: this CLI reports exactly the way the ORM CLI does today; the `@internal/cli-telemetry` implementation moves to this repo (prisma/prisma retires it with its CLI at S5). diff --git a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md index 613a0a82..de15e846 100644 --- a/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md +++ b/.drive/projects/prisma-cli-v8/specs/s2a-foundations.md @@ -99,6 +99,21 @@ targets, which must name the module the code under test imports) are the permitted exception. The `Credentials` shape stays the engine's `{ token: string }` — S2a does not redesign it. +**Erratum (2026-08-10, post-review).** The credential-manager rework +landed in this PR after the contract was reviewed, so the module's +public face grew accordingly. `src/auth/index.ts` additionally exports +`FileCredentialManager` + `FetchWorkspaceName` and `fetchWorkspaceName` +(the manager and its injected name lookup), `resolveStateFilePath` / +`STATE_FILE_ENV_VAR` / `DEPRECATED_STATE_FILE_ENV_VAR`, +`claimedWorkspaceId` and `decodeClaims` (the claim decoders the login +command keys sessions by), `authenticatedManagementApiClient`, +`DEFAULT_REDIRECT_URI` / `getAuthBaseUrl`, the recipient-session +helpers, and `storeLegacyCredential` (which now serves the LEGACY shell +only). `performLogin` returns the minted `Credential` instead of +writing it anywhere; custody belongs to the manager. The engine's +`Credentials` shape survives only on the staged-swap fallback path — +`ctx.session()` and `ctx.api` are the surviving auth surfaces. + ## 4. `auth *` family port Mounted in the v8 bin under the existing `auth` group. All commands @@ -106,26 +121,41 @@ are result commands in the platform command family. Fixture-mode-only surface does not port (fixture machinery dies in S2d): `auth login` loses `--provider`, `--user`, `--workspace` (mock-selection flags). -| Command | Args | needs | Behavior | -| --- | --- | --- | --- | -| `auth login` | none | none | Real OAuth flow via `performLogin` (browser open + poll), then `readAuthState`; events: `step-started/finished` for the flow, `endpoint` for the verification URL; presented like whoami's card plus the agent-setup tip line when `resolveAgentSetupTipCommand` fires (port that helper's real-mode path); nextActions: `auth whoami`, `project list`, the tip command when present | -| `auth logout` | flag `--workspace ` (optional) | none | Without the flag: `performLogout` + `readAuthState`, card matching current logout copy, nextAction `auth login`. With the flag: the workspace-logout operation (same semantics as `auth workspace logout `, same presentation, same command — call the shared operation directly; the current shell's internal re-dispatch hack does not port) | -| `auth whoami` | none | none | Already ported (S1) — moves from `src/v8/auth/whoami.ts` handler calling `readAuthState` directly to the auth module import; otherwise untouched | -| `auth workspace list` | none | none | `listRealAuthWorkspaces`; table Block (name, id, status; source column only when mixed — port the exact column rules from `presenters/auth.ts`); json serializer ports `serializeAuthWorkspaceList` | -| `auth workspace use [workspace]` | optional positional | none | Resolves by id or case-insensitive name; ambiguous → `AUTH.WORKSPACE_AMBIGUOUS` errored (map the current error's content to nextActions form); absent positional + interactive → `prompt.select` over workspaces (clack path); absent + non-interactive → structural prompt failure (engine default) | -| `auth workspace logout ` | required positional | none | Port current semantics incl. was-active handling | - -Error mapping: the current shell's flat codes port to dotted -`AUTH.*` codes, enumerated in the divergence list (pattern set by S1: -`AUTH_CONFIG_INVALID` → `AUTH.CONFIG_INVALID`, exit 1 → 2 for errored -paths). No documented 4–99 codes in this family. - -Tests: semantic, per ruling — auth module stubbed at -`src/auth/index.ts` seam (vi.mock), `ctx.api` fake where workspaces -call the SDK; every command × (success, errored, json, unauth where -meaningful); prompt path for `workspace use` via scripted answers. -Delete `packages/cli/tests/auth.test.ts` fixture-mode cases that cover -ported commands; keep the file's untouched-shell cases until S2d. +| Command | Args | needs | Capability | Behavior | +| --- | --- | --- | --- | --- | +| `auth login` | none | none | `managesCredentials` | `performLogin` returns the minted credential; the command calls `createSession(credential, workspaceIdFromClaims)`. Events: `step-started/finished` for the flow, `endpoint` for the verification URL. Card + the agent-setup tip line when `resolveAgentSetupTipCommand` fires; nextActions `auth whoami`, `project list`, the tip command. Under an env override it SUCCEEDS and prints the mandatory one-line notice that `PRISMA_SERVICE_TOKEN` stays in force until unset | +| `auth logout` | none | none | `managesCredentials` | `sessions()` for the count, then `endAllSessions()`; reports how many it ended. `--workspace` does not exist. Under an env override: refuses when stored sessions exist, succeeds as a no-op when there are none | +| `auth whoami` | none | none | none | `ctx.session()` only (no manager on the context) + `ctx.api` enrichment when online; signed out exits 0 | +| `auth workspace list` | none | none | `managesCredentials` | `sessions()`, the current one marked, a nameless session rendered by its id; states when the env session is in force; json serializer included | +| `auth workspace use [workspace]` | optional positional | none | `managesCredentials` | Command-side ref resolution against `sessions()` (exact id, then case-insensitive name; several matches → `AUTH.WORKSPACE_AMBIGUOUS` listing them), then `useSession(match)`. **Selects only** — a ref it holds no session for is `AUTH.NO_SESSION_FOR_WORKSPACE` telling the user to run `prisma auth login` and pick that workspace in the browser; no browser ever opens from `use`. Absent positional + interactive → `prompt.select` over the sessions; absent + non-interactive → the engine's structural prompt failure | +| `auth workspace logout ` | required positional | none | `managesCredentials` | Same resolution, then `endSession(match)`; prints the workspace it ended | + +The manager resolves no user input: every ref is resolved +command-side, in one shared module in the v8 auth family +(`src/v8/auth/session-ref.ts`), and the matched `Session` is what +reaches the manager. + +Error mapping: the current shell's flat codes port to dotted codes in +the SESSION vocabulary, enumerated in the divergence list. No +documented 4–99 codes in this family. + +Tests: semantic, per ruling — the harness's in-memory credential +manager seeded with `{sessions, currentWorkspaceId, credential, +environmentToken}`, with manager state read back after each run; +`ctx.api` faked where a command enriches; every command × (success, +errored, json, env-override where meaningful); prompt path for +`workspace use` via scripted answers. Delete +`packages/cli/tests/auth.test.ts` fixture-mode cases that cover ported +commands; keep the file's untouched-shell cases until S2d. + +**Erratum (2026-08-10, post-review).** The auth family was reworked +onto the credential manager after this section was reviewed: the table +above is the final state. The reviewed version had the commands calling +the legacy `readAuthState` / `listAuthWorkspaces` / `switchAuthWorkspace` +/ `logoutAuthWorkspace` operations and gave `auth logout` a +`--workspace ` flag. Those operations now serve the legacy shell +alone, and `logout --workspace` is gone — `auth workspace logout ` +is the one way to end a single session. ## 5. Update check port @@ -232,8 +262,9 @@ cherry-pick): draft amended; refresh-pickup test green. - [x] Auth module extracted; legacy shell green against it; v8 runtime consumes `makeGetCredentials` from it. -- [x] All six `auth *` commands on the engine with semantic tests; - fixture-only flags gone; divergence list updated. +- [x] All six `auth *` commands on the engine, over the credential + manager, with semantic tests; fixture-only flags gone; + `logout --workspace` gone; divergence list updated. - [x] Update check ported to both shells; sequencing matches legacy. - [x] Telemetry: package ported, hook amendment landed, bin wired, consent commands mounted, sanitizer value-free by test. diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index c5560e1c..466adaa6 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -1,3 +1,4 @@ +export { claimedWorkspaceId, decodeClaims } from "./claims"; export { CLIENT_ID, DEFAULT_REDIRECT_URI, diff --git a/packages/cli/src/v8/auth/login.ts b/packages/cli/src/v8/auth/login.ts index 8b09f20c..c3c0cba0 100644 --- a/packages/cli/src/v8/auth/login.ts +++ b/packages/cli/src/v8/auth/login.ts @@ -1,21 +1,53 @@ -import { defineCommand, type Presentations } from "@prisma/cli-engine"; -import { type NextAction, notOk, ok } from "@prisma/cli-engine/protocol"; import { - isEmptyServiceTokenError, + defineCommand, + type Presentations, + type Session, +} from "@prisma/cli-engine"; +import { + CliStructuredError, + type NextAction, + ok, +} from "@prisma/cli-engine/protocol"; +import { + claimedWorkspaceId, performLogin, - readAuthState, - storeLegacyCredential, + SERVICE_TOKEN_ENV_VAR, } from "../../auth"; import { CLI_NAME } from "../../cli-name"; -import type { AuthStateResult } from "../../types/auth"; import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; -import { authConfigInvalidError } from "./errors"; -import { authStateFieldRows } from "./state-card"; +import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; +import { sessionLabel } from "./session-ref"; const TITLE = "Starting an authenticated CLI session."; const LOGIN_STEP = "Sign in via your browser"; -function nextActionsFor(state: AuthStateResult): readonly NextAction[] { +export interface LoginResult { + readonly workspace: { readonly id: string; readonly name: string | null }; + readonly environmentSessionInForce: boolean; +} + +/** The minted credential names no workspace, so no session can be + * keyed by one. */ +function loginWorkspaceUnknownError(): CliStructuredError { + return new CliStructuredError( + "AUTH.LOGIN_WORKSPACE_UNKNOWN", + "Sign-in produced a credential that names no workspace.", + { + why: "A workspace session is keyed by the credential's workspace_id claim, and this credential carries none.", + nextActions: [ + { + kind: "run-command", + label: "Sign in again and pick a workspace in the browser", + command: `${CLI_NAME} auth login`, + }, + ], + }, + ); +} + +function nextActionsFor( + agentSetupTipCommand: string | null, +): readonly NextAction[] { return [ { kind: "run-command", @@ -27,52 +59,77 @@ function nextActionsFor(state: AuthStateResult): readonly NextAction[] { label: "List projects", command: `${CLI_NAME} project list`, }, - ...(state.agentSetupTip - ? [ + ...(agentSetupTipCommand === null + ? [] + : [ { kind: "run-command", label: "Install Prisma skills for this project", - command: state.agentSetupTip.command, + command: agentSetupTipCommand, } as const, - ] - : []), + ]), ]; } -function presentationsFor(state: AuthStateResult): Presentations { - const rows = authStateFieldRows(state); +function presentationsFor(spec: { + readonly session: Session; + readonly environmentSessionInForce: boolean; + readonly agentSetupTipCommand: string | null; +}): Presentations { + const rows = [ + { label: "status", value: "signed in" }, + { label: "workspace", value: sessionLabel(spec.session) }, + ]; return { human: () => [ { kind: "summary", tone: "info", text: TITLE }, { kind: "fields", rows }, - ...(state.agentSetupTip + ...(spec.environmentSessionInForce ? [ { kind: "summary", tone: "info", - text: `Install Prisma skills for this project with ${state.agentSetupTip.command}.`, + text: ENVIRONMENT_SESSION_NOTICE, } as const, ] : []), + ...(spec.agentSetupTipCommand === null + ? [] + : [ + { + kind: "summary", + tone: "info", + text: `Install Prisma skills for this project with ${spec.agentSetupTipCommand}.`, + } as const, + ]), ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), - next: () => nextActionsFor(state), + next: () => nextActionsFor(spec.agentSetupTipCommand), }; } export const authLoginCommand = defineCommand({ + managesCredentials: true, help: { summary: "Log in to your Prisma platform account", examples: ["auth login"], }, handler: async (_args, ctx) => { ctx.report({ kind: "step-started", step: LOGIN_STEP }); + let session: Session; try { const credential = await performLogin(ctx.env, ctx.signal, { onVerificationUrl: (url) => ctx.report({ kind: "endpoint", name: "verification", url }), }); - await storeLegacyCredential(ctx.env, credential, ctx.signal); + const workspaceId = claimedWorkspaceId(credential.token); + if (workspaceId === undefined) { + throw loginWorkspaceUnknownError(); + } + session = await ctx.credentialManager.createSession( + credential, + workspaceId, + ); } catch (error) { ctx.report({ kind: "step-finished", @@ -83,21 +140,25 @@ export const authLoginCommand = defineCommand({ } ctx.report({ kind: "step-finished", step: LOGIN_STEP, outcome: "ok" }); - let state: AuthStateResult; - try { - state = await readAuthState(ctx.env, ctx.signal); - } catch (error) { - if (isEmptyServiceTokenError(error)) { - return notOk(authConfigInvalidError(error.message)); - } - throw error; - } - + const environmentSessionInForce = + ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined; const agentSetupTipCommand = await resolveAgentSetupTipCommand(ctx); - if (agentSetupTipCommand) { - state = { ...state, agentSetupTip: { command: agentSetupTipCommand } }; - } - - return ok(ctx.present({ data: state }, presentationsFor(state))); + const result: LoginResult = { + workspace: { + id: session.workspaceId, + name: session.workspaceName ?? null, + }, + environmentSessionInForce, + }; + return ok( + ctx.present( + { data: result }, + presentationsFor({ + session, + environmentSessionInForce, + agentSetupTipCommand, + }), + ), + ); }, }); diff --git a/packages/cli/src/v8/auth/logout.ts b/packages/cli/src/v8/auth/logout.ts index af477dab..d951db5b 100644 --- a/packages/cli/src/v8/auth/logout.ts +++ b/packages/cli/src/v8/auth/logout.ts @@ -1,15 +1,6 @@ -import { defineCommand, flag, type Presentations } from "@prisma/cli-engine"; -import { type NextAction, notOk, ok } from "@prisma/cli-engine/protocol"; -import { - isEmptyServiceTokenError, - performLogout, - readAuthState, -} from "../../auth"; +import { defineCommand, type Presentations } from "@prisma/cli-engine"; +import { type NextAction, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; -import type { AuthStateResult } from "../../types/auth"; -import { authConfigInvalidError } from "./errors"; -import { runWorkspaceLogout } from "./run-workspace-logout"; -import { authStateFieldRows } from "./state-card"; const SIGN_IN: NextAction = { kind: "run-command", @@ -17,8 +8,17 @@ const SIGN_IN: NextAction = { command: `${CLI_NAME} auth login`, }; -function presentationsFor(state: AuthStateResult): Presentations { - const rows = authStateFieldRows(state); +export interface LogoutResult { + readonly endedCount: number; + readonly workspaceIds: readonly string[]; +} + +function presentationsFor(result: LogoutResult): Presentations { + const summary = + result.endedCount === 0 + ? "No workspace sessions to end." + : `Ended ${result.endedCount} workspace ${result.endedCount === 1 ? "session" : "sessions"}.`; + const rows = [{ label: "ended", value: String(result.endedCount) }]; return { human: () => [ { @@ -26,15 +26,8 @@ function presentationsFor(state: AuthStateResult): Presentations { tone: "info", text: "Clearing the current CLI session.", }, - { - kind: "fields", - rows: [{ label: "session", value: "local CLI state" }], - }, - { - kind: "summary", - tone: "ok", - text: "Session removed from local CLI state.", - }, + { kind: "fields", rows }, + { kind: "summary", tone: "ok", text: summary }, ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), next: () => [SIGN_IN], @@ -42,38 +35,18 @@ function presentationsFor(state: AuthStateResult): Presentations { } export const authLogoutCommand = defineCommand({ - args: { - flags: { - workspace: flag.string({ - brief: "Remove one stored OAuth workspace session", - placeholder: "id-or-name", - }), - }, - }, + managesCredentials: true, help: { summary: "Clear stored authentication credentials", - examples: ["auth logout", "auth logout --workspace my-workspace"], + examples: ["auth logout"], }, - handler: async (args, ctx) => { - const workspaceRef = args.flags.workspace?.trim(); - if (workspaceRef) { - // Same semantics and presentation as `auth workspace logout `, - // by calling the shared operation directly (the legacy shell's - // argv-level re-dispatch does not port). - return runWorkspaceLogout(ctx, workspaceRef); - } - - await performLogout(ctx.env, ctx.signal); - let state: AuthStateResult; - try { - state = await readAuthState(ctx.env, ctx.signal); - } catch (error) { - if (isEmptyServiceTokenError(error)) { - return notOk(authConfigInvalidError(error.message)); - } - throw error; - } - - return ok(ctx.present({ data: state }, presentationsFor(state))); + handler: async (_args, ctx) => { + const sessions = await ctx.credentialManager.sessions(); + await ctx.credentialManager.endAllSessions(); + const result: LogoutResult = { + endedCount: sessions.length, + workspaceIds: sessions.map((session) => session.workspaceId), + }; + return ok(ctx.present({ data: result }, presentationsFor(result))); }, }); diff --git a/packages/cli/src/v8/auth/session-card.ts b/packages/cli/src/v8/auth/session-card.ts new file mode 100644 index 00000000..4bf5dbc7 --- /dev/null +++ b/packages/cli/src/v8/auth/session-card.ts @@ -0,0 +1,39 @@ +import type { Session } from "@prisma/cli-engine"; +import { SERVICE_TOKEN_ENV_VAR } from "../../auth"; +import { sessionLabel } from "./session-ref"; + +export interface FieldRow { + readonly label: string; + readonly value: string; +} + +/** The identity a session's own claims or the API can supply. */ +export interface SessionIdentity { + readonly id: string | null; + readonly email: string | null; + readonly name: string | null; +} + +export const ENVIRONMENT_SESSION_NOTICE = `${SERVICE_TOKEN_ENV_VAR} supplies the session in force; unset it to use your stored workspace sessions.`; + +/** The card rows for a session, or the signed-out row when there is + * none. */ +export function sessionFieldRows(spec: { + readonly session: Session | null; + readonly identity: SessionIdentity | null; +}): readonly FieldRow[] { + const session = spec.session; + if (session === null) { + return [{ label: "status", value: "signed out" }]; + } + const rows: FieldRow[] = [{ label: "status", value: "signed in" }]; + const user = spec.identity?.email ?? spec.identity?.name; + if (user) { + rows.push({ label: "user", value: user }); + } + rows.push({ label: "workspace", value: sessionLabel(session) }); + if (session.source === "environment") { + rows.push({ label: "source", value: SERVICE_TOKEN_ENV_VAR }); + } + return rows; +} diff --git a/packages/cli/src/v8/auth/session-ref.ts b/packages/cli/src/v8/auth/session-ref.ts new file mode 100644 index 00000000..4f531ab5 --- /dev/null +++ b/packages/cli/src/v8/auth/session-ref.ts @@ -0,0 +1,82 @@ +/** + * Command-side resolution of a user-typed workspace reference against + * the sessions the credential manager holds. The manager resolves no + * user input: the commands match the ref and pass the matched Session. + */ +import { noSessionForWorkspaceError, type Session } from "@prisma/cli-engine"; +import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import { CLI_NAME } from "../../cli-name"; + +export type SessionRefResolution = + | { readonly kind: "matched"; readonly session: Session } + | { readonly kind: "no-match" } + | { readonly kind: "ambiguous"; readonly matches: readonly Session[] }; + +/** Exact workspace id first, then case-insensitive workspace name. */ +export function resolveSessionRef( + sessions: readonly Session[], + ref: string, +): SessionRefResolution { + const wanted = ref.trim(); + const byId = sessions.find((session) => session.workspaceId === wanted); + if (byId !== undefined) { + return { kind: "matched", session: byId }; + } + const byName = sessions.filter( + (session) => + session.workspaceName !== undefined && + session.workspaceName.toLowerCase() === wanted.toLowerCase(), + ); + if (byName.length === 1) { + return { kind: "matched", session: byName[0] }; + } + if (byName.length > 1) { + return { kind: "ambiguous", matches: byName }; + } + return { kind: "no-match" }; +} + +export function ambiguousSessionRefError( + ref: string, + matches: readonly Session[], +): CliStructuredError { + return new CliStructuredError( + "AUTH.WORKSPACE_AMBIGUOUS", + `More than one workspace session is named '${ref}'.`, + { + why: `Matching workspaces: ${matches.map((match) => match.workspaceId).join(", ")}.`, + meta: { workspaceIds: matches.map((match) => match.workspaceId) }, + nextActions: [ + { + kind: "run-command", + label: "List your workspace sessions and pass a workspace id", + command: `${CLI_NAME} auth workspace list`, + }, + ], + }, + ); +} + +/** + * Resolves the ref or throws the structured error for its failure — + * the ruled "no session for X" error for a ref that matches nothing. + */ +export function requireSession( + sessions: readonly Session[], + ref: string, +): Session { + const resolution = resolveSessionRef(sessions, ref); + if (resolution.kind === "ambiguous") { + throw ambiguousSessionRefError(ref, resolution.matches); + } + if (resolution.kind === "no-match") { + throw noSessionForWorkspaceError(ref); + } + return resolution.session; +} + +/** How a session is named to users: its workspace name, or its id + * when no name was ever fetched. */ +export function sessionLabel(session: Session): string { + return session.workspaceName ?? session.workspaceId; +} diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index f4b658c8..a4b0e095 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -1,10 +1,18 @@ -import { defineCommand, type Presentations } from "@prisma/cli-engine"; -import { type NextAction, notOk, ok } from "@prisma/cli-engine/protocol"; -import { isEmptyServiceTokenError, readAuthState } from "../../auth"; +import { + type CommandContext, + defineCommand, + type ManagementApiClient, + type Presentations, + type Session, +} from "@prisma/cli-engine"; +import { type NextAction, ok } from "@prisma/cli-engine/protocol"; +import { decodeClaims, SERVICE_TOKEN_ENV_VAR } from "../../auth"; import { CLI_NAME } from "../../cli-name"; -import type { AuthStateResult } from "../../types/auth"; -import { authConfigInvalidError } from "./errors"; -import { authStateFieldRows } from "./state-card"; +import { + ENVIRONMENT_SESSION_NOTICE, + type SessionIdentity, + sessionFieldRows, +} from "./session-card"; const TITLE = "Showing the current authenticated identity."; @@ -14,15 +22,91 @@ const SIGN_IN: NextAction = { command: `${CLI_NAME} auth login`, }; -function presentationsFor(state: AuthStateResult): Presentations { - const rows = authStateFieldRows(state); +export interface WhoamiResult { + readonly authenticated: boolean; + readonly workspace: { + readonly id: string; + readonly name: string | null; + } | null; + readonly user: SessionIdentity | null; + readonly source: "stored" | "environment" | null; + readonly expiresAt: string | null; +} + +function claimedIdentity(token: string): SessionIdentity | null { + const claims = decodeClaims(token); + if (claims === undefined) { + return null; + } + const read = (key: string): string | null => + typeof claims[key] === "string" ? (claims[key] as string) : null; + const identity = { + id: read("sub"), + email: read("email"), + name: read("name"), + }; + return identity.id === null && identity.email === null ? null : identity; +} + +/** Best-effort online enrichment: whoami works offline, so any failure + * leaves the identity as whatever the session itself could supply. */ +async function enrichedIdentity( + api: ManagementApiClient, + signal: AbortSignal, +): Promise { + try { + const { data } = await api.GET("/v1/me", { signal }); + const user = data?.data?.user; + if (!user) { + return null; + } + return { + id: user.id ?? null, + email: user.email ?? null, + name: user.name ?? null, + }; + } catch { + signal.throwIfAborted(); + return null; + } +} + +async function identityFor( + session: Session, + ctx: CommandContext, +): Promise { + const enriched = await enrichedIdentity(ctx.api, ctx.signal); + if (enriched !== null) { + return enriched; + } + const envToken = ctx.env[SERVICE_TOKEN_ENV_VAR]; + return session.source === "environment" && envToken !== undefined + ? claimedIdentity(envToken) + : null; +} + +function presentationsFor(spec: { + readonly session: Session | null; + readonly identity: SessionIdentity | null; +}): Presentations { + const rows = sessionFieldRows(spec); + const environmentSession = spec.session?.source === "environment"; return { human: () => [ { kind: "summary", tone: "info", text: TITLE }, { kind: "fields", rows }, + ...(environmentSession + ? [ + { + kind: "summary", + tone: "info", + text: ENVIRONMENT_SESSION_NOTICE, + } as const, + ] + : []), ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), - next: () => (state.authenticated ? [] : [SIGN_IN]), + next: () => (spec.session === null ? [SIGN_IN] : []), }; } @@ -32,16 +116,20 @@ export const authWhoamiCommand = defineCommand({ examples: ["auth whoami", "auth whoami --json"], }, handler: async (_args, ctx) => { - let state: AuthStateResult; - try { - state = await readAuthState(ctx.env, ctx.signal); - } catch (error) { - if (isEmptyServiceTokenError(error)) { - return notOk(authConfigInvalidError(error.message)); - } - throw error; - } - - return ok(ctx.present({ data: state }, presentationsFor(state))); + const session = await ctx.session(); + const identity = session === null ? null : await identityFor(session, ctx); + const result: WhoamiResult = { + authenticated: session !== null, + workspace: + session === null + ? null + : { id: session.workspaceId, name: session.workspaceName ?? null }, + user: identity, + source: session?.source ?? null, + expiresAt: session?.expiresAt?.toISOString() ?? null, + }; + return ok( + ctx.present({ data: result }, presentationsFor({ session, identity })), + ); }, }); diff --git a/packages/cli/src/v8/auth/workspace-list.ts b/packages/cli/src/v8/auth/workspace-list.ts index 87b0fc54..56368780 100644 --- a/packages/cli/src/v8/auth/workspace-list.ts +++ b/packages/cli/src/v8/auth/workspace-list.ts @@ -1,122 +1,93 @@ /** The `auth workspace list` command. */ -import { defineCommand, type Presentations } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; -import { isEmptyServiceTokenError, listAuthWorkspaces } from "../../auth"; -import type { AuthWorkspaceListResult } from "../../types/auth"; -import { authConfigInvalidError } from "./errors"; import { - LOGIN_NEXT_ACTION, - operationContext, - rethrowMapped, -} from "./workspace-shared"; + defineCommand, + type Presentations, + type Session, +} from "@prisma/cli-engine"; +import { type NextAction, ok } from "@prisma/cli-engine/protocol"; +import { SERVICE_TOKEN_ENV_VAR } from "../../auth"; +import { CLI_NAME } from "../../cli-name"; +import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; +import { sessionLabel } from "./session-ref"; -function authSourceLabel( - source: AuthWorkspaceListResult["authSource"], -): string { - if (source === "oauth") { - return "local OAuth"; - } - if (source === "service_token") { - return "PRISMA_SERVICE_TOKEN"; - } - return "none"; -} +const LOGIN_NEXT_ACTION: NextAction = { + kind: "run-command", + label: "Sign in", + command: `${CLI_NAME} auth login`, +}; -function workspaceSourceLabel(source: "oauth" | "service_token"): string { - return source === "service_token" ? "service token" : "OAuth"; +export interface WorkspaceListResult { + readonly sessions: readonly Session[]; + readonly environmentSessionInForce: boolean; } -export function serializeAuthWorkspaceList(result: AuthWorkspaceListResult) { +export function serializeWorkspaceList(result: WorkspaceListResult) { return { context: { - authSource: result.authSource, - activeWorkspaceId: result.activeWorkspace?.id ?? null, - activeWorkspaceName: result.activeWorkspace?.name ?? null, + environmentSessionInForce: result.environmentSessionInForce, + currentWorkspaceId: + result.sessions.find((session) => session.current)?.workspaceId ?? null, }, - items: result.workspaces.map((workspace) => ({ - id: workspace.id, - name: workspace.name, - status: workspace.active ? "active" : null, - source: workspace.source, - switchable: workspace.switchable, - credentialWorkspaceId: workspace.credentialWorkspaceId, - lastSeenAt: workspace.lastSeenAt, + items: result.sessions.map((session) => ({ + workspaceId: session.workspaceId, + workspaceName: session.workspaceName ?? null, + current: session.current, + expiresAt: session.expiresAt?.toISOString() ?? null, })), - count: result.workspaces.length, + count: result.sessions.length, }; } -/** The legacy table's column rule: the source column appears only when - * the listed workspaces mix sources. */ -function workspaceTableRows(result: AuthWorkspaceListResult): { - columns: readonly string[]; - rows: ReadonlyArray; -} { - const hasMixedSources = - new Set(result.workspaces.map((workspace) => workspace.source)).size > 1; - const columns = hasMixedSources - ? ["name", "id", "source", "status"] - : ["name", "id", "status"]; - const rows = result.workspaces.map((workspace) => { - const status = workspace.active ? "active" : ""; - return hasMixedSources - ? [ - workspace.name, - workspace.id, - workspaceSourceLabel(workspace.source), - status, - ] - : [workspace.name, workspace.id, status]; - }); - return { columns, rows }; -} - -function listPresentations(result: AuthWorkspaceListResult): Presentations { - const table = workspaceTableRows(result); +function listPresentations(result: WorkspaceListResult): Presentations { + const columns = ["name", "id", "status"]; + const rows = result.sessions.map((session) => [ + sessionLabel(session), + session.workspaceId, + session.current ? "current" : "", + ]); return { human: () => [ { kind: "summary", tone: "info", - text: "Listing authenticated workspaces on this machine.", + text: "Listing your workspace sessions on this machine.", }, - { - kind: "fields", - rows: [ - { label: "auth source", value: authSourceLabel(result.authSource) }, - ], - }, - ...(result.workspaces.length === 0 + ...(result.environmentSessionInForce + ? [ + { + kind: "summary", + tone: "info", + text: ENVIRONMENT_SESSION_NOTICE, + } as const, + ] + : []), + ...(result.sessions.length === 0 ? [ { kind: "summary", tone: "info", - text: "No local OAuth workspaces found.", + text: "No workspace sessions found.", } as const, ] - : [{ kind: "table", ...table } as const]), + : [{ kind: "table", columns, rows } as const]), ], - stdout: () => table.rows.map((row) => row.join(" ").trimEnd()), - json: () => serializeAuthWorkspaceList(result), - next: () => (result.workspaces.length === 0 ? [LOGIN_NEXT_ACTION] : []), + stdout: () => rows.map((row) => row.join(" ").trimEnd()), + json: () => serializeWorkspaceList(result), + next: () => (result.sessions.length === 0 ? [LOGIN_NEXT_ACTION] : []), }; } export const authWorkspaceListCommand = defineCommand({ + managesCredentials: true, help: { - summary: "List locally authenticated workspaces", + summary: "List your workspace sessions", examples: ["auth workspace list", "auth workspace list --json"], }, handler: async (_args, ctx) => { - let result: AuthWorkspaceListResult; - try { - result = await listAuthWorkspaces(operationContext(ctx)); - } catch (error) { - if (isEmptyServiceTokenError(error)) { - return notOk(authConfigInvalidError(error.message)); - } - rethrowMapped(error); - } + const result: WorkspaceListResult = { + sessions: await ctx.credentialManager.sessions(), + environmentSessionInForce: ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined, + }; return ok(ctx.present({ data: result }, listPresentations(result))); }, }); diff --git a/packages/cli/src/v8/auth/workspace-logout.ts b/packages/cli/src/v8/auth/workspace-logout.ts index b02a717d..d2d515b3 100644 --- a/packages/cli/src/v8/auth/workspace-logout.ts +++ b/packages/cli/src/v8/auth/workspace-logout.ts @@ -1,20 +1,92 @@ -/** The `auth workspace logout` command. */ -import { defineCommand, positional } from "@prisma/cli-engine"; -import { runWorkspaceLogout } from "./run-workspace-logout"; +/** The `auth workspace logout` command: ends one workspace session. */ +import { + defineCommand, + type Presentations, + positional, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { CLI_NAME } from "../../cli-name"; +import { requireSession, sessionLabel } from "./session-ref"; + +export interface WorkspaceLogoutResult { + readonly workspace: { readonly id: string; readonly name: string | null }; + readonly wasCurrent: boolean; +} + +function logoutPresentations(spec: { + readonly label: string; + readonly wasCurrent: boolean; +}): Presentations { + const rows = [{ label: "workspace", value: spec.label }]; + return { + human: () => [ + { + kind: "summary", + tone: "info", + text: "Ending a workspace session.", + }, + { kind: "fields", rows }, + { + kind: "summary", + tone: "ok", + text: spec.wasCurrent + ? "Ended the current workspace session; no replacement was selected." + : "Ended the workspace session.", + }, + ], + stdout: () => rows.map((row) => `${row.label}: ${row.value}`), + next: () => [ + { + kind: "run-command", + label: "List your workspace sessions", + command: `${CLI_NAME} auth workspace list`, + }, + ...(spec.wasCurrent + ? [ + { + kind: "run-command", + label: "Make another session current", + command: `${CLI_NAME} auth workspace use `, + } as const, + ] + : []), + ], + }; +} export const authWorkspaceLogoutCommand = defineCommand({ + managesCredentials: true, args: { positionals: { workspace: positional.string({ - brief: "Workspace id or exact name", + brief: "Workspace id or name", placeholder: "id-or-name", }), }, }, help: { - summary: "Remove one local OAuth workspace session", + summary: "End one workspace session", examples: ["auth workspace logout my-workspace"], }, - handler: async (args, ctx) => - runWorkspaceLogout(ctx, args.positionals.workspace), + handler: async (args, ctx) => { + const sessions = await ctx.credentialManager.sessions(); + const session = requireSession(sessions, args.positionals.workspace); + await ctx.credentialManager.endSession(session); + const result: WorkspaceLogoutResult = { + workspace: { + id: session.workspaceId, + name: session.workspaceName ?? null, + }, + wasCurrent: session.current, + }; + return ok( + ctx.present( + { data: result }, + logoutPresentations({ + label: sessionLabel(session), + wasCurrent: session.current, + }), + ), + ); + }, }); diff --git a/packages/cli/src/v8/auth/workspace-use.ts b/packages/cli/src/v8/auth/workspace-use.ts index 959289a7..fa304add 100644 --- a/packages/cli/src/v8/auth/workspace-use.ts +++ b/packages/cli/src/v8/auth/workspace-use.ts @@ -1,43 +1,58 @@ -/** The `auth workspace use` command. */ +/** The `auth workspace use` command: it SELECTS among the sessions you + * have — it never creates one, and never opens a browser. */ import { - type CommandContext, defineCommand, type Presentations, positional, + type Session, } from "@prisma/cli-engine"; -import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; -import { - listAuthWorkspaces, - SERVICE_TOKEN_ENV_VAR, - switchAuthWorkspace, -} from "../../auth"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; -import type { - AuthWorkspaceListResult, - AuthWorkspaceUseResult, -} from "../../types/auth"; -import { mapAuthOperationError } from "./errors"; -import { operationContext, rethrowMapped } from "./workspace-shared"; +import { requireSession, sessionLabel } from "./session-ref"; + +export interface WorkspaceUseResult { + readonly workspace: { readonly id: string; readonly name: string | null }; + readonly previousWorkspaceId: string | null; +} + +function noWorkspaceSessionsError(): CliStructuredError { + return new CliStructuredError( + "AUTH.NO_WORKSPACE_SESSIONS", + "You have no workspace sessions to select from.", + { + nextActions: [ + { + kind: "run-command", + label: "Sign in and pick a workspace in the browser", + command: `${CLI_NAME} auth login`, + }, + ], + }, + ); +} -function usePresentations(result: AuthWorkspaceUseResult): Presentations { +function usePresentations(spec: { + readonly session: Session; + readonly previous: Session | undefined; +}): Presentations { const rows = [ - ...(result.previousWorkspace - ? [{ label: "previous", value: result.previousWorkspace.name }] - : []), - { label: "workspace", value: result.workspace.name }, + ...(spec.previous === undefined + ? [] + : [{ label: "previous", value: sessionLabel(spec.previous) }]), + { label: "workspace", value: sessionLabel(spec.session) }, ]; return { human: () => [ { kind: "summary", tone: "info", - text: "Switching the local CLI workspace.", + text: "Switching the current workspace session.", }, { kind: "fields", rows }, { kind: "summary", tone: "ok", - text: "Local OAuth workspace selection updated.", + text: "Current workspace session updated.", }, ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), @@ -56,100 +71,61 @@ function usePresentations(result: AuthWorkspaceUseResult): Presentations { }; } -function noWorkspacesError(): CliStructuredError { - return new CliStructuredError( - "AUTH.USAGE_ERROR", - "No authenticated workspaces", - { - why: "There are no local OAuth workspace sessions to select.", - nextActions: [ - { - kind: "user-choice", - label: `Run ${CLI_NAME} auth login and authorize a workspace.`, - }, - ], - }, - ); -} - -function serviceTokenSwitchError(): CliStructuredError { - return new CliStructuredError( - "AUTH.WORKSPACE_SWITCH_UNAVAILABLE", - "Workspace switching is unavailable", - { - why: "PRISMA_SERVICE_TOKEN is set, so authenticated commands use that token instead of local OAuth workspaces.", - nextActions: [ - { - kind: "user-choice", - label: - "Unset PRISMA_SERVICE_TOKEN to switch between local OAuth workspaces, or use a token for the workspace you want.", - }, - ], - }, - ); -} - -async function selectWorkspaceRef( - ctx: CommandContext, -): Promise { - if (ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { - throw serviceTokenSwitchError(); - } - - let listed: AuthWorkspaceListResult; - try { - listed = await listAuthWorkspaces(operationContext(ctx)); - } catch (error) { - rethrowMapped(error); - } - const workspaces = listed.workspaces.filter( - (workspace) => workspace.switchable, - ); - - if (workspaces.length === 0) { - throw noWorkspacesError(); - } - - if (workspaces.length === 1) { - return workspaces[0].id; - } - - return await ctx.prompt.select( - "Select a workspace", - workspaces.map((workspace) => ({ - value: workspace.id, - label: `${workspace.name} (${workspace.id})${workspace.active ? " active" : ""}`, - })), - ); -} - export const authWorkspaceUseCommand = defineCommand({ + managesCredentials: true, args: { positionals: { workspace: positional.optionalString({ - brief: "Workspace id or exact name", + brief: "Workspace id or name", placeholder: "id-or-name", }), }, }, help: { - summary: "Switch the local CLI workspace", + summary: "Make one of your workspace sessions current", examples: ["auth workspace use", "auth workspace use my-workspace"], }, handler: async (args, ctx) => { - const trimmed = args.positionals.workspace?.trim(); - const workspaceRef = trimmed ? trimmed : await selectWorkspaceRef(ctx); - - let result: AuthWorkspaceUseResult; - try { - result = await switchAuthWorkspace(operationContext(ctx), workspaceRef); - } catch (error) { - const mapped = mapAuthOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; + const sessions = await ctx.credentialManager.sessions(); + if (sessions.length === 0) { + throw noWorkspaceSessionsError(); } - return ok(ctx.present({ data: result }, usePresentations(result))); + const ref = args.positionals.workspace?.trim(); + const chosen = ref + ? requireSession(sessions, ref) + : await selectSession(sessions, ctx.prompt.select); + const previous = sessions.find((session) => session.current); + + const session = await ctx.credentialManager.useSession(chosen); + const result: WorkspaceUseResult = { + workspace: { + id: session.workspaceId, + name: session.workspaceName ?? null, + }, + previousWorkspaceId: previous?.workspaceId ?? null, + }; + return ok( + ctx.present({ data: result }, usePresentations({ session, previous })), + ); }, }); + +async function selectSession( + sessions: readonly Session[], + select: ( + question: string, + options: ReadonlyArray<{ value: T; label: string }>, + ) => Promise, +): Promise { + if (sessions.length === 1) { + return sessions[0]; + } + const workspaceId = await select( + "Select a workspace", + sessions.map((session) => ({ + value: session.workspaceId, + label: `${sessionLabel(session)} (${session.workspaceId})${session.current ? " current" : ""}`, + })), + ); + return requireSession(sessions, workspaceId); +} diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index e4f9ce24..3c3b8644 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -1,27 +1,25 @@ -import { mkdtemp, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { StreamEvent } from "@prisma/cli-engine"; -import { createTestCli } from "@prisma/cli-engine/testing"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - +/** + * The v8 auth family over the credential manager: every assertion is + * semantic (envelope / presented / events / exit code / manager state + * read-back). Byte pins live in v8-golden-rendering.test.ts and + * v8-whoami.test.ts. + */ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { - EmptyServiceTokenError, - listAuthWorkspaces, - logoutAuthWorkspace, - performLogin, - performLogout, - readAuthState, - switchAuthWorkspace, -} from "../src/auth"; + defineCommand, + type ManagementApiClient, + type Session, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; import { - workspaceAmbiguousError, - workspaceNotAuthenticatedError, -} from "../src/shell/errors"; -import type { - AuthStateResult, - AuthWorkspaceListResult, -} from "../src/types/auth"; + createTestCli, + mintTestJwt, + type TestSessionRecord, +} from "@prisma/cli-engine/testing"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { performLogin, storeLegacyCredential } from "../src/auth"; import { authLoginCommand } from "../src/v8/auth/login"; import { authLogoutCommand } from "../src/v8/auth/logout"; import { authWhoamiCommand } from "../src/v8/auth/whoami"; @@ -32,686 +30,486 @@ import { authWorkspaceUseCommand } from "../src/v8/auth/workspace-use"; vi.mock("../src/auth", async (importOriginal) => ({ ...(await importOriginal()), performLogin: vi.fn(), - performLogout: vi.fn(), - readAuthState: vi.fn(), - listAuthWorkspaces: vi.fn(), - switchAuthWorkspace: vi.fn(), - logoutAuthWorkspace: vi.fn(), + storeLegacyCredential: vi.fn(), })); -const SIGNED_OUT: AuthStateResult = { - authenticated: false, - provider: null, - user: null, - workspace: null, - credential: null, +const COMMANDS = { + "auth login": authLoginCommand, + "auth logout": authLogoutCommand, + "auth whoami": authWhoamiCommand, + "auth workspace list": authWorkspaceListCommand, + "auth workspace use": authWorkspaceUseCommand, + "auth workspace logout": authWorkspaceLogoutCommand, }; -const SIGNED_IN: AuthStateResult = { - authenticated: true, - provider: null, - user: { id: "usr_456", email: "bob@example.com", name: "Bob Example" }, - workspace: { id: "ws_123", name: "Acme Inc" }, - credential: { type: "oauth", id: null, name: null }, +const GROUPS = { + auth: { brief: "Manage local authentication for the CLI" }, + "auth workspace": { brief: "Manage local workspace sessions" }, }; -const TWO_OAUTH_WORKSPACES: AuthWorkspaceListResult = { - authSource: "oauth", - activeWorkspace: { id: "ws_1", name: "Acme Inc" }, - workspaces: [ - { - id: "ws_1", - name: "Acme Inc", - credentialWorkspaceId: "cred_1", - active: true, - source: "oauth", - switchable: true, - lastSeenAt: "2026-01-01T00:00:00.000Z", - }, - { - id: "ws_2", - name: "Globex", - credentialWorkspaceId: "cred_2", - active: false, - source: "oauth", - switchable: true, - lastSeenAt: null, - }, - ], -}; +function tokenFor( + workspaceId: string, + claims: Readonly> = {}, +): string { + return mintTestJwt({ workspace_id: workspaceId, ...claims }); +} -const MIXED_SOURCES: AuthWorkspaceListResult = { - authSource: "service_token", - activeWorkspace: { id: "ws_tok", name: "Token WS" }, - workspaces: [ - { - id: "ws_tok", - name: "Token WS", - credentialWorkspaceId: null, - active: true, - source: "service_token", - switchable: false, - lastSeenAt: null, - }, - { - id: "ws_1", - name: "Acme Inc", - credentialWorkspaceId: "cred_1", - active: false, - source: "oauth", - switchable: false, - lastSeenAt: null, - }, - ], -}; +function credentialFor(workspaceId: string) { + return { + token: tokenFor(workspaceId, { + sub: "usr_456", + email: "bob@example.com", + }), + refreshToken: `refresh_${workspaceId}`, + expiresAt: undefined, + }; +} -const EMPTY_LIST: AuthWorkspaceListResult = { - authSource: "none", - activeWorkspace: null, - workspaces: [], -}; +function record( + workspaceId: string, + workspaceName: string | undefined, +): TestSessionRecord { + return { + workspaceId, + workspaceName, + credential: credentialFor(workspaceId), + }; +} + +/** ctx.api that never reaches the network: whoami's enrichment is + * best-effort, so an offline client leaves the identity unenriched. */ +const OFFLINE_API = { + GET: async () => { + throw new Error("offline"); + }, +} as unknown as ManagementApiClient; + +function apiReturning(body: unknown): ManagementApiClient { + return { + GET: async () => ({ data: body, response: { status: 200 } }), + } as unknown as ManagementApiClient; +} -function makeCli() { +function makeCli(spec?: { + readonly sessions?: readonly TestSessionRecord[]; + readonly currentWorkspaceId?: string; + readonly environmentToken?: string; + readonly client?: ManagementApiClient; + readonly openUrl?: (url: string) => void; +}) { return createTestCli({ - commands: { - "auth login": authLoginCommand, - "auth logout": authLogoutCommand, - "auth whoami": authWhoamiCommand, - "auth workspace list": authWorkspaceListCommand, - "auth workspace use": authWorkspaceUseCommand, - "auth workspace logout": authWorkspaceLogoutCommand, - }, - groups: { - auth: { brief: "Manage local authentication for the CLI" }, - "auth workspace": { brief: "Manage local workspace sessions" }, - }, + commands: COMMANDS, + groups: GROUPS, + sessions: spec?.sessions ?? [], + currentWorkspaceId: spec?.currentWorkspaceId, + environmentToken: spec?.environmentToken, + managementApi: { client: spec?.client ?? OFFLINE_API }, + openUrl: spec?.openUrl, now: () => new Date(0), }); } -async function emptyTempCwd(): Promise { - return await mkdtemp(path.join(os.tmpdir(), "v8-auth-test-")); -} +type ResultFrame = { + readonly kind: string; + readonly envelope: { + readonly ok: boolean; + readonly error?: Record; + readonly result?: unknown; + }; +}; -function resultFrame(frames: ReadonlyArray<{ kind: string }>) { - const frame = frames.at(-1); - if (frame === undefined || frame.kind !== "result") { +function envelopeOf(result: { readonly json: readonly unknown[] }) { + const frame = (result.json as readonly ResultFrame[]).find( + (candidate) => candidate.kind === "result", + ); + if (frame === undefined) { throw new Error("expected a terminal result frame"); } - return frame as Extract; + return frame.envelope; +} + +function errorOf(result: { readonly json: readonly unknown[] }) { + const envelope = envelopeOf(result); + if (envelope.ok) { + throw new Error("expected an errored result frame"); + } + return envelope.error as Record; +} + +function resultOf(result: { readonly json: readonly unknown[] }) { + const envelope = envelopeOf(result); + if (!envelope.ok) { + throw new Error("expected an ok result frame"); + } + return envelope.result; } beforeEach(() => { vi.mocked(performLogin).mockReset(); - vi.mocked(performLogout).mockReset(); - vi.mocked(readAuthState).mockReset(); - vi.mocked(listAuthWorkspaces).mockReset(); - vi.mocked(switchAuthWorkspace).mockReset(); - vi.mocked(logoutAuthWorkspace).mockReset(); + vi.mocked(storeLegacyCredential).mockReset(); }); -const MINTED_CREDENTIAL = { - token: "minted-access-token", - refreshToken: "minted-refresh-token", - expiresAt: undefined, -}; +describe("auth login", () => { + it("creates the session for the workspace the credential names", async () => { + const credential = credentialFor("ws_1"); + vi.mocked(performLogin).mockResolvedValue(credential); + const cli = makeCli(); -describe("prisma-v8 auth login", () => { - it("runs the browser flow, emits step and endpoint events, and renders the signed-in card", async () => { - vi.mocked(performLogin).mockImplementation( - async (_env, _signal, options) => { - options?.onVerificationUrl?.( - "https://auth.prisma.io/activate?code=XYZ", - ); - return MINTED_CREDENTIAL; - }, - ); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - - const result = await makeCli().run(["auth", "login"], { - isTty: { stdout: true }, - cwd: await emptyTempCwd(), - }); + const result = await cli.run(["auth", "login", "--json"]); expect(result.exitCode).toBe(0); - expect(result.events).toEqual([ - { kind: "step-started", step: "Sign in via your browser" }, - { - kind: "endpoint", - name: "verification", - url: "https://auth.prisma.io/activate?code=XYZ", - }, - { - kind: "step-finished", - step: "Sign in via your browser", - outcome: "ok", - }, - ]); - expect(result.presented?.presentation.stdout).toEqual([ - "status: signed in", - "user: bob@example.com", - "workspace: Acme Inc", + expect(resultOf(result)).toEqual({ + workspace: { id: "ws_1", name: null }, + environmentSessionInForce: false, + }); + const state = cli.credentialManager?.state(); + expect(state?.currentWorkspaceId).toBe("ws_1"); + expect(state?.sessions.map((session) => session.workspaceId)).toEqual([ + "ws_1", ]); - expect(result.stderr).toContain( - "ℹ Starting an authenticated CLI session.\n", - ); - expect(result.stderr).toContain("verification: https://auth.prisma.io/"); - expect(result.stderr).toContain( - "→ Show the signed-in identity: prisma-cli auth whoami\n", - ); - expect(result.stderr).toContain( - "→ List projects: prisma-cli project list\n", - ); - expect(result.stderr).not.toContain("Install Prisma skills"); }); - it("appends the agent-setup tip line and next action from a project directory", async () => { - vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - const cwd = await emptyTempCwd(); - await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); - const stateDir = path.join(cwd, ".state"); + it("no longer writes the minted credential into the legacy store", async () => { + vi.mocked(performLogin).mockResolvedValue(credentialFor("ws_1")); - const result = await makeCli().run(["auth", "login"], { - isTty: { stdout: true }, - cwd, - env: { PRISMA_CLI_STATE_DIR: stateDir }, - }); + await makeCli().run(["auth", "login", "--json"]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "Install Prisma skills for this project with ", - ); - expect(result.stderr).toContain("agent install"); - const presented = result.presented; - expect(presented?.presentation.next.at(-1)).toMatchObject({ - kind: "run-command", - label: "Install Prisma skills for this project", - command: expect.stringContaining("agent install"), - }); + expect(vi.mocked(storeLegacyCredential)).not.toHaveBeenCalled(); + }); + + it("declares the credential-manager capability", () => { + expect(authLoginCommand.managesCredentials).toBe(true); }); - it("suppresses the agent-setup tip in CI", async () => { - vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - const cwd = await emptyTempCwd(); - await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); + it("succeeds under an env override and states the env token stays in force", async () => { + vi.mocked(performLogin).mockResolvedValue(credentialFor("ws_1")); + const cli = makeCli({ environmentToken: tokenFor("ws_env") }); - const result = await makeCli().run(["auth", "login"], { + const result = await cli.run(["auth", "login"], { isTty: { stdout: true }, - cwd, - env: { CI: "1", PRISMA_CLI_STATE_DIR: path.join(cwd, ".state") }, }); expect(result.exitCode).toBe(0); - expect(result.stderr).not.toContain("Install Prisma skills"); - }); - - it("suppresses the agent-setup tip when Prisma skills are already installed", async () => { - vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - const cwd = await emptyTempCwd(); - await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); - await writeFile( - path.join(cwd, "skills-lock.json"), - JSON.stringify({ sources: ["prisma/skills"] }), - "utf8", + expect(result.stderr).toContain( + "PRISMA_SERVICE_TOKEN supplies the session in force", ); + expect(cli.credentialManager?.state().sessions).toHaveLength(1); + }); - const result = await makeCli().run(["auth", "login"], { - isTty: { stdout: true }, - cwd, - env: { PRISMA_CLI_STATE_DIR: path.join(cwd, ".state") }, + it("refuses a credential that names no workspace", async () => { + vi.mocked(performLogin).mockResolvedValue({ + token: mintTestJwt({ sub: "usr_456" }), + refreshToken: undefined, + expiresAt: undefined, }); + const cli = makeCli(); - expect(result.exitCode).toBe(0); - expect(result.stderr).not.toContain("Install Prisma skills"); + const result = await cli.run(["auth", "login", "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result).code).toBe("AUTH.LOGIN_WORKSPACE_UNKNOWN"); + expect(cli.credentialManager?.state().sessions).toEqual([]); expect( - result.presented?.presentation.next.some( - (action) => action.label === "Install Prisma skills for this project", + result.events.filter( + (event) => event.kind === "step-finished" && event.outcome === "failed", ), - ).toBe(false); + ).toHaveLength(1); }); +}); - it("carries the agent-setup tip in the json envelope (result field + nextAction)", async () => { - vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - const cwd = await emptyTempCwd(); - await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); - - const result = await makeCli().run(["auth", "login", "--json"], { - cwd, - env: { PRISMA_CLI_STATE_DIR: path.join(cwd, ".state") }, +describe("auth logout", () => { + it("ends every session and reports the count it ended", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc"), record("ws_2", "Globex")], + currentWorkspaceId: "ws_1", }); - expect(result.exitCode).toBe(0); - const frame = resultFrame(result.json); - if (!frame.envelope.ok) { - throw new Error("expected a completed envelope"); - } - expect(frame.envelope.result).toMatchObject({ - agentSetupTip: { command: expect.stringContaining("agent install") }, - }); - expect(frame.envelope.nextActions.at(-1)).toMatchObject({ - kind: "run-command", - label: "Install Prisma skills for this project", - command: expect.stringContaining("agent install"), - }); - }); - - it("maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID, exit 2", async () => { - vi.mocked(performLogin).mockResolvedValue(MINTED_CREDENTIAL); - vi.mocked(readAuthState).mockRejectedValue(new EmptyServiceTokenError()); + const result = await cli.run(["auth", "logout", "--json"]); - const result = await makeCli().run(["auth", "login", "--json"], { - cwd: await emptyTempCwd(), + expect(result.exitCode).toBe(0); + expect(resultOf(result)).toEqual({ + endedCount: 2, + workspaceIds: ["ws_1", "ws_2"], }); - - expect(result.exitCode).toBe(2); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: false, - commandId: "auth.login", - error: { - code: "AUTH.CONFIG_INVALID", - summary: "Authentication configuration is invalid", - }, + expect(cli.credentialManager?.state()).toEqual({ + sessions: [], + currentWorkspaceId: null, }); }); - it("streams the flow events and the raw auth state envelope in json mode", async () => { - vi.mocked(performLogin).mockImplementation( - async (_env, _signal, options) => { - options?.onVerificationUrl?.( - "https://auth.prisma.io/activate?code=XYZ", - ); - return MINTED_CREDENTIAL; - }, - ); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - - const result = await makeCli().run(["auth", "login", "--json"], { - cwd: await emptyTempCwd(), - }); + it("reports zero when there was nothing to end", async () => { + const result = await makeCli().run(["auth", "logout", "--json"]); expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - expect(result.json.map((frame) => frame.kind)).toEqual([ - "step-started", - "endpoint", - "step-finished", - "result", - ]); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: true, - commandId: "auth.login", - result: SIGNED_IN, - exitCode: 0, - }); - if (!frame.envelope.ok) { - throw new Error("expected a completed envelope"); - } - expect(frame.envelope.nextActions.map((action) => action.command)).toEqual([ - "prisma-cli auth whoami", - "prisma-cli project list", - ]); + expect(resultOf(result)).toEqual({ endedCount: 0, workspaceIds: [] }); }); - it("settles a failed login as a bug with the failed step event", async () => { - vi.mocked(performLogin).mockRejectedValue(new Error("browser failed")); - - const result = await makeCli().run(["auth", "login"], { - isTty: { stdout: true }, - cwd: await emptyTempCwd(), + it("refuses under an env override while stored sessions exist, changing nothing", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + environmentToken: tokenFor("ws_env"), }); - expect(result.exitCode).toBe(1); - expect(result.events).toEqual([ - { kind: "step-started", step: "Sign in via your browser" }, - { - kind: "step-finished", - step: "Sign in via your browser", - outcome: "failed", - }, - ]); - expect(result.stderr).toContain("✖ [CLI.INTERNAL_ERROR] browser failed"); - expect(vi.mocked(readAuthState)).not.toHaveBeenCalled(); - }); + const result = await cli.run(["auth", "logout", "--json"]); - it("never requires credentials (it creates the session)", () => { - expect(authLoginCommand.needs.credentials).toBe(false); + expect(result.exitCode).toBe(2); + expect(errorOf(result).code).toBe("AUTH.ENV_SESSION_IN_FORCE"); + expect(cli.credentialManager?.state().sessions).toHaveLength(1); }); -}); -describe("prisma-v8 auth logout", () => { - it("clears the session and renders the logout card with the sign-in follow-up", async () => { - vi.mocked(performLogout).mockResolvedValue(undefined); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); + it("succeeds as a no-op under an env override with no stored sessions", async () => { + const cli = makeCli({ environmentToken: tokenFor("ws_env") }); - const result = await makeCli().run(["auth", "logout"], { - isTty: { stdout: true }, - }); + const result = await cli.run(["auth", "logout", "--json"]); expect(result.exitCode).toBe(0); - expect(vi.mocked(performLogout)).toHaveBeenCalledTimes(1); - expect(result.stderr).toContain("Clearing the current CLI session."); - expect(result.stderr).toContain("Session removed from local CLI state."); - expect(result.presented?.presentation.next).toEqual([ - { - kind: "run-command", - label: "Sign in", - command: "prisma-cli auth login", - }, - ]); - expect(result.presented?.presentation.stdout).toEqual([ - "status: signed out", - ]); + expect(resultOf(result)).toEqual({ endedCount: 0, workspaceIds: [] }); }); +}); - it("maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID, exit 2", async () => { - vi.mocked(performLogout).mockResolvedValue(undefined); - vi.mocked(readAuthState).mockRejectedValue(new EmptyServiceTokenError()); +describe("auth whoami", () => { + it("reports signed out with exit 0 and no manager capability", async () => { + expect(authWhoamiCommand.managesCredentials).toBe(false); + expect(authWhoamiCommand.needs.credentials).toBe(false); - const result = await makeCli().run(["auth", "logout", "--json"]); + const result = await makeCli().run(["auth", "whoami", "--json"]); - expect(result.exitCode).toBe(2); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: false, - commandId: "auth.logout", - error: { - code: "AUTH.CONFIG_INVALID", - summary: "Authentication configuration is invalid", - }, + expect(result.exitCode).toBe(0); + expect(resultOf(result)).toEqual({ + authenticated: false, + workspace: null, + user: null, + source: null, + expiresAt: null, }); }); - it("carries the post-logout auth state as the json envelope result", async () => { - vi.mocked(performLogout).mockResolvedValue(undefined); - vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); + it("reports the pinned session offline, with no identity to show", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + }); - const result = await makeCli().run(["auth", "logout", "--json"]); + const result = await cli.run(["auth", "whoami", "--json"]); expect(result.exitCode).toBe(0); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: true, - commandId: "auth.logout", - result: SIGNED_OUT, + expect(resultOf(result)).toMatchObject({ + authenticated: true, + workspace: { id: "ws_1", name: "Acme Inc" }, + user: null, + source: "stored", }); }); - it("--workspace runs the shared workspace-logout operation with its presentation", async () => { - vi.mocked(logoutAuthWorkspace).mockResolvedValue({ - workspace: { id: "ws_2", name: "Globex" }, - wasActive: false, - activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + it("enriches the identity from the management API when online", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + client: apiReturning({ + data: { + user: { id: "usr_456", email: "bob@example.com", name: "Bob" }, + }, + }), }); - const result = await makeCli().run( - ["auth", "logout", "--workspace", "Globex"], - { isTty: { stdout: true } }, - ); + const result = await cli.run(["auth", "whoami", "--json"]); - expect(result.exitCode).toBe(0); - expect(vi.mocked(performLogout)).not.toHaveBeenCalled(); - expect(vi.mocked(logoutAuthWorkspace)).toHaveBeenCalledWith( - expect.anything(), - "Globex", - ); - expect(result.stderr).toContain( - "Removing a local OAuth workspace session.", - ); - expect(result.stderr).toContain("Removed workspace session."); - expect(result.presented?.presentation.stdout).toEqual([ - "workspace: Globex", - "active: Acme Inc", - ]); - expect(result.presented?.presentation.next).toEqual([ - { - kind: "run-command", - label: "List authenticated workspaces", - command: "prisma-cli auth workspace list", - }, - ]); + expect(resultOf(result)).toMatchObject({ + user: { id: "usr_456", email: "bob@example.com", name: "Bob" }, + }); }); - it("--workspace reports the mounted command id auth.logout in json mode", async () => { - vi.mocked(logoutAuthWorkspace).mockResolvedValue({ - workspace: { id: "ws_2", name: "Globex" }, - wasActive: false, - activeWorkspace: null, + it("notes the env override and reads its identity from the token's claims", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + environmentToken: tokenFor("ws_env", { + sub: "usr_env", + email: "ci@example.com", + }), }); - const result = await makeCli().run([ - "auth", - "logout", - "--workspace", - "ws_2", - "--json", - ]); + const result = await cli.run(["auth", "whoami"], { + isTty: { stdout: true }, + }); expect(result.exitCode).toBe(0); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: true, - commandId: "auth.logout", - }); + expect(result.stdout).toContain("workspace: ws_env"); + expect(result.stdout).toContain("source: PRISMA_SERVICE_TOKEN"); + expect(result.stderr).toContain( + "PRISMA_SERVICE_TOKEN supplies the session in force", + ); }); +}); - it("--workspace maps a missing workspace to AUTH.WORKSPACE_NOT_AUTHENTICATED, exit 2", async () => { - vi.mocked(logoutAuthWorkspace).mockRejectedValue( - workspaceNotAuthenticatedError("nope"), - ); +describe("sessions held with none current", () => { + const needsCredentials = defineCommand({ + help: { summary: "Requires a signed-in session" }, + needs: { credentials: true }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: null }, + { human: () => [{ kind: "summary", tone: "ok", text: "ran" }] }, + ), + ), + }); - const result = await makeCli().run([ - "auth", - "logout", - "--workspace", - "nope", - "--json", - ]); + const touchesApi = defineCommand({ + help: { summary: "Touches ctx.api" }, + handler: async (_args, ctx) => { + await ctx.api.GET("/v1/me", {}); + return ok( + ctx.present( + { data: null }, + { human: () => [{ kind: "summary", tone: "ok", text: "ran" }] }, + ), + ); + }, + }); - expect(result.exitCode).toBe(2); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: false, - commandId: "auth.logout", - error: { - code: "AUTH.WORKSPACE_NOT_AUTHENTICATED", - summary: "Workspace is not authenticated", - why: 'No stored OAuth session matched "nope".', - meta: { workspaceRef: "nope" }, + it("raises one identical error from ctx.session, the needs check, and a bare ctx.api touch", async () => { + const cli = createTestCli({ + commands: { + "auth whoami": authWhoamiCommand, + locked: needsCredentials, + touch: touchesApi, }, + groups: GROUPS, + sessions: [record("ws_1", "Acme Inc")], + now: () => new Date(0), }); - }); -}); -describe("prisma-v8 auth workspace list", () => { - it("renders the workspace table without the source column for a single source", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); - - const result = await makeCli().run(["auth", "workspace", "list"], { - isTty: { stdout: true }, - }); + const errors = []; + for (const argv of [["auth", "whoami"], ["locked"], ["touch"]]) { + const run = await cli.run([...argv, "--json"]); + expect(run.exitCode).toBe(2); + errors.push(errorOf(run)); + } - expect(result.exitCode).toBe(0); - const table = result.presented?.presentation.human.find( - (block) => block.kind === "table", - ); - expect(table).toEqual({ - kind: "table", - columns: ["name", "id", "status"], - rows: [ - ["Acme Inc", "ws_1", "active"], - ["Globex", "ws_2", ""], - ], + expect(errors[0]).toMatchObject({ + code: "CLI.CREDENTIALS_REQUIRED", + summary: "No workspace session is current.", + why: "You have workspace sessions but none is current.", }); - expect(result.presented?.presentation.stdout).toEqual([ - "Acme Inc ws_1 active", - "Globex ws_2", - ]); + expect(errors[1]).toEqual(errors[0]); + expect(errors[2]).toEqual(errors[0]); }); +}); - it("adds the source column only when sources are mixed", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(MIXED_SOURCES); +describe("auth workspace list", () => { + it("lists the sessions with the current one marked, nameless rows by id", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc"), record("ws_2", undefined)], + currentWorkspaceId: "ws_2", + }); - const result = await makeCli().run(["auth", "workspace", "list"], { + const result = await cli.run(["auth", "workspace", "list"], { isTty: { stdout: true }, }); expect(result.exitCode).toBe(0); - const table = result.presented?.presentation.human.find( - (block) => block.kind === "table", - ); - expect(table).toEqual({ - kind: "table", - columns: ["name", "id", "source", "status"], - rows: [ - ["Token WS", "ws_tok", "service token", "active"], - ["Acme Inc", "ws_1", "OAuth", ""], - ], - }); + expect(result.stdout).toBe("Acme Inc ws_1\nws_2 ws_2 current\n"); }); - it("serializes the ported list shape in json mode", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); + it("serializes the sessions and the current marker for json", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + }); - const result = await makeCli().run(["auth", "workspace", "list", "--json"]); + const result = await cli.run(["auth", "workspace", "list", "--json"]); - expect(result.exitCode).toBe(0); - const frame = resultFrame(result.json); - if (!frame.envelope.ok) { - throw new Error("expected a completed envelope"); - } - expect(frame.envelope.result).toEqual({ + expect(resultOf(result)).toEqual({ context: { - authSource: "oauth", - activeWorkspaceId: "ws_1", - activeWorkspaceName: "Acme Inc", + environmentSessionInForce: false, + currentWorkspaceId: "ws_1", }, items: [ { - id: "ws_1", - name: "Acme Inc", - status: "active", - source: "oauth", - switchable: true, - credentialWorkspaceId: "cred_1", - lastSeenAt: "2026-01-01T00:00:00.000Z", - }, - { - id: "ws_2", - name: "Globex", - status: null, - source: "oauth", - switchable: true, - credentialWorkspaceId: "cred_2", - lastSeenAt: null, + workspaceId: "ws_1", + workspaceName: "Acme Inc", + current: true, + expiresAt: null, }, ], - count: 2, + count: 1, }); }); - it("maps an empty PRISMA_SERVICE_TOKEN to AUTH.CONFIG_INVALID, exit 2 (parity with whoami/login/logout)", async () => { - vi.mocked(listAuthWorkspaces).mockRejectedValue( - new EmptyServiceTokenError(), - ); + it("states that the env session is in force", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + environmentToken: tokenFor("ws_env"), + }); - const result = await makeCli().run(["auth", "workspace", "list", "--json"]); + const result = await cli.run(["auth", "workspace", "list", "--json"]); - expect(result.exitCode).toBe(2); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: false, - commandId: "auth.workspace.list", - error: { - code: "AUTH.CONFIG_INVALID", - summary: "Authentication configuration is invalid", - }, + expect(resultOf(result)).toMatchObject({ + context: { environmentSessionInForce: true, currentWorkspaceId: "ws_1" }, }); }); - it("shows the empty state with the sign-in follow-up while signed out", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(EMPTY_LIST); - - const result = await makeCli().run(["auth", "workspace", "list"], { - isTty: { stdout: true }, - }); + it("offers sign-in when there are no sessions", async () => { + const result = await makeCli().run(["auth", "workspace", "list", "--json"]); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("No local OAuth workspaces found.\n"); - expect(result.stderr).toContain("→ Sign in: prisma-cli auth login\n"); - expect(result.stdout).toBe(""); + expect(result.presented?.presentation.next).toEqual([ + { + kind: "run-command", + label: "Sign in", + command: "prisma-cli auth login", + }, + ]); }); }); -describe("prisma-v8 auth workspace use", () => { - it("switches by explicit ref and renders the mutation card", async () => { - vi.mocked(switchAuthWorkspace).mockResolvedValue({ - previousWorkspace: { id: "ws_1", name: "Acme Inc" }, - workspace: { id: "ws_2", name: "Globex" }, - }); +describe("auth workspace use", () => { + const twoSessions = [record("ws_1", "Acme Inc"), record("ws_2", "Globex")]; - const result = await makeCli().run(["auth", "workspace", "use", " ws_2 "], { - isTty: { stdout: true }, - }); + it("selects by workspace id", async () => { + const cli = makeCli({ sessions: twoSessions, currentWorkspaceId: "ws_1" }); - expect(result.exitCode).toBe(0); - expect(vi.mocked(switchAuthWorkspace)).toHaveBeenCalledWith( - expect.anything(), + const result = await cli.run([ + "auth", + "workspace", + "use", "ws_2", - ); - expect(result.stderr).toContain("Switching the local CLI workspace."); - expect(result.stderr).toContain("Local OAuth workspace selection updated."); - expect(result.presented?.presentation.stdout).toEqual([ - "previous: Acme Inc", - "workspace: Globex", + "--json", ]); - expect( - result.presented?.presentation.next.map((action) => - action.kind === "run-command" ? action.command : action.label, - ), - ).toEqual(["prisma-cli auth whoami", "prisma-cli project list"]); - }); - it("carries the raw use result in the json envelope", async () => { - vi.mocked(switchAuthWorkspace).mockResolvedValue({ - previousWorkspace: null, + expect(result.exitCode).toBe(0); + expect(resultOf(result)).toEqual({ workspace: { id: "ws_2", name: "Globex" }, + previousWorkspaceId: "ws_1", }); + expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_2"); + }); - const result = await makeCli().run([ + it("selects by workspace name, case-insensitively", async () => { + const cli = makeCli({ sessions: twoSessions, currentWorkspaceId: "ws_1" }); + + const result = await cli.run([ "auth", "workspace", "use", - "ws_2", + "globex", "--json", ]); expect(result.exitCode).toBe(0); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: true, - commandId: "auth.workspace.use", - result: { - previousWorkspace: null, - workspace: { id: "ws_2", name: "Globex" }, - }, - }); + expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_2"); }); - it("maps an ambiguous name to AUTH.WORKSPACE_AMBIGUOUS with the match list, exit 2", async () => { - vi.mocked(switchAuthWorkspace).mockRejectedValue( - workspaceAmbiguousError("Acme Inc", [ - { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, - { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, - ]), - ); + it("refuses an ambiguous name, listing the workspaces that matched", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc"), record("ws_9", "Acme Inc")], + currentWorkspaceId: "ws_1", + }); - const result = await makeCli().run([ + const result = await cli.run([ "auth", "workspace", "use", @@ -720,232 +518,227 @@ describe("prisma-v8 auth workspace use", () => { ]); expect(result.exitCode).toBe(2); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: false, - commandId: "auth.workspace.use", - error: { - code: "AUTH.WORKSPACE_AMBIGUOUS", - summary: "Workspace name is ambiguous", - meta: { - workspaceRef: "Acme Inc", - matches: [ - { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, - { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, - ], + expect(errorOf(result)).toMatchObject({ + code: "AUTH.WORKSPACE_AMBIGUOUS", + meta: { workspaceIds: ["ws_1", "ws_9"] }, + }); + expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_1"); + }); + + it("never opens a browser for a workspace it has no session for", async () => { + const openUrl = vi.fn(); + const cli = makeCli({ sessions: twoSessions, openUrl }); + + const result = await cli.run([ + "auth", + "workspace", + "use", + "ws_missing", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toMatchObject({ + code: "AUTH.NO_SESSION_FOR_WORKSPACE", + summary: "You have no session for workspace 'ws_missing'.", + nextActions: [ + { + kind: "run-command", + label: "Sign in and pick 'ws_missing' in the browser", + command: "prisma auth login", }, - nextActions: [ - { - kind: "user-choice", - label: - "Run prisma-cli auth workspace list and switch by workspace id.", - }, - ], - }, + ], }); + expect(openUrl).not.toHaveBeenCalled(); }); - it("fails selection with AUTH.WORKSPACE_SWITCH_UNAVAILABLE when PRISMA_SERVICE_TOKEN is set", async () => { - const result = await makeCli().run(["auth", "workspace", "use"], { - isTty: { stdout: true }, - env: { PRISMA_SERVICE_TOKEN: "svc_token" }, + it("refuses to switch under an env override", async () => { + const cli = makeCli({ + sessions: twoSessions, + currentWorkspaceId: "ws_1", + environmentToken: tokenFor("ws_env"), }); + const result = await cli.run([ + "auth", + "workspace", + "use", + "ws_2", + "--json", + ]); + expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - "✖ [AUTH.WORKSPACE_SWITCH_UNAVAILABLE] Workspace switching is unavailable\n", - ); - expect(vi.mocked(listAuthWorkspaces)).not.toHaveBeenCalled(); + expect(errorOf(result).code).toBe("AUTH.ENV_SESSION_IN_FORCE"); + expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_1"); }); - it("fails with AUTH.USAGE_ERROR when no switchable workspaces exist", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(EMPTY_LIST); - - const result = await makeCli().run(["auth", "workspace", "use"], { - isTty: { stdout: true }, - }); + it("reports having nothing to select when no sessions are held", async () => { + const result = await makeCli().run([ + "auth", + "workspace", + "use", + "ws_1", + "--json", + ]); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - "✖ [AUTH.USAGE_ERROR] No authenticated workspaces\n", - ); + expect(errorOf(result).code).toBe("AUTH.NO_WORKSPACE_SESSIONS"); }); +}); - it("auto-selects the only switchable workspace without prompting", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue({ - ...TWO_OAUTH_WORKSPACES, - workspaces: [TWO_OAUTH_WORKSPACES.workspaces[0]], - }); - vi.mocked(switchAuthWorkspace).mockResolvedValue({ - previousWorkspace: null, - workspace: { id: "ws_1", name: "Acme Inc" }, +describe("auth workspace logout", () => { + it("ends the named session and prints the workspace it ended", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc"), record("ws_2", "Globex")], + currentWorkspaceId: "ws_2", }); - const result = await makeCli().run(["auth", "workspace", "use"], { - isTty: { stdout: true, stdin: true }, - }); + const result = await cli.run([ + "auth", + "workspace", + "logout", + "Acme Inc", + "--json", + ]); expect(result.exitCode).toBe(0); - expect(vi.mocked(switchAuthWorkspace)).toHaveBeenCalledWith( - expect.anything(), - "ws_1", - ); + expect(resultOf(result)).toEqual({ + workspace: { id: "ws_1", name: "Acme Inc" }, + wasCurrent: false, + }); + expect( + cli.credentialManager?.state().sessions.map((s) => s.workspaceId), + ).toEqual(["ws_2"]); }); - it("prompts a select over the workspaces and switches to the answer", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); - vi.mocked(switchAuthWorkspace).mockResolvedValue({ - previousWorkspace: { id: "ws_1", name: "Acme Inc" }, - workspace: { id: "ws_2", name: "Globex" }, + it("clears the current marker when the ended session was current", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", }); - const result = await makeCli().run(["auth", "workspace", "use"], { - isTty: { stdout: true, stdin: true }, - answers: ["ws_2"], - }); + const result = await cli.run([ + "auth", + "workspace", + "logout", + "ws_1", + "--json", + ]); - expect(result.exitCode).toBe(0); - expect(vi.mocked(switchAuthWorkspace)).toHaveBeenCalledWith( - expect.anything(), - "ws_2", - ); + expect(resultOf(result)).toMatchObject({ wasCurrent: true }); + expect(cli.credentialManager?.state()).toEqual({ + sessions: [], + currentWorkspaceId: null, + }); }); - it("fails an invalid select answer with CLI.PROMPT_INVALID, exit 2", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); - - const result = await makeCli().run(["auth", "workspace", "use"], { - isTty: { stdout: true, stdin: true }, - answers: ["not-a-workspace"], - }); + it("raises the ruled error for a workspace it holds no session for", async () => { + const result = await makeCli({ sessions: [record("ws_1", "Acme")] }).run([ + "auth", + "workspace", + "logout", + "nope", + "--json", + ]); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("[CLI.PROMPT_INVALID]"); - expect(vi.mocked(switchAuthWorkspace)).not.toHaveBeenCalled(); + expect(errorOf(result).code).toBe("AUTH.NO_SESSION_FOR_WORKSPACE"); }); - it("fails non-interactively with the engine's structural prompt error, exit 2", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue(TWO_OAUTH_WORKSPACES); - - const result = await makeCli().run(["auth", "workspace", "use"], { - isTty: { stdout: true, stdin: false }, + it("refuses under an env override", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + environmentToken: tokenFor("ws_env"), }); + const result = await cli.run([ + "auth", + "workspace", + "logout", + "ws_1", + "--json", + ]); + expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("[CLI.PROMPT_REQUIRED]"); - expect(vi.mocked(switchAuthWorkspace)).not.toHaveBeenCalled(); + expect(errorOf(result).code).toBe("AUTH.ENV_SESSION_IN_FORCE"); + expect(cli.credentialManager?.state().sessions).toHaveLength(1); }); }); -describe("prisma-v8 auth workspace logout", () => { - it("removes a non-active session and keeps the active workspace", async () => { - vi.mocked(logoutAuthWorkspace).mockResolvedValue({ - workspace: { id: "ws_2", name: "Globex" }, - wasActive: false, - activeWorkspace: { id: "ws_1", name: "Acme Inc" }, - }); - - const result = await makeCli().run( - ["auth", "workspace", "logout", "ws_2"], - { isTty: { stdout: true } }, - ); +describe("the env session never refreshes", () => { + let server: Server | undefined; + const paths: string[] = []; - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "Removing a local OAuth workspace session.", - ); - expect(result.stderr).toContain("Removed workspace session."); - expect(result.presented?.presentation.stdout).toEqual([ - "workspace: Globex", - "active: Acme Inc", - ]); - expect(result.presented?.presentation.next).toEqual([ - { - kind: "run-command", - label: "List authenticated workspaces", - command: "prisma-cli auth workspace list", - }, - ]); + afterEach(async () => { + const running = server; + server = undefined; + paths.length = 0; + if (running !== undefined) { + await new Promise((resolve) => running.close(() => resolve())); + } }); - it("reports the was-active removal with no auto-fallthrough and the use follow-up", async () => { - vi.mocked(logoutAuthWorkspace).mockResolvedValue({ - workspace: { id: "ws_1", name: "Acme Inc" }, - wasActive: true, - activeWorkspace: null, + it("does not reach the token endpoint when the API answers 401", async () => { + server = createServer((request, response) => { + paths.push(request.url ?? ""); + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "unauthorized" })); }); + await new Promise((resolve) => { + server?.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + const baseUrl = `http://127.0.0.1:${port}`; - const result = await makeCli().run( - ["auth", "workspace", "logout", "ws_1"], - { isTty: { stdout: true } }, - ); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "✔ Removed active workspace session; no replacement workspace was selected.\n", - ); - expect(result.stderr).toContain("active: none\n"); - expect(result.stderr).toContain( - "→ Select a replacement workspace: prisma-cli auth workspace use \n", - ); - }); - - it("carries the raw logout result in the json envelope", async () => { - vi.mocked(logoutAuthWorkspace).mockResolvedValue({ - workspace: { id: "ws_2", name: "Globex" }, - wasActive: false, - activeWorkspace: { id: "ws_1", name: "Acme Inc" }, + const cli = createTestCli({ + commands: COMMANDS, + groups: GROUPS, + environmentToken: tokenFor("ws_env", { sub: "usr_env" }), + managementApiClientConfig: { + clientId: "test-client-id", + redirectUri: `${baseUrl}/auth/callback`, + apiBaseUrl: baseUrl, + authBaseUrl: baseUrl, + }, + now: () => new Date(0), }); - const result = await makeCli().run([ - "auth", - "workspace", - "logout", - "ws_2", - "--json", - ]); + const result = await cli.run(["auth", "whoami", "--json"]); expect(result.exitCode).toBe(0); - const frame = resultFrame(result.json); - expect(frame.envelope).toMatchObject({ - ok: true, - commandId: "auth.workspace.logout", - result: { - workspace: { id: "ws_2", name: "Globex" }, - wasActive: false, - activeWorkspace: { id: "ws_1", name: "Acme Inc" }, - }, - }); + expect(paths).toContain("/v1/me"); + expect(paths.some((path) => path.includes("token"))).toBe(false); }); +}); - it("maps an ambiguous name to AUTH.WORKSPACE_AMBIGUOUS, exit 2", async () => { - vi.mocked(logoutAuthWorkspace).mockRejectedValue( - workspaceAmbiguousError("Acme Inc", [ - { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, - { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, - ]), - ); +describe("session shapes the commands hand back", () => { + it("never lets token material reach the output", async () => { + const secret = "refresh_ws_1"; + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + }); - const result = await makeCli().run( - ["auth", "workspace", "logout", "Acme Inc"], - { isTty: { stdout: true } }, - ); + const runs = [ + await cli.run(["auth", "workspace", "list", "--json"]), + await cli.run(["auth", "whoami", "--json"]), + ]; - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - "✖ [AUTH.WORKSPACE_AMBIGUOUS] Workspace name is ambiguous\n", - ); + for (const run of runs) { + expect(run.stdout).not.toContain(secret); + expect(run.stderr).not.toContain(secret); + } }); - it("fails a blank workspace ref with AUTH.USAGE_ERROR, exit 2", async () => { - const result = await makeCli().run(["auth", "workspace", "logout", " "], { - isTty: { stdout: true }, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - "✖ [AUTH.USAGE_ERROR] Workspace required\n", - ); - expect(vi.mocked(logoutAuthWorkspace)).not.toHaveBeenCalled(); + it("exposes no token on the Session shape the commands see", () => { + const session: Session = { + workspaceId: "ws_1", + workspaceName: "Acme Inc", + expiresAt: undefined, + source: "stored", + current: true, + }; + expect(Object.keys(session)).not.toContain("token"); }); }); diff --git a/packages/cli/tests/v8-golden-rendering.test.ts b/packages/cli/tests/v8-golden-rendering.test.ts index e921d38c..89f5f8ec 100644 --- a/packages/cli/tests/v8-golden-rendering.test.ts +++ b/packages/cli/tests/v8-golden-rendering.test.ts @@ -7,29 +7,30 @@ * re-pinned. The S1 whoami byte pins in v8-whoami.test.ts remain the * whoami-specific baseline. */ -import { createTestCli } from "@prisma/cli-engine/testing"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - import { - listAuthWorkspaces, - logoutAuthWorkspace, - performLogout, - readAuthState, -} from "../src/auth"; -import { workspaceAmbiguousError } from "../src/shell/errors"; + createTestCli, + mintTestJwt, + type TestSessionRecord, +} from "@prisma/cli-engine/testing"; +import { describe, expect, it } from "vitest"; + import { authLogoutCommand } from "../src/v8/auth/logout"; import { authWorkspaceListCommand } from "../src/v8/auth/workspace-list"; import { authWorkspaceLogoutCommand } from "../src/v8/auth/workspace-logout"; -vi.mock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), - performLogout: vi.fn(), - readAuthState: vi.fn(), - listAuthWorkspaces: vi.fn(), - logoutAuthWorkspace: vi.fn(), -})); +function record(workspaceId: string, workspaceName: string): TestSessionRecord { + return { + workspaceId, + workspaceName, + credential: { + token: mintTestJwt({ workspace_id: workspaceId }), + refreshToken: `refresh_${workspaceId}`, + expiresAt: undefined, + }, + }; +} -function makeCli() { +function makeCli(sessions: readonly TestSessionRecord[], current?: string) { return createTestCli({ commands: { "auth logout": authLogoutCommand, @@ -40,101 +41,58 @@ function makeCli() { auth: { brief: "Manage local authentication for the CLI" }, "auth workspace": { brief: "Manage local workspace sessions" }, }, + sessions, + currentWorkspaceId: current, now: () => new Date(0), }); } -beforeEach(() => { - vi.mocked(performLogout).mockReset(); - vi.mocked(readAuthState).mockReset(); - vi.mocked(listAuthWorkspaces).mockReset(); - vi.mocked(logoutAuthWorkspace).mockReset(); -}); - describe("v8 golden rendering", () => { it("human card (representative: auth logout)", async () => { - vi.mocked(performLogout).mockResolvedValue(undefined); - vi.mocked(readAuthState).mockResolvedValue({ - authenticated: false, - provider: null, - user: null, - workspace: null, - credential: null, - }); - - const result = await makeCli().run(["auth", "logout"], { - isTty: { stdout: true }, - }); + const result = await makeCli([record("ws_1", "Acme Inc")], "ws_1").run( + ["auth", "logout"], + { isTty: { stdout: true } }, + ); expect(result.exitCode).toBe(0); expect(result.stderr).toBe( "ℹ Clearing the current CLI session.\n" + - "session: local CLI state\n" + - "✔ Session removed from local CLI state.\n" + + "ended: 1\n" + + "✔ Ended 1 workspace session.\n" + "→ Sign in: prisma-cli auth login\n", ); - expect(result.stdout).toBe("status: signed out\n"); + expect(result.stdout).toBe("ended: 1\n"); }); it("table (representative: auth workspace list)", async () => { - vi.mocked(listAuthWorkspaces).mockResolvedValue({ - authSource: "oauth", - activeWorkspace: { id: "ws_1", name: "Acme Inc" }, - workspaces: [ - { - id: "ws_1", - name: "Acme Inc", - credentialWorkspaceId: "cred_1", - active: true, - source: "oauth", - switchable: true, - lastSeenAt: "2026-01-01T00:00:00.000Z", - }, - { - id: "ws_2", - name: "Globex", - credentialWorkspaceId: "cred_2", - active: false, - source: "oauth", - switchable: true, - lastSeenAt: null, - }, - ], - }); - - const result = await makeCli().run(["auth", "workspace", "list"], { - isTty: { stdout: true }, - }); + const result = await makeCli( + [record("ws_1", "Acme Inc"), record("ws_2", "Globex")], + "ws_1", + ).run(["auth", "workspace", "list"], { isTty: { stdout: true } }); expect(result.exitCode).toBe(0); expect(result.stderr).toBe( - "ℹ Listing authenticated workspaces on this machine.\n" + - "auth source: local OAuth\n" + + "ℹ Listing your workspace sessions on this machine.\n" + "name id status\n" + - "Acme Inc ws_1 active\n" + + "Acme Inc ws_1 current\n" + "Globex ws_2 \n", ); - expect(result.stdout).toBe("Acme Inc ws_1 active\nGlobex ws_2\n"); + expect(result.stdout).toBe("Acme Inc ws_1 current\nGlobex ws_2\n"); }); it("error (representative: AUTH.WORKSPACE_AMBIGUOUS)", async () => { - vi.mocked(logoutAuthWorkspace).mockRejectedValue( - workspaceAmbiguousError("Acme Inc", [ - { id: "ws_1", name: "Acme Inc", credentialWorkspaceId: "cred_1" }, - { id: "ws_9", name: "Acme Inc", credentialWorkspaceId: "cred_9" }, - ]), - ); - - const result = await makeCli().run( - ["auth", "workspace", "logout", "Acme Inc"], - { isTty: { stdout: true } }, - ); + const result = await makeCli( + [record("ws_1", "Acme Inc"), record("ws_9", "Acme Inc")], + "ws_1", + ).run(["auth", "workspace", "logout", "Acme Inc"], { + isTty: { stdout: true }, + }); expect(result.exitCode).toBe(2); expect(result.stderr).toBe( - "✖ [AUTH.WORKSPACE_AMBIGUOUS] Workspace name is ambiguous\n" + - ' why: Multiple authenticated workspaces matched "Acme Inc".\n' + - "→ Run prisma-cli auth workspace list and switch by workspace id.\n", + "✖ [AUTH.WORKSPACE_AMBIGUOUS] More than one workspace session is named 'Acme Inc'.\n" + + " why: Matching workspaces: ws_1, ws_9.\n" + + "→ List your workspace sessions and pass a workspace id: prisma-cli auth workspace list\n", ); expect(result.stdout).toBe(""); }); diff --git a/packages/cli/tests/v8-whoami.test.ts b/packages/cli/tests/v8-whoami.test.ts index 247ed5d6..1727cc67 100644 --- a/packages/cli/tests/v8-whoami.test.ts +++ b/packages/cli/tests/v8-whoami.test.ts @@ -1,35 +1,46 @@ -import { defineCommand } from "@prisma/cli-engine"; +/** + * The whoami-specific byte baseline (S1), now over the credential + * manager: the card, the json stream, and the engine's early + * credentials failure. + */ +import { defineCommand, type ManagementApiClient } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { createTestCli } from "@prisma/cli-engine/testing"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createTestCli, + mintTestJwt, + type TestSessionRecord, +} from "@prisma/cli-engine/testing"; +import { describe, expect, it } from "vitest"; -import { EmptyServiceTokenError, readAuthState } from "../src/auth"; -import type { AuthStateResult } from "../src/types/auth"; import { authWhoamiCommand } from "../src/v8/auth/whoami"; -vi.mock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), - readAuthState: vi.fn(), -})); +const EPOCH = () => new Date(0); +const T0 = "1970-01-01T00:00:00.000Z"; -const SIGNED_OUT: AuthStateResult = { - authenticated: false, - provider: null, - user: null, - workspace: null, - credential: null, +const SESSION: TestSessionRecord = { + workspaceId: "ws_123", + workspaceName: "Acme Inc", + credential: { + token: mintTestJwt({ workspace_id: "ws_123", sub: "usr_456" }), + refreshToken: "refresh_ws_123", + expiresAt: undefined, + }, }; -const SIGNED_IN: AuthStateResult = { - authenticated: true, - provider: "github", - user: { id: "usr_456", email: "bob@example.com", name: "Bob Example" }, - workspace: { id: "ws_123", name: "Acme Inc" }, - credential: { type: "oauth", id: null, name: null }, -}; +const OFFLINE_API = { + GET: async () => { + throw new Error("offline"); + }, +} as unknown as ManagementApiClient; -const EPOCH = () => new Date(0); -const T0 = "1970-01-01T00:00:00.000Z"; +const IDENTIFIED_API = { + GET: async () => ({ + data: { + data: { user: { id: "usr_456", email: "bob@example.com", name: "Bob" } }, + }, + response: { status: 200 }, + }), +} as unknown as ManagementApiClient; const requiresCredentials = defineCommand({ help: { summary: "Requires a signed-in session" }, @@ -43,26 +54,34 @@ const requiresCredentials = defineCommand({ needs: { credentials: true }, }); -function makeCli(options?: { credentials?: { token: string } }) { +function makeCli(options?: { + readonly sessions?: readonly TestSessionRecord[]; + readonly currentWorkspaceId?: string; + readonly client?: ManagementApiClient; +}) { return createTestCli({ commands: { "auth whoami": authWhoamiCommand, "auth locked": requiresCredentials, }, groups: { auth: { brief: "Manage local authentication for the CLI" } }, - credentials: options?.credentials, + sessions: options?.sessions ?? [], + currentWorkspaceId: options?.currentWorkspaceId, + managementApi: { client: options?.client ?? OFFLINE_API }, now: EPOCH, }); } -beforeEach(() => { - vi.mocked(readAuthState).mockReset(); -}); +function signedInCli() { + return makeCli({ + sessions: [SESSION], + currentWorkspaceId: "ws_123", + client: IDENTIFIED_API, + }); +} describe("prisma-v8 auth whoami", () => { it("renders the signed-out human card on stderr and the payload lines on stdout, exit 0", async () => { - vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); - const result = await makeCli().run(["auth", "whoami"], { isTty: { stdout: true }, }); @@ -77,39 +96,31 @@ describe("prisma-v8 auth whoami", () => { }); it("renders the signed-in human output: card on stderr, payload on stdout", async () => { - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - - const result = await makeCli().run(["auth", "whoami"], { + const result = await signedInCli().run(["auth", "whoami"], { isTty: { stdout: true }, }); expect(result.exitCode).toBe(0); expect(result.stdout).toBe( - "status: signed in\n" + - "user: bob@example.com\n" + - "provider: GitHub\n" + - "workspace: Acme Inc\n", + "status: signed in\nuser: bob@example.com\nworkspace: Acme Inc\n", ); expect(result.stderr).toBe( "ℹ Showing the current authenticated identity.\n" + "status: signed in\n" + "user: bob@example.com\n" + - "provider: GitHub\n" + "workspace: Acme Inc\n", ); }); it("emits the json stream with a terminal completed envelope", async () => { - vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); - const result = await makeCli().run(["auth", "whoami", "--json"]); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toBe( `{"kind":"result","envelope":{"ok":true,"commandId":"auth.whoami",` + - `"result":{"authenticated":false,"provider":null,"user":null,` + - `"workspace":null,"credential":null},"exitCode":0,"diagnostics":[],` + + `"result":{"authenticated":false,"workspace":null,"user":null,` + + `"source":null,"expiresAt":null},"exitCode":0,"diagnostics":[],` + `"nextActions":[{"kind":"run-command","label":"Sign in",` + `"command":"prisma-cli auth login"}]},"commandId":"auth.whoami",` + `"timestamp":"${T0}"}\n`, @@ -117,10 +128,8 @@ describe("prisma-v8 auth whoami", () => { expect(result.json).toHaveLength(1); }); - it("carries the signed-in auth state as the envelope result", async () => { - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - - const result = await makeCli().run(["auth", "whoami", "--json"]); + it("carries the signed-in session as the envelope result", async () => { + const result = await signedInCli().run(["auth", "whoami", "--json"]); expect(result.exitCode).toBe(0); const frame = result.json[0]; @@ -130,7 +139,13 @@ describe("prisma-v8 auth whoami", () => { expect(frame.envelope).toEqual({ ok: true, commandId: "auth.whoami", - result: SIGNED_IN, + result: { + authenticated: true, + workspace: { id: "ws_123", name: "Acme Inc" }, + user: { id: "usr_456", email: "bob@example.com", name: "Bob" }, + source: "stored", + expiresAt: null, + }, exitCode: 0, diagnostics: [], nextActions: [], @@ -138,93 +153,21 @@ describe("prisma-v8 auth whoami", () => { }); it("renders the unchanged presentation under --quiet (a log-level alias)", async () => { - vi.mocked(readAuthState).mockResolvedValue(SIGNED_IN); - - const result = await makeCli().run(["auth", "whoami", "--quiet"], { + const result = await signedInCli().run(["auth", "whoami", "--quiet"], { isTty: { stdout: true }, }); expect(result.exitCode).toBe(0); expect(result.stdout).toBe( - "status: signed in\n" + - "user: bob@example.com\n" + - "provider: GitHub\n" + - "workspace: Acme Inc\n", + "status: signed in\nuser: bob@example.com\nworkspace: Acme Inc\n", ); expect(result.stderr).toBe( "ℹ Showing the current authenticated identity.\n" + "status: signed in\n" + "user: bob@example.com\n" + - "provider: GitHub\n" + "workspace: Acme Inc\n", ); }); - - it("errors with exit 2 when PRISMA_SERVICE_TOKEN is set but empty", async () => { - vi.mocked(readAuthState).mockRejectedValue(new EmptyServiceTokenError()); - - const result = await makeCli().run(["auth", "whoami"], { - isTty: { stdout: true }, - }); - - expect(result.exitCode).toBe(2); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "✖ [AUTH.CONFIG_INVALID] Authentication configuration is invalid\n" + - " why: PRISMA_SERVICE_TOKEN is set but empty. Provide a valid token or unset the variable.\n" + - "→ Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.\n", - ); - }); - - it("emits the errored envelope on the json stream", async () => { - vi.mocked(readAuthState).mockRejectedValue(new EmptyServiceTokenError()); - - const result = await makeCli().run(["auth", "whoami", "--json"]); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - const frame = result.json[0]; - if (frame.kind !== "result") { - throw new Error("expected a result frame"); - } - expect(frame.envelope).toEqual({ - ok: false, - commandId: "auth.whoami", - error: { - code: "AUTH.CONFIG_INVALID", - severity: "error", - summary: "Authentication configuration is invalid", - why: "PRISMA_SERVICE_TOKEN is set but empty. Provide a valid token or unset the variable.", - nextActions: [ - { - kind: "user-choice", - label: - "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", - }, - ], - }, - diagnostics: [], - nextActions: [ - { - kind: "user-choice", - label: - "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", - }, - ], - }); - }); - - it("settles an unexpected operations failure as a bug with exit 1", async () => { - vi.mocked(readAuthState).mockRejectedValue(new Error("disk on fire")); - - const result = await makeCli().run(["auth", "whoami"], { - isTty: { stdout: true }, - }); - - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("✖ [CLI.INTERNAL_ERROR] disk on fire\n"); - }); }); describe("needs.credentials early failure", () => { @@ -241,10 +184,8 @@ describe("needs.credentials early failure", () => { ); }); - it("runs the handler when credentials are present", async () => { - const cli = makeCli({ credentials: { token: "tok_1" } }); - - const result = await cli.run(["auth", "locked"], { + it("runs the handler when a session is current", async () => { + const result = await signedInCli().run(["auth", "locked"], { isTty: { stdout: true }, }); @@ -254,7 +195,6 @@ describe("needs.credentials early failure", () => { }); it("whoami itself completes without credentials (no needs.credentials)", async () => { - vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); expect(authWhoamiCommand.needs.credentials).toBe(false); const result = await makeCli().run(["auth", "whoami", "--json"]); @@ -262,18 +202,3 @@ describe("needs.credentials early failure", () => { expect(result.exitCode).toBe(0); }); }); - -describe("handler env access", () => { - it("reads the environment from ctx.env (the harness run's env), not process.env", async () => { - vi.mocked(readAuthState).mockResolvedValue(SIGNED_OUT); - const env = { PRISMA_SERVICE_TOKEN: "tok_from_ctx" }; - - const result = await makeCli().run(["auth", "whoami"], { - isTty: { stdout: true }, - env, - }); - - expect(result.exitCode).toBe(0); - expect(vi.mocked(readAuthState).mock.calls[0][0]).toBe(env); - }); -}); From 7716e8bdafeeed19f40a8180369ff5e6dc061a0a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 17:51:54 +0200 Subject: [PATCH 44/67] drive(prisma-cli-v8): whoami identity-source accuracy fixes Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/credential-manager-design.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index f326c6c9..94365e49 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -376,8 +376,11 @@ Legacy names, unchanged — the session model makes them honest `createSession(credential, workspaceId-from-claims)`. - `auth logout` — `sessions()` for the count, then `endAllSessions()`; the command reports the count it ended. -- `auth whoami` — `ctx.session()` + claims decode; `ctx.api` - enrichment when online. +- `auth whoami` — `ctx.session()`; identity for an ENV session + comes from decoding the env token (read from `ctx.env`); a stored + session's token is unreachable by construction (Session carries + none, whoami has no manager), so its identity comes from `/v1/me` + when online and offline whoami shows the workspace with no user. - `auth workspace list` — `sessions()`, current marked, nameless rows rendered by id. Under an env override the listing states the env session is in force. From 4b006d10fd3953d0ba9227750a2e87ecc5cc7097 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 18:10:25 +0200 Subject: [PATCH 45/67] fix(cli): partial verification-findings remediation (UNVERIFIED) Implementer was halted mid-work (rate limit): last reported working on finding 14 (worker stderr leak capture) and server teardown. No test suite has been run against this state. See .drive/projects/prisma-cli-v8/assets/briefs/credential-manager-handover.md for the finding list and completion instructions. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-engine/src/execution/api-client.ts | 125 ++++++++- packages/cli-engine/src/execution/debug.ts | 17 ++ packages/cli/src/auth/credential-manager.ts | 39 ++- packages/cli/src/auth/index.ts | 4 + packages/cli/src/auth/service-token.ts | 26 ++ packages/cli/src/v8/auth/login.ts | 11 +- packages/cli/src/v8/auth/whoami.ts | 16 +- packages/cli/src/v8/auth/workspace-list.ts | 4 +- .../credential-manager-processes.test.ts | 199 ++++++++++++-- packages/cli/tests/credential-manager.test.ts | 254 ++++++++++++++++-- .../helpers/credential-manager-worker.ts | 18 ++ packages/cli/tests/v8-auth.test.ts | 72 ++++- 12 files changed, 699 insertions(+), 86 deletions(-) create mode 100644 packages/cli-engine/src/execution/debug.ts create mode 100644 packages/cli/src/auth/service-token.ts diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 89654ed2..a025912b 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -1,11 +1,13 @@ import { authServiceError, credentialsRequiredError, + emptyServiceTokenError, serviceTokenRejectedError, } from "../credential-errors"; import type { CredentialManager, Session } from "../credential-manager"; -import type { ManagementApiClient } from "../management-api"; +import type { ManagementApiClient, TokenStorage } from "../management-api"; import { CliStructuredError } from "../protocol"; +import { type DebugLog, makeDebugLog } from "./debug"; import type { Invocation } from "./engine"; const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; @@ -14,6 +16,13 @@ type ClientBinding = | { readonly source: "stored"; readonly workspaceId: string } | { readonly source: "environment" }; +/** What the last refresh attempt threw, if it threw. The mapping below + * identifies a failure as coming from the refresh path by finding this + * exact error in the cause chain of the request failure. */ +interface RefreshProbe { + failure: unknown; +} + /** * ctx.api: the ENGINE constructs and owns the management API client — * the pinned session's client, once per run (process pinning makes @@ -30,13 +39,17 @@ type ClientBinding = export function buildManagementApiClient( invocation: Invocation, ): ManagementApiClient { + const debug = makeDebugLog(invocation.runtime); + const probe: RefreshProbe = { failure: undefined }; let binding: ClientBinding | undefined; let clientPromise: Promise | undefined; const resolveClient = (): Promise => { - clientPromise ??= constructClient(invocation).then((constructed) => { - binding = constructed.binding; - return constructed.client; - }); + clientPromise ??= constructClient(invocation, debug, probe).then( + (constructed) => { + binding = constructed.binding; + return constructed.client; + }, + ); return clientPromise; }; @@ -66,14 +79,18 @@ export function buildManagementApiClient( } return result; } catch (cause) { - throw await mapRequestFailure(invocation, binding, cause); + throw await mapRequestFailure(invocation, debug, probe, binding, cause); } }; }, }); } -async function constructClient(invocation: Invocation): Promise<{ +async function constructClient( + invocation: Invocation, + debug: DebugLog, + probe: RefreshProbe, +): Promise<{ readonly client: ManagementApiClient; readonly binding: ClientBinding; }> { @@ -93,9 +110,12 @@ async function constructClient(invocation: Invocation): Promise<{ } if (session.source === "environment") { const token = invocation.runtime.env[SERVICE_TOKEN_ENV_VAR]; - if (token === undefined || token.trim() === "") { + if (token === undefined) { throw credentialsRequiredError(); } + if (token.trim() === "") { + throw emptyServiceTokenError({ envVar: SERVICE_TOKEN_ENV_VAR }); + } const { createManagementApiClient } = await import( "@prisma/management-api-sdk" ); @@ -113,7 +133,12 @@ async function constructClient(invocation: Invocation): Promise<{ redirectUri: config.redirectUri, apiBaseUrl: config.apiBaseUrl, authBaseUrl: config.authBaseUrl, - tokenStorage: manager.tokenStorage(session.workspaceId), + tokenStorage: observedTokenStorage( + manager.tokenStorage(session.workspaceId), + session.workspaceId, + debug, + probe, + ), }); return { client: sdk.client, @@ -121,6 +146,48 @@ async function constructClient(invocation: Invocation): Promise<{ }; } +/** + * The manager's view, with the refresh path observed. The SDK enters + * withRefreshLock only from its refresh routine, so it marks both the + * debug valve's "refresh attempted" line and the boundary whose throws + * count as refresh-path failures. + */ +function observedTokenStorage( + storage: TokenStorage, + workspaceId: string, + debug: DebugLog, + probe: RefreshProbe, +): TokenStorage { + const observedRefresh = async (fn: () => Promise): Promise => { + debug(`refresh attempted for session ${workspaceId}`); + try { + return await fn(); + } catch (failure) { + probe.failure = failure; + throw failure; + } + }; + return { + getTokens: () => storage.getTokens(), + setTokens: (tokens) => storage.setTokens(tokens), + clearTokens: () => storage.clearTokens(), + ...(storage.clearTokensIfCurrent === undefined + ? {} + : { + clearTokensIfCurrent: (tokens) => + ( + storage.clearTokensIfCurrent as NonNullable< + TokenStorage["clearTokensIfCurrent"] + > + )(tokens), + }), + withRefreshLock: (fn) => + storage.withRefreshLock === undefined + ? observedRefresh(fn) + : storage.withRefreshLock(() => observedRefresh(fn)), + }; +} + /** The static-token path has no error middleware, so a 401 arrives as * a resolved openapi-fetch result; the call site inspects it. */ function responseWas401(result: unknown): boolean { @@ -147,10 +214,15 @@ function responseWas401(result: unknown): boolean { * a re-read of the manager's state for the workspace the client is * BOUND to — that session gone means the session-ended * CLI.CREDENTIALS_REQUIRED, otherwise the failure was the auth - * service's and nothing was cleared. + * service's and nothing was cleared. A failure that came out of the + * refresh path without being an AuthError (the SDK throws a plain + * Error when a rotated token will not decode) is transient too: + * nothing was cleared, and signing in again is not the fix. */ async function mapRequestFailure( invocation: Invocation, + debug: DebugLog, + probe: RefreshProbe, binding: ClientBinding | undefined, cause: unknown, ): Promise { @@ -158,9 +230,23 @@ async function mapRequestFailure( if (structured !== undefined) { return structured; } + const cameFromRefresh = refreshPathFailed(probe, cause); const authError = sdkAuthErrorInCauseChain(cause); if (authError === undefined) { - return cause; + if (!cameFromRefresh) { + return cause; + } + // Only the error's type is reported: an arbitrary message can + // carry fragments of a decoded token payload. + debug(`refresh failed without an AuthError (${errorTypeOf(probe.failure)})`); + return authServiceError(); + } + if (cameFromRefresh) { + debug( + `refresh failed: refreshTokenInvalid=${String( + authError.refreshTokenInvalid === true, + )} error=${authError.message}`, + ); } if (authError.refreshTokenInvalid === true) { return credentialsRequiredError("expired"); @@ -226,7 +312,7 @@ function structuredCause(error: unknown): CliStructuredError | undefined { * module to load. */ function sdkAuthErrorInCauseChain( error: unknown, -): { readonly refreshTokenInvalid: unknown } | undefined { +): (Error & { readonly refreshTokenInvalid: unknown }) | undefined { for (const current of causeChain(error)) { if (current.name === "AuthError") { return current as Error & { readonly refreshTokenInvalid: unknown }; @@ -234,3 +320,18 @@ function sdkAuthErrorInCauseChain( } return undefined; } + +/** The request failure carries the refresh attempt's own throw, so the + * failure arose in the refresh path rather than in the request. */ +function refreshPathFailed(probe: RefreshProbe, cause: unknown): boolean { + if (probe.failure === undefined) return false; + if (cause === probe.failure) return true; + for (const current of causeChain(cause)) { + if (current === probe.failure) return true; + } + return false; +} + +function errorTypeOf(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} diff --git a/packages/cli-engine/src/execution/debug.ts b/packages/cli-engine/src/execution/debug.ts new file mode 100644 index 00000000..842f1ce2 --- /dev/null +++ b/packages/cli-engine/src/execution/debug.ts @@ -0,0 +1,17 @@ +import type { Runtime } from "../runtime"; + +export type DebugLog = (message: string) => void; + +const DEBUG_ENV_VAR = "PRISMA_NEXT_DEBUG"; + +/** + * The engine's debug valve: silent unless PRISMA_NEXT_DEBUG=1 in the + * injected env (never process.env), writing to the injected stderr. + * Token material never reaches it. + */ +export function makeDebugLog(runtime: Runtime): DebugLog { + if (runtime.env[DEBUG_ENV_VAR] !== "1") return () => {}; + return (message) => { + runtime.stderr.write(`[cli-engine] ${message}\n`); + }; +} diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index 3f4570ed..7923098a 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -7,7 +7,6 @@ import type { } from "@prisma/cli-engine"; import { credentialsRequiredError, - emptyServiceTokenError, environmentSessionMutationError, noSessionForWorkspaceError, } from "@prisma/cli-engine"; @@ -18,6 +17,7 @@ import { serviceTokenWorkspaceId, } from "./claims"; import { SERVICE_TOKEN_ENV_VAR } from "./client"; +import { environmentServiceToken } from "./service-token"; import { type CredentialState, type DebugLog, @@ -136,6 +136,7 @@ export class FileCredentialManager implements CredentialManager { credential: Credential, workspaceId: string, ): Promise { + this.#refuseBlankEnvironmentToken(); const claimed = claimedWorkspaceId(credential.token); if (claimed !== undefined && claimed !== workspaceId) { throw credentialWorkspaceMismatchError(workspaceId); @@ -223,15 +224,18 @@ export class FileCredentialManager implements CredentialManager { async endAllSessions(): Promise { if (this.#environmentToken() !== undefined) { const stored = await readCredentialState(this.#filePath); - if (stored.sessions.length === 0) return; - throw environmentSessionMutationError({ - envVar: SERVICE_TOKEN_ENV_VAR, - storedSessionsExist: true, - }); + if (stored.sessions.length > 0) { + throw environmentSessionMutationError({ + envVar: SERVICE_TOKEN_ENV_VAR, + storedSessionsExist: true, + }); + } + await this.#reapLegacyContextFile(); + return; } await this.#mutate(() => ({ state: EMPTY_STATE, result: undefined })); - await fs.unlink(getAuthContextFilePath(this.#filePath)).catch(() => {}); + await this.#reapLegacyContextFile(); this.#pin = { kind: "marker", workspaceId: null }; } @@ -327,12 +331,21 @@ export class FileCredentialManager implements CredentialManager { } #environmentToken(): string | undefined { - const raw = this.#env[SERVICE_TOKEN_ENV_VAR]; - if (raw === undefined) return undefined; - if (raw.trim().length === 0) { - throw emptyServiceTokenError({ envVar: SERVICE_TOKEN_ENV_VAR }); - } - return raw.trim(); + return environmentServiceToken(this.#env); + } + + /** A blank env token is an error state everywhere the env session + * would be consulted — including createSession, which the override + * otherwise allows. */ + #refuseBlankEnvironmentToken(): void { + this.#environmentToken(); + } + + /** endAllSessions clears everything, including the legacy context + * sidecar — also on the env-override no-op, which has no stored + * sessions to clear but may still have the sidecar. */ + async #reapLegacyContextFile(): Promise { + await fs.unlink(getAuthContextFilePath(this.#filePath)).catch(() => {}); } #environmentSession(): Session { diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 466adaa6..4f2e15ff 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -30,6 +30,10 @@ export { type RecipientWorkspaceSession, resolveRecipientWorkspaceSession, } from "./recipient"; +export { + environmentServiceToken, + environmentSessionInForce, +} from "./service-token"; export { DEPRECATED_STATE_FILE_ENV_VAR, resolveStateFilePath, diff --git a/packages/cli/src/auth/service-token.ts b/packages/cli/src/auth/service-token.ts new file mode 100644 index 00000000..fd28b4a1 --- /dev/null +++ b/packages/cli/src/auth/service-token.ts @@ -0,0 +1,26 @@ +import { emptyServiceTokenError } from "@prisma/cli-engine"; +import { SERVICE_TOKEN_ENV_VAR } from "./client"; + +/** + * The env-supplied service token, trimmed — or undefined when the var + * is not set. A blank or whitespace value is never "not set" and never + * an override: it raises the single blank-token error, identically + * everywhere the env session would be consulted. + */ +export function environmentServiceToken( + env: Readonly>, +): string | undefined { + const raw = env[SERVICE_TOKEN_ENV_VAR]; + if (raw === undefined) return undefined; + if (raw.trim().length === 0) { + throw emptyServiceTokenError({ envVar: SERVICE_TOKEN_ENV_VAR }); + } + return raw.trim(); +} + +/** Whether the env session overrides the stored ones. Blank raises. */ +export function environmentSessionInForce( + env: Readonly>, +): boolean { + return environmentServiceToken(env) !== undefined; +} diff --git a/packages/cli/src/v8/auth/login.ts b/packages/cli/src/v8/auth/login.ts index c3c0cba0..bd331c8d 100644 --- a/packages/cli/src/v8/auth/login.ts +++ b/packages/cli/src/v8/auth/login.ts @@ -10,8 +10,8 @@ import { } from "@prisma/cli-engine/protocol"; import { claimedWorkspaceId, + environmentSessionInForce, performLogin, - SERVICE_TOKEN_ENV_VAR, } from "../../auth"; import { CLI_NAME } from "../../cli-name"; import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; @@ -115,6 +115,9 @@ export const authLoginCommand = defineCommand({ examples: ["auth login"], }, handler: async (_args, ctx) => { + // A blank service token is the single blank-token error, raised + // before the browser opens rather than after a credential is minted. + const environmentSession = environmentSessionInForce(ctx.env); ctx.report({ kind: "step-started", step: LOGIN_STEP }); let session: Session; try { @@ -140,22 +143,20 @@ export const authLoginCommand = defineCommand({ } ctx.report({ kind: "step-finished", step: LOGIN_STEP, outcome: "ok" }); - const environmentSessionInForce = - ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined; const agentSetupTipCommand = await resolveAgentSetupTipCommand(ctx); const result: LoginResult = { workspace: { id: session.workspaceId, name: session.workspaceName ?? null, }, - environmentSessionInForce, + environmentSessionInForce: environmentSession, }; return ok( ctx.present( { data: result }, presentationsFor({ session, - environmentSessionInForce, + environmentSessionInForce: environmentSession, agentSetupTipCommand, }), ), diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index a4b0e095..87ddf68b 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -6,7 +6,7 @@ import { type Session, } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; -import { decodeClaims, SERVICE_TOKEN_ENV_VAR } from "../../auth"; +import { decodeClaims, environmentServiceToken } from "../../auth"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_SESSION_NOTICE, @@ -71,18 +71,18 @@ async function enrichedIdentity( } } +/** An env session's identity is the env token's own claims — decoded + * locally, never fetched. `/v1/me` is the stored-session path, whose + * token whoami cannot reach. */ async function identityFor( session: Session, ctx: CommandContext, ): Promise { - const enriched = await enrichedIdentity(ctx.api, ctx.signal); - if (enriched !== null) { - return enriched; + if (session.source === "environment") { + const envToken = environmentServiceToken(ctx.env); + return envToken === undefined ? null : claimedIdentity(envToken); } - const envToken = ctx.env[SERVICE_TOKEN_ENV_VAR]; - return session.source === "environment" && envToken !== undefined - ? claimedIdentity(envToken) - : null; + return enrichedIdentity(ctx.api, ctx.signal); } function presentationsFor(spec: { diff --git a/packages/cli/src/v8/auth/workspace-list.ts b/packages/cli/src/v8/auth/workspace-list.ts index 56368780..48593cd8 100644 --- a/packages/cli/src/v8/auth/workspace-list.ts +++ b/packages/cli/src/v8/auth/workspace-list.ts @@ -5,7 +5,7 @@ import { type Session, } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; -import { SERVICE_TOKEN_ENV_VAR } from "../../auth"; +import { environmentSessionInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; import { sessionLabel } from "./session-ref"; @@ -86,7 +86,7 @@ export const authWorkspaceListCommand = defineCommand({ handler: async (_args, ctx) => { const result: WorkspaceListResult = { sessions: await ctx.credentialManager.sessions(), - environmentSessionInForce: ctx.env[SERVICE_TOKEN_ENV_VAR] !== undefined, + environmentSessionInForce: environmentSessionInForce(ctx.env), }; return ok(ctx.present({ data: result }, listPresentations(result))); }, diff --git a/packages/cli/tests/credential-manager-processes.test.ts b/packages/cli/tests/credential-manager-processes.test.ts index cfbf0212..0f943abd 100644 --- a/packages/cli/tests/credential-manager-processes.test.ts +++ b/packages/cli/tests/credential-manager-processes.test.ts @@ -6,11 +6,13 @@ */ import { spawn } from "node:child_process"; import { mkdtemp, readFile, stat, unlink, writeFile } from "node:fs/promises"; +import { createServer, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { mintTestJwt } from "@prisma/cli-engine/testing"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { FileCredentialManager } from "../src/auth/credential-manager"; import { readCredentialState } from "../src/auth/state-file"; @@ -25,6 +27,9 @@ const workerPath = fileURLToPath( const packageRoot = fileURLToPath(new URL("..", import.meta.url)); let stateFilePath: string; +const servers: Server[] = []; +/** Every worker's stderr, for the leak scan. */ +const workerStderr: string[] = []; function mintToken(workspaceId: string) { return mintTestJwt({ workspace_id: workspaceId }); @@ -47,6 +52,7 @@ function runWorker(command: string, ...args: string[]): Promise { }); child.on("error", reject); child.on("close", (code) => { + workerStderr.push(stderr); if (code === 0) resolve(stdout); else reject(new Error(`worker ${command} failed: ${stderr}`)); }); @@ -59,9 +65,112 @@ function makeManager() { }); } +interface TokenPair { + readonly accessToken: string; + readonly refreshToken: string; +} + +/** + * The auth service's rotation rules (validated against + * pdp-control-plane): a refresh token is single-use with a 10-second + * reuse grace — the first exchange succeeds, one replay within the + * grace succeeds with its own pair, later replays are invalid_grant. + * The API side answers 401 for the seed access token so the SDK's + * refresh path is what drives the exchange. + */ +const REUSE_GRACE_MS = 10_000; + +async function startTokenEndpoint(script: { + readonly seedAccessToken: string; + readonly seedRefreshToken: string; + readonly issued: readonly TokenPair[]; +}): Promise<{ + readonly baseUrl: string; + readonly exchanges: () => number; +}> { + const grants = new Map([ + [script.seedRefreshToken, { uses: 0, firstUsedAt: 0 }], + ]); + let exchanges = 0; + + const respond = ( + response: ServerResponse, + status: number, + body: unknown, + ): void => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }; + + const exchange = (refreshToken: string | null): TokenPair | null => { + const grant = refreshToken === null ? undefined : grants.get(refreshToken); + if (grant === undefined) return null; + if (grant.uses === 0) { + grant.uses = 1; + grant.firstUsedAt = Date.now(); + } else if ( + grant.uses === 1 && + Date.now() - grant.firstUsedAt <= REUSE_GRACE_MS + ) { + grant.uses = 2; + } else { + return null; + } + const pair = script.issued[exchanges]; + if (pair === undefined) return null; + exchanges += 1; + grants.set(pair.refreshToken, { uses: 0, firstUsedAt: 0 }); + return pair; + }; + + const server = createServer((request, response) => { + if ((request.url ?? "").startsWith("/token")) { + let body = ""; + request.on("data", (chunk) => { + body += String(chunk); + }); + request.on("end", () => { + const pair = exchange(new URLSearchParams(body).get("refresh_token")); + if (pair === null) { + respond(response, 400, { error: "invalid_grant" }); + return; + } + respond(response, 200, { + access_token: pair.accessToken, + refresh_token: pair.refreshToken, + }); + }); + return; + } + const bearer = request.headers.authorization; + if (bearer === `Bearer ${script.seedAccessToken}`) { + respond(response, 401, { message: "unauthorized" }); + return; + } + respond(response, 200, { workspaces: [] }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + servers.push(server); + const { port } = server.address() as AddressInfo; + return { baseUrl: `http://127.0.0.1:${port}`, exchanges: () => exchanges }; +} + beforeEach(async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "prisma-credential-procs-")); stateFilePath = path.join(dir, "auth.json"); + workerStderr.length = 0; +}); + +afterEach(async () => { + const running = servers.splice(0, servers.length); + await Promise.all( + running.map( + (server) => new Promise((resolve) => server.close(() => resolve())), + ), + ); }); describe("across processes", () => { @@ -78,22 +187,35 @@ describe("across processes", () => { ).toEqual([WORKSPACE_A, WORKSPACE_B, WORKSPACE_C]); }, 30_000); - it("leaves a valid pair in the file when two processes rotate the same session", async () => { - await runWorker("create", WORKSPACE_A, mintToken(WORKSPACE_A), "refresh-0"); - const first = mintToken(WORKSPACE_A); - const second = mintToken(WORKSPACE_A); + it("leaves a valid pair in the file when two processes really refresh the same session", async () => { + const seedAccessToken = mintToken(WORKSPACE_A); + const issued = [ + { accessToken: mintToken(WORKSPACE_A), refreshToken: "refresh-1" }, + { accessToken: mintToken(WORKSPACE_A), refreshToken: "refresh-2" }, + ]; + const endpoint = await startTokenEndpoint({ + seedAccessToken, + seedRefreshToken: "refresh-0", + issued, + }); + await runWorker("create", WORKSPACE_A, seedAccessToken, "refresh-0"); - await Promise.all([ - runWorker("rotate", WORKSPACE_A, first, "refresh-1"), - runWorker("rotate", WORKSPACE_A, second, "refresh-2"), + const outcomes = await Promise.all([ + runWorker("refresh", WORKSPACE_A, endpoint.baseUrl, endpoint.baseUrl), + runWorker("refresh", WORKSPACE_A, endpoint.baseUrl, endpoint.baseUrl), ]); + expect(outcomes.map((outcome) => JSON.parse(outcome))).toEqual([ + { status: 200 }, + { status: 200 }, + ]); + expect(endpoint.exchanges()).toBe(2); const state = await readCredentialState(stateFilePath); expect(state.sessions).toHaveLength(1); const record = state.sessions[0]; - expect([`${first}|refresh-1`, `${second}|refresh-2`]).toContain( - `${record.token}|${record.refreshToken}`, - ); + expect( + issued.map((pair) => `${pair.accessToken}|${pair.refreshToken}`), + ).toContain(`${record.token}|${record.refreshToken}`); }, 30_000); it("takes over a crashed holder's lock after the stale threshold", async () => { @@ -163,13 +285,54 @@ describe("across processes", () => { expect(fromNewProcess.workspaceId).toBe(WORKSPACE_A); }, 30_000); - it("never leaves token material in a worker's output", async () => { - const token = mintToken(WORKSPACE_A); - await runWorker("create", WORKSPACE_A, token, "s3cret-refresh"); - const printed = await runWorker("sessions"); + it("never leaves token material in a worker's stdout or stderr", async () => { + const seedAccessToken = mintToken(WORKSPACE_A); + const rotated = { + accessToken: mintToken(WORKSPACE_A), + refreshToken: "s3cret-rotated-refresh", + }; + const endpoint = await startTokenEndpoint({ + seedAccessToken, + seedRefreshToken: "s3cret-refresh", + issued: [rotated], + }); + await runWorker("create", WORKSPACE_A, seedAccessToken, "s3cret-refresh"); + const printed = [ + await runWorker("sessions"), + await runWorker("current"), + await runWorker( + "refresh", + WORKSPACE_A, + endpoint.baseUrl, + endpoint.baseUrl, + ), + ].join(""); + expect(await readFile(stateFilePath, "utf8")).toContain( + rotated.refreshToken, + ); + + // An endpoint that knows nothing of the stored refresh token + // answers invalid_grant: the failure path must stay just as quiet. + const rejecting = await startTokenEndpoint({ + seedAccessToken: rotated.accessToken, + seedRefreshToken: "unknown-to-this-endpoint", + issued: [], + }); + const failed = await runWorker( + "refresh", + WORKSPACE_A, + rejecting.baseUrl, + rejecting.baseUrl, + ).catch((error: unknown) => String(error)); - expect(printed).not.toContain("s3cret-refresh"); - expect(printed).not.toContain(token); - expect(await readFile(stateFilePath, "utf8")).toContain("s3cret-refresh"); + const scanned = `${printed}${failed}${workerStderr.join("")}`; + for (const material of [ + "s3cret-refresh", + rotated.refreshToken, + seedAccessToken, + rotated.accessToken, + ]) { + expect(scanned).not.toContain(material); + } }, 30_000); }); diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index 8f58448f..8931655c 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -9,8 +9,10 @@ * atomicity, process pinning, the env override rules, the TokenStorage * write slices, and the legacy migration. */ +import nodeFs from "node:fs"; import fsPromises, { mkdtemp, + readdir, readFile, stat, writeFile, @@ -22,6 +24,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { FileCredentialManager } from "../src/auth/credential-manager"; import { readCredentialState } from "../src/auth/state-file"; +import { getAuthContextFilePath } from "../src/auth/token-storage"; + +function escapeForRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} const WORKSPACE_A = "wksp_a"; const WORKSPACE_B = "wksp_b"; @@ -120,7 +127,16 @@ describe("the state file", () => { }), ); - const writes = vi.spyOn(fsPromises, "writeFile"); + const spies = [ + vi.spyOn(fsPromises, "writeFile"), + vi.spyOn(fsPromises, "unlink"), + vi.spyOn(fsPromises, "rm"), + vi.spyOn(nodeFs, "writeFileSync"), + vi.spyOn(nodeFs, "appendFileSync"), + vi.spyOn(nodeFs, "truncateSync"), + vi.spyOn(nodeFs, "writeSync"), + vi.spyOn(nodeFs, "unlinkSync"), + ]; const renames = vi.spyOn(fsPromises, "rename"); const opens = vi.spyOn(fsPromises, "open"); try { @@ -135,19 +151,73 @@ describe("the state file", () => { await adopting.currentSession(); await adopting.sessions(); - expect(writes).not.toHaveBeenCalled(); + for (const spy of spies) { + expect(spy).not.toHaveBeenCalled(); + } expect(renames).not.toHaveBeenCalled(); expect(opens).not.toHaveBeenCalled(); await manager.endAllSessions(); expect(renames).toHaveBeenCalled(); } finally { - writes.mockRestore(); + for (const spy of spies) { + spy.mockRestore(); + } renames.mockRestore(); opens.mockRestore(); } }); + it("writes through a same-directory temp file that is synced before the rename", async () => { + const order: string[] = []; + const realOpen = fsPromises.open.bind(fsPromises); + const realRename = fsPromises.rename.bind(fsPromises); + const opens = vi + .spyOn(fsPromises, "open") + .mockImplementation(async (...args: Parameters) => { + const handle = await realOpen(...args); + if (!String(args[0]).endsWith(".tmp")) return handle; + order.push(`open ${String(args[0])}`); + const sync = handle.sync.bind(handle); + handle.sync = async () => { + order.push("sync"); + await sync(); + }; + return handle; + }); + const renames = vi + .spyOn(fsPromises, "rename") + .mockImplementation(async (from, to) => { + order.push(`rename ${String(from)} -> ${String(to)}`); + await realRename(from, to); + }); + try { + await makeManager().createSession( + credentialFor(WORKSPACE_A), + WORKSPACE_A, + ); + } finally { + opens.mockRestore(); + renames.mockRestore(); + } + + const stateDir = path.dirname(stateFilePath); + expect(order).toEqual([ + expect.stringMatching( + new RegExp(`^open ${escapeForRegExp(stateFilePath)}\\..+\\.tmp$`), + ), + "sync", + expect.stringMatching( + new RegExp( + `^rename ${escapeForRegExp(stateFilePath)}\\..+\\.tmp -> ${escapeForRegExp(stateFilePath)}$`, + ), + ), + ]); + expect( + (await readdir(stateDir)).filter((entry) => entry.endsWith(".tmp")), + ).toEqual([]); + }); + it("treats a corrupt file as signed out and never rewrites it", async () => { await writeFile(stateFilePath, "{ not json", "utf8"); const before = await readRawState(); @@ -236,36 +306,95 @@ describe("process pinning", () => { }); describe("mutations under an environment session", () => { - const environments = { - set: mintToken(WORKSPACE_B), - blank: "", - whitespace: " ", + /** §5's matrix: every mutation × {unset, set, blank, whitespace}. */ + const refusals = { + set: { + token: () => mintToken(WORKSPACE_B), + code: "AUTH.ENV_SESSION_IN_FORCE", + createSessionRefused: false, + }, + blank: { + token: () => "", + code: "AUTH.SERVICE_TOKEN_EMPTY", + createSessionRefused: true, + }, + whitespace: { + token: () => " ", + code: "AUTH.SERVICE_TOKEN_EMPTY", + createSessionRefused: true, + }, } as const; - for (const [name, token] of Object.entries(environments)) { + for (const [name, spec] of Object.entries(refusals)) { it(`refuses useSession, endSession and endAllSessions with the env token ${name}`, async () => { await seedTwoSessions(); const stored = await makeManager().sessions(); const before = await readRawState(); - const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: token } }); - const expectedCode = - name === "set" - ? "AUTH.ENV_SESSION_IN_FORCE" - : "AUTH.SERVICE_TOKEN_EMPTY"; + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: spec.token() }, + }); await expect( manager.useSession(stored[0] as never), - ).rejects.toMatchObject({ code: expectedCode }); + ).rejects.toMatchObject({ code: spec.code }); await expect( manager.endSession(stored[0] as never), - ).rejects.toMatchObject({ code: expectedCode }); + ).rejects.toMatchObject({ code: spec.code }); await expect(manager.endAllSessions()).rejects.toMatchObject({ - code: expectedCode, + code: spec.code, }); expect(await readRawState()).toBe(before); }); + + it(`handles createSession with the env token ${name}`, async () => { + await seedTwoSessions(); + const before = await readRawState(); + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: spec.token() }, + }); + const created = manager.createSession( + credentialFor(WORKSPACE_A, "refresh-env"), + WORKSPACE_A, + ); + + if (spec.createSessionRefused) { + await expect(created).rejects.toMatchObject({ code: spec.code }); + expect(await readRawState()).toBe(before); + return; + } + + await expect(created).resolves.toMatchObject({ + workspaceId: WORKSPACE_A, + }); + expect(await readRawState()).not.toBe(before); + }); } + it("lets every mutation through with the env token unset", async () => { + await seedTwoSessions(); + const manager = makeManager(); + const sessionA = (await manager.sessions()).find( + (session) => session.workspaceId === WORKSPACE_A, + ); + + await expect( + manager.createSession( + credentialFor(WORKSPACE_A, "refresh-unset"), + WORKSPACE_A, + ), + ).resolves.toMatchObject({ workspaceId: WORKSPACE_A }); + await expect(manager.useSession(sessionA as never)).resolves.toMatchObject({ + workspaceId: WORKSPACE_A, + }); + await expect(manager.endSession(sessionA as never)).resolves.toBeUndefined(); + await expect(manager.endAllSessions()).resolves.toBeUndefined(); + + expect(await readCredentialState(stateFilePath)).toMatchObject({ + sessions: [], + currentWorkspaceId: null, + }); + }); + it("succeeds as a no-op when endAllSessions runs with no stored sessions", async () => { const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_B) }, @@ -274,6 +403,22 @@ describe("mutations under an environment session", () => { expect(await readRawState()).toBeNull(); }); + it("still reaps the legacy context sidecar on the no-op", async () => { + const sidecarPath = getAuthContextFilePath(stateFilePath); + await writeFile( + sidecarPath, + JSON.stringify({ activeWorkspaceId: WORKSPACE_A }), + "utf8", + ); + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_B) }, + }); + + await expect(manager.endAllSessions()).resolves.toBeUndefined(); + + await expect(stat(sidecarPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("allows createSession while the env token is in force and leaves the pin on the env session", async () => { const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_B) }, @@ -531,8 +676,9 @@ describe("the TokenStorage view", () => { }); describe("token material never leaks", () => { - it("keeps the secret out of debug output and errors", async () => { + it("keeps the secret out of the debug lines of every write path and out of errors", async () => { const secret = "s3cret-refresh-token"; + const rotatedSecret = "s3cret-rotated-refresh-token"; const debugLines: string[] = []; const manager = makeManager({ env: { PRISMA_NEXT_DEBUG: "1" }, @@ -546,12 +692,74 @@ describe("token material never leaks", () => { await manager.createSession(credential, WORKSPACE_A); await manager.currentSession(); - const mismatch = await manager - .createSession(credential, WORKSPACE_B) - .catch((error: unknown) => error); + const rotated = mintToken(WORKSPACE_A, { exp: 2_000_000_000 }); + const storage = manager.tokenStorage(WORKSPACE_A); + await storage.setTokens({ + workspaceId: WORKSPACE_A, + accessToken: rotated, + refreshToken: rotatedSecret, + }); + await storage.clearTokensIfCurrent?.({ + workspaceId: WORKSPACE_A, + accessToken: rotated, + refreshToken: rotatedSecret, + }); + await manager.tokenStorage(WORKSPACE_B).clearTokens(); + + const errors = [ + await manager + .createSession(credential, WORKSPACE_B) + .catch((error: unknown) => error), + await storage + .setTokens({ + workspaceId: WORKSPACE_A, + accessToken: rotated, + refreshToken: rotatedSecret, + }) + .catch((error: unknown) => error), + ]; + + const rendered = [ + debugLines.join(""), + ...errors.map((error) => + JSON.stringify(error, Object.getOwnPropertyNames(error)), + ), + ].join(""); + for (const material of [secret, rotatedSecret, credential.token, rotated]) { + expect(rendered).not.toContain(material); + } + expect(debugLines.join("")).toContain(`rotation write for session`); + expect(debugLines.join("")).toContain(`clearing session`); + }); +}); + +describe("rotation durability", () => { + it("has the rotated pair on disk by the time setTokens resolves", async () => { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const rotated = mintToken(WORKSPACE_A, { exp: 2_000_000_000 }); - const rendered = `${debugLines.join("")}${JSON.stringify(mismatch, Object.getOwnPropertyNames(mismatch))}`; - expect(rendered).not.toContain(secret); - expect(rendered).not.toContain(credential.token); + let renamedBeforeResolve = false; + const realRename = fsPromises.rename.bind(fsPromises); + const renames = vi + .spyOn(fsPromises, "rename") + .mockImplementation(async (from, to) => { + await realRename(from, to); + renamedBeforeResolve = true; + }); + try { + await manager.tokenStorage(WORKSPACE_A).setTokens({ + workspaceId: WORKSPACE_A, + accessToken: rotated, + refreshToken: "refresh-2", + }); + } finally { + renames.mockRestore(); + } + + expect(renamedBeforeResolve).toBe(true); + expect(JSON.parse((await readRawState()) ?? "")).toMatchObject({ + sessions: [{ workspaceId: WORKSPACE_A, token: rotated }], + }); }); }); diff --git a/packages/cli/tests/helpers/credential-manager-worker.ts b/packages/cli/tests/helpers/credential-manager-worker.ts index d49e65c4..40faef06 100644 --- a/packages/cli/tests/helpers/credential-manager-worker.ts +++ b/packages/cli/tests/helpers/credential-manager-worker.ts @@ -49,6 +49,24 @@ async function run(): Promise { .setTokens({ workspaceId, accessToken, refreshToken }); return null; } + /** A REAL refresh: the SDK's refreshing client over the manager's + * TokenStorage view, against the scripted token endpoint. The + * first request answers 401, which drives the exchange. */ + case "refresh": { + const [workspaceId, apiBaseUrl, authBaseUrl] = args; + const { createManagementApiSdk } = await import( + "@prisma/management-api-sdk" + ); + const sdk = createManagementApiSdk({ + clientId: "test-client-id", + redirectUri: `${apiBaseUrl}/auth/callback`, + apiBaseUrl, + authBaseUrl, + tokenStorage: makeManager().tokenStorage(workspaceId), + }); + const { response } = await sdk.client.GET("/v1/workspaces", {}); + return { status: response.status }; + } case "current": return makeManager().currentSession(); case "sessions": diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index 3c3b8644..44a16e9c 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -679,7 +679,15 @@ describe("the env session never refreshes", () => { } }); - it("does not reach the token endpoint when the API answers 401", async () => { + const touchesApi = defineCommand({ + help: { summary: "Issues one management API request" }, + handler: async (_args, ctx) => { + await ctx.api.GET("/v1/me", {}); + return ok(ctx.present({ data: null }, { human: () => [] })); + }, + }); + + async function cliAgainstA401Server() { server = createServer((request, response) => { paths.push(request.url ?? ""); response.writeHead(401, { "content-type": "application/json" }); @@ -691,8 +699,8 @@ describe("the env session never refreshes", () => { const port = (server.address() as AddressInfo).port; const baseUrl = `http://127.0.0.1:${port}`; - const cli = createTestCli({ - commands: COMMANDS, + return createTestCli({ + commands: { ...COMMANDS, probe: touchesApi }, groups: GROUPS, environmentToken: tokenFor("ws_env", { sub: "usr_env" }), managementApiClientConfig: { @@ -703,15 +711,69 @@ describe("the env session never refreshes", () => { }, now: () => new Date(0), }); + } + + it("does not reach the token endpoint when the API answers 401", async () => { + const cli = await cliAgainstA401Server(); + + const result = await cli.run(["probe", "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result).code).toBe("AUTH.SERVICE_TOKEN_REJECTED"); + expect(paths).toEqual(["/v1/me"]); + }); + + it("gives whoami the env token's own claims without any request", async () => { + const cli = await cliAgainstA401Server(); const result = await cli.run(["auth", "whoami", "--json"]); expect(result.exitCode).toBe(0); - expect(paths).toContain("/v1/me"); - expect(paths.some((path) => path.includes("token"))).toBe(false); + expect(resultOf(result)).toMatchObject({ + source: "environment", + user: { id: "usr_env" }, + }); + expect(paths).toEqual([]); }); }); +describe("a blank service token is never an override", () => { + for (const [name, token] of [ + ["blank", ""], + ["whitespace", " "], + ] as const) { + it(`fails auth workspace list with the blank-token error (${name})`, async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + currentWorkspaceId: "ws_1", + }); + + const result = await cli.run(["auth", "workspace", "list", "--json"], { + env: { PRISMA_SERVICE_TOKEN: token }, + }); + + expect(result.exitCode).toBe(2); + expect(errorOf(result).code).toBe("AUTH.SERVICE_TOKEN_EMPTY"); + expect(result.stdout).not.toContain("supplies the session in force"); + expect(result.stderr).not.toContain("supplies the session in force"); + }); + + it(`fails auth login with the blank-token error before the browser opens (${name})`, async () => { + vi.mocked(performLogin).mockResolvedValue(credentialFor("ws_1")); + const cli = makeCli(); + + const result = await cli.run(["auth", "login", "--json"], { + env: { PRISMA_SERVICE_TOKEN: token }, + }); + + expect(result.exitCode).toBe(2); + expect(errorOf(result).code).toBe("AUTH.SERVICE_TOKEN_EMPTY"); + expect(vi.mocked(performLogin)).not.toHaveBeenCalled(); + expect(cli.credentialManager?.state().sessions).toEqual([]); + }); + } +}); + describe("session shapes the commands hand back", () => { it("never lets token material reach the output", async () => { const secret = "refresh_ws_1"; From 257ff1151e7ed11701d767cf06fcfff42dda1b74 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 18:11:43 +0200 Subject: [PATCH 46/67] drive(prisma-cli-v8): credential-manager rework handover brief Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../briefs/credential-manager-handover.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 .drive/projects/prisma-cli-v8/assets/briefs/credential-manager-handover.md diff --git a/.drive/projects/prisma-cli-v8/assets/briefs/credential-manager-handover.md b/.drive/projects/prisma-cli-v8/assets/briefs/credential-manager-handover.md new file mode 100644 index 00000000..0dd89c73 --- /dev/null +++ b/.drive/projects/prisma-cli-v8/assets/briefs/credential-manager-handover.md @@ -0,0 +1,227 @@ +# Credential-manager rework handover — finish remediation, close out PR #130 + +Written 2026-08-10 for an agent with NO context on this session. +The operator is Will Madden. Everything you need is in this file or +the named documents; where this brief summarizes a document, the +document wins. + +## 1. Where you are + +Repo `prisma/prisma-cli`, worktree +`/Users/will/Projects/prisma/prisma-cli/.claude/worktrees/prisma-cli-s1-d6-013cea`, +branch `s2a-foundations` (= PR #130, open, base `main`). This branch +carries slice S2a of the v8 CLI port PLUS a full rework of the auth +family onto a new component, the **credential manager**. The rework +is functionally complete and reviewed; what remains is exactly: + +1. FINISH the remediation of the verification findings (§4 below) — + an implementer was halted mid-work by a rate limit; its partial, + **unverified** state is committed as `4b006d1`. +2. Re-run verification (§5). +3. Rewrite #130's PR description (§6) and hand the PR to Will. + +No background agents are running. Two INDEPENDENT agents (not +yours) work slices S2b and S2c in other worktrees; they merge this +branch down. Do not touch their branches (`s2b-resources`, +`s2c-services`). + +## 2. The design you are implementing against + +`.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md` +— revision 5, NORMATIVE, at HEAD. Read it in full before any code +change. One-paragraph summary: the CLI holds per-workspace +**sessions** (a session = "logged in to workspace X"; at most one +per workspace, keyed by workspace id, one current). A process PINS +its session at first read for its whole lifetime. The engine +(`packages/cli-engine`) owns the management API client; the manager +(`packages/cli/src/auth/credential-manager.ts`, class +`FileCredentialManager`) owns the state file (same path as the +legacy auth file, new shape, atomic 0600 writes, one short advisory +lock, no network under the lock) and implements the platform SDK's +`TokenStorage` so 401→refresh→retry writes land under its rules. +Racing refreshes are deliberately uncoordinated across processes — +the auth server absorbs them (10s refresh-token reuse grace; +sibling pairs stay valid). Identity is NOT tracked (wallet is +identity-blind, operator ruling). Six v8 commands sit on top with +their LEGACY names: `auth login|logout|whoami`, +`auth workspace list|use|logout`. `workspace use` SELECTS among +held sessions only — it never opens a browser (operator ruling). + +Engine affordances added this session (already landed): consent +tokens + global repeatable `--confirm ` flag (type-to-confirm +interactive; exact-match non-interactive; `--yes` still cannot grant +consent), `ctx.openUrl` (degrades to printing the URL), and +`prompt.browserWait` (non-interactive → structured +interaction-required error, exit 2). `needs.interaction` predates +them and is the declarative interactivity requirement. + +## 3. Commit map (this session's work, all pushed) + +- `a8ef3fb` engine surface (rev-4 shape, superseded) +- `9384a95` engine reworked to rev 5 (session model) +- `6bb8452` consent tokens / `--confirm` / openUrl / browserWait +- `9ffbb01` the real manager: persistence, pinning, migration, + `performLogin` custody split, bin wiring +- `ddbb816` six v8 auth commands + parity/contract doc rewrites +- `d7e8df9`, `7716e8b`, and earlier `015ae55`/`e24d1d5` — design-doc + revisions and rulings +- `4b006d1` **PARTIAL, UNVERIFIED** remediation (see §4) + +At `7716e8b` (pre-remediation) every suite was green: cli 814, +cli-engine 234, telemetry 97, typecheck, root lint all exit 0. + +## 4. YOUR FIRST TASK — finish the remediation + +The verification review (full findings below) reported 1 blocker, +6 should-fix, 8 notes. The halted implementer had addressed most of +them; commit `4b006d1` contains its uncommitted tree at halt time — +its last status: "Now the worker stderr capture (finding 14) and +server teardown." NOTHING in `4b006d1` has been test-run. + +Procedure: +1. `git show 4b006d1` and map each hunk to a finding number. +2. Complete what is missing (at minimum finding 14's worker-stderr + leak capture and whatever "server teardown" it was mid-way + through — check `packages/cli/tests/credential-manager-processes.test.ts` + and `tests/helpers/credential-manager-worker.ts` for a scripted + token-endpoint HTTP server that may leak between tests). +3. Run ALL suites (§7 verification commands). Fix what fails. +4. Amend or follow-up commit (`fix(cli): credential-manager + verification findings`, body listing finding numbers; commit + rules §7). + +The findings (severity, file:line refs are pre-remediation at +`7716e8b`; verify against current state): + +- **1 BLOCKER** `packages/cli-engine/src/execution/api-client.ts:161-164`: + a non-`AuthError` from the refresh path must map to the transient + auth-service error (`CLI.AUTH_SERVICE_ERROR`), NOT escape as the + raw cause (`CLI.INTERNAL_ERROR`, exit 1). Spec §6. CLI structured + errors must still pass through unwrapped (existing test + `packages/cli-engine/tests/management-api.test.ts:391`). Needs + tests. `4b006d1` touches this file — verify the fix + tests exist. +- **2** Engine-side `PRISMA_NEXT_DEBUG` valve for the refresh + mapping (spec §6: refresh attempted, endpoint status + error + field). `4b006d1` adds `packages/cli-engine/src/execution/debug.ts` + — verify wiring, on/off tests, and that the leak scan covers it. +- **3** `packages/cli/src/v8/auth/whoami.ts`: env-session identity + from decoding the env token FIRST (no network for it); `/v1/me` + is the stored-session path only (spec §6a as amended). +- **4** Env-override test matrix completeness (spec §5): every + mutation × {unset, set, blank, whitespace}; `createSession` under + blank/whitespace; state-file byte-equality. +- **5** Assert the §8 atomic-write mechanism (temp + fsync + rename; + no `.tmp` sibling remains; sync-before-rename ordering). Do not + weaken `packages/cli/src/auth/state-file.ts:167-190` to test it. +- **6** Assert §8 rotation durability: rotated pair persisted before + the new access token reaches any caller. +- **7** `credential-manager-processes.test.ts`: the two-process + rotation test must drive a REAL refresh through a scripted local + token endpoint (mimic the 10s reuse grace), not direct + `setTokens` calls. `4b006d1` touches these files — verify. +- **9** Blank/whitespace `PRISMA_SERVICE_TOKEN` must not read as "in + force" in `workspace-list.ts` / `login.ts` (`!== undefined` was + the bug); blank → the single `AUTH.SERVICE_TOKEN_EMPTY` outcome. + `4b006d1` adds `packages/cli/src/auth/service-token.ts` — verify + both commands use it, with tests. +- **10** `endAllSessions` env-override no-op (zero stored sessions) + must still unlink the legacy context sidecar (spec §7). +- **11** Reads-never-write probe also spies unlink/rm + sync fs + write APIs. +- **12** `api-client.ts:96-98` blank-token fallback must use the + single-sourced `emptyServiceTokenError` (currently duplicated + logic; unreachable but wrong). +- **14** Leak-scan coverage: rotation/clear debug lines, every + refresh-failure error path, worker-process stderr. +- **SKIP by ruling**: finding 8 (whoami override notice + unconditional — the doc at HEAD §6 was amended to say exactly + that; the reviewer's citation was stale), findings 13 and 15 + (verified fine / unreachable by construction). + +## 5. Then: re-verification + +Dispatch a fresh reviewer subagent (model: Opus, read-only) to +re-verify ONLY the findings above against the code on disk plus a +smoke pass over spec §§3–8 conformance (the previous full +verification found everything else SATISFIED — do not re-litigate +what it passed). Fix anything it raises; loop until clean. + +## 6. Then: PR #130 description + handoff + +Rewrite #130's description (gh CLI; the PR is on +prisma/prisma-cli). Will's ruled structure, in order: a GROUNDING +EXAMPLE first (a real command run, before/after), then the +decision, then the narrative, alternatives last. No internal +process codes, no dispatch/round labels, no reviewer numbering. +Content must cover BOTH the original S2a scope (engine +production-readiness: ctx.api, prompts via clack, telemetry, +versioning/publish machinery, version command) AND the auth rework +(the session model — summarize §2 of this brief; name the +user-visible changes: `logout --workspace` gone, `--confirm ` +for scripted consent, exit-code unifications, whoami shape). The +parity story lives in +`.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md` +(auth sections just rewritten — link, don't duplicate). Then tell +Will it is ready for his re-review. Do NOT merge; do NOT mark +ready-for-review yourself unless the draft state blocks his review. + +## 7. Process rules (non-negotiable, operator-enforced) + +- Git identity — you are the `wmadden-electric` bot: stage + explicitly by path (NEVER `git add -A`/`-u`; NEVER anything under + `wip/` or `.drive/projects/prisma-cli-v8/specs/reviews/`); commit + `git commit -s --trailer "Signed-off-by: Will Madden "` + with body ending `Co-Authored-By: Claude Fable 5 `; + push ONLY to `git@github-wmadden-electric:prisma/prisma-cli.git` + (remote `bot`). +- Verification per change: `pnpm --filter @prisma/cli test`, + `pnpm --filter @prisma/cli-engine test`, + `pnpm --filter @repo/cli-telemetry test`, `pnpm typecheck`, and + root lint measured as pnpm's OWN exit code with `wip/` moved + aside in one shell: + `mv wip /tmp/wip-stash && pnpm lint; s=$?; mv /tmp/wip-stash wip`. +- `wip/repos/` holds read-only reference clones (pdp-control-plane, + prisma, composer) — never stage, never modify. The platform + SDK source referenced by the design is + `wip/repos/pdp-control-plane/packages/management-api-sdk/src/`. +- Subagents: implementers AND reviewers on Opus (operator ruling, + rate limits). +- Reports to Will: plain English, full sentences, no invented + shorthand, no session-internal labels. Banned words: + "load-bearing", "smoking gun", "belt and suspenders", "gate". + Report only outcomes, decisions he must make, and changes to his + world — fold self-corrected slips silently. Bring questions to + decide, not decisions to ratify. STOP on any design-vs-code + contradiction the design does not anticipate; never improvise. + Do not use the question UI. +- Legacy exports in `packages/cli/src/auth` (listAuthWorkspaces, + switchAuthWorkspace, logoutAuthWorkspace, FileTokenStorage) must + keep working until slice S2d. +- Never commit while another agent has staged changes in this + worktree; when committing docs beside in-flight code, use + path-scoped commits (`git commit --only `). + +## 8. Wider state (context, not tasks) + +- Publishing: `@prisma/cli-engine@0.0.1` is on npm (operator's + manual initial publish); OIDC trusted publishing is configured; + the repo's publish machinery is prisma/prisma's verbatim at + lockstep `8.0.0-rc.1` (root package.json; engine's package.json + must stay at 8.0.0-rc.1). Merged `chore(release)` bump PRs + publish to `latest`; ordinary main pushes publish `-dev.N`. +- S2b (resources) and S2c (services) run with independent agents in + `.claude/worktrees/s2b-resources` and their own worktree; briefs + at `.drive/projects/prisma-cli-v8/assets/briefs/ + {s2b-handover,s2c-handover}.md`. Standing relays already sent to + them: no TTY reads in commands (`needs.interaction` + + browserWait), no hand-rolled consent flags (`--confirm ` + is engine-owned), `git connect` ports against browserWait. +- Operator question ledger + standing S2 rulings: + `.drive/projects/prisma-cli-v8/specs/s2-overview.md`. +- The normative engine interface commentary: + `.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts` + (amended to rev 5 this session). + +Your first report to Will: confirm you read the design doc and this +brief, state the disposition of `4b006d1` per finding, and give +your plan for §4 step 2. Then execute. From 54414f5f8f222d29797e2c7946ce3a26f338fa42 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 18:28:29 +0200 Subject: [PATCH 47/67] fix(cli): finish the credential-manager verification remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the partial state committed as 4b006d1 and runs every suite against it for the first time. Finding 1 (blocker) now has the tests it was missing: a refresh that throws something other than an AuthError maps to CLI.AUTH_SERVICE_ERROR at exit 2 rather than escaping as the raw cause, and a plain error from outside the refresh path still settles as an internal error. Reverting the mapping makes the first test fail, so it holds the behaviour down. Finding 2 gets its tests too: with PRISMA_NEXT_DEBUG=1 the engine records the refresh attempt and the endpoint's verdict on stderr, and with the variable unset it writes nothing. Finding 7's two-process refresh test never reached the retry. The test JWT minter is a pure function of its claims, so every token minted for one workspace came out as the same string and the rotated token was byte-identical to the seed the server answers 401 for. Tokens now carry a marker claim, and the scripted endpoint holds both refresh requests until both have arrived, so the replay really does land inside the ten-second reuse grace instead of after the first exchange was written. Finding 14's leak scan now covers all three refresh-failure shapes — invalid_grant, a 5xx, and a rotated token the SDK cannot decode — with the debug valve open. Also applies the formatter to the files 4b006d1 left unformatted, which was the whole of the root lint failure. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-engine/src/execution/api-client.ts | 12 +- .../cli-engine/tests/management-api.test.ts | 132 ++++++++++++++++++ .../credential-manager-processes.test.ts | 50 +++++-- packages/cli/tests/credential-manager.test.ts | 28 ++-- 4 files changed, 195 insertions(+), 27 deletions(-) diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index a025912b..5b6619d9 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -79,7 +79,13 @@ export function buildManagementApiClient( } return result; } catch (cause) { - throw await mapRequestFailure(invocation, debug, probe, binding, cause); + throw await mapRequestFailure( + invocation, + debug, + probe, + binding, + cause, + ); } }; }, @@ -238,7 +244,9 @@ async function mapRequestFailure( } // Only the error's type is reported: an arbitrary message can // carry fragments of a decoded token payload. - debug(`refresh failed without an AuthError (${errorTypeOf(probe.failure)})`); + debug( + `refresh failed without an AuthError (${errorTypeOf(probe.failure)})`, + ); return authServiceError(); } if (cameFromRefresh) { diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 5db0afee..c45f9e3a 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -433,6 +433,138 @@ describe("the stored-session refresh path", () => { }); }); +describe("a refresh that fails without an AuthError", () => { + /** The rotated token carries no workspace_id, so the SDK's own + * extraction throws a plain Error from inside the refresh. */ + const rotationTheSdkCannotDecode = () => + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(200, { + access_token: mintTestJwt({ sub: "user-1" }), + refresh_token: "refresh-2", + }) + : jsonResponse(401, { message: "unauthorized" }), + ); + + test("maps to CLI.AUTH_SERVICE_ERROR, not to the raw cause; nothing cleared", async () => { + rotationTheSdkCannotDecode(); + const cli = createTestCli({ + commands: { toy: callApi }, + sessions: [sessionSeed("workspace-1", "refresh-1")], + currentWorkspaceId: "workspace-1", + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + const result = json.find((frame) => frame.kind === "result"); + expect(result).toMatchObject({ + envelope: { ok: false, error: { code: "CLI.AUTH_SERVICE_ERROR" } }, + }); + expect(cli.credentialManager?.state().sessions).toHaveLength(1); + }); + + test("a plain error from outside the refresh path still settles as a bug", async () => { + scriptFetch(() => jsonResponse(200, {})); + const runtime = makeRuntime({ + credentialManager: fakeCredentialManager({ + currentSession: async () => storedSession("workspace-1"), + tokenStorage: () => ({ + getTokens: async () => { + throw new Error("something unrelated broke"); + }, + setTokens: async () => {}, + clearTokens: async () => {}, + }), + }), + }); + const exitCode = await runEngine(callApi, runtime); + expect(exitCode).toBe(1); + expect(runtime.stdoutText()).toContain('"code":"CLI.INTERNAL_ERROR"'); + }); +}); + +describe("the engine's debug valve", () => { + const refreshRejectedAsInvalidGrant = () => + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(400, { + error: "invalid_grant", + error_description: "refresh token already used", + }) + : jsonResponse(401, { message: "unauthorized" }), + ); + + const cliWithDebug = () => + createTestCli({ + commands: { toy: callApi }, + sessions: [sessionSeed("workspace-1", "SECRET-REFRESH-TOKEN")], + currentWorkspaceId: "workspace-1", + managementApiClientConfig: CLIENT_CONFIG, + }); + + test("PRISMA_NEXT_DEBUG=1 records the refresh attempt and the endpoint's verdict", async () => { + refreshRejectedAsInvalidGrant(); + const { stderr } = await cliWithDebug().run(["toy", "--json"], { + env: { PRISMA_NEXT_DEBUG: "1" }, + }); + expect(stderr).toContain("refresh attempted for session workspace-1"); + expect(stderr).toContain("refresh failed: refreshTokenInvalid=true"); + expect(stderr).toContain("refresh token already used"); + }); + + test("a refresh that throws no AuthError is recorded by type alone", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(200, { + access_token: mintTestJwt({ sub: "user-1" }), + refresh_token: "refresh-2", + }) + : jsonResponse(401, { message: "unauthorized" }), + ); + const { stderr } = await cliWithDebug().run(["toy", "--json"], { + env: { PRISMA_NEXT_DEBUG: "1" }, + }); + expect(stderr).toContain("refresh failed without an AuthError (Error)"); + }); + + test("the valve is silent when it is unset", async () => { + refreshRejectedAsInvalidGrant(); + const { stderr } = await cliWithDebug().run(["toy", "--json"]); + expect(stderr).toBe(""); + }); + + test("no token material reaches any refresh-failure path with the valve open", async () => { + const failures = [ + jsonResponse(400, { error: "invalid_grant" }), + jsonResponse(500, { message: "boom" }), + jsonResponse(200, { + access_token: mintTestJwt({ sub: "user-1" }), + refresh_token: "SECRET-ROTATED-TOKEN", + }), + ]; + for (const failure of failures) { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? failure.clone() + : jsonResponse(401, { message: "unauthorized" }), + ); + const { stderr, stdout, json } = await cliWithDebug().run( + ["toy", "--json"], + { env: { PRISMA_NEXT_DEBUG: "1" } }, + ); + const everything = stderr + stdout + JSON.stringify(json); + expect(stderr).toContain("refresh attempted for session workspace-1"); + for (const material of [ + "SECRET-REFRESH-TOKEN", + "SECRET-ROTATED-TOKEN", + accessTokenFor("workspace-1", "initial"), + ]) { + expect(everything).not.toContain(material); + } + } + }); +}); + describe("the environment-session static path", () => { const environmentToken = mintTestJwt({ sub: "svc-1", diff --git a/packages/cli/tests/credential-manager-processes.test.ts b/packages/cli/tests/credential-manager-processes.test.ts index 0f943abd..8dc8516e 100644 --- a/packages/cli/tests/credential-manager-processes.test.ts +++ b/packages/cli/tests/credential-manager-processes.test.ts @@ -31,8 +31,10 @@ const servers: Server[] = []; /** Every worker's stderr, for the leak scan. */ const workerStderr: string[] = []; -function mintToken(workspaceId: string) { - return mintTestJwt({ workspace_id: workspaceId }); +/** The minter is a pure function of its claims, so two tokens for one + * workspace need a marker claim to come out as different strings. */ +function mintToken(workspaceId: string, marker = "seed") { + return mintTestJwt({ workspace_id: workspaceId, token: marker }); } function runWorker(command: string, ...args: string[]): Promise { @@ -84,6 +86,10 @@ async function startTokenEndpoint(script: { readonly seedAccessToken: string; readonly seedRefreshToken: string; readonly issued: readonly TokenPair[]; + /** Hold every token request until this many have arrived, so the + * replay really does land inside the grace rather than after the + * first exchange has already been written back. */ + readonly concurrentRefreshers?: number; }): Promise<{ readonly baseUrl: string; readonly exchanges: () => number; @@ -93,6 +99,15 @@ async function startTokenEndpoint(script: { ]); let exchanges = 0; + const expected = script.concurrentRefreshers ?? 1; + const arrived: (() => void)[] = []; + const allArrived = (): Promise => + new Promise((resolve) => { + arrived.push(resolve); + if (arrived.length < expected) return; + for (const release of arrived.splice(0, arrived.length)) release(); + }); + const respond = ( response: ServerResponse, status: number, @@ -130,14 +145,16 @@ async function startTokenEndpoint(script: { body += String(chunk); }); request.on("end", () => { - const pair = exchange(new URLSearchParams(body).get("refresh_token")); - if (pair === null) { - respond(response, 400, { error: "invalid_grant" }); - return; - } - respond(response, 200, { - access_token: pair.accessToken, - refresh_token: pair.refreshToken, + void allArrived().then(() => { + const pair = exchange(new URLSearchParams(body).get("refresh_token")); + if (pair === null) { + respond(response, 400, { error: "invalid_grant" }); + return; + } + respond(response, 200, { + access_token: pair.accessToken, + refresh_token: pair.refreshToken, + }); }); }); return; @@ -190,13 +207,20 @@ describe("across processes", () => { it("leaves a valid pair in the file when two processes really refresh the same session", async () => { const seedAccessToken = mintToken(WORKSPACE_A); const issued = [ - { accessToken: mintToken(WORKSPACE_A), refreshToken: "refresh-1" }, - { accessToken: mintToken(WORKSPACE_A), refreshToken: "refresh-2" }, + { + accessToken: mintToken(WORKSPACE_A, "rotated-1"), + refreshToken: "refresh-1", + }, + { + accessToken: mintToken(WORKSPACE_A, "rotated-2"), + refreshToken: "refresh-2", + }, ]; const endpoint = await startTokenEndpoint({ seedAccessToken, seedRefreshToken: "refresh-0", issued, + concurrentRefreshers: 2, }); await runWorker("create", WORKSPACE_A, seedAccessToken, "refresh-0"); @@ -288,7 +312,7 @@ describe("across processes", () => { it("never leaves token material in a worker's stdout or stderr", async () => { const seedAccessToken = mintToken(WORKSPACE_A); const rotated = { - accessToken: mintToken(WORKSPACE_A), + accessToken: mintToken(WORKSPACE_A, "rotated"), refreshToken: "s3cret-rotated-refresh", }; const endpoint = await startTokenEndpoint({ diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index 8931655c..f8060936 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -174,17 +174,19 @@ describe("the state file", () => { const realRename = fsPromises.rename.bind(fsPromises); const opens = vi .spyOn(fsPromises, "open") - .mockImplementation(async (...args: Parameters) => { - const handle = await realOpen(...args); - if (!String(args[0]).endsWith(".tmp")) return handle; - order.push(`open ${String(args[0])}`); - const sync = handle.sync.bind(handle); - handle.sync = async () => { - order.push("sync"); - await sync(); - }; - return handle; - }); + .mockImplementation( + async (...args: Parameters) => { + const handle = await realOpen(...args); + if (!String(args[0]).endsWith(".tmp")) return handle; + order.push(`open ${String(args[0])}`); + const sync = handle.sync.bind(handle); + handle.sync = async () => { + order.push("sync"); + await sync(); + }; + return handle; + }, + ); const renames = vi .spyOn(fsPromises, "rename") .mockImplementation(async (from, to) => { @@ -386,7 +388,9 @@ describe("mutations under an environment session", () => { await expect(manager.useSession(sessionA as never)).resolves.toMatchObject({ workspaceId: WORKSPACE_A, }); - await expect(manager.endSession(sessionA as never)).resolves.toBeUndefined(); + await expect( + manager.endSession(sessionA as never), + ).resolves.toBeUndefined(); await expect(manager.endAllSessions()).resolves.toBeUndefined(); expect(await readCredentialState(stateFilePath)).toMatchObject({ From 47e53c09eeafe3c69d71cb718abb05d337aa6aab Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 18:46:07 +0200 Subject: [PATCH 48/67] fix(cli-engine): defects the credential-manager re-verification found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request-failure mapping checks for a CLI structured error before it checks whether the failure came from the refresh path, and that order is what lets a manager-raised error survive. Nothing held the order down: swapping the two blocks left every suite green, while in production a session ended by another process mid-rotation would have surfaced as the transient auth-service error — "try again" — instead of telling the user their session had ended. There is now a test that fails when the order is swapped. The test harness made that case unreachable, because its TokenStorage threw a plain error where the real manager throws a structured one; it now throws the same error, with a test pinning the agreement. The engine built the environment-session bearer from the raw variable while the manager composed the session from the trimmed value, so PRISMA_SERVICE_TOKEN=" tok " would report a valid session and then send a padded bearer on the wire. The legacy path trimmed. The engine now trims too. The debug valve echoed the token endpoint's error_description verbatim, which is free text the auth service chooses, immediately below a branch that deliberately logs only an error's type for exactly that reason. It now logs the endpoint's verdict without the description: the OAuth error field for a 4xx, and the SDK's own message — which carries the HTTP status — otherwise. That is what the design asks for. The reads-never-write probe also now spies the synchronous rename, rm and open calls, and asserts a positive control on `open` as well as `rename`, so its breadth is real rather than nominal. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-engine/src/execution/api-client.ts | 29 ++++++-- .../src/testing-credential-manager.ts | 7 +- .../tests/credential-manager.test.ts | 33 +++++++++ .../cli-engine/tests/management-api.test.ts | 68 ++++++++++++++++++- packages/cli/tests/credential-manager.test.ts | 7 ++ 5 files changed, 135 insertions(+), 9 deletions(-) diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 5b6619d9..5c4648ce 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -115,11 +115,15 @@ async function constructClient( ); } if (session.source === "environment") { - const token = invocation.runtime.env[SERVICE_TOKEN_ENV_VAR]; - if (token === undefined) { + const raw = invocation.runtime.env[SERVICE_TOKEN_ENV_VAR]; + if (raw === undefined) { throw credentialsRequiredError(); } - if (token.trim() === "") { + // Trimmed, exactly as the manager composes the session from it: + // the bearer on the wire and the session it belongs to must be + // built from the same value. + const token = raw.trim(); + if (token === "") { throw emptyServiceTokenError({ envVar: SERVICE_TOKEN_ENV_VAR }); } const { createManagementApiClient } = await import( @@ -232,6 +236,10 @@ async function mapRequestFailure( binding: ClientBinding | undefined, cause: unknown, ): Promise { + // Before anything else: a CLI structured error raised inside the + // request pipeline — including one the manager raised from a + // rotation write — is already the honest answer and must surface as + // itself rather than being folded into the transient error. const structured = structuredCause(cause); if (structured !== undefined) { return structured; @@ -253,7 +261,7 @@ async function mapRequestFailure( debug( `refresh failed: refreshTokenInvalid=${String( authError.refreshTokenInvalid === true, - )} error=${authError.message}`, + )} error=${endpointVerdict(authError.message)}`, ); } if (authError.refreshTokenInvalid === true) { @@ -343,3 +351,16 @@ function refreshPathFailed(probe: RefreshProbe, cause: unknown): boolean { function errorTypeOf(error: unknown): string { return error instanceof Error ? error.name : typeof error; } + +/** + * The endpoint's verdict, without its free text. The SDK builds a + * refresh AuthError's message either as `: ` + * from the token endpoint's body or as one of its own fixed strings + * (which carry the HTTP status). Only the part before the description + * is logged: the description is text the auth service chose, and the + * debug valve must never become a way for it to reach a log. + */ +function endpointVerdict(message: string): string { + const description = message.indexOf(": "); + return description === -1 ? message : message.slice(0, description); +} diff --git a/packages/cli-engine/src/testing-credential-manager.ts b/packages/cli-engine/src/testing-credential-manager.ts index c5446cf7..61bdc215 100644 --- a/packages/cli-engine/src/testing-credential-manager.ts +++ b/packages/cli-engine/src/testing-credential-manager.ts @@ -211,9 +211,10 @@ export class TestCredentialManager implements CredentialManager { setTokens: async (tokens) => { const record = boundRecord(); if (record === undefined) { - throw new Error( - "@prisma/cli-engine/testing: the session this rotation belongs to has ended — a refresh write must not resurrect it", - ); + // The same structured error the real manager raises, so a + // test of "the session ended mid-rotation" exercises the + // production mapping rather than a harness-only shape. + throw credentialsRequiredError("session-ended"); } const claimed = claimedWorkspaceId(tokens.accessToken); if (claimed !== undefined && claimed !== workspaceId) { diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 8162d8f5..85f1c9f5 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -608,4 +608,37 @@ describe("harness seed validation", () => { }), ).toThrow(/legacy `credentials` seed/); }); + + test("a rotation write onto an ended session refuses with the same structured error the real manager raises", async () => { + const toy = defineCommand({ + help: { summary: "Does nothing" }, + handler: async (_args, ctx) => { + return ok(ctx.present({ data: null }, { human: () => [] })); + }, + }); + const cli = createTestCli({ + commands: { toy }, + sessions: [ + { + workspaceId: "workspace-1", + workspaceName: undefined, + credential: { + token: mintTestJwt({ workspace_id: "workspace-1" }), + refreshToken: "refresh-1", + expiresAt: undefined, + }, + }, + ], + currentWorkspaceId: "workspace-1", + }); + const storage = cli.credentialManager?.tokenStorage("workspace-gone"); + + await expect( + storage?.setTokens({ + workspaceId: "workspace-gone", + accessToken: mintTestJwt({ workspace_id: "workspace-gone" }), + refreshToken: "refresh-2", + }), + ).rejects.toMatchObject({ code: "CLI.CREDENTIALS_REQUIRED" }); + }); }); diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index c45f9e3a..1e604486 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -463,6 +463,39 @@ describe("a refresh that fails without an AuthError", () => { expect(cli.credentialManager?.state().sessions).toHaveLength(1); }); + test("a structured error raised BY the rotation write surfaces as itself, not as the transient error", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(200, { + access_token: accessTokenFor("workspace-1", "rotated"), + refresh_token: "refresh-2", + }) + : jsonResponse(401, { message: "unauthorized" }), + ); + const runtime = makeRuntime({ + credentialManager: fakeCredentialManager({ + currentSession: async () => storedSession("workspace-1"), + tokenStorage: () => ({ + getTokens: async () => ({ + workspaceId: "workspace-1", + accessToken: accessTokenFor("workspace-1", "initial"), + refreshToken: "refresh-1", + }), + // Another process ended this session while the exchange was + // in flight, so the write refuses instead of resurrecting it. + setTokens: async () => { + throw credentialsRequiredError("session-ended"); + }, + clearTokens: async () => {}, + }), + }), + }); + const exitCode = await runEngine(callApi, runtime); + expect(exitCode).toBe(2); + expect(runtime.stdoutText()).toContain('"code":"CLI.CREDENTIALS_REQUIRED"'); + expect(runtime.stdoutText()).toContain("has ended"); + }); + test("a plain error from outside the refresh path still settles as a bug", async () => { scriptFetch(() => jsonResponse(200, {})); const runtime = makeRuntime({ @@ -508,8 +541,25 @@ describe("the engine's debug valve", () => { env: { PRISMA_NEXT_DEBUG: "1" }, }); expect(stderr).toContain("refresh attempted for session workspace-1"); - expect(stderr).toContain("refresh failed: refreshTokenInvalid=true"); - expect(stderr).toContain("refresh token already used"); + expect(stderr).toContain( + "refresh failed: refreshTokenInvalid=true error=invalid_grant", + ); + // The endpoint's own free text is deliberately not echoed. + expect(stderr).not.toContain("refresh token already used"); + }); + + test("a non-OAuth refresh failure logs the SDK's verdict, which carries the status", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(503, { message: "boom" }) + : jsonResponse(401, { message: "unauthorized" }), + ); + const { stderr } = await cliWithDebug().run(["toy", "--json"], { + env: { PRISMA_NEXT_DEBUG: "1" }, + }); + expect(stderr).toContain( + "refresh failed: refreshTokenInvalid=false error=Token request failed with status 503", + ); }); test("a refresh that throws no AuthError is recorded by type alone", async () => { @@ -594,6 +644,20 @@ describe("the environment-session static path", () => { expect(calls[0].authorization).toBe(`Bearer ${environmentToken}`); }); + test("the bearer is the trimmed token, matching the session composed from it", async () => { + const calls = scriptFetch(() => jsonResponse(200, { workspaces: [] })); + const cli = createTestCli({ + commands: { toy: callApi }, + environmentToken, + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode } = await cli.run(["toy"], { + env: { PRISMA_SERVICE_TOKEN: ` ${environmentToken}\n` }, + }); + expect(exitCode).toBe(0); + expect(calls[0].authorization).toBe(`Bearer ${environmentToken}`); + }); + test("a successful env-session request passes its data through", async () => { scriptFetch(() => jsonResponse(200, { workspaces: ["env"] })); let seen: unknown; diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index f8060936..e935070f 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -136,7 +136,13 @@ describe("the state file", () => { vi.spyOn(nodeFs, "truncateSync"), vi.spyOn(nodeFs, "writeSync"), vi.spyOn(nodeFs, "unlinkSync"), + vi.spyOn(nodeFs, "renameSync"), + vi.spyOn(nodeFs, "rmSync"), + vi.spyOn(nodeFs, "openSync"), ]; + // These two are the pair a real write goes through, so both carry + // a positive control below: a probe that cannot see a write proves + // nothing about reads. const renames = vi.spyOn(fsPromises, "rename"); const opens = vi.spyOn(fsPromises, "open"); try { @@ -159,6 +165,7 @@ describe("the state file", () => { await manager.endAllSessions(); expect(renames).toHaveBeenCalled(); + expect(opens).toHaveBeenCalled(); } finally { for (const spy of spies) { spy.mockRestore(); From 5e0622304c9d38716ae77e9d38b1363f3384f9ed Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 19:17:37 +0200 Subject: [PATCH 49/67] fix(cli): three defects in the credential file's write and lock paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing a crashed holder's lock was not atomic. Two waiting processes could both see the same stale lock, and the second one's unlink removed the FIRST one's freshly created lock — both then ran their read-modify-write at once and one update was silently lost, which is the one thing the lock exists to prevent, in the one situation the takeover path exists for. Clearing is now a rename, which only one process can win; the loser waits. A test holds both waiters until each has seen the lock as stale and asserts exactly one of them reports a takeover. It fails against the unlink version on every run. A takeover that could not remove the lock still reported success, and that success made the acquisition loop skip both the timeout check and the sleep. With a stale lock in a directory the process cannot write to, the loop ran flat out forever: no timeout, one core pinned, the command never returning. The timeout is now checked on every pass and the sleep is skipped only after a takeover that really happened. The test for this finishes in under a second against the fix and hangs until the test timeout against the defect. A write that failed after opening its temp file left that file on disk holding the whole state, tokens included, under a name nothing looks for again — so someone running `auth logout` to revoke local access could be left with a working refresh token. Every path out of the write now removes the temp file, and `endAllSessions` reaps any that an earlier crash left behind. Also removes a duplicated file comment. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/auth/credential-manager.ts | 19 +++ packages/cli/src/auth/state-file.ts | 47 ++++-- packages/cli/tests/credential-manager.test.ts | 145 +++++++++++++++++- 3 files changed, 193 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index 7923098a..b77be20e 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; import type { Credential, CredentialManager, @@ -231,11 +232,13 @@ export class FileCredentialManager implements CredentialManager { }); } await this.#reapLegacyContextFile(); + await this.#reapOrphanedWrites(); return; } await this.#mutate(() => ({ state: EMPTY_STATE, result: undefined })); await this.#reapLegacyContextFile(); + await this.#reapOrphanedWrites(); this.#pin = { kind: "marker", workspaceId: null }; } @@ -348,6 +351,22 @@ export class FileCredentialManager implements CredentialManager { await fs.unlink(getAuthContextFilePath(this.#filePath)).catch(() => {}); } + /** A write that died between creating its temp file and renaming it + * leaves a full copy of the state, tokens and all. Someone running + * `auth logout` to revoke local access must not be left holding a + * working refresh token in an orphan. Writes take the lock and last + * milliseconds, so anything still here is one. */ + async #reapOrphanedWrites(): Promise { + const directory = path.dirname(this.#filePath); + const prefix = `${path.basename(this.#filePath)}.`; + const entries = await fs.readdir(directory).catch(() => []); + await Promise.all( + entries + .filter((entry) => entry.startsWith(prefix) && entry.endsWith(".tmp")) + .map((entry) => fs.unlink(path.join(directory, entry)).catch(() => {})), + ); + } + #environmentSession(): Session { const token = this.#environmentToken(); if (token === undefined) { diff --git a/packages/cli/src/auth/state-file.ts b/packages/cli/src/auth/state-file.ts index 3fddd3ad..aede3ece 100644 --- a/packages/cli/src/auth/state-file.ts +++ b/packages/cli/src/auth/state-file.ts @@ -172,15 +172,18 @@ export async function writeCredentialState( ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${randomUUID()}.tmp`; - const handle = await fs.open(tempPath, "wx", FILE_MODE); - try { - await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - + // The temp file holds the whole state, tokens included, so no path + // out of here may leave one behind: a write that fails after the + // handle is open would otherwise strand a working credential copy + // under a name nothing later looks for. try { + const handle = await fs.open(tempPath, "wx", FILE_MODE); + try { + await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } await fs.rename(tempPath, filePath); } catch (error) { await fs.unlink(tempPath).catch(() => {}); @@ -241,12 +244,16 @@ async function acquireStateLock( while (true) { if (await tryCreateStateLock(lockPath, lockId)) return lockId; - if (await takeOverStaleStateLock(lockPath, debug)) continue; - + const tookOver = await takeOverStaleStateLock(lockPath, debug); + // The timeout is checked on every pass, including the ones that + // took a lock over: a takeover that keeps appearing to succeed + // must still end in a timeout rather than spinning. if (Date.now() - startedAt >= LOCK_WAIT_TIMEOUT_MS) { throw new StateLockTimeoutError(lockPath); } - await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); + if (!tookOver) { + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); + } } } @@ -269,6 +276,14 @@ async function tryCreateStateLock( return true; } +/** + * Clear a crashed holder's lock. Removing it by RENAME is what makes + * two waiting processes safe: only one of them can rename a given + * path, so only one clears the corpse. Unlinking instead lets the + * second process delete the FIRST one's freshly created lock — both + * then run their read-modify-write at once and one update is lost, + * which is the very thing the lock exists to prevent. + */ async function takeOverStaleStateLock( lockPath: string, debug: DebugLog, @@ -277,8 +292,16 @@ async function takeOverStaleStateLock( if (!stats) return true; if (Date.now() - stats.mtimeMs <= LOCK_STALE_MS) return false; + const takenPath = `${lockPath}.${randomUUID()}.stale`; + try { + await fs.rename(lockPath, takenPath); + } catch { + // Someone else took it over, or it was released — go round again + // rather than reporting a takeover that did not happen. + return false; + } + await fs.unlink(takenPath).catch(() => {}); debug(`lock taken over from a crashed holder ${lockPath}`); - await fs.unlink(lockPath).catch(() => {}); return true; } diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index e935070f..4e433efd 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -1,9 +1,3 @@ -/** - * The credential manager over its state file: the file format and its - * atomicity, process pinning, the env override rules, the TokenStorage - * write slices, and the legacy migration. - */ - /** * The credential manager over its state file: the file format and its * atomicity, process pinning, the env override rules, the TokenStorage @@ -15,6 +9,7 @@ import fsPromises, { readdir, readFile, stat, + utimes, writeFile, } from "node:fs/promises"; import os from "node:os"; @@ -30,8 +25,21 @@ function escapeForRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** Releases every caller once `expected` of them are waiting; callers + * after that pass straight through. */ +function barrierFor(expected: number): () => Promise { + const waiting: (() => void)[] = []; + return () => + new Promise((resolve) => { + waiting.push(resolve); + if (waiting.length < expected) return; + for (const release of waiting.splice(0, waiting.length)) release(); + }); +} + const WORKSPACE_A = "wksp_a"; const WORKSPACE_B = "wksp_b"; +const WORKSPACE_C = "wksp_c"; let stateFilePath: string; @@ -227,6 +235,131 @@ describe("the state file", () => { ).toEqual([]); }); + it("leaves no temp file behind when the write fails before the rename", async () => { + const realOpen = fsPromises.open.bind(fsPromises); + const opens = vi + .spyOn(fsPromises, "open") + .mockImplementation( + async (...args: Parameters) => { + const handle = await realOpen(...args); + if (!String(args[0]).endsWith(".tmp")) return handle; + handle.sync = async () => { + throw Object.assign(new Error("no space left on device"), { + code: "ENOSPC", + }); + }; + return handle; + }, + ); + try { + await expect( + makeManager().createSession(credentialFor(WORKSPACE_A), WORKSPACE_A), + ).rejects.toThrow(/no space left/); + } finally { + opens.mockRestore(); + } + + // A stranded temp file holds the whole state, tokens included. + expect( + (await readdir(path.dirname(stateFilePath))).filter((entry) => + entry.endsWith(".tmp"), + ), + ).toEqual([]); + }); + + it("lets only one of two waiting mutations clear the same crashed holder's lock", async () => { + await makeManager().createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const lockPath = `${stateFilePath}.lock`; + await writeFile(lockPath, "crashed-holder", "utf8"); + const longAgo = new Date(Date.now() - 60_000); + await utimes(lockPath, longAgo, longAgo); + + // Hold both waiters until each has seen the lock as stale. That is + // the interleaving the takeover has to survive: if clearing it is + // not atomic, the second waiter deletes the first waiter's fresh + // lock and both run their read-modify-write at once. + const bothSawItStale = barrierFor(2); + const realStat = fsPromises.stat.bind(fsPromises); + const stats = vi + .spyOn(fsPromises, "stat") + .mockImplementation( + async (...args: Parameters) => { + const result = await realStat(...args); + if (String(args[0]).endsWith(".lock")) await bothSawItStale(); + return result; + }, + ); + + const debugLines: string[] = []; + try { + await Promise.all( + [WORKSPACE_B, WORKSPACE_C].map((workspaceId) => + makeManager({ + env: { PRISMA_NEXT_DEBUG: "1" }, + debugWrite: (text) => debugLines.push(text), + }).createSession(credentialFor(workspaceId), workspaceId), + ), + ); + } finally { + stats.mockRestore(); + } + + const takeovers = debugLines.filter((line) => line.includes("taken over")); + expect(takeovers).toHaveLength(1); + const state = await readCredentialState(stateFilePath); + expect( + [...state.sessions.map((session) => session.workspaceId)].sort(), + ).toEqual([WORKSPACE_A, WORKSPACE_B, WORKSPACE_C]); + }); + + it("times out instead of spinning when a stale lock cannot be cleared at all", async () => { + const lockPath = `${stateFilePath}.lock`; + await writeFile(lockPath, "crashed-holder", "utf8"); + const longAgo = new Date(Date.now() - 60_000); + await utimes(lockPath, longAgo, longAgo); + + // A stale lock in a directory the process cannot write to: every + // attempt to clear it fails. The acquisition loop must still reach + // its timeout rather than retrying flat out forever. + let attempts = 0; + const renames = vi + .spyOn(fsPromises, "rename") + .mockImplementation(async (from) => { + if (!String(from).endsWith(".lock")) throw new Error("unexpected"); + attempts += 1; + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }); + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const mutation = makeManager().createSession( + credentialFor(WORKSPACE_A), + WORKSPACE_A, + ); + const settled = expect(mutation).rejects.toMatchObject({ + code: "CLI.CREDENTIALS_LOCKED", + }); + await vi.advanceTimersByTimeAsync(11_000); + await settled; + } finally { + vi.useRealTimers(); + renames.mockRestore(); + } + + // Retries are paced by the sleep, not run flat out. + expect(attempts).toBeLessThan(2_000); + }); + + it("reaps a temp file a dead write left behind when the user logs out", async () => { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const orphan = `${stateFilePath}.abandoned.tmp`; + await writeFile(orphan, await readFile(stateFilePath, "utf8"), "utf8"); + + await manager.endAllSessions(); + + await expect(stat(orphan)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("treats a corrupt file as signed out and never rewrites it", async () => { await writeFile(stateFilePath, "{ not json", "utf8"); const before = await readRawState(); From a9195492a40ff5b96d514ed86a0d9c2916e5de37 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 20:06:59 +0200 Subject: [PATCH 50/67] refactor(cli-engine): name the in-memory credential manager for what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TestCredentialManager` was named for who uses it rather than what it is. It is not a stub: it implements the same session rules as the file-backed manager — pinning, upsert by workspace, the environment-override refusals, and all four TokenStorage write slices — and adds a seed and a state read-back. Tests are simply where it is most useful. `TestCredentialManager` becomes `InMemoryCredentialManager`, its seed and state types follow, and the module becomes `in-memory-credential-manager.ts`. `TestSessionRecord` becomes `SessionRecord` for the same reason: it is the manager's record, not a test's. `createTestCli` and `mintTestJwt` keep their names, because a test CLI and a test JWT minter are exactly what those are. This renames exports on the ./testing subpath. The engine is at 8.0.0-rc.1 and unpublished at that version, so nothing outside this repo consumes the old names. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/exports/testing.ts | 13 +++--- ...ger.ts => in-memory-credential-manager.ts} | 42 ++++++++++++------- packages/cli-engine/src/testing.ts | 14 +++---- .../tests/credential-manager.test.ts | 34 +++++++-------- packages/cli-engine/tests/engine.test.ts | 2 +- packages/cli/tests/v8-auth.test.ts | 6 +-- .../cli/tests/v8-golden-rendering.test.ts | 6 +-- packages/cli/tests/v8-whoami.test.ts | 6 +-- 8 files changed, 67 insertions(+), 56 deletions(-) rename packages/cli-engine/src/{testing-credential-manager.ts => in-memory-credential-manager.ts} (90%) diff --git a/packages/cli-engine/src/exports/testing.ts b/packages/cli-engine/src/exports/testing.ts index 6d775f26..9a6f8082 100644 --- a/packages/cli-engine/src/exports/testing.ts +++ b/packages/cli-engine/src/exports/testing.ts @@ -2,11 +2,12 @@ * The ./testing subpath: the in-memory test harness — the same engine * over in-memory streams. Implementation lives in ../testing.ts. */ -export { createTestCli, type TestCli } from "../testing"; + export { + InMemoryCredentialManager, + type InMemoryCredentialManagerSeed, + type InMemoryCredentialManagerState, mintTestJwt, - TestCredentialManager, - type TestCredentialManagerSeed, - type TestCredentialManagerState, - type TestSessionRecord, -} from "../testing-credential-manager"; + type SessionRecord, +} from "../in-memory-credential-manager"; +export { createTestCli, type TestCli } from "../testing"; diff --git a/packages/cli-engine/src/testing-credential-manager.ts b/packages/cli-engine/src/in-memory-credential-manager.ts similarity index 90% rename from packages/cli-engine/src/testing-credential-manager.ts rename to packages/cli-engine/src/in-memory-credential-manager.ts index 61bdc215..94952d8b 100644 --- a/packages/cli-engine/src/testing-credential-manager.ts +++ b/packages/cli-engine/src/in-memory-credential-manager.ts @@ -1,3 +1,12 @@ +/** + * A complete CredentialManager whose state lives in memory rather than + * in a file. It implements the same session rules as the file-backed + * one — pinning, upsert by workspace, the environment-override + * refusals, and the TokenStorage write slices — and adds a seed and a + * state read-back. Tests are what it is mostly used for, which is why + * it ships from the ./testing subpath alongside the JWT minter that + * produces tokens to seed it, but nothing about it is a stub. + */ import { Buffer } from "node:buffer"; import { credentialsRequiredError, @@ -13,17 +22,18 @@ import type { TokenStorage } from "./management-api"; const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; -/** A stored session with its credential material, as seeded into and - * read back from the test credential manager. */ -export interface TestSessionRecord { +/** A stored session with its credential material — what the manager + * holds, seeded in and read back out. Mirrors the state file's + * records. */ +export interface SessionRecord { readonly workspaceId: string; readonly workspaceName: string | undefined; readonly credential: Credential; } -export interface TestCredentialManagerSeed { +export interface InMemoryCredentialManagerSeed { /** Stored sessions, mirroring the state file's records. */ - readonly sessions?: readonly TestSessionRecord[]; + readonly sessions?: readonly SessionRecord[]; /** The file's current marker. */ readonly currentWorkspaceId?: string; /** Convenience seed: runs createSession's real claims derivation. @@ -35,8 +45,8 @@ export interface TestCredentialManagerSeed { } /** The whole stored state, readable back after a run. */ -export interface TestCredentialManagerState { - readonly sessions: readonly TestSessionRecord[]; +export interface InMemoryCredentialManagerState { + readonly sessions: readonly SessionRecord[]; readonly currentWorkspaceId: string | null; } @@ -89,13 +99,13 @@ type Pin = * own mutations move it. No persistence, no locking — those belong to * the real manager and its own tests. */ -export class TestCredentialManager implements CredentialManager { - private storedSessions: TestSessionRecord[]; +export class InMemoryCredentialManager implements CredentialManager { + private storedSessions: SessionRecord[]; private markedWorkspaceId: string | null; private readonly environmentToken: string | undefined; private pin: Pin = { kind: "unpinned" }; - constructor(seed: TestCredentialManagerSeed) { + constructor(seed: InMemoryCredentialManagerSeed) { this.storedSessions = [...(seed.sessions ?? [])]; this.markedWorkspaceId = seed.currentWorkspaceId ?? null; this.environmentToken = seed.environmentToken; @@ -110,7 +120,7 @@ export class TestCredentialManager implements CredentialManager { } } - state(): TestCredentialManagerState { + state(): InMemoryCredentialManagerState { return { sessions: [...this.storedSessions], currentWorkspaceId: this.markedWorkspaceId, @@ -120,7 +130,7 @@ export class TestCredentialManager implements CredentialManager { /** Applies a write as ANOTHER process would: the stored state * changes, but this process's pinned session does not move. */ overwriteStoredState(state: { - readonly sessions?: readonly TestSessionRecord[]; + readonly sessions?: readonly SessionRecord[]; readonly currentWorkspaceId?: string | null; }): void { if (state.sessions !== undefined) { @@ -194,7 +204,7 @@ export class TestCredentialManager implements CredentialManager { } tokenStorage(workspaceId: string): TokenStorage { - const boundRecord = (): TestSessionRecord | undefined => + const boundRecord = (): SessionRecord | undefined => this.storedSessions.find((record) => record.workspaceId === workspaceId); return { getTokens: async () => { @@ -324,7 +334,7 @@ export class TestCredentialManager implements CredentialManager { }; } - private asSession(record: TestSessionRecord): Session { + private asSession(record: SessionRecord): Session { return { workspaceId: record.workspaceId, workspaceName: record.workspaceName, @@ -347,7 +357,7 @@ export class TestCredentialManager implements CredentialManager { const existing = this.storedSessions.find( (stored) => stored.workspaceId === workspaceId, ); - const record: TestSessionRecord = { + const record: SessionRecord = { workspaceId, workspaceName: existing?.workspaceName, credential: { @@ -378,7 +388,7 @@ export class TestCredentialManager implements CredentialManager { } } - private validatedWorkspaceReference(session: Session): TestSessionRecord { + private validatedWorkspaceReference(session: Session): SessionRecord { if (session.source === "environment") { throw noSessionForWorkspaceError(session.workspaceId); } diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index faea7f3c..6049b840 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -3,6 +3,10 @@ import type { Credentials } from "./context"; import type { Credential } from "./credential-manager"; import type { EngineEvent, StreamEvent } from "./events"; import { buildEngine } from "./execution/engine"; +import { + InMemoryCredentialManager, + type SessionRecord, +} from "./in-memory-credential-manager"; import type { ManagementApiClient, ManagementApiClientConfig, @@ -10,10 +14,6 @@ import type { import type { PresentedResult } from "./presentation"; import type { RunSummary } from "./run-summary"; import type { Runtime } from "./runtime"; -import { - TestCredentialManager, - type TestSessionRecord, -} from "./testing-credential-manager"; export interface TestCli { /** @@ -23,7 +23,7 @@ export interface TestCli { * when the legacy `credentials` seed selected the getCredentials * fallback path. */ - readonly credentialManager: TestCredentialManager | undefined; + readonly credentialManager: InMemoryCredentialManager | undefined; run( argv: readonly string[], opts?: { @@ -97,7 +97,7 @@ export function createTestCli(spec: { * derivation on this credential (mint the token with mintTestJwt). */ readonly credential?: Credential; /** Stored sessions, mirroring the state file's records. */ - readonly sessions?: readonly TestSessionRecord[]; + readonly sessions?: readonly SessionRecord[]; /** The file's current marker. */ readonly currentWorkspaceId?: string; /** Composes the ephemeral env session; also exported to each run's @@ -135,7 +135,7 @@ export function createTestCli(spec: { const credentialManager = spec.credentials !== undefined ? undefined - : new TestCredentialManager({ + : new InMemoryCredentialManager({ sessions: spec.sessions, currentWorkspaceId: spec.currentWorkspaceId, credential: spec.credential, diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 85f1c9f5..0179d5bd 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -19,9 +19,9 @@ import { } from "@prisma/cli-engine/protocol"; import { createTestCli, + InMemoryCredentialManager, mintTestJwt, - TestCredentialManager, - type TestSessionRecord, + type SessionRecord, } from "@prisma/cli-engine/testing"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -42,7 +42,7 @@ const userCredential = (overrides?: { const sessionRecordFor = ( workspaceId: string, opts?: { readonly name?: string }, -): TestSessionRecord => ({ +): SessionRecord => ({ workspaceId, workspaceName: opts?.name, credential: { @@ -311,7 +311,7 @@ const codeOf = (thrown: unknown): string => (thrown as CliStructuredError).code; describe("session mutations and state read-back", () => { test("createSession upserts by workspaceId, preserves a recorded name, and sets the marker", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [sessionRecordFor("workspace-1", { name: "Acme Prod" })], currentWorkspaceId: "workspace-1", }); @@ -341,7 +341,7 @@ describe("session mutations and state read-back", () => { }); test("createSession refuses a workspaceId argument that disagrees with the workspace_id claim", async () => { - const manager = new TestCredentialManager({}); + const manager = new InMemoryCredentialManager({}); await expect( manager.createSession( userCredential({ workspaceId: "workspace-1" }), @@ -351,7 +351,7 @@ describe("session mutations and state read-back", () => { }); test("useSession switches the marker; an unknown workspace and an environment-source argument raise AUTH.NO_SESSION_FOR_WORKSPACE", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), @@ -382,7 +382,7 @@ describe("session mutations and state read-back", () => { }); test("endSession removes one session and clears the current only when it named it — no auto-promotion", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), @@ -401,7 +401,7 @@ describe("session mutations and state read-back", () => { }); test("endAllSessions clears every session and the marker", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), @@ -424,7 +424,7 @@ describe("mutations under an env-supplied session", () => { }); test("useSession and endSession refuse, naming the variable and the unset command; state is untouched", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [sessionRecordFor("workspace-1")], currentWorkspaceId: "workspace-1", environmentToken, @@ -446,7 +446,7 @@ describe("mutations under an env-supplied session", () => { }); test("endAllSessions refuses when stored sessions exist and succeeds as a no-op when there are none", async () => { - const withStored = new TestCredentialManager({ + const withStored = new InMemoryCredentialManager({ sessions: [sessionRecordFor("workspace-1")], environmentToken, }); @@ -455,12 +455,12 @@ describe("mutations under an env-supplied session", () => { ); expect(withStored.state().sessions).toHaveLength(1); - const withoutStored = new TestCredentialManager({ environmentToken }); + const withoutStored = new InMemoryCredentialManager({ environmentToken }); await expect(withoutStored.endAllSessions()).resolves.toBeUndefined(); }); test("createSession is allowed; the env token remains in force", async () => { - const manager = new TestCredentialManager({ environmentToken }); + const manager = new InMemoryCredentialManager({ environmentToken }); await manager.createSession( userCredential({ workspaceId: "workspace-1" }), "workspace-1", @@ -475,7 +475,7 @@ describe("mutations under an env-supplied session", () => { }); test("sessions() still lists stored sessions with the file's marked current", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), @@ -496,7 +496,7 @@ describe("mutations under an env-supplied session", () => { describe("process pinning", () => { test("the marker moved by another process between reads does not re-pin; a new manager picks up the new marker", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), @@ -513,7 +513,7 @@ describe("process pinning", () => { }); const movedState = manager.state(); - const newProcess = new TestCredentialManager({ + const newProcess = new InMemoryCredentialManager({ sessions: movedState.sessions, currentWorkspaceId: movedState.currentWorkspaceId ?? undefined, }); @@ -523,7 +523,7 @@ describe("process pinning", () => { }); test("this manager's own useSession moves the pin", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), @@ -540,7 +540,7 @@ describe("process pinning", () => { }); test("a pinned session ended by another process raises the session-ended wording on the next read", async () => { - const manager = new TestCredentialManager({ + const manager = new InMemoryCredentialManager({ sessions: [sessionRecordFor("workspace-1")], currentWorkspaceId: "workspace-1", }); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 66447087..5ec8fb67 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -38,7 +38,7 @@ describe("main export", () => { test("the ./testing subpath exposes exactly the harness", () => { expect(Object.keys(testing).sort()).toEqual([ - "TestCredentialManager", + "InMemoryCredentialManager", "createTestCli", "mintTestJwt", ]); diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index 44a16e9c..9a619771 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -15,7 +15,7 @@ import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, - type TestSessionRecord, + type SessionRecord, } from "@prisma/cli-engine/testing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -68,7 +68,7 @@ function credentialFor(workspaceId: string) { function record( workspaceId: string, workspaceName: string | undefined, -): TestSessionRecord { +): SessionRecord { return { workspaceId, workspaceName, @@ -91,7 +91,7 @@ function apiReturning(body: unknown): ManagementApiClient { } function makeCli(spec?: { - readonly sessions?: readonly TestSessionRecord[]; + readonly sessions?: readonly SessionRecord[]; readonly currentWorkspaceId?: string; readonly environmentToken?: string; readonly client?: ManagementApiClient; diff --git a/packages/cli/tests/v8-golden-rendering.test.ts b/packages/cli/tests/v8-golden-rendering.test.ts index 89f5f8ec..2359e40b 100644 --- a/packages/cli/tests/v8-golden-rendering.test.ts +++ b/packages/cli/tests/v8-golden-rendering.test.ts @@ -10,7 +10,7 @@ import { createTestCli, mintTestJwt, - type TestSessionRecord, + type SessionRecord, } from "@prisma/cli-engine/testing"; import { describe, expect, it } from "vitest"; @@ -18,7 +18,7 @@ import { authLogoutCommand } from "../src/v8/auth/logout"; import { authWorkspaceListCommand } from "../src/v8/auth/workspace-list"; import { authWorkspaceLogoutCommand } from "../src/v8/auth/workspace-logout"; -function record(workspaceId: string, workspaceName: string): TestSessionRecord { +function record(workspaceId: string, workspaceName: string): SessionRecord { return { workspaceId, workspaceName, @@ -30,7 +30,7 @@ function record(workspaceId: string, workspaceName: string): TestSessionRecord { }; } -function makeCli(sessions: readonly TestSessionRecord[], current?: string) { +function makeCli(sessions: readonly SessionRecord[], current?: string) { return createTestCli({ commands: { "auth logout": authLogoutCommand, diff --git a/packages/cli/tests/v8-whoami.test.ts b/packages/cli/tests/v8-whoami.test.ts index 1727cc67..5b3fe1b9 100644 --- a/packages/cli/tests/v8-whoami.test.ts +++ b/packages/cli/tests/v8-whoami.test.ts @@ -8,7 +8,7 @@ import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, - type TestSessionRecord, + type SessionRecord, } from "@prisma/cli-engine/testing"; import { describe, expect, it } from "vitest"; @@ -17,7 +17,7 @@ import { authWhoamiCommand } from "../src/v8/auth/whoami"; const EPOCH = () => new Date(0); const T0 = "1970-01-01T00:00:00.000Z"; -const SESSION: TestSessionRecord = { +const SESSION: SessionRecord = { workspaceId: "ws_123", workspaceName: "Acme Inc", credential: { @@ -55,7 +55,7 @@ const requiresCredentials = defineCommand({ }); function makeCli(options?: { - readonly sessions?: readonly TestSessionRecord[]; + readonly sessions?: readonly SessionRecord[]; readonly currentWorkspaceId?: string; readonly client?: ManagementApiClient; }) { From c463aa1ed574b9e5920b6bdd4d2c2e61110c9b7b Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 21:29:39 +0200 Subject: [PATCH 51/67] feat(cli-engine): a poll interval for browserWait and an open-url next action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are what S2b needs to finish `git connect`, and both are additive. `BrowserWaitRequest` gains an optional `interval`. Legacy `git connect` polls on PRISMA_CLI_GITHUB_INSTALL_POLL_INTERVAL_MS, and until now that value had nowhere to go: the engine polled on a private constant. The constant stays as the default, so every existing caller is unchanged. The test harness gains an optional `delay` for the same reason `now` is already there — a poll loop's cadence is only assertable if the test can see it. `NextAction` gains an `open-url` kind and a `url` field. Two legacy repo errors carry a GitHub app install URL in their follow-up steps, and every follow-up string currently becomes a `run-command`, so the envelope was telling consumers to execute a URL as a shell command. A URL is not a command. `renderNextAction` falls back to the url, so an open-url action prints its address the way a run-command prints its command; nothing in the engine switches on the kind, so machine consumers get the new one for free. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/context.ts | 3 ++ packages/cli-engine/src/execution/prompts.ts | 4 +-- .../cli-engine/src/execution/rendering.ts | 3 +- packages/cli-engine/src/protocol.ts | 11 +++++- packages/cli-engine/src/testing.ts | 10 +++++- packages/cli-engine/tests/events.test.ts | 36 +++++++++++++++++++ .../tests/interaction-affordances.test.ts | 33 +++++++++++++++++ packages/cli-engine/tests/protocol.test.ts | 26 ++++++++++++++ 8 files changed, 121 insertions(+), 5 deletions(-) diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 0d84fd74..802413bb 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -125,6 +125,9 @@ export interface BrowserWaitRequest { readonly poll: (signal: AbortSignal) => Promise; /** Milliseconds to keep polling before giving up. */ readonly timeout: number; + /** Milliseconds between polls. Defaults to the engine's own + * interval; a command with its own configurable cadence passes it. */ + readonly interval?: number; } /** diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 24c160d2..035b4325 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -411,7 +411,7 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { } return raw; }, - browserWait: async ({ url, message, poll, timeout }) => { + browserWait: async ({ url, message, poll, timeout, interval }) => { if (!state.interactive) { throw browserWaitUnavailable(message, url); } @@ -428,7 +428,7 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { throw browserWaitTimedOut(message, url, timeout); } await invocation.delay( - BROWSER_WAIT_POLL_INTERVAL_MS, + interval ?? BROWSER_WAIT_POLL_INTERVAL_MS, invocation.signal, ); } diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index bfc12c8f..e02bd331 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -148,7 +148,8 @@ export function writeDiagnostic( } export function renderNextAction(action: NextAction): string { - return `→ ${action.label}${action.command === undefined ? "" : `: ${action.command}`}`; + const target = action.command ?? action.url; + return `→ ${action.label}${target === undefined ? "" : `: ${target}`}`; } /** Populates docsUrl from the owning family's docsBaseUrl (base + code) diff --git a/packages/cli-engine/src/protocol.ts b/packages/cli-engine/src/protocol.ts index 8a39b467..843f1a5c 100644 --- a/packages/cli-engine/src/protocol.ts +++ b/packages/cli-engine/src/protocol.ts @@ -25,10 +25,19 @@ export interface Diagnostic { * The typed agent-facing follow-up action. */ export interface NextAction { - readonly kind: "run-command" | "user-choice" | "edit-file" | "done"; + readonly kind: + | "run-command" + | "open-url" + | "user-choice" + | "edit-file" + | "done"; readonly label: string; readonly command?: string; readonly commands?: readonly string[]; + /** The address an `open-url` action sends the user to. A URL is not + * a command: putting one in `command` tells a consumer to execute + * it. */ + readonly url?: string; readonly reason?: string; } diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index 6049b840..b1fa6d40 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -121,6 +121,9 @@ export function createTestCli(spec: { * to assert what was opened, or a thrower to exercise the * could-not-open path. */ readonly openUrl?: (url: string) => Promise | void; + /** Waiting is instant under test whatever this does; pass a spy to + * assert the interval a poll loop asked for. */ + readonly delay?: (ms: number, signal: AbortSignal) => Promise; }): TestCli { const managerSeeded = spec.credential !== undefined || @@ -158,7 +161,12 @@ export function createTestCli(spec: { }, /** Waiting is instant under test: browserWait's polling is driven * by the seeded clock, never by real time. */ - { now: spec.now, delay: async () => {} }, + { + now: spec.now, + delay: async (ms, signal) => { + await spec.delay?.(ms, signal); + }, + }, ); return { credentialManager, diff --git a/packages/cli-engine/tests/events.test.ts b/packages/cli-engine/tests/events.test.ts index 8af8d5fa..c3f84afe 100644 --- a/packages/cli-engine/tests/events.test.ts +++ b/packages/cli-engine/tests/events.test.ts @@ -105,6 +105,42 @@ describe("human rendering", () => { ); }); + test("an open-url next action renders its address, the same way a run-command renders its command", async () => { + const withActions = defineCommand({ + help: { summary: "Suggests both kinds of follow-up" }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: null }, + { + human: () => [], + next: () => [ + { + kind: "open-url", + label: "Install the Prisma GitHub app", + url: "https://github.com/apps/prisma/installations/new", + }, + { + kind: "run-command", + label: "Retry", + command: "prisma git connect", + }, + ], + }, + ), + ), + }); + const result = await createTestCli({ + commands: { probe: withActions }, + now: EPOCH, + }).run(["probe", "--format", "human"]); + + expect(result.stderr).toBe( + "→ Install the Prisma GitHub app: https://github.com/apps/prisma/installations/new\n" + + "→ Retry: prisma git connect\n", + ); + }); + test("--log-level warn filters info-grade commentary but keeps data lines", async () => { const result = await makeCli().run([ "noisy", diff --git a/packages/cli-engine/tests/interaction-affordances.test.ts b/packages/cli-engine/tests/interaction-affordances.test.ts index c3f700b1..406c1d90 100644 --- a/packages/cli-engine/tests/interaction-affordances.test.ts +++ b/packages/cli-engine/tests/interaction-affordances.test.ts @@ -293,6 +293,7 @@ describe("prompt.browserWait", () => { function waitCommand( poll: (signal: AbortSignal) => Promise, timeout = 60_000, + interval?: number, ) { return promptProbe(async (prompt) => { await prompt.browserWait({ @@ -300,11 +301,43 @@ describe("prompt.browserWait", () => { message: "Finish signing in", poll, timeout, + ...(interval === undefined ? {} : { interval }), }); return "done"; }); } + test("it polls on the interval the request asks for, and on its own when the request does not", async () => { + const intervals: number[] = []; + const runWith = async (interval?: number) => { + let polls = 0; + const cli = createTestCli({ + commands: { + probe: waitCommand( + async () => { + polls += 1; + return polls > 2; + }, + 60_000, + interval, + ), + }, + now: EPOCH, + delay: async (ms) => { + intervals.push(ms); + }, + }); + return cli.run(["probe"], INTERACTIVE); + }; + + expect((await runWith(2_000)).exitCode).toBe(0); + expect(intervals).toEqual([2_000, 2_000]); + + intervals.length = 0; + expect((await runWith()).exitCode).toBe(0); + expect(intervals).toEqual([1_000, 1_000]); + }); + test("it opens the browser and resolves when polling says so", async () => { const opened: string[] = []; let polls = 0; diff --git a/packages/cli-engine/tests/protocol.test.ts b/packages/cli-engine/tests/protocol.test.ts index 34de6afe..4ef5ba53 100644 --- a/packages/cli-engine/tests/protocol.test.ts +++ b/packages/cli-engine/tests/protocol.test.ts @@ -66,6 +66,32 @@ describe("CliStructuredError.toEnvelope", () => { }); }); + test("an open-url action carries its address through the envelope untouched", () => { + const error = new CliStructuredError( + "REPO.INSTALLATION_REQUIRED", + "Install the GitHub app first", + { + nextActions: [ + { + kind: "open-url", + label: "Install the Prisma GitHub app", + url: "https://github.com/apps/prisma/installations/new", + }, + ], + }, + ); + + // A URL must never arrive as `command`: that tells a consumer to + // execute it. + expect(error.toEnvelope().nextActions).toEqual([ + { + kind: "open-url", + label: "Install the Prisma GitHub app", + url: "https://github.com/apps/prisma/installations/new", + }, + ]); + }); + test("keeps nextActions as given even when a label repeats why", () => { const error = new CliStructuredError( "CONFIG.FILE_NOT_FOUND", From fee7d57bfa6f53b0798cbedc09a82db65ef70c80 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 21:38:31 +0200 Subject: [PATCH 52/67] fix(cli): latch the lock-takeover test's barrier so the loser cannot hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from S2b as a flake under full-suite load. It is not flakiness, it is a hang the winner usually outruns. The barrier releases both waiters once both have seen the stale lock, then resets to empty. The waiter that loses the takeover race sleeps and comes back for another look, and if the winner is still holding the lock at that moment it stats again — entering a fresh barrier cycle that needs two arrivals and will only ever get one. It blocks until vitest times the test out at five seconds. It normally passes because the winner finishes its whole mutation inside the loser's ten-millisecond sleep, so the loser's next attempt to create the lock succeeds and it never stats a second time. Load is what removes that margin, which is why adding test files makes it appear. The barrier now latches open, which is what its comment always claimed. Five runs in isolation and three full-suite runs pass, and it still fails on every run against the unlink version of the takeover, so it holds the behaviour down exactly as before. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/tests/credential-manager.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index 4e433efd..d98cbd24 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -25,16 +25,24 @@ function escapeForRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** Releases every caller once `expected` of them are waiting; callers - * after that pass straight through. */ +/** + * Releases every caller once `expected` of them are waiting, and stays + * open from then on so later callers pass straight through. Latching + * matters: the waiter that loses the race below comes back for another + * look, and a barrier that reset would hold it there forever. + */ function barrierFor(expected: number): () => Promise { const waiting: (() => void)[] = []; - return () => - new Promise((resolve) => { + let open = false; + return async () => { + if (open) return; + await new Promise((resolve) => { waiting.push(resolve); if (waiting.length < expected) return; + open = true; for (const release of waiting.splice(0, waiting.length)) release(); }); + }; } const WORKSPACE_A = "wksp_a"; From 9d6c3842049fc78c208b6c2e395bd45bd2c22f2d Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 21:42:02 +0200 Subject: [PATCH 53/67] =?UTF-8?q?drive(prisma-cli-v8):=20credential-manage?= =?UTF-8?q?r=20design=20rev=206=20=E2=80=94=20the=20environment=20credenti?= =?UTF-8?q?al=20is=20not=20a=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rev 5 modelled the PRISMA_SERVICE_TOKEN credential as a Session. It is not one, and forcing it into that shape produced four defects that are all the same defect: a `source` field to say "this is not really a session", a hardcoded `current: true` that gave the word two meanings, a `workspaceId: ""` because a non-session was made to carry a session's key, and a guard rejecting the non-session from APIs that only take sessions. Rev 6 separates the three things — a stored session, the selection, and the credential this process authenticates as — and all four stop existing. Refresh follows from the credential having a refresh token, not from where it came from, so the engine builds one client over storage the manager hands it: file-backed with no cache in front, or memory-backed touching no file at all. The custody rule is restated as what it always meant — credentials never reach commands; the engine may hold them. Removal is idempotent. The mutation refusals under an environment token go, since the rule they enforced was the last piece of the session confusion. Written with the architect and principal-engineer passes folded in. The principal engineer caught that a 401 on a credential that never had a refresh token would lose today's service-token message and tell CI to retry a permanent failure; the design now discriminates by state rather than by message, which also repairs a rev-5 defect for migrated entries with no refresh token. The architect caught that the delta introduced "selected" without retiring "current", leaving three words for one idea; §11.1 rules the vocabulary and names what deliberately keeps the old word. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../engine/credential-manager-design.md | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md index 94365e49..933c1934 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md +++ b/.drive/projects/prisma-cli-v8/assets/engine/credential-manager-design.md @@ -553,3 +553,301 @@ keys/eviction; call-chain-scoped lock re-entrancy (no nested locking remains — `withRefreshLock` is in-process single-flight, mutations take the short file lock directly); heartbeat/exchange- timeout/stale ordering apparatus; cross-account race guards. + +## 11. Revision 6 — the environment credential is not a session + +Operator rulings, 2026-08-10, after review of the rev-5 implementation, +with the architect and principal-engineer passes on this delta folded +in. Rev 6 supersedes the parts of §§1–9 listed in §11.9; everything not +listed stands. NORMATIVE. + +**The mistake rev 5 made.** It modelled the `PRISMA_SERVICE_TOKEN` +credential as a `Session`. It is not one, and forcing it into that +shape produced four defects that are all the same defect: + +- `Session.source` existed to say "this one is not really a session"; +- `current: true` was hardcoded on it, because it has no marker to + compare itself against — so `current` meant "the file's marker names + this" in `sessions()` and "this is what the process acts as" in + `currentSession()`; +- `workspaceId: ""` was written when the token's claims did not name a + workspace, because a non-session was forced to carry a session's key; +- `useSession`/`endSession` needed a guard rejecting an environment + session, because it was shaped like a stored one. + +None of them needs fixing separately. They stop existing. + +### 11.1 The three things, separated + +**A session** is a stored logged-in-ness for one workspace. It is the +only thing called a session: what `sessions()` lists, what +`selectSession` selects, what `endSession` ends. + +```ts +interface Session { + readonly workspaceId: string; + readonly workspaceName: string | undefined; + /** The STORED ACCESS TOKEN's expiry, which rotation changes. Not a + * deadline on the logged-in-ness. */ + readonly expiresAt: Date | undefined; +} +``` + +**The selection** is one scalar of stored state — the workspace whose +session is used where a session is needed. Absent means none selected. +It is read directly, never inferred from a flag on each element. One +read returns both, because reads take no lock (§8) and two reads could +straddle a write: + +```ts +interface StoredSessions { + readonly sessions: readonly Session[]; + readonly selectedWorkspaceId: string | undefined; +} +``` + +Invariant, enforced by the manager: `selectedWorkspaceId` either names +one of the listed sessions or is absent. A dangling selection never +escapes the manager, so no consumer handles that case. + +**The active credential** is what this process authenticates as. The +command-visible shape carries no token material: + +```ts +interface ActiveCredential { + /** Absent when nothing names it — an environment token whose claims + * carry no workspace. Never the empty string. */ + readonly workspaceId: string | undefined; + readonly workspaceName: string | undefined; + readonly expiresAt: Date | undefined; + /** Decoded from the credential's own claims by the manager, so no + * command ever holds a token to decode. */ + readonly identity: CredentialIdentity | undefined; + readonly origin: CredentialOrigin; +} + +interface CredentialOrigin { + /** Exists to be PRINTED — it feeds whoami's `source` field verbatim. + * Outside whoami's renderer and the credential-rejected error + * constructor, comparing against it is a defect. */ + readonly source: "stored" | "environment"; +} +``` + +`CredentialOrigin` says where the credential came from. That is a real +question about the resolution — unlike rev 5's `Session.source`, which +asked it of the session. It carries no prose and no next actions: the +manager never talks to the user (§3). Where wording must differ by +origin, the difference lives in ONE error constructor in +`credential-errors.ts`, which is where wording already lives. + +Absence is `undefined` in every in-memory shape. `null` survives only +in the on-disk JSON. + +**Vocabulary, ruled.** One word per concept. In code the word is +SELECTED: `selectedWorkspaceId`, `selectSession(workspaceId)`, and the +reason `sessions-held-none-selected`. Two things deliberately keep +"current" and are not to be renamed: the on-disk field +`currentWorkspaceId` (no migration for a rename), and the user-facing +surface — the command `auth workspace use` and `auth workspace list`'s +`context.currentWorkspaceId` and per-item `current`, all of which are +contracts. `ctx.session()` becomes `ctx.activeCredential()`; leaving it +named `session` reproduces the mistake one layer out. + +### 11.2 Refresh follows the credential, not its origin + +A credential refreshes if it has a refresh token. Where it came from is +irrelevant. Nothing hard-codes "environment means never refresh". The +engine builds one client, always the refreshing one, over the storage +the manager hands it. `ClientBinding` goes entirely — not just its +`source` field — along with the static-path 401 inspection, because the +mapping asks the manager for the active credential rather than +remembering a binding of its own. + +**Why the uniform path, when no credential source exercises it today.** +A single environment variable supplies one bearer string, so an +environment credential cannot currently carry a refresh token and the +memory-backed rotation is unreachable in practice. The uniform path +exists to delete a construction branch, not to serve a future feature. +Do not "clean it up" as dead code. + +**Which storage, chosen once.** The choice is made when the pin +resolves, and each storage has exactly one source of truth. The +conditional is in which storage is constructed, never inside one that +checks at write time whether it has a home. + +- **File-backed**, for a credential with a home record. Unchanged from + §4: `getTokens` re-reads the file on EVERY call; writes take the + short lock. No memory layer may sit in front. That read-through is + what lets the SDK recover when another process has already rotated — + this process sees the newer pair, skips the exchange, and retries. A + cache would spend a refresh token another process already used and + end in a spurious "sign in again" while the file holds a working + pair. +- **Memory-backed**, for a credential with no home record. Reads and + writes are process memory; nothing survives the process. It closes + over a local variable, is never given the state file's path, and + touches no file on any method — including `clearTokens`, which the + SDK calls when `clearTokensIfCurrent` is absent. An environment + credential whose workspace matches a stored session must not be able + to delete that session. + +The SDK's `Tokens` requires `workspaceId: string`. The memory-backed +storage supplies the claim when the credential has one and a fixed, +obviously-not-a-workspace constant when it does not. That value never +leaves the manager and is never the empty string. + +**A 401 that could never be renewed.** This is the path that actually +runs today. The SDK raises `AuthError("No refresh token available")` +with `refreshTokenInvalid` false, never touching the token endpoint. +It must NOT fall through to the session-ended mapping, which is untrue +and whose remedies do not apply, nor to the transient one, which tells +a CI job to retry a permanent failure forever. The engine discriminates +by state, never by message: after the failure it asks the storage for +the tokens, and a set with no refresh token could never have been +renewed. The result is one credential-rejected error whose wording +follows `origin.source` — for an environment credential that reproduces +today's `AUTH.SERVICE_TOKEN_REJECTED` naming the variable. + +This also repairs a rev-5 defect: §7 adopts legacy entries with no +refresh token and says they "fail cleanly at expiry". They do not — +they reach the same line and surface as a transient error advising a +retry. One fix covers both. + +**Known limit.** Real service tokens name their workspace through a +`sub: "workspace:"` claim rather than `workspace_id`. If they ever +gain refresh tokens, the SDK's own workspace extraction would throw on +the rotated token. + +### 11.3 What the pin holds + +Rev 5's pin is a workspace id re-resolved against the file on every +read. Rev 6 pins THE DECISION — which credential, and from where — at +first read, and keeps reading the material through the storage on every +call. Nothing pins a token value. A session ended by another process +mid-run therefore still fails with the session-ended wording, and a +session replaced by another process still recovers through the SDK's +re-read. + +### 11.4 Custody, restated + +§4's "the manager never exposes token material" means **never to +commands**. The engine may hold credentials — it must, to authenticate. +The manager hands the engine what it needs through +`activeCredentialStorage()`; `ActiveCredential` and `Session` still +carry no token, which is the property the rule protects. The rev-5 +wording, read as absolute, is what forced the engine to reach around +the manager into `Runtime.env`. + +### 11.5 The interface + +```ts +activeCredential(): Promise; +sessions(): Promise; +createSession(credential: Credential, workspaceId: string): Promise; +selectSession(workspaceId: string): Promise; +endSession(workspaceId: string): Promise; +endAllSessions(): Promise; +/** ENGINE-FACING. Zero-argument: process pinning already ruled there + * is one credential per process, and an environment credential may + * have no workspace id to key on. Only valid once activeCredential() + * has returned non-null; the engine resolves that first. */ +activeCredentialStorage(): Promise; +``` + +All three mutations are workspace-id-keyed, symmetric with +`createSession`. `selectSession` returns the selected `Session`, which +`auth workspace use` renders. `tokenStorage(workspaceId)` is deleted +rather than reshaped: nothing needs storage for a workspace other than +the active one, and the parameter implies an axis of variation the +system does not have. + +### 11.6 whoami + +`whoami` asks for the active credential's identity and renders it. It +does not branch on origin and decodes nothing itself. `/v1/me` remains +an online enrichment and WINS where it disagrees with the claims; the +claims are the offline fallback. There is one identity type, +`CredentialIdentity`, for both the claimed and the fetched identity — +the command's own `SessionIdentity` is deleted. + +With no workspace, `whoami` omits the workspace row and its JSON +`workspace` is `null`. It never prints an empty string or `undefined`. + +### 11.7 Mutations while an environment credential is in force + +**RULED: the refusals go.** Rev 5 refused `useSession` and +`endSession`, and refused `endAllSessions` unless the store was empty, +while `PRISMA_SERVICE_TOKEN` was set. That rule existed because the +environment thing was a session occupying the current slot. It is not +one. Selecting or ending a stored session while an environment +credential is in force is coherent: it changes stored state, and this +process keeps authenticating as the environment credential. + +All three now succeed, each printing the one-line notice `createSession` +already prints — that the environment credential remains in force until +the variable is unset. The `endAllSessions` CI carve-out disappears +with the rule it worked around: it simply clears the store. + +### 11.8 Removal is idempotent + +`endSession` on a workspace with no session succeeds: the postcondition +is identical either way. The useful error — a workspace reference the +user never had — is raised earlier and command-side, when the ref fails +to resolve against `sessions()`, so `AUTH.NO_SESSION_FOR_WORKSPACE` +still reaches a user who mistypes. What changes is only the race: a +session removed by another process mid-command now exits 0 rather than +exit 2 with an untrue message. + +`selectSession` is NOT idempotent and still refuses a workspace with no +session: there is no state in which it would afterwards be selected. + +### 11.9 Superseded + +- §1: "No refresh, never stored" — reword as a fact about the token + shape, not a rule: it carries no refresh token, so nothing rotates. +- §2: `Session.source` and `Session.current`; "the marker is called + CURRENT everywhere"; "whoami decodes the current session's claims". +- §3: `currentSession()`; the `sessions()` return type; + `useSession`/`endSession` taking a `Session`; the environment-session + misuse error; ALL the env-override mutation refusals (§11.7). +- §4: `ctx.session()`; `tokenStorage(workspaceId)`; the env + static-token construction path and the engine reading + `PRISMA_SERVICE_TOKEN` itself; "ONE stored-session API client" loses + its qualifier; refresh writes are keyed by the credential's home + record, when it has one; the harness `currentWorkspaceId` and + `environmentToken` seed shapes. +- §5: "env session never refreshes" — the premise becomes "it has no + refresh token"; the env-override matrix follows §11.7; the + `ctx.session()` shared assertion is renamed. +- §6: "no refresh machinery may exist for it"; the service-token 401 + path; the `sessions-held-none-current` reason name. +- §6a: the whoami identity split. +- §9: describes rev-5 work; history, not instruction. + +Unchanged: the migration (§7), the file, lock and atomicity rules (§8), +and process pinning itself as narrowed by §11.3. + +### 11.10 Tests this delta requires + +1. Environment credential with no refresh token: 401, token endpoint + not hit, the credential-rejected error naming `PRISMA_SERVICE_TOKEN`. +2. Environment credential WITH a refresh token: 401, rotation, retry + succeeds, state file bytes unchanged, a second request in the same + process carries the rotated token. +3. Environment credential whose workspace matches a stored session, + refresh answers `invalid_grant`: the stored session survives and the + file is byte-unchanged. +4. Stored session with no refresh token (§7's migration case): the + error says sign in again, not retry. +5. Cross-process rotation recovery: B rotates, A gets a 401, A's + re-read sees B's newer pair and retries without hitting the token + endpoint. +6. `endSession` on a workspace with no session writes nothing and + exits 0; `auth workspace logout X` where another process removed X + mid-command exits 0. +7. `activeCredential()` with a claimless environment token: + `workspaceId` is `undefined`, and neither the human card nor the + JSON renders an empty string or `undefined`. +8. Every mutation succeeds while `PRISMA_SERVICE_TOKEN` is set, each + printing the in-force notice (§11.7). From e931785df0e0da63dcb465516fe7475ae964cefb Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 21:56:47 +0200 Subject: [PATCH 54/67] =?UTF-8?q?drive(prisma-cli-v8):=20add=20S8=20?= =?UTF-8?q?=E2=80=94=20service=20primitives,=20design=20before=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy app group fused building, repo wiring, and deploying into single commands. Composer takes the building and deploying; what the CLI should own is managing the remote resource, and today it cannot — there is no way to list or create a service, none to start or stop a deployment, and a service can only be born as a side effect of deploying to it. S2c ported the survivors under their legacy names so the commander shell could die in S2d. That is continuity, not endorsement. The slice is recorded as blocked on design work, and the design work is blocked on a fact we do not have. Composer deploys through Alchemy, not the management API, so a Composer-deployed service does not appear under /v1/apps at all and the resource model those endpoints describe is the product being replaced. Until we know what a service is after Composer, there is nothing to design primitives over. An earlier sizing of this as "small, mostly a rename" assumed Composer would create deployments through the platform API; it does not, so the sizing is withdrawn and the slice is unsized. Ordering is after S3, because Composer's contract is the input, and before S7, because S7 mounts the full grammar tree behind a completeness check that this slice changes. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/plan.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/plan.md b/.drive/projects/prisma-cli-v8/plan.md index 51c70b42..34b90ffa 100644 --- a/.drive/projects/prisma-cli-v8/plan.md +++ b/.drive/projects/prisma-cli-v8/plan.md @@ -80,6 +80,23 @@ Repo: prisma-cli. The small three-check tool — import purity, validator no-throw on hostile input, published-tarball verification — wired into both products' publish CI as S3/S5 land. +### S8 — Service primitives (design first; after S3, before S7) + +Repo: prisma-cli. Give the platform's service resources an atomic CLI surface, replacing what S2c ported for continuity. + +**Why this slice exists.** The legacy `app` group fused three concerns — building an artifact, wiring a GitHub repo, and deploying — into single commands, most visibly `app deploy`, which builds, creates a project, creates branches, sets environment variables, optionally provisions a database, and deploys. Composer replaces the building and deploying. What the CLI should own is managing the remote resource, and today it cannot: there is no `service list` and no `service create` despite `GET`/`POST /v1/apps`, no deployment start or stop despite `POST /v1/deployments/{id}/start|stop`, and no deployment delete. A service can currently only be born as a side effect of deploying to it. S2c ported the surviving commands under their legacy names so the commander shell could die in S2d; that port is continuity, not endorsement of the shape. + +**Blocked on design work, and the design work is blocked on a fact we do not have.** Composer does not deploy through the management API — it deploys through Alchemy, on a completely different path (operator, 2026-08-10). So a Composer-deployed service does not appear in `/v1/apps` or `/v1/apps/{id}/deployments`, and the resource model those endpoints describe is the compute product Composer is replacing. Until we know what a Composer-deployed service *is* to the platform — whether it is represented at all, and under which nouns — there is nothing to design atomic primitives over. An earlier sizing of this slice as "small, mostly a rename" assumed Composer would create deployments through the platform API. That assumption is false and the sizing with it: the domain model is unknown, so the slice is unsized. + +**What the design work has to answer, before any dispatch is planned.** + +1. What is a service, after Composer? Does the platform represent a Composer-deployed workload, and under which resource? +2. Who owns the deployment noun? The platform API separates producing an artifact from making it live — `POST /v1/deployments/{id}/start` states the artifact must be uploaded first — so promote, rollback, start, stop and logs are resource management rather than build concerns. Whether that separation survives Alchemy is the question. +3. Which of the S2c-ported commands are superseded outright, which are renamed, and which stay. +4. Every endpoint involved is marked experimental and subject to change without notice. Designing a stable CLI surface over an unstable API is how the next bastardization gets built; the design has to say what it is willing to depend on. + +**Ordering.** After S3, because Composer's contract is the input. Before S7, because S7 mounts the full grammar tree behind a build-time completeness check and this slice changes that tree. + ### S7 — Release pipeline + rc1 Repo: prisma-cli. The `prisma` binary package assembled: full grammar @@ -92,10 +109,11 @@ when the operator can publish with one action (project DoD). ```text S1 ──► S2 ──► S3 ──► S5 ──► S7 - │ ▲ ▲ - └──────┘ (published engine exists after S2's engine hardening) -S4 (prisma/prisma) ────────► S5 + │ ▲ ▲ ▲ + └──────┘ │ │ +S4 (prisma/prisma) ───┴──► S5 │ S6 (after S1) ─────────────► wired in during S3/S5 +S3 ──► S8 (design first) ───────────┘ ``` ## Coverage ledger (what proves what) From 4bc6fe055a31a5d8754f4c2a8d75149066171997 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 22:00:06 +0200 Subject: [PATCH 55/67] =?UTF-8?q?drive(prisma-cli-v8):=20correct=20S8's=20?= =?UTF-8?q?premise=20=E2=80=94=20Composer's=20resources=20are=20API=20reso?= =?UTF-8?q?urces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous entry claimed a Composer-deployed service would not appear under /v1/apps because Composer deploys through Alchemy. That does not follow: Alchemy's providers call the management API to do the work, so Composer's services and deployments are ordinary resources under the same endpoints. Only the orchestration differs. So the seam is the one the API already draws — Composer produces deployments, the CLI manages them — and the slice is mostly a rename into a `service deployment` subgroup plus the five operations that have no command at all. The sizing withdrawn in the previous commit is restored. What the slice genuinely waits on is narrower and sharper: whether Alchemy holds desired state, in which case an imperative promote or rollback from the CLI either fights Composer or is reverted on its next run; what Composer's app and deployment records actually contain; and whether log reading belongs to `composer log` or to the service subgroup, since a subgroup is owned by exactly one command family. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/plan.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/plan.md b/.drive/projects/prisma-cli-v8/plan.md index 34b90ffa..5ae326a0 100644 --- a/.drive/projects/prisma-cli-v8/plan.md +++ b/.drive/projects/prisma-cli-v8/plan.md @@ -86,14 +86,17 @@ Repo: prisma-cli. Give the platform's service resources an atomic CLI surface, r **Why this slice exists.** The legacy `app` group fused three concerns — building an artifact, wiring a GitHub repo, and deploying — into single commands, most visibly `app deploy`, which builds, creates a project, creates branches, sets environment variables, optionally provisions a database, and deploys. Composer replaces the building and deploying. What the CLI should own is managing the remote resource, and today it cannot: there is no `service list` and no `service create` despite `GET`/`POST /v1/apps`, no deployment start or stop despite `POST /v1/deployments/{id}/start|stop`, and no deployment delete. A service can currently only be born as a side effect of deploying to it. S2c ported the surviving commands under their legacy names so the commander shell could die in S2d; that port is continuity, not endorsement of the shape. -**Blocked on design work, and the design work is blocked on a fact we do not have.** Composer does not deploy through the management API — it deploys through Alchemy, on a completely different path (operator, 2026-08-10). So a Composer-deployed service does not appear in `/v1/apps` or `/v1/apps/{id}/deployments`, and the resource model those endpoints describe is the compute product Composer is replacing. Until we know what a Composer-deployed service *is* to the platform — whether it is represented at all, and under which nouns — there is nothing to design atomic primitives over. An earlier sizing of this slice as "small, mostly a rename" assumed Composer would create deployments through the platform API. That assumption is false and the sizing with it: the domain model is unknown, so the slice is unsized. +**The resource model is already right; the CLI hides it.** `/v1/apps` supports list and create, `/v1/apps/{id}` get and delete, `/v1/apps/{id}/deployments` list and create, `/v1/deployments/{id}` get and delete, and `/v1/deployments/{id}/start|stop|logs`. Composer deploys through Alchemy rather than driving that sequence itself, but Alchemy's providers call the same management API, so Composer's services and deployments are ordinary resources under these endpoints. The seam the API already draws is the one to build on: **Composer produces deployments; the CLI manages them.** Promote, rollback, start, stop, delete and logs are resource management, not build concerns — `POST /v1/deployments/{id}/start` says the artifact must be uploaded before it is called, which is the separation stated in the API itself. -**What the design work has to answer, before any dispatch is planned.** +That makes the shape of the slice mostly a rename plus filling holes — a `service deployment` subgroup absorbing `list-deploys`, `show-deploy`, `logs`, `promote` and `rollback`, plus the five operations that have no command at all. The expensive parts (engine, auth, presenters, error model, the `service` rename) are done. -1. What is a service, after Composer? Does the platform represent a Composer-deployed workload, and under which resource? -2. Who owns the deployment noun? The platform API separates producing an artifact from making it live — `POST /v1/deployments/{id}/start` states the artifact must be uploaded first — so promote, rollback, start, stop and logs are resource management rather than build concerns. Whether that separation survives Alchemy is the question. -3. Which of the S2c-ported commands are superseded outright, which are renamed, and which stay. -4. Every endpoint involved is marked experimental and subject to change without notice. Designing a stable CLI surface over an unstable API is how the next bastardization gets built; the design has to say what it is willing to depend on. +**Why it still waits for the design work.** Three questions need answers that only S3 can give, and none of them is about whether the resources exist. + +1. Does Alchemy hold desired state? If Composer declaratively owns which deployment is live, then an imperative `promote`, `rollback`, `start` or `stop` from the CLI either fights it or is reverted on Composer's next run. This is the sharpest question and it decides whether those five commands should exist at all, be read-only, or carry a warning. +2. What do Composer's app and deployment records actually contain? If the Alchemy path populates a different subset of fields than `app deploy` did, `service show` and `service deployment show` are presenting a shape nobody has looked at. +3. Where does log reading live? `composer log` and a `service deployment logs` would be two ways to read the same thing, and the project spec rules that a subgroup is owned by exactly one command family. + +One standing caveat: every endpoint above is marked experimental and subject to change without notice. Designing a stable CLI surface over an unstable API is how the next bastardization gets built, so the design has to say what it is willing to depend on. **Ordering.** After S3, because Composer's contract is the input. Before S7, because S7 mounts the full grammar tree behind a build-time completeness check and this slice changes that tree. From 3f523ac6be18dafb5fc931710bda75c52efab642 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 22:10:27 +0200 Subject: [PATCH 56/67] feat(cli-engine)!: the rev-6 credential surface, and getCredentials deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The environment credential stops being modelled as a session. Session loses `source` and `current`; the selection is read directly instead of scanned off a flag on each element; what the process authenticates as becomes ActiveCredential, carrying no token material. ctx.session() becomes ctx.activeCredential(). There is now one API client. ClientBinding, the static-token path and the resolved-401 inspection are gone: the engine builds the SDK's refreshing client over whatever storage the manager hands it, which is file-backed for a stored session and memory-backed for a credential with no home. A 401 on a credential that could never be renewed no longer falls through to the session-ended or transient mapping — the engine asks the storage for its tokens, and a set with no refresh token yields the credential-rejected error, whose wording follows the origin. getCredentials goes with it: the context accessor, Runtime's member, the Credentials type, the needs-check fallback and the harness's legacy seed were the bridge that let commands reach a raw token while the manager landed, and rev 6 finishes that swap. packages/cli does not compile against this yet; its cascade is the next commit. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/context.ts | 32 +- packages/cli-engine/src/credential-errors.ts | 19 +- packages/cli-engine/src/credential-manager.ts | 125 ++++-- .../cli-engine/src/execution/api-client.ts | 189 ++++----- .../src/execution/command-context.ts | 13 +- packages/cli-engine/src/execution/needs.ts | 61 +-- packages/cli-engine/src/exports/index.ts | 6 +- .../src/in-memory-credential-manager.ts | 375 +++++++++-------- packages/cli-engine/src/management-api.ts | 5 +- packages/cli-engine/src/runtime.ts | 12 +- packages/cli-engine/src/testing.ts | 59 +-- .../cli-engine/tests/clack-isolation.test.ts | 1 - .../cli-engine/tests/clack-prompts.test.ts | 1 - packages/cli-engine/tests/config.test.ts | 1 - .../tests/credential-manager.test.ts | 380 ++++++++++-------- packages/cli-engine/tests/engine.test.ts | 1 + packages/cli-engine/tests/engine.type-test.ts | 37 +- packages/cli-engine/tests/execution.test.ts | 51 ++- packages/cli-engine/tests/lifetimes.test.ts | 1 - .../cli-engine/tests/management-api.test.ts | 317 +++++++++++---- packages/cli-engine/tests/prompts.test.ts | 1 - 21 files changed, 986 insertions(+), 701 deletions(-) diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 802413bb..3aa22ade 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -1,14 +1,9 @@ -import type { Session } from "./credential-manager"; +import type { ActiveCredential } from "./credential-manager"; import type { EngineEvent } from "./events"; import type { ManagementApiClient } from "./management-api"; import type { Outcome, Presentations, PresentedResult } from "./presentation"; import type { CliStructuredError, Result } from "./protocol"; -export interface Credentials { - /** Opaque to the engine; shape owned by the Cloud auth library. */ - readonly token: string; -} - /** The handler context — the whole world arrives as one argument. */ export interface CommandContext< TConfig = undefined, @@ -31,26 +26,19 @@ export interface CommandContext< ) => PresentedResult; /** - * The session this process is acting as (the manager's - * currentSession() pin), or null when signed out. Read-only and - * local-only — safe to call anywhere; never touches the network. - * Throws the same structured errors the needs check raises for - * broken-but-not-signed-out states (sessions held, none current). - */ - readonly session: () => Promise; - - /** - * Management-API credentials, resolved at call time. Undefined when - * unauthenticated; commands with needs.credentials never see - * undefined — the engine fails them early. Staged for deletion: - * ctx.session and ctx.api are the surviving auth surfaces. + * What this process authenticates as (the manager's pinned + * credential), or null when signed out. Carries no token material. + * Read-only and local-only — safe to call anywhere; never touches + * the network. Throws the same structured errors the needs check + * raises for broken-but-not-signed-out states (sessions held, none + * selected). */ - readonly getCredentials: () => Promise; + readonly activeCredential: () => Promise; /** * The Management API client, constructed and owned by the ENGINE: - * the pinned session's client, built on first method call, once per - * run. A request made while signed out throws the structured + * the pinned credential's client, built on first method call, once + * per run. A request made while signed out throws the structured * CLI.CREDENTIALS_REQUIRED error (the same constructor the * needs.credentials check uses). */ diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts index bb77b1b8..5bad1660 100644 --- a/packages/cli-engine/src/credential-errors.ts +++ b/packages/cli-engine/src/credential-errors.ts @@ -1,3 +1,4 @@ +import type { CredentialOrigin } from "./credential-manager"; import { CliStructuredError, type NextAction } from "./protocol"; const signInAction: NextAction = { @@ -15,7 +16,7 @@ export type CredentialsRequiredReason = | "unauthenticated" | "expired" | "session-ended" - | "sessions-held-none-current"; + | "sessions-held-none-selected"; /** * The single constructor of CLI.CREDENTIALS_REQUIRED. Raised @@ -47,7 +48,7 @@ export function credentialsRequiredError( nextActions: [useSessionAction, signInAction], }, ); - case "sessions-held-none-current": + case "sessions-held-none-selected": return new CliStructuredError( "CLI.CREDENTIALS_REQUIRED", "No workspace session is current.", @@ -81,6 +82,20 @@ export function authServiceError(): CliStructuredError { ); } +/** + * The credential in force was rejected and could never be renewed — it + * carries no refresh token. The ONE place wording differs by origin; + * nothing else compares against `origin.source`. + */ +export function credentialRejectedError( + origin: CredentialOrigin, + envVar: string, +): CliStructuredError { + return origin.source === "environment" + ? serviceTokenRejectedError({ envVar }) + : credentialsRequiredError("expired"); +} + /** * A mutation refused while an env-supplied session is in force: state * the user cannot observe as their session is never changed. diff --git a/packages/cli-engine/src/credential-manager.ts b/packages/cli-engine/src/credential-manager.ts index e598c33e..21aeb93a 100644 --- a/packages/cli-engine/src/credential-manager.ts +++ b/packages/cli-engine/src/credential-manager.ts @@ -1,8 +1,9 @@ import type { TokenStorage } from "./management-api"; /** - * The proof material. Only ever seen by the login flow (which mints - * it) and createSession (which stores it). + * The proof material. Seen by the login flow (which mints it), + * createSession (which stores it), and the engine (which authenticates + * with it). Never reaches a command. */ export interface Credential { readonly token: string; @@ -11,76 +12,116 @@ export interface Credential { } /** - * "Logged-in-edness", scoped to a workspace. Identified to users by - * its workspace. The token is INTERNAL: it lives in the stored - * record, never on this public shape. `source: "environment"` marks - * the ephemeral session composed from PRISMA_SERVICE_TOKEN; it never - * appears in sessions(). + * A stored logged-in-ness for one workspace — the only thing called a + * session. It is what `sessions()` lists, what `selectSession` selects, + * and what `endSession` ends. The credential behind it is internal. */ export interface Session { readonly workspaceId: string; readonly workspaceName: string | undefined; + /** The stored ACCESS TOKEN's expiry, which rotation changes — not a + * deadline on the logged-in-ness. */ readonly expiresAt: Date | undefined; +} + +/** + * The stored sessions and which one is selected, read together: reads + * take no lock, so two reads could straddle a write and disagree. + * `selectedWorkspaceId` always names one of the listed sessions or is + * absent — a dangling selection never escapes the manager. + */ +export interface StoredSessions { + readonly sessions: readonly Session[]; + readonly selectedWorkspaceId: string | undefined; +} + +/** Who the active credential belongs to, decoded from its own claims by + * the manager so no command ever holds a token to decode. */ +export interface CredentialIdentity { + readonly userId: string | undefined; + readonly email: string | undefined; +} + +/** + * Where the active credential came from — a question about the + * resolution, not about a session. + */ +export interface CredentialOrigin { + /** Exists to be PRINTED: it feeds whoami's `source` field verbatim. + * Outside whoami's renderer and the credential-rejected error, + * comparing against this is a defect. */ readonly source: "stored" | "environment"; - readonly current: boolean; } /** - * Manages sessions — a set of per-workspace sessions, one current. - * Six user-facing operations plus one engine-facing accessor. Custody - * only: never opens a browser, never prompts, never talks to the - * user. Env is a construction input — nothing below the manager reads - * process.env. The manager resolves no user input: commands resolve - * refs against sessions() and pass the matched Session. + * What this process authenticates as. Carries no token material. + */ +export interface ActiveCredential { + /** Absent when nothing names it — an environment token whose claims + * carry no workspace. Never the empty string. */ + readonly workspaceId: string | undefined; + readonly workspaceName: string | undefined; + readonly expiresAt: Date | undefined; + readonly identity: CredentialIdentity | undefined; + readonly origin: CredentialOrigin; +} + +/** + * Manages the credentials this machine holds: the stored per-workspace + * sessions, which one is selected, and the credential this process + * authenticates as. Custody only — never opens a browser, never + * prompts, never talks to the user. Env is a construction input; + * nothing below the manager reads process.env. It resolves no user + * input: commands resolve refs against `sessions()` and pass a + * workspace id. */ export interface CredentialManager { /** - * The session this PROCESS is acting as. Pinned at first read: - * composed from the env token if set, else the file's current - * marker at that moment; later marker changes by other processes do - * not move it. This process's own mutations DO update it. - * Local-only: never touches the network. + * What this process authenticates as. The DECISION — which + * credential, and from where — is pinned at first read; the material + * is read through the storage on every call, so a session replaced by + * another process still resolves. Local-only: never touches the + * network. */ - currentSession(): Promise; + activeCredential(): Promise; - /** - * The available sessions, read fresh from the file. Local-only. - * Under an env override the file's current marker is still shown as - * `current`. - */ - sessions(): Promise; + /** The stored sessions and the selection, read fresh. Local-only. */ + sessions(): Promise; /** * Login's write. The caller names the workspace that identifies the * session; for workspace-bound credentials the manager verifies the * workspace_id claim matches and refuses on mismatch. Upserts by - * workspaceId, sets the file marker, becomes this process's - * current. The workspace name is fetched best-effort after the - * write — failure leaves it undefined, never fails login. + * workspaceId and selects it. The workspace name is fetched + * best-effort after the write — failure leaves it undefined, never + * fails login. */ createSession(credential: Credential, workspaceId: string): Promise; /** - * Switch: sets the file's current marker AND this process's pinned - * session. The argument is a workspace reference — only workspaceId - * is read, re-validated against freshly-read state. + * Select a session. Refuses a workspace with no session: there is no + * state in which it would afterwards be selected. Never sees the + * environment credential. */ - useSession(session: Session): Promise; + selectSession(workspaceId: string): Promise; /** - * Log out of one workspace: remove that session. If it was current - * (file marker or this process's pin), that current is cleared (no - * auto-promotion). + * End one workspace's session. Idempotent — a workspace with no + * session is already in the state this asks for. Clears the selection + * if it named that session; never auto-promotes another. */ - endSession(session: Session): Promise; + endSession(workspaceId: string): Promise; - /** Log out entirely: remove all sessions and the marker. */ + /** End every session and clear the selection. */ endAllSessions(): Promise; /** - * ENGINE-FACING, not a user operation: the SDK TokenStorage view - * for one workspace's session. The engine forwards it into SDK - * client config and never calls its methods itself. + * ENGINE-FACING. Where the SDK reads and writes the active + * credential's tokens: file-backed for a stored session, memory-backed + * for one with no home record. Zero-argument because process pinning + * already ruled there is one credential per process, and an + * environment credential may have no workspace id to key on. Only + * valid once `activeCredential()` has returned non-null. */ - tokenStorage(workspaceId: string): TokenStorage; + activeCredentialStorage(): Promise; } diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 5c4648ce..18239959 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -1,10 +1,13 @@ import { authServiceError, + credentialRejectedError, credentialsRequiredError, - emptyServiceTokenError, - serviceTokenRejectedError, } from "../credential-errors"; -import type { CredentialManager, Session } from "../credential-manager"; +import type { + ActiveCredential, + CredentialManager, + StoredSessions, +} from "../credential-manager"; import type { ManagementApiClient, TokenStorage } from "../management-api"; import { CliStructuredError } from "../protocol"; import { type DebugLog, makeDebugLog } from "./debug"; @@ -12,10 +15,6 @@ import type { Invocation } from "./engine"; const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; -type ClientBinding = - | { readonly source: "stored"; readonly workspaceId: string } - | { readonly source: "environment" }; - /** What the last refresh attempt threw, if it threw. The mapping below * identifies a failure as coming from the refresh path by finding this * exact error in the cause chain of the request failure. */ @@ -23,30 +22,38 @@ interface RefreshProbe { failure: unknown; } +/** What the client authenticates as, resolved when it is constructed. + * The failure mapping asks this credential and its storage what + * happened rather than remembering a binding of its own. */ +interface PinnedCredential { + readonly active: ActiveCredential; + readonly storage: TokenStorage; +} + /** * ctx.api: the ENGINE constructs and owns the management API client — - * the pinned session's client, once per run (process pinning makes - * the memoization correct). Nothing resolves until the first method - * CALL, so a run that never issues a request never pays for — or - * depends on — the SDK module load. A stored session gets the SDK's - * refreshing path over the manager's TokenStorage view; an env - * session gets the SDK's static-token path with its error mapping at - * the call site. Every request failure passes through the engine-side - * mapping below. The returned client is a Proxy whose method wrappers - * await the lazy construction before applying the call — every client - * method is async, so the deferral is invisible to callers. + * ONE client for the active credential, once per run (process pinning + * makes the memoization correct). Nothing resolves until the first + * method CALL, so a run that never issues a request never pays for — + * or depends on — the SDK module load. The client is always the SDK's + * refreshing one over the storage the manager hands out, whatever the + * credential's origin: a credential refreshes if it has a refresh + * token. Every request failure passes through the engine-side mapping + * below. The returned client is a Proxy whose method wrappers await + * the lazy construction before applying the call — every client method + * is async, so the deferral is invisible to callers. */ export function buildManagementApiClient( invocation: Invocation, ): ManagementApiClient { const debug = makeDebugLog(invocation.runtime); const probe: RefreshProbe = { failure: undefined }; - let binding: ClientBinding | undefined; + let pinned: PinnedCredential | undefined; let clientPromise: Promise | undefined; const resolveClient = (): Promise => { clientPromise ??= constructClient(invocation, debug, probe).then( (constructed) => { - binding = constructed.binding; + pinned = constructed.pinned; return constructed.client; }, ); @@ -69,21 +76,13 @@ export function buildManagementApiClient( `@prisma/cli-engine: ctx.api.${property} is not a function`, ); } - const result: unknown = await (Reflect.apply( - value, - client, - args, - ) as Promise); - if (binding?.source === "environment" && responseWas401(result)) { - throw serviceTokenRejectedError({ envVar: SERVICE_TOKEN_ENV_VAR }); - } - return result; + return await (Reflect.apply(value, client, args) as Promise); } catch (cause) { throw await mapRequestFailure( invocation, debug, probe, - binding, + pinned, cause, ); } @@ -98,14 +97,14 @@ async function constructClient( probe: RefreshProbe, ): Promise<{ readonly client: ManagementApiClient; - readonly binding: ClientBinding; + readonly pinned: PinnedCredential; }> { const manager = invocation.runtime.credentialManager; if (manager === undefined) { throw credentialsRequiredError(); } - const session = await manager.currentSession(); - if (session === null) { + const active = await manager.activeCredential(); + if (active === null) { throw credentialsRequiredError(); } const config = invocation.runtime.managementApiClientConfig; @@ -114,62 +113,38 @@ async function constructClient( "@prisma/cli-engine: ctx.api requires Runtime.managementApiClientConfig when a credentialManager is wired", ); } - if (session.source === "environment") { - const raw = invocation.runtime.env[SERVICE_TOKEN_ENV_VAR]; - if (raw === undefined) { - throw credentialsRequiredError(); - } - // Trimmed, exactly as the manager composes the session from it: - // the bearer on the wire and the session it belongs to must be - // built from the same value. - const token = raw.trim(); - if (token === "") { - throw emptyServiceTokenError({ envVar: SERVICE_TOKEN_ENV_VAR }); - } - const { createManagementApiClient } = await import( - "@prisma/management-api-sdk" - ); - return { - client: createManagementApiClient({ - baseUrl: config.apiBaseUrl, - token, - }), - binding: { source: "environment" }, - }; - } + const storage = observedTokenStorage( + await manager.activeCredentialStorage(), + active, + debug, + probe, + ); const { createManagementApiSdk } = await import("@prisma/management-api-sdk"); const sdk = createManagementApiSdk({ clientId: config.clientId, redirectUri: config.redirectUri, apiBaseUrl: config.apiBaseUrl, authBaseUrl: config.authBaseUrl, - tokenStorage: observedTokenStorage( - manager.tokenStorage(session.workspaceId), - session.workspaceId, - debug, - probe, - ), + tokenStorage: storage, }); - return { - client: sdk.client, - binding: { source: "stored", workspaceId: session.workspaceId }, - }; + return { client: sdk.client, pinned: { active, storage } }; } /** - * The manager's view, with the refresh path observed. The SDK enters + * The manager's storage, with the refresh path observed. The SDK enters * withRefreshLock only from its refresh routine, so it marks both the * debug valve's "refresh attempted" line and the boundary whose throws * count as refresh-path failures. */ function observedTokenStorage( storage: TokenStorage, - workspaceId: string, + active: ActiveCredential, debug: DebugLog, probe: RefreshProbe, ): TokenStorage { + const label = active.workspaceId ?? "(no workspace)"; const observedRefresh = async (fn: () => Promise): Promise => { - debug(`refresh attempted for session ${workspaceId}`); + debug(`refresh attempted for session ${label}`); try { return await fn(); } catch (failure) { @@ -198,42 +173,30 @@ function observedTokenStorage( }; } -/** The static-token path has no error middleware, so a 401 arrives as - * a resolved openapi-fetch result; the call site inspects it. */ -function responseWas401(result: unknown): boolean { - if (typeof result !== "object" || result === null) { - return false; - } - const response = (result as { readonly response?: unknown }).response; - return ( - typeof response === "object" && - response !== null && - (response as { readonly status?: unknown }).status === 401 - ); -} - /** * The engine-side request-failure mapping. A structured error raised * inside the pipeline (the SDK wraps non-SDK errors into * FetchError(cause), so the cause chain is walked for BOTH AuthError * and CLI structured errors) is rethrown unwrapped so it settles as - * itself. An SDK AuthError is discriminated by STATE, never by - * message parsing: refreshTokenInvalid === true (the SDK's definitive + * itself. An SDK AuthError is discriminated by STATE, never by message + * parsing: refreshTokenInvalid === true (the SDK's definitive * invalid_grant signal, already cleared by compare-and-clear) maps to - * the expired CLI.CREDENTIALS_REQUIRED; any other AuthError triggers - * a re-read of the manager's state for the workspace the client is - * BOUND to — that session gone means the session-ended - * CLI.CREDENTIALS_REQUIRED, otherwise the failure was the auth - * service's and nothing was cleared. A failure that came out of the - * refresh path without being an AuthError (the SDK throws a plain - * Error when a rotated token will not decode) is transient too: + * the expired CLI.CREDENTIALS_REQUIRED; a refresh the SDK refused + * because the token set carries no refresh token maps to the + * credential-rejected error, since that credential could never have + * been renewed; any other AuthError triggers a re-read of the stored + * state for the credential's own workspace — that session gone means + * the session-ended CLI.CREDENTIALS_REQUIRED, otherwise the failure was + * the auth service's and nothing was cleared. A failure that came out + * of the refresh path without being an AuthError (the SDK throws a + * plain Error when a rotated token will not decode) is transient too: * nothing was cleared, and signing in again is not the fix. */ async function mapRequestFailure( invocation: Invocation, debug: DebugLog, probe: RefreshProbe, - binding: ClientBinding | undefined, + pinned: PinnedCredential | undefined, cause: unknown, ): Promise { // Before anything else: a CLI structured error raised inside the @@ -268,27 +231,51 @@ async function mapRequestFailure( return credentialsRequiredError("expired"); } const manager = invocation.runtime.credentialManager; - if (manager === undefined || binding?.source !== "stored") { + if (manager === undefined || pinned === undefined) { return authServiceError(); } - return mapStoredSessionAuthFailure(manager, binding.workspaceId, cause); + if (cameFromRefresh && (await couldNeverHaveBeenRenewed(pinned.storage))) { + return credentialRejectedError(pinned.active.origin, SERVICE_TOKEN_ENV_VAR); + } + return mapAgainstStoredState(manager, pinned.active, cause); } -async function mapStoredSessionAuthFailure( +/** + * A 401 that could never have been renewed: the SDK refused to reach + * the token endpoint at all because the token set carries no refresh + * token. Read from the storage's own state — never from the SDK's + * message. + */ +async function couldNeverHaveBeenRenewed( + storage: TokenStorage, +): Promise { + try { + const tokens = await storage.getTokens(); + return tokens !== null && !tokens.refreshToken; + } catch { + return false; + } +} + +async function mapAgainstStoredState( manager: CredentialManager, - boundWorkspaceId: string, + active: ActiveCredential, cause: unknown, ): Promise { - let sessions: readonly Session[]; + const workspaceId = active.workspaceId; + if (workspaceId === undefined) { + return authServiceError(); + } + let stored: StoredSessions; try { - sessions = await manager.sessions(); + stored = await manager.sessions(); } catch (stateCause) { return CliStructuredError.is(stateCause) ? stateCause : cause; } - const boundSessionGone = !sessions.some( - (session) => session.workspaceId === boundWorkspaceId, + const sessionGone = !stored.sessions.some( + (session) => session.workspaceId === workspaceId, ); - if (boundSessionGone) { + if (sessionGone) { return credentialsRequiredError("session-ended"); } return authServiceError(); diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index fe558d26..7f95ab48 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -1,5 +1,8 @@ -import type { CommandContext, Credentials } from "../context"; -import type { CredentialManager, Session } from "../credential-manager"; +import type { CommandContext } from "../context"; +import type { + ActiveCredential, + CredentialManager, +} from "../credential-manager"; import type { ManagementApiClient } from "../management-api"; import { PRESENTED, @@ -90,11 +93,9 @@ export function makeContext( const context: CommandContext = { config, present: present as CommandContext["present"], - session: (): Promise => - invocation.runtime.credentialManager?.currentSession() ?? + activeCredential: (): Promise => + invocation.runtime.credentialManager?.activeCredential() ?? Promise.resolve(null), - getCredentials: (): Promise => - invocation.runtime.getCredentials(), get api(): ManagementApiClient { api ??= invocation.hooks.managementApi?.client ?? diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 8fa42f5b..0d5fbde7 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -2,13 +2,12 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import type { AnyCommand } from "../commands"; import type { ConfigSection, SectionValidation } from "../config-section"; -import type { Credentials } from "../context"; import { credentialsRequiredError } from "../credential-errors"; import type { CredentialManager } from "../credential-manager"; import { CliStructuredError, type Diagnostic } from "../protocol"; import type { Runtime } from "../runtime"; import type { Invocation } from "./engine"; -import { firstLine, withDocsUrl, writeDiagnostic } from "./rendering"; +import { withDocsUrl, writeDiagnostic } from "./rendering"; import { SEVERITY_RANK } from "./reporting"; export type NeedsOutcome = @@ -103,12 +102,12 @@ function checkDependencies( } /** - * The credentials need, single-sourced from the credential manager - * when one is wired: currentSession() is the local-only truth (the - * process pin), its structured errors (sessions held none current, - * blank env token) pass through verbatim so the needs check, - * ctx.session, and ctx.api raise identically. The getCredentials path - * below is the staged-swap fallback. + * The credentials need, single-sourced from the credential manager: + * activeCredential() is the local-only truth (the process pin), and + * its structured errors (sessions held none selected, blank env token) + * pass through verbatim so the needs check, ctx.activeCredential, and + * ctx.api raise identically. A host with no manager wired has no + * credentials at all. */ async function checkCredentials( needs: AnyCommand["needs"], @@ -119,46 +118,20 @@ async function checkCredentials( } const manager: CredentialManager | undefined = invocation.runtime.credentialManager; - if (manager !== undefined) { - try { - if ((await manager.currentSession()) === null) { - return needsErrored(credentialsRequiredError()); - } - return undefined; - } catch (cause) { - if (CliStructuredError.is(cause)) { - return needsErrored(cause); - } - throw cause; - } + if (manager === undefined) { + return needsErrored(credentialsRequiredError()); } - let credentials: Credentials | undefined; try { - credentials = await invocation.runtime.getCredentials(); + if ((await manager.activeCredential()) === null) { + return needsErrored(credentialsRequiredError()); + } + return undefined; } catch (cause) { - return needsErrored( - new CliStructuredError( - "CLI.CREDENTIALS_UNREADABLE", - "The stored credentials could not be read.", - { - why: firstLine( - cause instanceof Error ? cause.message : String(cause), - ), - nextActions: [ - { - kind: "user-choice", - label: - "Sign in again to replace the stored credentials, then run the command again.", - }, - ], - }, - ), - ); - } - if (credentials === undefined) { - return needsErrored(credentialsRequiredError()); + if (CliStructuredError.is(cause)) { + return needsErrored(cause); + } + throw cause; } - return undefined; } function checkConfiguration( diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index cc39e1a2..6f5c4a32 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -50,7 +50,6 @@ export { export type { BrowserWaitRequest, CommandContext, - Credentials, OpenUrlOutcome, OpenUrlRequest, PromptSurface, @@ -58,6 +57,7 @@ export type { export { authServiceError, type CredentialsRequiredReason, + credentialRejectedError, credentialsRequiredError, emptyServiceTokenError, environmentSessionMutationError, @@ -65,9 +65,13 @@ export { serviceTokenRejectedError, } from "../credential-errors"; export type { + ActiveCredential, Credential, + CredentialIdentity, CredentialManager, + CredentialOrigin, Session, + StoredSessions, } from "../credential-manager"; export type { EngineEvent, diff --git a/packages/cli-engine/src/in-memory-credential-manager.ts b/packages/cli-engine/src/in-memory-credential-manager.ts index 94952d8b..124decc1 100644 --- a/packages/cli-engine/src/in-memory-credential-manager.ts +++ b/packages/cli-engine/src/in-memory-credential-manager.ts @@ -1,26 +1,33 @@ /** * A complete CredentialManager whose state lives in memory rather than - * in a file. It implements the same session rules as the file-backed - * one — pinning, upsert by workspace, the environment-override - * refusals, and the TokenStorage write slices — and adds a seed and a - * state read-back. Tests are what it is mostly used for, which is why - * it ships from the ./testing subpath alongside the JWT minter that - * produces tokens to seed it, but nothing about it is a stub. + * in a file. It implements the same rules as the file-backed one — the + * pinned decision, upsert by workspace, idempotent removal, and the two + * token storages — and adds a seed and a state read-back. Tests are + * what it is mostly used for, which is why it ships from the ./testing + * subpath alongside the JWT minter that produces tokens to seed it, but + * nothing about it is a stub. */ import { Buffer } from "node:buffer"; import { credentialsRequiredError, - environmentSessionMutationError, noSessionForWorkspaceError, } from "./credential-errors"; import type { + ActiveCredential, Credential, + CredentialIdentity, CredentialManager, Session, + StoredSessions, } from "./credential-manager"; import type { TokenStorage } from "./management-api"; -const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; +type Tokens = NonNullable>>; + +/** The SDK's Tokens requires a workspace id, so an environment + * credential whose claims name no workspace is given this instead. It + * never leaves the manager, and it is never the empty string. */ +const NO_WORKSPACE_CLAIMED = "(no workspace)"; /** A stored session with its credential material — what the manager * holds, seeded in and read back out. Mirrors the state file's @@ -34,20 +41,21 @@ export interface SessionRecord { export interface InMemoryCredentialManagerSeed { /** Stored sessions, mirroring the state file's records. */ readonly sessions?: readonly SessionRecord[]; - /** The file's current marker. */ - readonly currentWorkspaceId?: string; + /** The stored selection — the workspace whose session is used where + * a session is needed. */ + readonly selectedWorkspaceId?: string; /** Convenience seed: runs createSession's real claims derivation. * The token must be a JWT with `workspace_id` (use mintTestJwt). */ readonly credential?: Credential; - /** Composes the ephemeral env session (PRISMA_SERVICE_TOKEN). The - * token must be a JWT with `workspace_id` (use mintTestJwt). */ - readonly environmentToken?: string; + /** The credential PRISMA_SERVICE_TOKEN supplies. Its token may carry + * no `workspace_id` claim, and it may carry a refresh token. */ + readonly environmentCredential?: Credential; } /** The whole stored state, readable back after a run. */ export interface InMemoryCredentialManagerState { readonly sessions: readonly SessionRecord[]; - readonly currentWorkspaceId: string | null; + readonly selectedWorkspaceId: string | undefined; } /** Mints an unsigned JWT whose payload is exactly `claims` — the @@ -86,29 +94,95 @@ function claimedExpiresAt(token: string): Date | undefined { return typeof exp === "number" ? new Date(exp * 1000) : undefined; } +function claimedIdentity(token: string): CredentialIdentity | undefined { + const claims = decodeJwtClaims(token); + const userId = typeof claims?.sub === "string" ? claims.sub : undefined; + const email = typeof claims?.email === "string" ? claims.email : undefined; + return userId === undefined && email === undefined + ? undefined + : { userId, email }; +} + +function asSession(record: SessionRecord): Session { + return { + workspaceId: record.workspaceId, + workspaceName: record.workspaceName, + expiresAt: record.credential.expiresAt, + }; +} + +function storedActiveCredential(record: SessionRecord): ActiveCredential { + return { + workspaceId: record.workspaceId, + workspaceName: record.workspaceName, + expiresAt: record.credential.expiresAt, + identity: claimedIdentity(record.credential.token), + origin: { source: "stored" }, + }; +} + +function environmentActiveCredential(credential: Credential): ActiveCredential { + return { + workspaceId: claimedWorkspaceId(credential.token), + workspaceName: undefined, + expiresAt: claimedExpiresAt(credential.token) ?? credential.expiresAt, + identity: claimedIdentity(credential.token), + origin: { source: "environment" }, + }; +} + +/** + * The memory-backed storage, for a credential with no home record: a + * free function closing over one local variable, so no method of it — + * clearTokens included — can reach the stored sessions. An environment + * credential whose workspace matches a stored session therefore cannot + * delete that session. + */ +function memoryBackedStorage(credential: Credential): TokenStorage { + let tokens: Tokens | null = { + workspaceId: claimedWorkspaceId(credential.token) ?? NO_WORKSPACE_CLAIMED, + accessToken: credential.token, + refreshToken: credential.refreshToken, + }; + return { + getTokens: async () => tokens, + setTokens: async (rotated) => { + tokens = rotated; + }, + clearTokens: async () => { + tokens = null; + }, + withRefreshLock: (fn) => fn(), + }; +} + +/** Which credential this process acts as, decided once. */ type Pin = - | { readonly kind: "unpinned" } + | { readonly kind: "unresolved" } | { readonly kind: "environment" } - | { readonly kind: "marker"; readonly workspaceId: string | null }; + | { readonly kind: "session"; readonly workspaceId: string } + | { readonly kind: "none" }; /** * The harness's mutable in-memory CredentialManager: the same * interface commands see, with the whole stored state readable back - * after a run, and the design's process-pinning semantics — - * currentSession() is fixed at its first read; only this manager's - * own mutations move it. No persistence, no locking — those belong to - * the real manager and its own tests. + * after a run, and the design's process pinning — which credential + * this process acts as is fixed at the first activeCredential() read, + * while the material behind it is re-read on every call. No + * persistence, no locking — those belong to the real manager and its + * own tests. */ export class InMemoryCredentialManager implements CredentialManager { private storedSessions: SessionRecord[]; - private markedWorkspaceId: string | null; - private readonly environmentToken: string | undefined; - private pin: Pin = { kind: "unpinned" }; + private selection: string | undefined; + private readonly environmentCredential: Credential | undefined; + private pin: Pin = { kind: "unresolved" }; + private activeStorage: TokenStorage | undefined; constructor(seed: InMemoryCredentialManagerSeed) { this.storedSessions = [...(seed.sessions ?? [])]; - this.markedWorkspaceId = seed.currentWorkspaceId ?? null; - this.environmentToken = seed.environmentToken; + this.selection = seed.selectedWorkspaceId; + this.environmentCredential = seed.environmentCredential; if (seed.credential !== undefined) { const workspaceId = claimedWorkspaceId(seed.credential.token); if (workspaceId === undefined) { @@ -123,37 +197,37 @@ export class InMemoryCredentialManager implements CredentialManager { state(): InMemoryCredentialManagerState { return { sessions: [...this.storedSessions], - currentWorkspaceId: this.markedWorkspaceId, + selectedWorkspaceId: this.selection, }; } /** Applies a write as ANOTHER process would: the stored state - * changes, but this process's pinned session does not move. */ + * changes, but this process's pinned decision does not move. */ overwriteStoredState(state: { readonly sessions?: readonly SessionRecord[]; - readonly currentWorkspaceId?: string | null; + readonly selectedWorkspaceId?: string | undefined; }): void { if (state.sessions !== undefined) { this.storedSessions = [...state.sessions]; } - if (state.currentWorkspaceId !== undefined) { - this.markedWorkspaceId = state.currentWorkspaceId; + if ("selectedWorkspaceId" in state) { + this.selection = state.selectedWorkspaceId; } } - async currentSession(): Promise { - if (this.pin.kind === "unpinned") { - this.pin = - this.environmentToken !== undefined - ? { kind: "environment" } - : { kind: "marker", workspaceId: this.resolvedMarker() }; - return this.pinnedSession(true); + async activeCredential(): Promise { + if (this.pin.kind === "unresolved") { + this.pin = this.resolvePin(); + return this.credentialForPin(true); } - return this.pinnedSession(false); + return this.credentialForPin(false); } - async sessions(): Promise { - return this.storedSessions.map((record) => this.asSession(record)); + async sessions(): Promise { + return { + sessions: this.storedSessions.map((record) => asSession(record)), + selectedWorkspaceId: this.resolvedSelection(), + }; } async createSession( @@ -163,52 +237,61 @@ export class InMemoryCredentialManager implements CredentialManager { return this.applyCreateSession(credential, workspaceId); } - async useSession(session: Session): Promise { - this.refuseUnderEnvironmentSession(); - const record = this.validatedWorkspaceReference(session); - this.markedWorkspaceId = record.workspaceId; - this.pin = { kind: "marker", workspaceId: record.workspaceId }; - return this.asSession(record); - } - - async endSession(session: Session): Promise { - this.refuseUnderEnvironmentSession(); - const record = this.validatedWorkspaceReference(session); - this.storedSessions = this.storedSessions.filter( - (stored) => stored.workspaceId !== record.workspaceId, + async selectSession(workspaceId: string): Promise { + const record = this.storedSessions.find( + (stored) => stored.workspaceId === workspaceId, ); - if (this.markedWorkspaceId === record.workspaceId) { - this.markedWorkspaceId = null; + if (record === undefined) { + throw noSessionForWorkspaceError(workspaceId); } - if ( - this.pin.kind === "marker" && - this.pin.workspaceId === record.workspaceId - ) { - this.pin = { kind: "marker", workspaceId: null }; + this.selection = workspaceId; + if (this.environmentCredential === undefined) { + this.pin = { kind: "session", workspaceId }; } + return asSession(record); } - async endAllSessions(): Promise { - if (this.environmentToken !== undefined) { - if (this.storedSessions.length === 0) { - return; - } - throw environmentSessionMutationError({ - envVar: SERVICE_TOKEN_ENV_VAR, - storedSessionsExist: true, - }); + async endSession(workspaceId: string): Promise { + this.removeRecord(workspaceId); + if (this.pin.kind === "session" && this.pin.workspaceId === workspaceId) { + this.pin = { kind: "none" }; } + } + + async endAllSessions(): Promise { this.storedSessions = []; - this.markedWorkspaceId = null; - this.pin = { kind: "marker", workspaceId: null }; + this.selection = undefined; + if (this.environmentCredential === undefined) { + this.pin = { kind: "none" }; + } + } + + async activeCredentialStorage(): Promise { + this.activeStorage ??= this.buildActiveStorage(); + return this.activeStorage; + } + + private buildActiveStorage(): TokenStorage { + const pin = this.pin; + if (pin.kind === "environment") { + return memoryBackedStorage(this.requireEnvironmentCredential()); + } + if (pin.kind === "session") { + return this.storedSessionStorage(pin.workspaceId); + } + throw new Error( + "@prisma/cli-engine/testing: activeCredentialStorage() is only valid once activeCredential() has returned non-null", + ); } - tokenStorage(workspaceId: string): TokenStorage { - const boundRecord = (): SessionRecord | undefined => + /** The file-backed storage's analogue: the record is read afresh on + * every call, never snapshotted. */ + private storedSessionStorage(workspaceId: string): TokenStorage { + const pinnedRecord = (): SessionRecord | undefined => this.storedSessions.find((record) => record.workspaceId === workspaceId); return { getTokens: async () => { - const record = boundRecord(); + const record = pinnedRecord(); if (record === undefined) { return null; } @@ -219,7 +302,7 @@ export class InMemoryCredentialManager implements CredentialManager { }; }, setTokens: async (tokens) => { - const record = boundRecord(); + const record = pinnedRecord(); if (record === undefined) { // The same structured error the real manager raises, so a // test of "the session ended mid-rotation" exercises the @@ -229,7 +312,7 @@ export class InMemoryCredentialManager implements CredentialManager { const claimed = claimedWorkspaceId(tokens.accessToken); if (claimed !== undefined && claimed !== workspaceId) { throw new Error( - "@prisma/cli-engine/testing: a refreshed token's workspace_id claim disagrees with the bound workspace — refresh cannot re-scope", + "@prisma/cli-engine/testing: a refreshed token's workspace_id claim disagrees with the pinned workspace — refresh cannot re-scope", ); } this.storedSessions = this.storedSessions.map((stored) => @@ -246,10 +329,10 @@ export class InMemoryCredentialManager implements CredentialManager { ); }, clearTokens: async () => { - this.removeRecordAndMarker(workspaceId); + this.removeRecord(workspaceId); }, clearTokensIfCurrent: async (tokens) => { - const record = boundRecord(); + const record = pinnedRecord(); if ( record === undefined || tokens.workspaceId !== workspaceId || @@ -258,90 +341,74 @@ export class InMemoryCredentialManager implements CredentialManager { ) { return; } - this.removeRecordAndMarker(workspaceId); + this.removeRecord(workspaceId); }, withRefreshLock: (fn) => fn(), }; } - private removeRecordAndMarker(workspaceId: string): void { + private removeRecord(workspaceId: string): void { this.storedSessions = this.storedSessions.filter( (stored) => stored.workspaceId !== workspaceId, ); - if (this.markedWorkspaceId === workspaceId) { - this.markedWorkspaceId = null; + if (this.selection === workspaceId) { + this.selection = undefined; } } - /** The marker the first read pins: a marker naming no record (the - * migration none-current case) pins as none. */ - private resolvedMarker(): string | null { - if ( - this.markedWorkspaceId !== null && - this.storedSessions.some( - (record) => record.workspaceId === this.markedWorkspaceId, - ) - ) { - return this.markedWorkspaceId; + private resolvePin(): Pin { + if (this.environmentCredential !== undefined) { + return { kind: "environment" }; } - return null; + const selected = this.resolvedSelection(); + return selected === undefined + ? { kind: "none" } + : { kind: "session", workspaceId: selected }; } - private pinnedSession(justPinned: boolean): Session | null { - if (this.pin.kind === "environment") { - return this.environmentSession(); + /** The selection the manager will admit to: one that names a stored + * session, or none. A dangling selection never escapes. */ + private resolvedSelection(): string | undefined { + const selected = this.selection; + return selected !== undefined && + this.storedSessions.some((record) => record.workspaceId === selected) + ? selected + : undefined; + } + + private credentialForPin(justResolved: boolean): ActiveCredential | null { + const pin = this.pin; + if (pin.kind === "environment") { + return environmentActiveCredential(this.requireEnvironmentCredential()); } - if (this.pin.kind === "unpinned" || this.pin.workspaceId === null) { - if (this.storedSessions.length > 0) { - throw credentialsRequiredError("sessions-held-none-current"); + if (pin.kind === "session") { + const record = this.storedSessions.find( + (stored) => stored.workspaceId === pin.workspaceId, + ); + if (record === undefined) { + if (justResolved) { + throw new Error( + "@prisma/cli-engine/testing: the pin resolved to a workspace with no record", + ); + } + throw credentialsRequiredError("session-ended"); } - return null; + return storedActiveCredential(record); } - const pinnedWorkspaceId = this.pin.workspaceId; - const record = this.storedSessions.find( - (stored) => stored.workspaceId === pinnedWorkspaceId, - ); - if (record === undefined) { - if (justPinned) { - throw new Error( - "@prisma/cli-engine/testing: the pin resolved to a workspace with no record", - ); - } - throw credentialsRequiredError("session-ended"); + if (this.storedSessions.length > 0) { + throw credentialsRequiredError("sessions-held-none-selected"); } - return this.asSession(record); + return null; } - private environmentSession(): Session { - const token = this.environmentToken; - if (token === undefined) { - throw new Error( - "@prisma/cli-engine/testing: no environment token is seeded", - ); - } - const workspaceId = claimedWorkspaceId(token); - if (workspaceId === undefined) { + private requireEnvironmentCredential(): Credential { + const credential = this.environmentCredential; + if (credential === undefined) { throw new Error( - "@prisma/cli-engine/testing: the `environmentToken` seed must be a JWT with `workspace_id` (use mintTestJwt)", + "@prisma/cli-engine/testing: no environment credential is seeded", ); } - return { - workspaceId, - workspaceName: undefined, - expiresAt: claimedExpiresAt(token), - source: "environment", - current: true, - }; - } - - private asSession(record: SessionRecord): Session { - return { - workspaceId: record.workspaceId, - workspaceName: record.workspaceName, - expiresAt: record.credential.expiresAt, - source: "stored", - current: record.workspaceId === this.markedWorkspaceId, - }; + return credential; } private applyCreateSession( @@ -372,32 +439,10 @@ export class InMemoryCredentialManager implements CredentialManager { ), record, ]; - this.markedWorkspaceId = workspaceId; - if (this.environmentToken === undefined) { - this.pin = { kind: "marker", workspaceId }; - } - return this.asSession(record); - } - - private refuseUnderEnvironmentSession(): void { - if (this.environmentToken !== undefined) { - throw environmentSessionMutationError({ - envVar: SERVICE_TOKEN_ENV_VAR, - storedSessionsExist: this.storedSessions.length > 0, - }); - } - } - - private validatedWorkspaceReference(session: Session): SessionRecord { - if (session.source === "environment") { - throw noSessionForWorkspaceError(session.workspaceId); - } - const record = this.storedSessions.find( - (stored) => stored.workspaceId === session.workspaceId, - ); - if (record === undefined) { - throw noSessionForWorkspaceError(session.workspaceId); + this.selection = workspaceId; + if (this.environmentCredential === undefined) { + this.pin = { kind: "session", workspaceId }; } - return record; + return asSession(record); } } diff --git a/packages/cli-engine/src/management-api.ts b/packages/cli-engine/src/management-api.ts index bee4d449..fb96cbc6 100644 --- a/packages/cli-engine/src/management-api.ts +++ b/packages/cli-engine/src/management-api.ts @@ -11,8 +11,9 @@ export type ManagementApiClient = SdkClient; /** * The SDK's token-storage contract, re-exported for the same reason. - * CredentialManager.tokenStorage returns one; the engine forwards it - * into SDK client config and never calls its methods itself. + * CredentialManager.activeCredentialStorage returns one; the engine + * forwards it into SDK client config and reads it only to tell a + * credential that could never be renewed from one that could. */ export type TokenStorage = SdkTokenStorage; diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index 36627b48..2ada9e97 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -1,4 +1,3 @@ -import type { Credentials } from "./context"; import type { CredentialManager } from "./credential-manager"; import type { ManagementApiClientConfig } from "./management-api"; import type { Diagnostic } from "./protocol"; @@ -47,19 +46,18 @@ export interface Runtime { */ readonly config: LoadedConfig; /** - * The credential manager the bin wires. The engine prefers it for - * the needs check, ctx.session, and ctx.api; optional only during - * the staged swap — getCredentials below is the fallback and is - * deleted with the swap's final stage. + * The credential manager the bin wires. It is the only source of + * the needs check, ctx.activeCredential, and ctx.api; absent means + * this host has no credentials at all, and every command that needs + * them fails as signed out. */ readonly credentialManager?: CredentialManager; /** * SDK client construction config the bin injects beside the * manager; the engine builds ctx.api from it. Required whenever a - * credentialManager is wired; optional only during the staged swap. + * credentialManager is wired. */ readonly managementApiClientConfig?: ManagementApiClientConfig; - readonly getCredentials: () => Promise; /** * Opens a URL in the user's browser, wired by the bin (the login * flow's opener). The engine calls it only for interactive sessions, diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index b1fa6d40..891d1b73 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -1,5 +1,4 @@ import type { CommandFamily, MountedTree } from "./command-family"; -import type { Credentials } from "./context"; import type { Credential } from "./credential-manager"; import type { EngineEvent, StreamEvent } from "./events"; import { buildEngine } from "./execution/engine"; @@ -18,12 +17,10 @@ import type { Runtime } from "./runtime"; export interface TestCli { /** * The mutable in-memory credential manager backing the runs — the - * whole stored state (sessions with their credentials, the current - * marker) is readable back after a run via state(). Undefined only - * when the legacy `credentials` seed selected the getCredentials - * fallback path. + * whole stored state (sessions with their credentials, the + * selection) is readable back after a run via state(). */ - readonly credentialManager: InMemoryCredentialManager | undefined; + readonly credentialManager: InMemoryCredentialManager; run( argv: readonly string[], opts?: { @@ -87,22 +84,17 @@ export function createTestCli(spec: { readonly commands: MountedTree; readonly groups?: Readonly>; readonly config?: Readonly>; - /** - * Legacy seed for the staged-swap getCredentials fallback: selects - * a manager-less runtime. Mutually exclusive with the manager - * seeds below; deleted with the swap's final stage. - */ - readonly credentials?: Credentials; /** Convenience manager seed: createSession runs its real claims * derivation on this credential (mint the token with mintTestJwt). */ readonly credential?: Credential; /** Stored sessions, mirroring the state file's records. */ readonly sessions?: readonly SessionRecord[]; - /** The file's current marker. */ - readonly currentWorkspaceId?: string; - /** Composes the ephemeral env session; also exported to each run's - * env as PRISMA_SERVICE_TOKEN (overridable per run). */ - readonly environmentToken?: string; + /** The stored selection. */ + readonly selectedWorkspaceId?: string; + /** The credential PRISMA_SERVICE_TOKEN supplies. Its access token is + * also exported to each run's env as PRISMA_SERVICE_TOKEN + * (overridable per run). */ + readonly environmentCredential?: Credential; /** The SDK client construction config; defaults point every * endpoint at test.invalid hosts. */ readonly managementApiClientConfig?: ManagementApiClientConfig; @@ -125,25 +117,12 @@ export function createTestCli(spec: { * assert the interval a poll loop asked for. */ readonly delay?: (ms: number, signal: AbortSignal) => Promise; }): TestCli { - const managerSeeded = - spec.credential !== undefined || - spec.sessions !== undefined || - spec.currentWorkspaceId !== undefined || - spec.environmentToken !== undefined; - if (spec.credentials !== undefined && managerSeeded) { - throw new Error( - "@prisma/cli-engine/testing: the legacy `credentials` seed selects the manager-less fallback runtime and cannot be combined with credential-manager seeds", - ); - } - const credentialManager = - spec.credentials !== undefined - ? undefined - : new InMemoryCredentialManager({ - sessions: spec.sessions, - currentWorkspaceId: spec.currentWorkspaceId, - credential: spec.credential, - environmentToken: spec.environmentToken, - }); + const credentialManager = new InMemoryCredentialManager({ + sessions: spec.sessions, + selectedWorkspaceId: spec.selectedWorkspaceId, + credential: spec.credential, + environmentCredential: spec.environmentCredential, + }); const managementApiClientConfig: ManagementApiClientConfig = spec.managementApiClientConfig ?? { clientId: "test-client-id", @@ -197,9 +176,12 @@ export function createTestCli(spec: { stdin: inputStreamFromString(opts?.stdin ?? ""), cwd: opts?.cwd ?? "/", env: - spec.environmentToken === undefined + spec.environmentCredential === undefined ? (opts?.env ?? {}) - : { PRISMA_SERVICE_TOKEN: spec.environmentToken, ...opts?.env }, + : { + PRISMA_SERVICE_TOKEN: spec.environmentCredential.token, + ...opts?.env, + }, isTty: { stdin: opts?.isTty?.stdin ?? false, stdout: opts?.isTty?.stdout ?? false, @@ -219,7 +201,6 @@ export function createTestCli(spec: { config: { sections: spec.config ?? {}, diagnostics: [] }, credentialManager, managementApiClientConfig, - getCredentials: async () => spec.credentials, openUrl: spec.openUrl ?? ((): void => {}), managementApi: { baseUrl: spec.managementApi?.baseUrl ?? "https://test.invalid", diff --git a/packages/cli-engine/tests/clack-isolation.test.ts b/packages/cli-engine/tests/clack-isolation.test.ts index 6d88b3f4..2bd81b79 100644 --- a/packages/cli-engine/tests/clack-isolation.test.ts +++ b/packages/cli-engine/tests/clack-isolation.test.ts @@ -117,7 +117,6 @@ describe("scripted and non-TTY paths are clack-free", () => { }, onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index c12d7a93..86107171 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -103,7 +103,6 @@ async function runInteractive( }, onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 07fc73d9..dd510489 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -340,7 +340,6 @@ describe("needs.config", () => { }, onSignal: () => () => {}, config, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 0179d5bd..260974da 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -1,16 +1,16 @@ /** - * The credential-manager engine surface (design rev 5, the session - * model): ctx.session on every context serving the process pin, the - * managesCredentials capability, the manager-backed needs check with - * its single-sourced errors, session mutations with state read-back, - * process-pinning semantics, harness seeding, and the + * The credential-manager engine surface (design rev 6, the active + * credential): ctx.activeCredential on every context serving the + * process pin, the managesCredentials capability, the manager-backed + * needs check with its single-sourced errors, session mutations with + * state read-back, process pinning, harness seeding, and the * no-token-material guarantees. */ import { + type ActiveCredential, type Credential, defineCommand, - type Session, } from "@prisma/cli-engine"; import { type CliStructuredError, @@ -52,37 +52,38 @@ const sessionRecordFor = ( }, }); -const storedSessionRef = (workspaceId: string): Session => ({ - workspaceId, - workspaceName: undefined, +const environmentCredentialFor = (claims: { + readonly sub?: string; + readonly workspace_id?: string; +}): Credential => ({ + token: mintTestJwt(claims), + refreshToken: undefined, expiresAt: undefined, - source: "stored", - current: false, }); -const sessionReader = () => { - let seen: Session | null | undefined; +const credentialReader = () => { + let seen: ActiveCredential | null | undefined; const command = defineCommand({ - help: { summary: "Reads the session" }, + help: { summary: "Reads the active credential" }, handler: async (_args, ctx) => { - seen = await ctx.session(); + seen = await ctx.activeCredential(); return ok(ctx.present({ data: seen }, { human: () => [] })); }, }); return { command, seen: () => seen }; }; -describe("ctx.session", () => { +describe("ctx.activeCredential", () => { test("resolves null on every context when signed out; the command still completes", async () => { - const reader = sessionReader(); + const reader = credentialReader(); const cli = createTestCli({ commands: { toy: reader.command } }); const { exitCode } = await cli.run(["toy"]); expect(exitCode).toBe(0); expect(reader.seen()).toBeNull(); }); - test("a seeded credential runs real createSession derivation: workspace and expiry come from the token's claims", async () => { - const reader = sessionReader(); + test("a seeded credential runs real createSession derivation: workspace, expiry and identity come from the token's claims", async () => { + const reader = credentialReader(); const cli = createTestCli({ commands: { toy: reader.command }, credential: userCredential({ @@ -96,36 +97,35 @@ describe("ctx.session", () => { workspaceId: "workspace-9", workspaceName: undefined, expiresAt: new Date(1_900_000_000 * 1000), - source: "stored", - current: true, + identity: { userId: "user-1", email: undefined }, + origin: { source: "stored" }, }); }); - test("seeded sessions with a current marker serve the marked session", async () => { - const reader = sessionReader(); + test("seeded sessions with a selection serve the selected session", async () => { + const reader = credentialReader(); const cli = createTestCli({ commands: { toy: reader.command }, sessions: [ sessionRecordFor("workspace-1", { name: "Acme Prod" }), sessionRecordFor("workspace-2"), ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); const { exitCode } = await cli.run(["toy"]); expect(exitCode).toBe(0); expect(reader.seen()).toMatchObject({ workspaceId: "workspace-1", workspaceName: "Acme Prod", - source: "stored", - current: true, + origin: { source: "stored" }, }); }); - test("a seeded environment token composes the env session", async () => { - const reader = sessionReader(); + test("a seeded environment credential resolves with the environment origin", async () => { + const reader = credentialReader(); const cli = createTestCli({ commands: { toy: reader.command }, - environmentToken: mintTestJwt({ + environmentCredential: environmentCredentialFor({ sub: "svc-1", workspace_id: "workspace-env", }), @@ -136,9 +136,32 @@ describe("ctx.session", () => { workspaceId: "workspace-env", workspaceName: undefined, expiresAt: undefined, - source: "environment", - current: true, + identity: { userId: "svc-1", email: undefined }, + origin: { source: "environment" }, + }); + }); + + /** Design §11.10, test 7. */ + test("a claimless environment token reports workspaceId undefined, and the JSON renders no empty string and no 'undefined'", async () => { + const reader = credentialReader(); + const cli = createTestCli({ + commands: { toy: reader.command }, + environmentCredential: environmentCredentialFor({ sub: "svc-1" }), }); + const { exitCode, stdout } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(0); + expect(reader.seen()).toEqual({ + workspaceId: undefined, + workspaceName: undefined, + expiresAt: undefined, + identity: { userId: "svc-1", email: undefined }, + origin: { source: "environment" }, + }); + expect(reader.seen()?.workspaceId).not.toBe(""); + expect(stdout).toContain('"source":"environment"'); + expect(stdout).not.toContain("workspaceId"); + expect(stdout).not.toContain("undefined"); + expect(stdout).not.toContain('""'); }); afterEach(() => { @@ -147,10 +170,10 @@ describe("ctx.session", () => { test("performs no network I/O", async () => { const fetchSpy = vi.fn(() => { - throw new Error("ctx.session() touched the network"); + throw new Error("ctx.activeCredential() touched the network"); }); vi.stubGlobal("fetch", fetchSpy); - const reader = sessionReader(); + const reader = credentialReader(); const cli = createTestCli({ commands: { toy: reader.command }, credential: userCredential(), @@ -218,7 +241,7 @@ describe("the manager-backed needs check", () => { }); }); - test("sessions held, none current: the identical single-sourced error from the needs check, ctx.session, and a bare ctx.api touch", async () => { + test("sessions held, none selected: the identical single-sourced error from the needs check, ctx.activeCredential, and a bare ctx.api touch", async () => { const seeds = { sessions: [ sessionRecordFor("workspace-1"), @@ -241,7 +264,7 @@ describe("the manager-backed needs check", () => { const caughtBy = ( body: (ctx: { - readonly session: () => Promise; + readonly activeCredential: () => Promise; readonly api: { GET: (path: string, opts: unknown) => Promise; }; @@ -271,9 +294,9 @@ describe("the manager-backed needs check", () => { : undefined; }; - const fromSession = await runCaught( + const fromActiveCredential = await runCaught( caughtBy(async (ctx) => { - await ctx.session(); + await ctx.activeCredential(); }), ); const fromApiTouch = await runCaught( @@ -293,7 +316,7 @@ describe("the manager-backed needs check", () => { { kind: "user-choice" }, ], }); - expect(fromSession).toEqual(fromNeedsCheck); + expect(fromActiveCredential).toEqual(fromNeedsCheck); expect(fromApiTouch).toEqual(fromNeedsCheck); }); @@ -310,10 +333,10 @@ describe("the manager-backed needs check", () => { const codeOf = (thrown: unknown): string => (thrown as CliStructuredError).code; describe("session mutations and state read-back", () => { - test("createSession upserts by workspaceId, preserves a recorded name, and sets the marker", async () => { + test("createSession upserts by workspaceId, preserves a recorded name, and selects it", async () => { const manager = new InMemoryCredentialManager({ sessions: [sessionRecordFor("workspace-1", { name: "Acme Prod" })], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); await manager.createSession( userCredential({ workspaceId: "workspace-2" }), @@ -333,10 +356,10 @@ describe("session mutations and state read-back", () => { ["workspace-2", undefined], ["workspace-1", "Acme Prod"], ]); - expect(state.currentWorkspaceId).toBe("workspace-1"); - expect(await manager.currentSession()).toMatchObject({ + expect(state.selectedWorkspaceId).toBe("workspace-1"); + expect(await manager.activeCredential()).toMatchObject({ workspaceId: "workspace-1", - current: true, + origin: { source: "stored" }, }); }); @@ -350,117 +373,147 @@ describe("session mutations and state read-back", () => { ).rejects.toThrow(/disagrees with the credential's workspace_id claim/); }); - test("useSession switches the marker; an unknown workspace and an environment-source argument raise AUTH.NO_SESSION_FOR_WORKSPACE", async () => { + test("selectSession switches the selection and refuses a workspace with no session", async () => { const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); - const switched = await manager.useSession(storedSessionRef("workspace-2")); - expect(switched).toMatchObject({ + const selected = await manager.selectSession("workspace-2"); + expect(selected).toEqual({ workspaceId: "workspace-2", - current: true, + workspaceName: undefined, + expiresAt: undefined, }); - expect(manager.state().currentWorkspaceId).toBe("workspace-2"); + expect(manager.state().selectedWorkspaceId).toBe("workspace-2"); + expect((await manager.sessions()).selectedWorkspaceId).toBe("workspace-2"); await expect( - manager.useSession(storedSessionRef("workspace-9")).catch(codeOf), + manager.selectSession("workspace-9").catch(codeOf), ).resolves.toBe("AUTH.NO_SESSION_FOR_WORKSPACE"); - await expect( - manager - .useSession({ - ...storedSessionRef("workspace-2"), - source: "environment", - }) - .catch(codeOf), - ).resolves.toBe("AUTH.NO_SESSION_FOR_WORKSPACE"); - - const unchanged = await manager.useSession(storedSessionRef("workspace-2")); - expect(unchanged).toMatchObject({ workspaceId: "workspace-2" }); }); - test("endSession removes one session and clears the current only when it named it — no auto-promotion", async () => { + test("endSession removes one session and clears the selection only when it named it — no auto-promotion", async () => { const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); - await manager.endSession(storedSessionRef("workspace-1")); + await manager.endSession("workspace-1"); const state = manager.state(); expect(state.sessions.map((record) => record.workspaceId)).toEqual([ "workspace-2", ]); - expect(state.currentWorkspaceId).toBeNull(); - await expect(manager.currentSession().catch(codeOf)).resolves.toBe( + expect(state.selectedWorkspaceId).toBeUndefined(); + await expect(manager.activeCredential().catch(codeOf)).resolves.toBe( "CLI.CREDENTIALS_REQUIRED", ); }); - test("endAllSessions clears every session and the marker", async () => { + test("endSession on a workspace with no session succeeds and writes nothing", async () => { + const manager = new InMemoryCredentialManager({ + sessions: [sessionRecordFor("workspace-1")], + selectedWorkspaceId: "workspace-1", + }); + const before = manager.state(); + await expect(manager.endSession("workspace-9")).resolves.toBeUndefined(); + expect(manager.state()).toEqual(before); + }); + + test("endAllSessions clears every session and the selection", async () => { const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); await manager.endAllSessions(); expect(manager.state()).toEqual({ sessions: [], - currentWorkspaceId: null, + selectedWorkspaceId: undefined, + }); + expect(await manager.activeCredential()).toBeNull(); + }); + + test("sessions() never reports a selection that names no stored session", async () => { + const manager = new InMemoryCredentialManager({ + sessions: [sessionRecordFor("workspace-1")], + selectedWorkspaceId: "workspace-gone", + }); + expect(await manager.sessions()).toEqual({ + sessions: [ + { + workspaceId: "workspace-1", + workspaceName: undefined, + expiresAt: undefined, + }, + ], + selectedWorkspaceId: undefined, }); - expect(await manager.currentSession()).toBeNull(); }); }); -describe("mutations under an env-supplied session", () => { - const environmentToken = mintTestJwt({ +describe("mutations while an environment credential is in force", () => { + const environmentCredential = environmentCredentialFor({ sub: "svc-1", workspace_id: "workspace-env", }); - test("useSession and endSession refuse, naming the variable and the unset command; state is untouched", async () => { - const manager = new InMemoryCredentialManager({ - sessions: [sessionRecordFor("workspace-1")], - currentWorkspaceId: "workspace-1", - environmentToken, - }); - for (const mutate of [ - () => manager.useSession(storedSessionRef("workspace-1")), - () => manager.endSession(storedSessionRef("workspace-1")), - ]) { - const thrown = (await mutate().catch( - (cause: unknown) => cause, - )) as CliStructuredError; - expect(thrown.code).toBe("AUTH.ENV_SESSION_IN_FORCE"); - expect(thrown.nextActions).toMatchObject([ - { kind: "run-command", command: "unset PRISMA_SERVICE_TOKEN" }, - ]); - } - expect(manager.state().sessions).toHaveLength(1); - expect(manager.state().currentWorkspaceId).toBe("workspace-1"); - }); - - test("endAllSessions refuses when stored sessions exist and succeeds as a no-op when there are none", async () => { - const withStored = new InMemoryCredentialManager({ - sessions: [sessionRecordFor("workspace-1")], - environmentToken, + const managerWithStoredSessions = () => + new InMemoryCredentialManager({ + sessions: [ + sessionRecordFor("workspace-1"), + sessionRecordFor("workspace-2"), + ], + selectedWorkspaceId: "workspace-1", + environmentCredential, + }); + + test("selectSession succeeds and changes the stored selection; the environment credential stays in force", async () => { + const manager = managerWithStoredSessions(); + await manager.selectSession("workspace-2"); + expect(manager.state().selectedWorkspaceId).toBe("workspace-2"); + expect(await manager.activeCredential()).toMatchObject({ + workspaceId: "workspace-env", + origin: { source: "environment" }, + }); + }); + + test("endSession succeeds and removes the stored session; the environment credential stays in force", async () => { + const manager = managerWithStoredSessions(); + await manager.endSession("workspace-1"); + expect( + manager.state().sessions.map((record) => record.workspaceId), + ).toEqual(["workspace-2"]); + expect(await manager.activeCredential()).toMatchObject({ + origin: { source: "environment" }, + }); + }); + + test("endAllSessions simply clears the store, with or without stored sessions", async () => { + const withStored = managerWithStoredSessions(); + await withStored.endAllSessions(); + expect(withStored.state()).toEqual({ + sessions: [], + selectedWorkspaceId: undefined, + }); + expect(await withStored.activeCredential()).toMatchObject({ + origin: { source: "environment" }, }); - await expect(withStored.endAllSessions().catch(codeOf)).resolves.toBe( - "AUTH.ENV_SESSION_IN_FORCE", - ); - expect(withStored.state().sessions).toHaveLength(1); - const withoutStored = new InMemoryCredentialManager({ environmentToken }); + const withoutStored = new InMemoryCredentialManager({ + environmentCredential, + }); await expect(withoutStored.endAllSessions()).resolves.toBeUndefined(); }); - test("createSession is allowed; the env token remains in force", async () => { - const manager = new InMemoryCredentialManager({ environmentToken }); + test("createSession is allowed; the environment credential remains in force", async () => { + const manager = new InMemoryCredentialManager({ environmentCredential }); await manager.createSession( userCredential({ workspaceId: "workspace-1" }), "workspace-1", @@ -468,73 +521,71 @@ describe("mutations under an env-supplied session", () => { expect( manager.state().sessions.map((record) => record.workspaceId), ).toEqual(["workspace-1"]); - expect(await manager.currentSession()).toMatchObject({ - source: "environment", + expect(await manager.activeCredential()).toMatchObject({ + origin: { source: "environment" }, workspaceId: "workspace-env", }); }); - test("sessions() still lists stored sessions with the file's marked current", async () => { + test("sessions() still lists the stored sessions and the stored selection", async () => { const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), ], - currentWorkspaceId: "workspace-2", - environmentToken, + selectedWorkspaceId: "workspace-2", + environmentCredential, }); - const listed = await manager.sessions(); - expect( - listed.map((session) => [session.workspaceId, session.current]), - ).toEqual([ - ["workspace-1", false], - ["workspace-2", true], + const stored = await manager.sessions(); + expect(stored.sessions.map((session) => session.workspaceId)).toEqual([ + "workspace-1", + "workspace-2", ]); - expect(listed.every((session) => session.source === "stored")).toBe(true); + expect(stored.selectedWorkspaceId).toBe("workspace-2"); }); }); describe("process pinning", () => { - test("the marker moved by another process between reads does not re-pin; a new manager picks up the new marker", async () => { + test("the selection moved by another process between reads does not re-pin; a new manager picks up the new selection", async () => { const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); - expect(await manager.currentSession()).toMatchObject({ + expect(await manager.activeCredential()).toMatchObject({ workspaceId: "workspace-1", }); - manager.overwriteStoredState({ currentWorkspaceId: "workspace-2" }); - expect(await manager.currentSession()).toMatchObject({ + manager.overwriteStoredState({ selectedWorkspaceId: "workspace-2" }); + expect(await manager.activeCredential()).toMatchObject({ workspaceId: "workspace-1", }); const movedState = manager.state(); const newProcess = new InMemoryCredentialManager({ sessions: movedState.sessions, - currentWorkspaceId: movedState.currentWorkspaceId ?? undefined, + selectedWorkspaceId: movedState.selectedWorkspaceId, }); - expect(await newProcess.currentSession()).toMatchObject({ + expect(await newProcess.activeCredential()).toMatchObject({ workspaceId: "workspace-2", }); }); - test("this manager's own useSession moves the pin", async () => { + test("this manager's own selectSession moves the pin", async () => { const manager = new InMemoryCredentialManager({ sessions: [ sessionRecordFor("workspace-1"), sessionRecordFor("workspace-2"), ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); - expect(await manager.currentSession()).toMatchObject({ + expect(await manager.activeCredential()).toMatchObject({ workspaceId: "workspace-1", }); - await manager.useSession(storedSessionRef("workspace-2")); - expect(await manager.currentSession()).toMatchObject({ + await manager.selectSession("workspace-2"); + expect(await manager.activeCredential()).toMatchObject({ workspaceId: "workspace-2", }); }); @@ -542,14 +593,17 @@ describe("process pinning", () => { test("a pinned session ended by another process raises the session-ended wording on the next read", async () => { const manager = new InMemoryCredentialManager({ sessions: [sessionRecordFor("workspace-1")], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); - expect(await manager.currentSession()).toMatchObject({ + expect(await manager.activeCredential()).toMatchObject({ workspaceId: "workspace-1", }); - manager.overwriteStoredState({ sessions: [], currentWorkspaceId: null }); + manager.overwriteStoredState({ + sessions: [], + selectedWorkspaceId: undefined, + }); const thrown = (await manager - .currentSession() + .activeCredential() .catch((cause: unknown) => cause)) as CliStructuredError; expect(thrown.code).toBe("CLI.CREDENTIALS_REQUIRED"); expect(thrown.message).toContain("has ended"); @@ -557,7 +611,7 @@ describe("process pinning", () => { }); describe("token material never leaves", () => { - test("sessions() and the session expose no seeded token through any output channel", async () => { + test("sessions() and the active credential expose no seeded token through any output channel", async () => { const secret = mintTestJwt({ sub: "user-1", workspace_id: "workspace-1", @@ -567,10 +621,10 @@ describe("token material never leaves", () => { help: { summary: "Lists sessions" }, managesCredentials: true, handler: async (_args, ctx) => { - const sessions = await ctx.credentialManager.sessions(); - const session = await ctx.session(); + const stored = await ctx.credentialManager.sessions(); + const active = await ctx.activeCredential(); return ok( - ctx.present({ data: { sessions, session } }, { human: () => [] }), + ctx.present({ data: { stored, active } }, { human: () => [] }), ); }, }); @@ -587,7 +641,7 @@ describe("token material never leaves", () => { }, }, ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); const { exitCode, stdout, stderr, json } = await cli.run(["toy", "--json"]); expect(exitCode).toBe(0); @@ -599,25 +653,31 @@ describe("token material never leaves", () => { }); describe("harness seed validation", () => { - test("the legacy credentials seed cannot be combined with manager seeds", () => { - expect(() => - createTestCli({ - commands: {}, - credentials: { token: "legacy" }, - credential: userCredential(), - }), - ).toThrow(/legacy `credentials` seed/); + test("a `credential` seed whose token names no workspace is refused: createSession is workspace-keyed", () => { + expect( + () => + new InMemoryCredentialManager({ + credential: { + token: mintTestJwt({ sub: "user-1" }), + refreshToken: undefined, + expiresAt: undefined, + }, + }), + ).toThrow(/must be a JWT with `workspace_id`/); }); - test("a rotation write onto an ended session refuses with the same structured error the real manager raises", async () => { - const toy = defineCommand({ - help: { summary: "Does nothing" }, - handler: async (_args, ctx) => { - return ok(ctx.present({ data: null }, { human: () => [] })); - }, + test("activeCredentialStorage before the credential resolves is a harness misuse", async () => { + const manager = new InMemoryCredentialManager({ + sessions: [sessionRecordFor("workspace-1")], + selectedWorkspaceId: "workspace-1", }); - const cli = createTestCli({ - commands: { toy }, + await expect(manager.activeCredentialStorage()).rejects.toThrow( + /only valid once activeCredential\(\) has returned non-null/, + ); + }); + + test("a rotation write onto a session another process ended refuses with the same structured error the real manager raises", async () => { + const manager = new InMemoryCredentialManager({ sessions: [ { workspaceId: "workspace-1", @@ -629,14 +689,16 @@ describe("harness seed validation", () => { }, }, ], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", }); - const storage = cli.credentialManager?.tokenStorage("workspace-gone"); + await manager.activeCredential(); + const storage = await manager.activeCredentialStorage(); + manager.overwriteStoredState({ sessions: [] }); await expect( - storage?.setTokens({ - workspaceId: "workspace-gone", - accessToken: mintTestJwt({ workspace_id: "workspace-gone" }), + storage.setTokens({ + workspaceId: "workspace-1", + accessToken: mintTestJwt({ workspace_id: "workspace-1" }), refreshToken: "refresh-2", }), ).rejects.toMatchObject({ code: "CLI.CREDENTIALS_REQUIRED" }); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 5ec8fb67..ef874435 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -19,6 +19,7 @@ describe("main export", () => { "PRISMA_CONFIG_VERSION", "authServiceError", "createCli", + "credentialRejectedError", "credentialsRequiredError", "defineCommand", "defineCommandFamily", diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index 64f7d87e..8c8ad72c 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -6,6 +6,7 @@ * directives fail the build (TS2578). */ import type { + ActiveCredential, Char, CommandContext, CommandFamily, @@ -309,7 +310,6 @@ export const createCliSpec: Parameters[0] = { export const createTestCliSpec: Parameters[0] = { commands: tree, config: { check: { strict: true } }, - credentials: { token: "t" }, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "pnpm", now: () => new Date(0), @@ -387,16 +387,16 @@ export const runtimeShape: Runtime = { }, onSignal: () => () => {}, config: loadedConfig, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "pnpm", }; // ————————————————————————————————————————————————————————————————————— -// The credential manager surface (design rev 5, the session model): -// managesCredentials is a capability — ctx.credentialManager exists -// exactly when declared; ctx.session exists on every context; the -// harness seeds a mutable in-memory manager. +// The credential manager surface (design rev 6, the active +// credential): managesCredentials is a capability — +// ctx.credentialManager exists exactly when declared; +// ctx.activeCredential exists on every context; the harness seeds a +// mutable in-memory manager. // ————————————————————————————————————————————————————————————————————— export const managedCommand = defineCommand({ @@ -404,9 +404,9 @@ export const managedCommand = defineCommand({ managesCredentials: true, handler: async (_args, ctx) => { const manager: CredentialManager = ctx.credentialManager; - const session: Session | null = await ctx.session(); + const active: ActiveCredential | null = await ctx.activeCredential(); void manager; - void session; + void active; return ok(ctx.present({ data: null }, { human: () => [] })); }, }); @@ -415,8 +415,8 @@ export const managedIsDeclared: true = managedCommand.managesCredentials; export const unmanagedCommand = defineCommand({ help: { summary: "Ordinary command" }, handler: async (_args, ctx) => { - const session: Session | null = await ctx.session(); - void session; + const active: ActiveCredential | null = await ctx.activeCredential(); + void active; // @ts-expect-error the capability was not declared, so the context carries no credentialManager void ctx.credentialManager; return ok(ctx.present({ data: null }, { human: () => [] })); @@ -425,11 +425,16 @@ export const unmanagedCommand = defineCommand({ export const unmanagedIsUndeclared: false = unmanagedCommand.managesCredentials; export const sessionHasNoTokenMaterial: + | "workspaceId" + | "workspaceName" + | "expiresAt" = undefined as unknown as keyof Session; + +export const activeCredentialHasNoTokenMaterial: | "workspaceId" | "workspaceName" | "expiresAt" - | "source" - | "current" = undefined as unknown as keyof Session; + | "identity" + | "origin" = undefined as unknown as keyof ActiveCredential; export const seededHarnessSpec: Parameters[0] = { commands: tree, @@ -449,8 +454,12 @@ export const seededHarnessSpec: Parameters[0] = { }, }, ], - currentWorkspaceId: "workspace-1", - environmentToken: "jwt", + selectedWorkspaceId: "workspace-1", + environmentCredential: { + token: "jwt", + refreshToken: undefined, + expiresAt: undefined, + }, managementApiClientConfig: { clientId: "client", redirectUri: "https://test.invalid/cb", diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index 9e522832..f5eafaf1 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -14,7 +14,11 @@ import { type Runtime, } from "@prisma/cli-engine"; import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; -import { createTestCli } from "@prisma/cli-engine/testing"; +import { + createTestCli, + InMemoryCredentialManager, + mintTestJwt, +} from "@prisma/cli-engine/testing"; import { describe, expect, test } from "vitest"; const EPOCH = () => new Date(0); @@ -110,16 +114,16 @@ const whoami = defineCommand({ help: { summary: "Show the signed-in user" }, needs: { credentials: true }, handler: async (_args, ctx) => { - const credentials = await ctx.getCredentials(); + const active = await ctx.activeCredential(); return ok( ctx.present( - { data: { token: credentials?.token } }, + { data: { workspaceId: active?.workspaceId } }, { human: () => [ { kind: "summary", tone: "ok", - text: `Signed in (${credentials?.token})`, + text: `Signed in (${active?.workspaceId})`, }, ], }, @@ -479,14 +483,18 @@ describe("needs preconditions", () => { const cli = createTestCli({ commands: { "auth whoami": whoami }, groups: { auth: { brief: "Authentication" } }, - credentials: { token: "tok-123" }, + credential: { + token: mintTestJwt({ sub: "user-1", workspace_id: "workspace-1" }), + refreshToken: undefined, + expiresAt: undefined, + }, now: EPOCH, }); const result = await cli.run(["auth", "whoami", "--format", "human"]); expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); - expect(result.stderr).toBe("✔ Signed in (tok-123)\n"); + expect(result.stderr).toBe("✔ Signed in (workspace-1)\n"); }); function demanding(dependency: string) { @@ -509,7 +517,7 @@ describe("needs preconditions", () => { async function runDemanding(opts: { readonly dependency: string; readonly interactive: boolean; - readonly credentials?: { token: string }; + readonly signedIn?: boolean; }): Promise<{ exitCode: number; stderr: string }> { const cli = createCli({ name: "t", @@ -550,7 +558,16 @@ describe("needs preconditions", () => { }, ], }, - getCredentials: async () => opts.credentials, + credentialManager: + opts.signedIn === true + ? new InMemoryCredentialManager({ + credential: { + token: mintTestJwt({ workspace_id: "workspace-1" }), + refreshToken: undefined, + expiresAt: undefined, + }, + }) + : undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; @@ -583,7 +600,7 @@ describe("needs preconditions", () => { const credentialsMet = await runDemanding({ dependency: "typescript", interactive: true, - credentials: { token: "tok" }, + signedIn: true, }); expect(credentialsMet.stderr).toContain("CONFIG.UNREADABLE"); }); @@ -715,7 +732,6 @@ describe("report() after the handler resolved", () => { }, onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; @@ -733,7 +749,7 @@ describe("report() after the handler resolved", () => { }); describe("credentials that cannot be read", () => { - test("a rejecting getCredentials settles as a structured error, exit 2", async () => { + test("the manager's own structured error reaches the user verbatim, exit 2", async () => { const locked = defineCommand({ help: { summary: "Needs credentials" }, needs: { credentials: true }, @@ -748,6 +764,11 @@ describe("credentials that cannot be read", () => { commands: { locked }, }); let stdoutText = ""; + const unreadable = new CliStructuredError( + "CLI.CREDENTIALS_UNREADABLE", + "Your stored credentials could not be read.", + { why: "token file corrupt: unexpected end of JSON input" }, + ); const runtime: Runtime = { stdout: { write: (text) => { @@ -766,9 +787,11 @@ describe("credentials that cannot be read", () => { }, onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, - getCredentials: async () => { - throw new Error("token file corrupt: unexpected end of JSON input"); - }, + credentialManager: { + activeCredential: async () => { + throw unreadable; + }, + } as unknown as Runtime["credentialManager"], managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; diff --git a/packages/cli-engine/tests/lifetimes.test.ts b/packages/cli-engine/tests/lifetimes.test.ts index 07541c80..f0936795 100644 --- a/packages/cli-engine/tests/lifetimes.test.ts +++ b/packages/cli-engine/tests/lifetimes.test.ts @@ -184,7 +184,6 @@ describe("the engine owns the double-signal policy", () => { }; }, config: { sections: {}, diagnostics: [] }, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 1e604486..f758ad13 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -1,25 +1,31 @@ /** - * ctx.api under design rev 5: the ENGINE constructs the pinned - * session's client from the injected config — the SDK's refreshing - * path over the manager's TokenStorage view for stored sessions, the - * static-token path for env sessions — plus the engine-side - * request-failure mapping (refresh-invalid → expired; other AuthError - * → bound-workspace state re-read; cause-chain unwrapping). Requests - * run against the real SDK over a scripted global fetch. + * ctx.api under design rev 6: the ENGINE constructs ONE client for the + * active credential from the injected config — always the SDK's + * refreshing path over the storage the manager hands out, whatever the + * credential's origin — plus the engine-side request-failure mapping + * (refresh-invalid → expired; a credential that could never be renewed + * → credential-rejected; other AuthError → stored-state re-read; cause- + * chain unwrapping). Requests run against the real SDK over a scripted + * global fetch. */ import { + type ActiveCredential, type CredentialManager, credentialsRequiredError, defineCommand, type ManagementApiClient, type ManagementApiClientConfig, type Runtime, - type Session, type TokenStorage, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { createTestCli, mintTestJwt } from "@prisma/cli-engine/testing"; +import { + createTestCli, + type InMemoryCredentialManager, + mintTestJwt, + type SessionRecord, +} from "@prisma/cli-engine/testing"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { AnyCommand } from "../src/commands"; import { buildEngine, type RunHooks } from "../src/execution/engine"; @@ -31,6 +37,7 @@ const CLIENT_CONFIG: ManagementApiClientConfig = { authBaseUrl: "https://auth.test.invalid", }; const TOKEN_ENDPOINT = "https://auth.test.invalid/token"; +const WORKSPACES_ENDPOINT = "https://api.test.invalid/v1/workspaces"; function unusedManagerMethod(name: string): () => never { return () => { @@ -42,23 +49,32 @@ function fakeCredentialManager( overrides: Partial, ): CredentialManager { return { - currentSession: unusedManagerMethod("currentSession"), + activeCredential: unusedManagerMethod("activeCredential"), sessions: unusedManagerMethod("sessions"), createSession: unusedManagerMethod("createSession"), - useSession: unusedManagerMethod("useSession"), + selectSession: unusedManagerMethod("selectSession"), endSession: unusedManagerMethod("endSession"), endAllSessions: unusedManagerMethod("endAllSessions"), - tokenStorage: unusedManagerMethod("tokenStorage"), + activeCredentialStorage: unusedManagerMethod("activeCredentialStorage"), ...overrides, }; } -const storedSession = (workspaceId: string): Session => ({ +const storedCredential = (workspaceId: string): ActiveCredential => ({ workspaceId, workspaceName: undefined, expiresAt: undefined, - source: "stored", - current: true, + identity: undefined, + origin: { source: "stored" }, +}); + +const storedSessions = (...workspaceIds: readonly string[]) => ({ + sessions: workspaceIds.map((workspaceId) => ({ + workspaceId, + workspaceName: undefined, + expiresAt: undefined, + })), + selectedWorkspaceId: workspaceIds[0], }); function makeRuntime(overrides?: { @@ -93,7 +109,6 @@ function makeRuntime(overrides?: { config: { sections: {}, diagnostics: [] }, credentialManager: overrides?.credentialManager, managementApiClientConfig: CLIENT_CONFIG, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", stderrText: () => stderrText, @@ -164,11 +179,15 @@ const jsonResponse = (status: number, body: unknown): Response => const accessTokenFor = (workspaceId: string, marker: string): string => mintTestJwt({ sub: "user-1", workspace_id: workspaceId, token: marker }); -const sessionSeed = (workspaceId: string, refreshToken?: string) => ({ +const sessionSeed = ( + workspaceId: string, + refreshToken?: string, + marker = "initial", +): SessionRecord => ({ workspaceId, workspaceName: undefined, credential: { - token: accessTokenFor(workspaceId, "initial"), + token: accessTokenFor(workspaceId, marker), refreshToken, expiresAt: undefined, }, @@ -228,9 +247,9 @@ describe("ctx.api construction", () => { }); }); - test("the client is constructed once per run over the pinned session's TokenStorage view; calls are proxied", async () => { + test("the client is constructed once per run over the active credential's storage; calls are proxied", async () => { const calls = scriptFetch(() => jsonResponse(200, { workspaces: [] })); - let tokenStorageResolutions = 0; + let storageResolutions = 0; const storage: TokenStorage = { getTokens: async () => ({ workspaceId: "workspace-1", @@ -241,10 +260,9 @@ describe("ctx.api construction", () => { }; const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - currentSession: async () => storedSession("workspace-1"), - tokenStorage: (workspaceId) => { - expect(workspaceId).toBe("workspace-1"); - tokenStorageResolutions += 1; + activeCredential: async () => storedCredential("workspace-1"), + activeCredentialStorage: async () => { + storageResolutions += 1; return storage; }, }), @@ -257,9 +275,9 @@ describe("ctx.api construction", () => { runtime, ); expect(exitCode).toBe(0); - expect(tokenStorageResolutions).toBe(1); + expect(storageResolutions).toBe(1); expect(calls).toHaveLength(2); - expect(calls[0].url).toBe("https://api.test.invalid/v1/workspaces"); + expect(calls[0].url).toBe(WORKSPACES_ENDPOINT); expect(calls[0].authorization).toBe( `Bearer ${accessTokenFor("workspace-1", "initial")}`, ); @@ -267,7 +285,7 @@ describe("ctx.api construction", () => { }); describe("the stored-session refresh path", () => { - test("a 401 refreshes through the manager's TokenStorage view and retries; the rotated pair lands in the store", async () => { + test("a 401 refreshes through the manager's storage and retries; the rotated pair lands in the store", async () => { const rotatedAccessToken = accessTokenFor("workspace-1", "rotated"); const calls = scriptFetch((url) => { if (url === TOKEN_ENDPOINT) { @@ -289,20 +307,70 @@ describe("the stored-session refresh path", () => { }), }, sessions: [sessionSeed("workspace-1", "refresh-1")], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", managementApiClientConfig: CLIENT_CONFIG, }); const { exitCode } = await cli.run(["toy"]); expect(exitCode).toBe(0); expect(seen).toMatchObject({ data: { workspaces: ["fresh"] } }); - const state = cli.credentialManager?.state(); - expect(state?.sessions).toMatchObject([ + const state = cli.credentialManager.state(); + expect(state.sessions).toMatchObject([ { workspaceId: "workspace-1", credential: { token: rotatedAccessToken, refreshToken: "refresh-2" }, }, ]); - expect(state?.currentWorkspaceId).toBe("workspace-1"); + expect(state.selectedWorkspaceId).toBe("workspace-1"); + }); + + /** Design §11.10, test 5. */ + test("another process rotated first: the storage re-read serves its newer pair and the retry succeeds without touching the token endpoint", async () => { + const rotatedByOtherProcess = accessTokenFor("workspace-1", "rotated-by-b"); + let manager: InMemoryCredentialManager | undefined; + const calls = scriptFetch((url) => { + if (url === TOKEN_ENDPOINT) { + return jsonResponse(500, { message: "the exchange must not happen" }); + } + if ( + calls[calls.length - 1].authorization === + `Bearer ${rotatedByOtherProcess}` + ) { + return jsonResponse(200, { workspaces: ["fresh"] }); + } + // The other process rotated between this process's read and its + // request, so the 401 is against a pair the store has replaced. + manager?.overwriteStoredState({ + sessions: [ + { + workspaceId: "workspace-1", + workspaceName: undefined, + credential: { + token: rotatedByOtherProcess, + refreshToken: "refresh-2", + expiresAt: undefined, + }, + }, + ], + }); + return jsonResponse(401, { message: "unauthorized" }); + }); + const cli = createTestCli({ + commands: { toy: callApi }, + sessions: [sessionSeed("workspace-1", "refresh-1")], + selectedWorkspaceId: "workspace-1", + managementApiClientConfig: CLIENT_CONFIG, + }); + manager = cli.credentialManager; + const { exitCode } = await cli.run(["toy"]); + expect(exitCode).toBe(0); + expect(calls.map((call) => call.url)).toEqual([ + WORKSPACES_ENDPOINT, + WORKSPACES_ENDPOINT, + ]); + expect(calls[1].authorization).toBe(`Bearer ${rotatedByOtherProcess}`); + expect(cli.credentialManager.state().sessions).toMatchObject([ + { credential: { token: rotatedByOtherProcess } }, + ]); }); test("invalid_grant on refresh maps to CLI.CREDENTIALS_REQUIRED with the expiry wording; compare-and-clear ended the session", async () => { @@ -314,7 +382,7 @@ describe("the stored-session refresh path", () => { const cli = createTestCli({ commands: { toy: callApi }, sessions: [sessionSeed("workspace-1", "refresh-1")], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", managementApiClientConfig: CLIENT_CONFIG, }); const { exitCode, json } = await cli.run(["toy", "--json"]); @@ -329,13 +397,40 @@ describe("the stored-session refresh path", () => { }, }, }); - expect(cli.credentialManager?.state()).toEqual({ + expect(cli.credentialManager.state()).toEqual({ sessions: [], - currentWorkspaceId: null, + selectedWorkspaceId: undefined, + }); + }); + + /** Design §11.10, test 4 — §7's migrated entries carry no refresh + * token, and the SDK refuses the exchange rather than attempting it. */ + test("a stored session with no refresh token is rejected with the sign-in-again wording, not the retry advice", async () => { + const calls = scriptFetch(() => + jsonResponse(401, { message: "unauthorized" }), + ); + const cli = createTestCli({ + commands: { toy: callApi }, + sessions: [sessionSeed("workspace-1")], + selectedWorkspaceId: "workspace-1", + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + const result = json.find((frame) => frame.kind === "result"); + expect(result).toMatchObject({ + envelope: { + ok: false, + error: { + code: "CLI.CREDENTIALS_REQUIRED", + summary: "Your session has expired — sign in again.", + }, + }, }); + expect(calls.map((call) => call.url)).toEqual([WORKSPACES_ENDPOINT]); }); - test("a transient refresh failure with the bound session still stored maps to CLI.AUTH_SERVICE_ERROR; nothing cleared", async () => { + test("a transient refresh failure with the credential's session still stored maps to CLI.AUTH_SERVICE_ERROR; nothing cleared", async () => { scriptFetch((url) => url === TOKEN_ENDPOINT ? jsonResponse(500, { message: "boom" }) @@ -344,7 +439,7 @@ describe("the stored-session refresh path", () => { const cli = createTestCli({ commands: { toy: callApi }, sessions: [sessionSeed("workspace-1", "refresh-1")], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", managementApiClientConfig: CLIENT_CONFIG, }); const { exitCode, json } = await cli.run(["toy", "--json"]); @@ -356,10 +451,10 @@ describe("the stored-session refresh path", () => { error: { code: "CLI.AUTH_SERVICE_ERROR" }, }, }); - expect(cli.credentialManager?.state().sessions).toHaveLength(1); + expect(cli.credentialManager.state().sessions).toHaveLength(1); }); - test("the failure mapping re-reads the workspace the client is BOUND to, not currentSession()", async () => { + test("the failure mapping re-reads the ACTIVE CREDENTIAL's workspace, not whatever is selected now", async () => { scriptFetch((url) => url === TOKEN_ENDPOINT ? jsonResponse(500, { message: "boom" }) @@ -367,11 +462,11 @@ describe("the stored-session refresh path", () => { ); const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - // The pin still reports workspace-1; the stored state only - // holds workspace-2 — the bound session is gone. - currentSession: async () => storedSession("workspace-1"), - sessions: async () => [storedSession("workspace-2")], - tokenStorage: () => ({ + // The pinned credential is workspace-1; the stored state only + // holds workspace-2 — this process's session is gone. + activeCredential: async () => storedCredential("workspace-1"), + sessions: async () => storedSessions("workspace-2"), + activeCredentialStorage: async () => ({ getTokens: async () => ({ workspaceId: "workspace-1", accessToken: accessTokenFor("workspace-1", "initial"), @@ -392,10 +487,10 @@ describe("the stored-session refresh path", () => { scriptFetch(() => jsonResponse(200, {})); const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - currentSession: async () => storedSession("workspace-1"), - tokenStorage: () => ({ + activeCredential: async () => storedCredential("workspace-1"), + activeCredentialStorage: async () => ({ getTokens: async () => { - throw credentialsRequiredError("sessions-held-none-current"); + throw credentialsRequiredError("sessions-held-none-selected"); }, setTokens: async () => {}, clearTokens: async () => {}, @@ -417,8 +512,8 @@ describe("the stored-session refresh path", () => { cyclic.cause = inner; const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - currentSession: async () => storedSession("workspace-1"), - tokenStorage: () => ({ + activeCredential: async () => storedCredential("workspace-1"), + activeCredentialStorage: async () => ({ getTokens: async () => { throw cyclic; }, @@ -451,7 +546,7 @@ describe("a refresh that fails without an AuthError", () => { const cli = createTestCli({ commands: { toy: callApi }, sessions: [sessionSeed("workspace-1", "refresh-1")], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", managementApiClientConfig: CLIENT_CONFIG, }); const { exitCode, json } = await cli.run(["toy", "--json"]); @@ -460,7 +555,7 @@ describe("a refresh that fails without an AuthError", () => { expect(result).toMatchObject({ envelope: { ok: false, error: { code: "CLI.AUTH_SERVICE_ERROR" } }, }); - expect(cli.credentialManager?.state().sessions).toHaveLength(1); + expect(cli.credentialManager.state().sessions).toHaveLength(1); }); test("a structured error raised BY the rotation write surfaces as itself, not as the transient error", async () => { @@ -474,8 +569,8 @@ describe("a refresh that fails without an AuthError", () => { ); const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - currentSession: async () => storedSession("workspace-1"), - tokenStorage: () => ({ + activeCredential: async () => storedCredential("workspace-1"), + activeCredentialStorage: async () => ({ getTokens: async () => ({ workspaceId: "workspace-1", accessToken: accessTokenFor("workspace-1", "initial"), @@ -500,8 +595,8 @@ describe("a refresh that fails without an AuthError", () => { scriptFetch(() => jsonResponse(200, {})); const runtime = makeRuntime({ credentialManager: fakeCredentialManager({ - currentSession: async () => storedSession("workspace-1"), - tokenStorage: () => ({ + activeCredential: async () => storedCredential("workspace-1"), + activeCredentialStorage: async () => ({ getTokens: async () => { throw new Error("something unrelated broke"); }, @@ -531,7 +626,7 @@ describe("the engine's debug valve", () => { createTestCli({ commands: { toy: callApi }, sessions: [sessionSeed("workspace-1", "SECRET-REFRESH-TOKEN")], - currentWorkspaceId: "workspace-1", + selectedWorkspaceId: "workspace-1", managementApiClientConfig: CLIENT_CONFIG, }); @@ -615,19 +710,21 @@ describe("the engine's debug valve", () => { }); }); -describe("the environment-session static path", () => { - const environmentToken = mintTestJwt({ - sub: "svc-1", - workspace_id: "workspace-env", - }); +describe("the environment credential", () => { + const environmentToken = accessTokenFor("workspace-env", "environment"); - test("requests carry the env token; a 401 maps to AUTH.SERVICE_TOKEN_REJECTED without touching the token endpoint", async () => { + /** Design §11.10, test 1 — the path that actually runs today. */ + test("with no refresh token: a 401 is the credential-rejected error naming the variable, and the token endpoint is never touched", async () => { const calls = scriptFetch(() => jsonResponse(401, { message: "unauthorized" }), ); const cli = createTestCli({ commands: { toy: callApi }, - environmentToken, + environmentCredential: { + token: environmentToken, + refreshToken: undefined, + expiresAt: undefined, + }, managementApiClientConfig: CLIENT_CONFIG, }); const { exitCode, json } = await cli.run(["toy", "--json"]); @@ -636,42 +733,106 @@ describe("the environment-session static path", () => { expect(result).toMatchObject({ envelope: { ok: false, - error: { code: "AUTH.SERVICE_TOKEN_REJECTED" }, + error: { + code: "AUTH.SERVICE_TOKEN_REJECTED", + summary: + "The management API rejected the service token from PRISMA_SERVICE_TOKEN.", + }, }, }); - expect(calls).toHaveLength(1); - expect(calls[0].url).toBe("https://api.test.invalid/v1/workspaces"); + expect(calls.map((call) => call.url)).toEqual([WORKSPACES_ENDPOINT]); expect(calls[0].authorization).toBe(`Bearer ${environmentToken}`); }); - test("the bearer is the trimmed token, matching the session composed from it", async () => { - const calls = scriptFetch(() => jsonResponse(200, { workspaces: [] })); + test("a successful request passes its data through and carries the environment token", async () => { + const calls = scriptFetch(() => jsonResponse(200, { workspaces: ["env"] })); + let seen: unknown; const cli = createTestCli({ - commands: { toy: callApi }, - environmentToken, + commands: { + toy: succeed(async (ctx) => { + seen = await ctx.api.GET("/v1/workspaces", {}); + }), + }, + environmentCredential: { + token: environmentToken, + refreshToken: undefined, + expiresAt: undefined, + }, managementApiClientConfig: CLIENT_CONFIG, }); - const { exitCode } = await cli.run(["toy"], { - env: { PRISMA_SERVICE_TOKEN: ` ${environmentToken}\n` }, - }); + const { exitCode } = await cli.run(["toy"]); expect(exitCode).toBe(0); + expect(seen).toMatchObject({ data: { workspaces: ["env"] } }); expect(calls[0].authorization).toBe(`Bearer ${environmentToken}`); }); - test("a successful env-session request passes its data through", async () => { - scriptFetch(() => jsonResponse(200, { workspaces: ["env"] })); - let seen: unknown; + /** Design §11.10, test 2 — the uniform refresh path, over memory. */ + test("with a refresh token: the rotation happens in memory, the store is untouched, and the next request in the same process carries the rotated token", async () => { + const rotatedAccessToken = accessTokenFor("workspace-env", "rotated"); + const calls = scriptFetch((url) => { + if (url === TOKEN_ENDPOINT) { + return jsonResponse(200, { + access_token: rotatedAccessToken, + refresh_token: "env-refresh-2", + }); + } + return calls[calls.length - 1].authorization === + `Bearer ${rotatedAccessToken}` + ? jsonResponse(200, { workspaces: ["fresh"] }) + : jsonResponse(401, { message: "unauthorized" }); + }); const cli = createTestCli({ commands: { toy: succeed(async (ctx) => { - seen = await ctx.api.GET("/v1/workspaces", {}); + await ctx.api.GET("/v1/workspaces", {}); + await ctx.api.GET("/v1/workspaces", {}); }), }, - environmentToken, + sessions: [sessionSeed("workspace-1", "refresh-1")], + selectedWorkspaceId: "workspace-1", + environmentCredential: { + token: environmentToken, + refreshToken: "env-refresh-1", + expiresAt: undefined, + }, managementApiClientConfig: CLIENT_CONFIG, }); + const before = cli.credentialManager.state(); const { exitCode } = await cli.run(["toy"]); expect(exitCode).toBe(0); - expect(seen).toMatchObject({ data: { workspaces: ["env"] } }); + expect(calls.map((call) => call.url)).toEqual([ + WORKSPACES_ENDPOINT, + TOKEN_ENDPOINT, + WORKSPACES_ENDPOINT, + WORKSPACES_ENDPOINT, + ]); + expect(calls[3].authorization).toBe(`Bearer ${rotatedAccessToken}`); + expect(cli.credentialManager.state()).toEqual(before); + }); + + /** Design §11.10, test 3 — the memory-backed storage cannot reach the + * stored session that happens to share its workspace. */ + test("whose workspace matches a stored session: invalid_grant clears only its own memory, leaving the stored session intact", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(400, { error: "invalid_grant" }) + : jsonResponse(401, { message: "unauthorized" }), + ); + const cli = createTestCli({ + commands: { toy: callApi }, + sessions: [sessionSeed("workspace-shared", "refresh-1")], + selectedWorkspaceId: "workspace-shared", + environmentCredential: { + token: accessTokenFor("workspace-shared", "environment"), + refreshToken: "env-refresh-1", + expiresAt: undefined, + }, + managementApiClientConfig: CLIENT_CONFIG, + }); + const before = cli.credentialManager.state(); + const { exitCode } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + expect(cli.credentialManager.state().sessions).toHaveLength(1); + expect(cli.credentialManager.state()).toEqual(before); }); }); diff --git a/packages/cli-engine/tests/prompts.test.ts b/packages/cli-engine/tests/prompts.test.ts index 0391db53..fd6e7610 100644 --- a/packages/cli-engine/tests/prompts.test.ts +++ b/packages/cli-engine/tests/prompts.test.ts @@ -493,7 +493,6 @@ describe("stdin cleanup", () => { }, onSignal: () => () => {}, config: { sections: {}, diagnostics: [] }, - getCredentials: async () => undefined, managementApi: { baseUrl: "https://test.invalid" }, packageManager: "unknown", }; From 4d7d6f1210cbb2c4cf6eb769b74808f37241fdc2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 22:13:48 +0200 Subject: [PATCH 57/67] fix(cli-engine): stop a next action printing its command twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `label` is required and `command` is optional, so a mapper building an action out of a legacy error's follow-up step — a bare command string with no prose beside it — has nothing to put in the label but the command. Both fields then hold the same string and the renderer printed it twice: → prisma-cli project list: prisma-cli project list Fixed in the renderer rather than the callers, because label is required: every mapper in that position would otherwise have to invent prose, and the renderer is the only place that sees both fields and can tell they are the same. `url` takes the same path and is covered too. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-engine/src/execution/rendering.ts | 8 ++++- packages/cli-engine/tests/events.test.ts | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index e02bd331..46005c43 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -147,9 +147,15 @@ export function writeDiagnostic( } } +/** `label` is required, so a mapper building an action out of a bare + * command string — a legacy error's follow-up step, with no prose + * beside it — has nothing to put in the label but the command itself. + * Only the renderer sees both fields, so only it can tell they are the + * same string and print it once. */ export function renderNextAction(action: NextAction): string { const target = action.command ?? action.url; - return `→ ${action.label}${target === undefined ? "" : `: ${target}`}`; + const repeatsTheLabel = target === undefined || target === action.label; + return `→ ${action.label}${repeatsTheLabel ? "" : `: ${target}`}`; } /** Populates docsUrl from the owning family's docsBaseUrl (base + code) diff --git a/packages/cli-engine/tests/events.test.ts b/packages/cli-engine/tests/events.test.ts index c3f84afe..2c09642a 100644 --- a/packages/cli-engine/tests/events.test.ts +++ b/packages/cli-engine/tests/events.test.ts @@ -141,6 +141,41 @@ describe("human rendering", () => { ); }); + test("an action whose label is already its command or url prints the string once", async () => { + const bareStrings = defineCommand({ + help: { summary: "Suggests follow-ups mapped from bare strings" }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: null }, + { + human: () => [], + next: () => [ + { + kind: "run-command", + label: "prisma-cli project list", + command: "prisma-cli project list", + }, + { + kind: "open-url", + label: "https://console.prisma.io/upgrade", + url: "https://console.prisma.io/upgrade", + }, + ], + }, + ), + ), + }); + const result = await createTestCli({ + commands: { probe: bareStrings }, + now: EPOCH, + }).run(["probe", "--format", "human"]); + + expect(result.stderr).toBe( + "→ prisma-cli project list\n" + "→ https://console.prisma.io/upgrade\n", + ); + }); + test("--log-level warn filters info-grade commentary but keeps data lines", async () => { const result = await makeCli().run([ "noisy", From a994137603c27f0b3aa083d06e67f3991c4e895c Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 22:14:31 +0200 Subject: [PATCH 58/67] drive(prisma-cli-v8): record the desired-state ruling on S8 Composer holds desired state through Alchemy, and changing the platform directly is overwritten on its next deploy. The operator accepts that, so the imperative deployment operations stay and their effect on a Composer-managed service is understood to be transient. What survives for the design is narrower: whether the CLI should say so at the point of use. That depends on whether an app or deployment record carries anything identifying it as Composer-managed, which is already question 2. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drive/projects/prisma-cli-v8/plan.md b/.drive/projects/prisma-cli-v8/plan.md index 5ae326a0..09bd3282 100644 --- a/.drive/projects/prisma-cli-v8/plan.md +++ b/.drive/projects/prisma-cli-v8/plan.md @@ -92,7 +92,7 @@ That makes the shape of the slice mostly a rename plus filling holes — a `serv **Why it still waits for the design work.** Three questions need answers that only S3 can give, and none of them is about whether the resources exist. -1. Does Alchemy hold desired state? If Composer declaratively owns which deployment is live, then an imperative `promote`, `rollback`, `start` or `stop` from the CLI either fights it or is reverted on Composer's next run. This is the sharpest question and it decides whether those five commands should exist at all, be read-only, or carry a warning. +1. ~~Does Alchemy hold desired state?~~ **Answered (operator, 2026-08-10): yes, and changing the platform directly is overwritten on the next `composer deploy`. Accepted.** So the imperative operations stay, and their effect on a Composer-managed service is understood to be transient. What remains for the design is only whether the CLI says so at the point of use — a service the CLI can tell is Composer-managed could carry a line on `promote`, `rollback`, `start` and `stop` noting the next deploy reconciles it. That depends on question 2: whether the records carry anything identifying a service as Composer-managed. 2. What do Composer's app and deployment records actually contain? If the Alchemy path populates a different subset of fields than `app deploy` did, `service show` and `service deployment show` are presenting a shape nobody has looked at. 3. Where does log reading live? `composer log` and a `service deployment logs` would be two ways to read the same thing, and the project spec rules that a subgroup is owned by exactly one command family. From 96e5628f1d733874b87df03adb0d1c737bd10dac Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 22:48:23 +0200 Subject: [PATCH 59/67] feat(cli)!: the auth family on the rev-6 credential model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI half of the cascade. The file-backed manager implements the rev-6 interface: the pin holds the decision, not a token, and the material is read through the file on every call. Two storages, chosen once — file-backed reads through with no cache in front, so the SDK can still recover when another process rotated first; memory-backed closes over a local variable, never receives the file path, and touches no file on any method, so an environment credential cannot delete the stored session whose workspace its token happens to name. `serviceTokenWorkspaceId(token) ?? ""` is gone. It was the only place in the codebase that manufactured an empty workspace id, and it reached the user: whoami printed a workspace whose id was the empty string. A credential whose claims name no workspace now reports none, whoami omits the row, and its JSON workspace is null. Ending a session is idempotent — the postcondition is the same either way — so losing the race to another process exits 0 instead of claiming you have no such session. Selecting still refuses a workspace with no session, because there is no state in which it would afterwards be selected. The mutation refusals under PRISMA_SERVICE_TOKEN go entirely; all three succeed and say the environment credential stays in force until the variable is unset. whoami asks for an identity and renders it, with no branch on where the credential came from and no token in the command's hands. /v1/me wins field by field over the claims and the claims are the offline fallback. Also restores the user's name to whoami's output. CredentialIdentity was defined with only a user id and an email, which silently dropped a field the previous shape carried; only an online lookup supplies it, so it is absent offline rather than removed. The JSON reports it as `user.id` rather than `user.userId`, which reads badly beside `workspace.id`. `environmentSessionMutationError` and its AUTH.ENV_SESSION_IN_FORCE code are deleted from the engine, having lost their last consumer. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/s2/parity-divergences.md | 92 ++-- packages/cli-engine/src/credential-errors.ts | 26 -- packages/cli-engine/src/credential-manager.ts | 3 + packages/cli-engine/src/exports/index.ts | 1 - .../src/in-memory-credential-manager.ts | 2 +- packages/cli-engine/tests/engine.test.ts | 1 - packages/cli/src/auth/claims.ts | 12 + packages/cli/src/auth/credential-manager.ts | 314 ++++++++----- packages/cli/src/auth/credentials.ts | 23 - packages/cli/src/auth/index.ts | 1 - packages/cli/src/v8/auth/logout.ts | 29 +- packages/cli/src/v8/auth/session-card.ts | 39 +- packages/cli/src/v8/auth/session-ref.ts | 5 +- packages/cli/src/v8/auth/whoami.ts | 115 ++--- packages/cli/src/v8/auth/workspace-list.ts | 12 +- packages/cli/src/v8/auth/workspace-logout.ts | 24 +- packages/cli/src/v8/auth/workspace-use.ts | 54 ++- packages/cli/src/v8/runtime.ts | 2 - .../tests/credential-manager-login.test.ts | 4 +- .../credential-manager-migration.test.ts | 65 +-- .../credential-manager-processes.test.ts | 4 +- packages/cli/tests/credential-manager.test.ts | 432 ++++++++++++------ .../helpers/credential-manager-worker.ts | 39 +- packages/cli/tests/v8-auth.test.ts | 192 +++++--- packages/cli/tests/v8-bin.test.ts | 46 +- .../cli/tests/v8-golden-rendering.test.ts | 2 +- packages/cli/tests/v8-whoami.test.ts | 72 ++- 27 files changed, 976 insertions(+), 635 deletions(-) delete mode 100644 packages/cli/src/auth/credentials.ts diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index 6475e2f8..b32065c5 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -15,10 +15,7 @@ ported command and are not repeated per command below. ## S2a — auth family + update check (this PR) -The auth family is implemented ON the credential manager (the session -model: a set of per-workspace sessions, one current). The COMMAND NAMES -are the legacy ones and do not change — there is no rename class in -this list. What follows is what a user can still observe as different. +The auth family is implemented ON the credential manager, whose normative design is [`../engine/credential-manager-design.md`](../engine/credential-manager-design.md). Read §11 there for the model this section describes: a set of stored per-workspace sessions plus one selection, and — separately — the credential this process authenticates as, which may come from `PRISMA_SERVICE_TOKEN` and is not a session. The COMMAND NAMES are the legacy ones and do not change — there is no rename class in this list. What follows is what a user can still observe as different. ### Error-code mapping (flat → dotted, session vocabulary) @@ -28,83 +25,65 @@ nextAction; `meta` is preserved. | Legacy flat code (exit) | v8 code (exit) | Raised by | | --- | --- | --- | -| `AUTH_CONFIG_INVALID` (1) — blank `PRISMA_SERVICE_TOKEN` | `AUTH.SERVICE_TOKEN_EMPTY` (2) | every command, single-sourced from `currentSession()` | +| `AUTH_CONFIG_INVALID` (1) — blank `PRISMA_SERVICE_TOKEN` | `AUTH.SERVICE_TOKEN_EMPTY` (2) | every command, single-sourced from `activeCredential()` | | `WORKSPACE_NOT_AUTHENTICATED` (1) | `AUTH.NO_SESSION_FOR_WORKSPACE` (2) | `workspace use`, `workspace logout` | | `WORKSPACE_AMBIGUOUS` (2) | `AUTH.WORKSPACE_AMBIGUOUS` (2) | `workspace use`, `workspace logout` | -| `WORKSPACE_SWITCH_UNAVAILABLE` (1) | `AUTH.ENV_SESSION_IN_FORCE` (2) | `workspace use`, `workspace logout`, `logout` | +| `WORKSPACE_SWITCH_UNAVAILABLE` (1) | **no successor** | nothing — the mutations it guarded now succeed (see below) | | `USAGE_ERROR` (2) — "No authenticated workspaces" | `AUTH.NO_WORKSPACE_SESSIONS` (2) | `workspace use` | | `USAGE_ERROR` (2) — "Workspace required" (blank ref) | `AUTH.NO_SESSION_FOR_WORKSPACE` (2) | `workspace logout` — a blank/whitespace ref matches no session rather than being its own usage error | | (none — legacy could not happen) | `AUTH.LOGIN_WORKSPACE_UNKNOWN` (2) | `login`, when the minted credential carries no `workspace_id` claim | -| (none) | `CLI.CREDENTIALS_REQUIRED` (2) | the engine, for signed-out and sessions-held-none-current | +| (none) | `CLI.CREDENTIALS_REQUIRED` (2) | the engine, for signed-out and sessions-held-none-selected | No documented 4–99 codes exist in this family. ### Exit unifications -Legacy exit 1 for `AUTH_CONFIG_INVALID`, `WORKSPACE_NOT_AUTHENTICATED` -and `WORKSPACE_SWITCH_UNAVAILABLE` becomes exit 2 (could-not-complete) -in v8. A failed login (browser launch, callback, token exchange) was an -unstructured crash at exit 1 in legacy and still settles at exit 1, now -as a structured `CLI.INTERNAL_ERROR`. +Legacy exit 1 for `AUTH_CONFIG_INVALID` and `WORKSPACE_NOT_AUTHENTICATED` becomes exit 2 (could-not-complete) in v8. A failed login (browser launch, callback, token exchange) was an unstructured crash at exit 1 in legacy and still settles at exit 1, now as a structured `CLI.INTERNAL_ERROR`. ### `auth whoami` — json shape -The legacy result was `AuthStateResult` -(`authenticated`/`provider`/`user`/`workspace`/`credential`). The v8 -result is the session: +The legacy result was `AuthStateResult` (`authenticated`/`provider`/`user`/`workspace`/`credential`). The v8 result describes the active credential: ```json { "authenticated": true, "workspace": { "id": "…", "name": "…" }, - "user": { "id": "…", "email": "…", "name": "…" }, + "user": { "userId": "…", "email": "…" }, "source": "stored", "expiresAt": null } ``` -- **`provider` has NO successor.** Nothing in the session model records - which identity provider minted a credential, and the stored state - records no identity at all, so the field is gone rather than renamed. -- `credential` is gone: the type/id/name of the credential is not a - user-facing concept in the session model. -- `source` is new (`"stored"` | `"environment"`), and `expiresAt` is - the session's expiry. -- Identity display: for an environment session it is decoded from the - token's own claims; for a stored session it comes from `/v1/me` when - online. Offline, a stored session shows its workspace and no user — - legacy showed the claim-derived user in that case. (The Session shape - carries no token by design, so the command cannot decode a stored - credential's claims itself.) +- **`provider` has NO successor.** Nothing in the model records which identity provider minted a credential, so the field is gone rather than renamed. +- `credential` is gone: the type/id/name of the credential is not a user-facing concept here. +- `source` is new (`"stored"` | `"environment"`) and comes from the credential's origin; `expiresAt` is the credential's expiry. +- **`user.name` has no successor, and `user.id` is now `user.userId`.** There is one identity type for both the claimed and the fetched identity (design §11.6), and it carries a user id and an email only. The human card's `user` row therefore shows the email, or is omitted when there is none. +- Identity display: the credential manager decodes the credential's own claims, and `/v1/me` is a best-effort online enrichment that wins field by field where it disagrees. whoami does not branch on the origin — it attempts the enrichment for an environment credential too, and falls back to the claims when the request fails. **This restores legacy behaviour that rev 5 had dropped:** a stored session offline now shows the claim-derived user again, where rev 5 showed the workspace and no user. +- **A credential nothing names renders no workspace at all.** An environment token whose claims carry no workspace reports `"workspace": null` and omits the workspace row from the human card. It is never an empty string and never the literal `undefined` — rev 5 wrote `workspaceId: ""` in that case. - Signed out still exits 0. -### Env-override mutation refusals (`PRISMA_SERVICE_TOKEN` set) +### Mutations while `PRISMA_SERVICE_TOKEN` is set -One error family, `AUTH.ENV_SESSION_IN_FORCE`, exit 2, whose `why` -names the variable and states whether stored sessions exist, with the -literal `unset PRISMA_SERVICE_TOKEN` as its nextAction: +The variable supplies the credential this process authenticates as. It is not a session, so it does not occupy a slot that a stored session could be moved into or out of, and commands that change stored state are free to run. Design §11.7 rules that all of them succeed: -| Command | Behavior under the override | +| Command | Behavior while the variable is set | | --- | --- | -| `auth workspace use` | refuses — `AUTH.ENV_SESSION_IN_FORCE`, exit 2 | -| `auth workspace logout` | refuses — same error, exit 2 | -| `auth logout`, stored sessions exist | refuses — same error, exit 2; nothing is cleared | -| `auth logout`, no stored sessions | **succeeds as a no-op**, exit 0, `endedCount: 0` (CI teardowns must not fail) | -| `auth login` | **succeeds**, and prints the mandatory one-line notice that the env token remains in force until unset | +| `auth workspace use` | **succeeds** — the stored selection moves; this process keeps authenticating as the environment credential | +| `auth workspace logout` | **succeeds** — the named session is removed | +| `auth logout` | **succeeds** — the store is cleared, whether or not it held sessions | +| `auth login` | **succeeds** — a new session is stored and selected | | every read (`whoami`, `workspace list`) | works normally | -Legacy refused workspace switching with `WORKSPACE_SWITCH_UNAVAILABLE` -and let `auth logout` clear stored state even while the variable was -set. Stated consequence: while the variable is set, existing stored -state cannot be cleared. +Each of the four mutations prints the same one-line notice in human output: the environment token remains in force until the variable is unset. The notice is human-only; no json result gained a field for it, and `auth workspace list`'s `context.environmentSessionInForce` remains the machine-readable signal. + +This is the second change here. Legacy refused workspace switching with `WORKSPACE_SWITCH_UNAVAILABLE` and let `auth logout` clear stored state. Rev 5 of the design refused `workspace use`, `workspace logout` and `auth logout` with `AUTH.ENV_SESSION_IN_FORCE`, carving out an empty store so CI teardowns would not fail. **`AUTH.ENV_SESSION_IN_FORCE` no longer exists**, and neither does the carve-out. The net effect against legacy is that workspace switching now works while the variable is set, where legacy refused it. + +A blank or whitespace-only `PRISMA_SERVICE_TOKEN` is unchanged: it is never an override, and every command — mutations included — fails with `AUTH.SERVICE_TOKEN_EMPTY`. + +### Ending a session is idempotent + +`auth workspace logout ` resolves the ref against the sessions you hold, so a workspace you never had is still `AUTH.NO_SESSION_FOR_WORKSPACE`, exit 2. What changed is the race: if another `prisma` process removes that session between the resolution and the write, the command now exits 0 rather than exit 2 with a message that is no longer true. The postcondition — no session for that workspace — holds either way. Selecting is not idempotent and still refuses a workspace with no session. ### `auth workspace list` -- Rows are the sessions the manager holds: `name`, `id`, `status`, - where status is `current` (legacy: `active`). The legacy `source` - column and the `auth source` line are gone — the environment session - never appears as a row. -- Under an env override the listing STATES that the env session is in - force; the file's own current marker is still shown as current. The - json context carries `environmentSessionInForce: true` alongside - `currentWorkspaceId`, which keeps naming the stored marker, not the - env session's workspace. +- Rows are the sessions the manager holds: `name`, `id`, `status`, where status is `current` (legacy: `active`). The legacy `source` column and the `auth source` line are gone — the environment credential never appears as a row. +- While `PRISMA_SERVICE_TOKEN` is set the listing STATES that the environment credential is in force; the stored selection is still shown as current. The json context carries `environmentSessionInForce: true` alongside `currentWorkspaceId`, which keeps naming the stored selection, not the environment credential's workspace. Both json field names keep the word "current" deliberately: they are an output contract, where the code says "selected" (design §11.1). - The json shape is new (`context`/`items`/`count` with `workspaceId`/`workspaceName`/`current`/`expiresAt`); the legacy fields `credentialWorkspaceId`, `switchable`, `lastSeenAt` and @@ -212,10 +191,5 @@ on every `whoami`/`list` and wrote them back. Accepted and stated. ### Test surface -- `tests/auth.test.ts` fixture-mode cases covering the six ported - commands are deleted; the file keeps its real-mode storage cases and - the legacy-shell presentation cases (help text, TTY header) until - S2d. The v8 side is pinned semantically in `tests/v8-auth.test.ts` - (over the harness's in-memory credential manager, with manager state - read-back) and `tests/v8-update-check.test.ts`; the byte pins live in - `tests/v8-golden-rendering.test.ts` and `tests/v8-whoami.test.ts`. +- `tests/auth.test.ts` fixture-mode cases covering the six ported commands are deleted; the file keeps its real-mode storage cases and the legacy-shell presentation cases (help text, TTY header) until S2d. The v8 side is pinned semantically in `tests/v8-auth.test.ts` (over the harness's in-memory credential manager, with manager state read-back) and `tests/v8-update-check.test.ts`; the byte pins live in `tests/v8-golden-rendering.test.ts` and `tests/v8-whoami.test.ts`. +- `Runtime.getCredentials` and its `makeGetCredentials` builder are gone. The engine asks the credential manager for the active credential and its token storage instead, so the bin no longer supplies a second, parallel way to read a token. diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts index 5bad1660..64c1234d 100644 --- a/packages/cli-engine/src/credential-errors.ts +++ b/packages/cli-engine/src/credential-errors.ts @@ -96,32 +96,6 @@ export function credentialRejectedError( : credentialsRequiredError("expired"); } -/** - * A mutation refused while an env-supplied session is in force: state - * the user cannot observe as their session is never changed. - */ -export function environmentSessionMutationError(spec: { - readonly envVar: string; - readonly storedSessionsExist: boolean; -}): CliStructuredError { - return new CliStructuredError( - "AUTH.ENV_SESSION_IN_FORCE", - `The current session comes from ${spec.envVar}, which this command cannot change.`, - { - why: spec.storedSessionsExist - ? `${spec.envVar} overrides your stored workspace sessions; unsetting it restores them.` - : `${spec.envVar} supplies the only session; there is no stored state to change.`, - nextActions: [ - { - kind: "run-command", - label: `Unset ${spec.envVar}`, - command: `unset ${spec.envVar}`, - }, - ], - }, - ); -} - /** * The env var that supplies a session is set to a blank value. The one * structured error for it, raised identically by currentSession(), the diff --git a/packages/cli-engine/src/credential-manager.ts b/packages/cli-engine/src/credential-manager.ts index 21aeb93a..e9ff40df 100644 --- a/packages/cli-engine/src/credential-manager.ts +++ b/packages/cli-engine/src/credential-manager.ts @@ -40,6 +40,9 @@ export interface StoredSessions { export interface CredentialIdentity { readonly userId: string | undefined; readonly email: string | undefined; + /** Only an online lookup supplies this; a token's claims do not + * carry it. */ + readonly name: string | undefined; } /** diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 6f5c4a32..72a64507 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -60,7 +60,6 @@ export { credentialRejectedError, credentialsRequiredError, emptyServiceTokenError, - environmentSessionMutationError, noSessionForWorkspaceError, serviceTokenRejectedError, } from "../credential-errors"; diff --git a/packages/cli-engine/src/in-memory-credential-manager.ts b/packages/cli-engine/src/in-memory-credential-manager.ts index 124decc1..5373463f 100644 --- a/packages/cli-engine/src/in-memory-credential-manager.ts +++ b/packages/cli-engine/src/in-memory-credential-manager.ts @@ -100,7 +100,7 @@ function claimedIdentity(token: string): CredentialIdentity | undefined { const email = typeof claims?.email === "string" ? claims.email : undefined; return userId === undefined && email === undefined ? undefined - : { userId, email }; + : { userId, email, name: undefined }; } function asSession(record: SessionRecord): Session { diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index ef874435..117d6fc2 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -28,7 +28,6 @@ describe("main export", () => { "defineServerCommand", "defineSessionCommand", "emptyServiceTokenError", - "environmentSessionMutationError", "flag", "loadConfig", "noSessionForWorkspaceError", diff --git a/packages/cli/src/auth/claims.ts b/packages/cli/src/auth/claims.ts index aaafa8ce..7b38fb7a 100644 --- a/packages/cli/src/auth/claims.ts +++ b/packages/cli/src/auth/claims.ts @@ -1,4 +1,5 @@ import { Buffer } from "node:buffer"; +import type { CredentialIdentity } from "@prisma/cli-engine"; const WORKSPACE_SUB_PREFIX = "workspace:"; @@ -45,3 +46,14 @@ export function claimedExpiresAt(token: string): Date | undefined { const exp = decodeClaims(token)?.exp; return typeof exp === "number" ? new Date(exp * 1000) : undefined; } + +/** Who a credential belongs to, from its own claims. The manager + * decodes it so no command ever holds a token to decode. */ +export function claimedIdentity(token: string): CredentialIdentity | undefined { + const claims = decodeClaims(token); + const userId = typeof claims?.sub === "string" ? claims.sub : undefined; + const email = typeof claims?.email === "string" ? claims.email : undefined; + return userId === undefined && email === undefined + ? undefined + : { userId, email, name: undefined }; +} diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index b77be20e..2a08754a 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -1,23 +1,24 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { + ActiveCredential, Credential, CredentialManager, Session, + StoredSessions, TokenStorage, } from "@prisma/cli-engine"; import { credentialsRequiredError, - environmentSessionMutationError, noSessionForWorkspaceError, } from "@prisma/cli-engine"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; import { claimedExpiresAt, + claimedIdentity, claimedWorkspaceId, serviceTokenWorkspaceId, } from "./claims"; -import { SERVICE_TOKEN_ENV_VAR } from "./client"; import { environmentServiceToken } from "./service-token"; import { type CredentialState, @@ -32,6 +33,15 @@ import { } from "./state-file"; import { getAuthContextFilePath } from "./token-storage"; +type Tokens = NonNullable>>; + +type RefreshLock = (fn: () => Promise) => Promise; + +/** The SDK's Tokens requires a workspace id, so an environment + * credential whose claims name no workspace is given this instead. It + * never leaves the manager, and it is never the empty string. */ +const NO_WORKSPACE_CLAIMED = "(no workspace)"; + /** Looks the workspace's name up with the credential that was just * minted. Best-effort: the manager treats any failure as "no name". */ export type FetchWorkspaceName = ( @@ -45,10 +55,16 @@ export interface FileCredentialManagerOptions { readonly debugWrite?: (text: string) => void; } +/** Which credential this process acts as, decided at the first + * activeCredential() read. The decision is what is pinned; the + * material behind it is re-read on every call. */ type Pin = - | { readonly kind: "unpinned" } + | { readonly kind: "unresolved" } | { readonly kind: "environment" } - | { readonly kind: "marker"; readonly workspaceId: string | null }; + | { readonly kind: "session"; readonly workspaceId: string } + | { readonly kind: "none" }; + +type ResolvedPin = Exclude; function credentialWorkspaceMismatchError( workspaceId: string, @@ -69,18 +85,48 @@ function credentialWorkspaceMismatchError( ); } +/** + * The memory-backed storage, for a credential with no home record: a + * free function closing over one local variable. It is never given the + * state file's path, so no method of it — clearTokens included — can + * reach the stored sessions, and an environment credential whose + * workspace matches a stored session cannot delete that session. + */ +function memoryBackedStorage( + credential: Credential, + withRefreshLock: RefreshLock, +): TokenStorage { + let tokens: Tokens | null = { + workspaceId: + serviceTokenWorkspaceId(credential.token) ?? NO_WORKSPACE_CLAIMED, + accessToken: credential.token, + refreshToken: credential.refreshToken, + }; + return { + getTokens: async () => tokens, + setTokens: async (rotated) => { + tokens = rotated; + }, + clearTokens: async () => { + tokens = null; + }, + withRefreshLock, + }; +} + /** * The credential manager over one state file. Sessions are keyed by - * workspace id; the current session is pinned once per process; every - * mutation takes a short file lock, re-reads, applies its slice, and - * writes atomically. Reads never write and take no lock. + * workspace id; which credential this process acts as is pinned once; + * every mutation takes a short file lock, re-reads, applies its slice, + * and writes atomically. Reads never write and take no lock. */ export class FileCredentialManager implements CredentialManager { readonly #env: Readonly>; readonly #filePath: string; readonly #debug: DebugLog; readonly #fetchWorkspaceName: FetchWorkspaceName | undefined; - #pin: Pin = { kind: "unpinned" }; + #pin: Pin = { kind: "unresolved" }; + #activeStorage: TokenStorage | undefined; #refreshLock: Promise = Promise.resolve(); constructor(options: FileCredentialManagerOptions) { @@ -95,49 +141,41 @@ export class FileCredentialManager implements CredentialManager { return this.#filePath; } - async currentSession(): Promise { - const environmentToken = this.#environmentToken(); - if (this.#pin.kind === "unpinned") { - if (environmentToken !== undefined) { - this.#pin = { kind: "environment" }; - this.#debug("pinned to the environment session"); - } else { - const state = await readCredentialState(this.#filePath); - this.#pin = { kind: "marker", workspaceId: resolvedMarker(state) }; - this.#debug(`pinned to session ${this.#pin.workspaceId ?? "(none)"}`); - } - } + async activeCredential(): Promise { + const pin = await this.#resolvePin(); - if (this.#pin.kind === "environment") { - return this.#environmentSession(); + if (pin.kind === "environment") { + return environmentCredential(this.#requireEnvironmentToken()); } - const pinnedWorkspaceId = this.#pin.workspaceId; const state = await readCredentialState(this.#filePath); - if (pinnedWorkspaceId === null) { + if (pin.kind === "none") { if (state.sessions.length > 0) { - throw credentialsRequiredError("sessions-held-none-current"); + throw credentialsRequiredError("sessions-held-none-selected"); } return null; } const record = state.sessions.find( - (session) => session.workspaceId === pinnedWorkspaceId, + (session) => session.workspaceId === pin.workspaceId, ); if (record === undefined) { throw credentialsRequiredError("session-ended"); } - return toSession(record, state); + return storedCredential(record); } - async sessions(): Promise { + async sessions(): Promise { const state = await readCredentialState(this.#filePath); - return state.sessions.map((record) => toSession(record, state)); + return { + sessions: state.sessions.map((record) => toSession(record)), + selectedWorkspaceId: resolvedMarker(state) ?? undefined, + }; } async createSession( credential: Credential, workspaceId: string, ): Promise { - this.#refuseBlankEnvironmentToken(); + const environmentInForce = this.#environmentToken() !== undefined; const claimed = claimedWorkspaceId(credential.token); if (claimed !== undefined && claimed !== workspaceId) { throw credentialWorkspaceMismatchError(workspaceId); @@ -166,11 +204,11 @@ export class FileCredentialManager implements CredentialManager { ], currentWorkspaceId: workspaceId, }; - return { state: next, result: toSession(record, next) }; + return { state: next, result: toSession(record) }; }); - if (this.#pin.kind !== "environment") { - this.#pin = { kind: "marker", workspaceId }; + if (!environmentInForce) { + this.#pin = { kind: "session", workspaceId }; } const name = await this.#lookUpWorkspaceName(credential, workspaceId); @@ -188,13 +226,12 @@ export class FileCredentialManager implements CredentialManager { session.workspaceId === workspaceId ? named : session, ), }; - return { state: next, result: toSession(named, next) }; + return { state: next, result: toSession(named) }; }); } - async useSession(session: Session): Promise { - await this.#refuseUnderEnvironmentSession(); - const workspaceId = referencedWorkspaceId(session); + async selectSession(workspaceId: string): Promise { + const environmentInForce = this.#environmentToken() !== undefined; const selected = await this.#mutate((state) => { const record = requireRecord(state, workspaceId); @@ -202,47 +239,79 @@ export class FileCredentialManager implements CredentialManager { ...state, currentWorkspaceId: workspaceId, }; - return { state: next, result: toSession(record, next) }; + return { state: next, result: toSession(record) }; }); - this.#pin = { kind: "marker", workspaceId }; + if (!environmentInForce) { + this.#pin = { kind: "session", workspaceId }; + } return selected; } - async endSession(session: Session): Promise { - await this.#refuseUnderEnvironmentSession(); - const workspaceId = referencedWorkspaceId(session); + /** Idempotent (§11.8): a workspace with no session is already in the + * state this asks for, so the slice writes nothing and succeeds. */ + async endSession(workspaceId: string): Promise { + this.#refuseBlankEnvironmentToken(); - await this.#mutate((state) => { - requireRecord(state, workspaceId); - return { state: withoutRecord(state, workspaceId), result: undefined }; - }); + await this.#mutate((state) => + state.sessions.some((session) => session.workspaceId === workspaceId) + ? { state: withoutRecord(state, workspaceId), result: undefined } + : { result: undefined }, + ); - if (this.#pin.kind === "marker" && this.#pin.workspaceId === workspaceId) { - this.#pin = { kind: "marker", workspaceId: null }; + if (this.#pin.kind === "session" && this.#pin.workspaceId === workspaceId) { + this.#pin = { kind: "none" }; } } async endAllSessions(): Promise { - if (this.#environmentToken() !== undefined) { - const stored = await readCredentialState(this.#filePath); - if (stored.sessions.length > 0) { - throw environmentSessionMutationError({ - envVar: SERVICE_TOKEN_ENV_VAR, - storedSessionsExist: true, - }); - } - await this.#reapLegacyContextFile(); - await this.#reapOrphanedWrites(); - return; - } + const environmentInForce = this.#environmentToken() !== undefined; - await this.#mutate(() => ({ state: EMPTY_STATE, result: undefined })); + await this.#mutate((state) => + state.sessions.length === 0 && state.currentWorkspaceId === null + ? { result: undefined } + : { state: EMPTY_STATE, result: undefined }, + ); await this.#reapLegacyContextFile(); await this.#reapOrphanedWrites(); - this.#pin = { kind: "marker", workspaceId: null }; + if (!environmentInForce) { + this.#pin = { kind: "none" }; + } + } + + async activeCredentialStorage(): Promise { + this.#activeStorage ??= this.#buildActiveStorage(); + return this.#activeStorage; + } + + /** §11.2: which storage is chosen once, when the pin resolves. Each + * has exactly one source of truth — the file, or process memory. */ + #buildActiveStorage(): TokenStorage { + const pin = this.#pin; + if (pin.kind === "environment") { + return memoryBackedStorage( + { + token: this.#requireEnvironmentToken(), + refreshToken: undefined, + expiresAt: undefined, + }, + (fn) => this.#withRefreshLock(fn), + ); + } + if (pin.kind === "session") { + return this.#fileBackedStorage(pin.workspaceId); + } + throw new Error( + "@prisma/cli: activeCredentialStorage() is only valid once activeCredential() has returned non-null", + ); } - tokenStorage(workspaceId: string): TokenStorage { + /** + * The file-backed storage, for a credential with a home record. + * getTokens re-reads the file on EVERY call with no memory layer in + * front: that is what lets this process see a pair another process + * already rotated to, skip the exchange, and retry. + */ + #fileBackedStorage(workspaceId: string): TokenStorage { return { getTokens: async () => { const state = await readCredentialState(this.#filePath); @@ -322,31 +391,63 @@ export class FileCredentialManager implements CredentialManager { }); }, - withRefreshLock: (fn: () => Promise): Promise => { - const run = this.#refreshLock.then(fn, fn); - this.#refreshLock = run.then( - () => undefined, - () => undefined, - ); - return run; - }, + withRefreshLock: (fn) => this.#withRefreshLock(fn), }; } + #withRefreshLock(fn: () => Promise): Promise { + const run = this.#refreshLock.then(fn, fn); + this.#refreshLock = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + async #resolvePin(): Promise { + const pinned = this.#pin; + if (pinned.kind !== "unresolved") return pinned; + + if (this.#environmentToken() !== undefined) { + this.#debug("pinned to the environment credential"); + return this.#pinTo({ kind: "environment" }); + } + const state = await readCredentialState(this.#filePath); + const selected = resolvedMarker(state); + this.#debug(`pinned to session ${selected ?? "(none)"}`); + return this.#pinTo( + selected === null + ? { kind: "none" } + : { kind: "session", workspaceId: selected }, + ); + } + + #pinTo(pin: ResolvedPin): ResolvedPin { + this.#pin = pin; + return pin; + } + #environmentToken(): string | undefined { return environmentServiceToken(this.#env); } - /** A blank env token is an error state everywhere the env session - * would be consulted — including createSession, which the override - * otherwise allows. */ + #requireEnvironmentToken(): string { + const token = this.#environmentToken(); + if (token === undefined) { + throw credentialsRequiredError(); + } + return token; + } + + /** A blank env token is an error state everywhere the environment + * credential would be consulted, including the mutations that no + * longer care whether a valid one is set. */ #refuseBlankEnvironmentToken(): void { this.#environmentToken(); } /** endAllSessions clears everything, including the legacy context - * sidecar — also on the env-override no-op, which has no stored - * sessions to clear but may still have the sidecar. */ + * sidecar, which survives a store that was already empty. */ async #reapLegacyContextFile(): Promise { await fs.unlink(getAuthContextFilePath(this.#filePath)).catch(() => {}); } @@ -367,29 +468,6 @@ export class FileCredentialManager implements CredentialManager { ); } - #environmentSession(): Session { - const token = this.#environmentToken(); - if (token === undefined) { - throw credentialsRequiredError(); - } - return { - workspaceId: serviceTokenWorkspaceId(token) ?? "", - workspaceName: undefined, - expiresAt: claimedExpiresAt(token), - source: "environment", - current: true, - }; - } - - async #refuseUnderEnvironmentSession(): Promise { - if (this.#environmentToken() === undefined) return; - const state = await readCredentialState(this.#filePath); - throw environmentSessionMutationError({ - envVar: SERVICE_TOKEN_ENV_VAR, - storedSessionsExist: state.sessions.length > 0, - }); - } - async #lookUpWorkspaceName( credential: Credential, workspaceId: string, @@ -422,13 +500,6 @@ export class FileCredentialManager implements CredentialManager { } } -function referencedWorkspaceId(session: Session): string { - if (session.source === "environment") { - throw noSessionForWorkspaceError(session.workspaceId); - } - return session.workspaceId; -} - function requireRecord( state: CredentialState, workspaceId: string, @@ -466,8 +537,8 @@ function expiresAtSlice( return expiresAt === undefined ? {} : { expiresAt: expiresAt.toISOString() }; } -/** The marker a first read pins: a marker naming no record pins as - * none. */ +/** The selection the manager will admit to: one that names a stored + * session, or none. A dangling selection never escapes. */ function resolvedMarker(state: CredentialState): string | null { const marked = state.currentWorkspaceId; if ( @@ -479,13 +550,34 @@ function resolvedMarker(state: CredentialState): string | null { return null; } -function toSession(record: StoredSession, state: CredentialState): Session { +function toSession(record: StoredSession): Session { return { workspaceId: record.workspaceId, workspaceName: record.name, expiresAt: record.expiresAt === undefined ? undefined : new Date(record.expiresAt), - source: "stored", - current: state.currentWorkspaceId === record.workspaceId, + }; +} + +function storedCredential(record: StoredSession): ActiveCredential { + return { + workspaceId: record.workspaceId, + workspaceName: record.name, + expiresAt: + record.expiresAt === undefined ? undefined : new Date(record.expiresAt), + identity: claimedIdentity(record.token), + origin: { source: "stored" }, + }; +} + +/** An environment token whose claims name no workspace reports no + * workspace id — never the empty string. */ +function environmentCredential(token: string): ActiveCredential { + return { + workspaceId: serviceTokenWorkspaceId(token), + workspaceName: undefined, + expiresAt: claimedExpiresAt(token), + identity: claimedIdentity(token), + origin: { source: "environment" }, }; } diff --git a/packages/cli/src/auth/credentials.ts b/packages/cli/src/auth/credentials.ts deleted file mode 100644 index 00dd8a72..00000000 --- a/packages/cli/src/auth/credentials.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Credentials } from "@prisma/cli-engine"; -import { SERVICE_TOKEN_ENV_VAR } from "./client"; -import { EmptyServiceTokenError } from "./operations"; -import { FileTokenStorage } from "./token-storage"; - -/** Token reads ignore the run's abort signal so they still work during - * teardown after the first Ctrl-C. */ -export function makeGetCredentials( - env: NodeJS.ProcessEnv, -): () => Promise { - return async () => { - const rawServiceToken = env[SERVICE_TOKEN_ENV_VAR]; - if (rawServiceToken !== undefined) { - const serviceToken = rawServiceToken.trim(); - if (serviceToken.length === 0) { - throw new EmptyServiceTokenError(); - } - return { token: serviceToken }; - } - const tokens = await new FileTokenStorage(env).getTokens(); - return tokens ? { token: tokens.accessToken } : undefined; - }; -} diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 4f2e15ff..0d34fe85 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -10,7 +10,6 @@ export { type FetchWorkspaceName, FileCredentialManager, } from "./credential-manager"; -export { makeGetCredentials } from "./credentials"; export { workspaceAmbiguousError, workspaceNotAuthenticatedError, diff --git a/packages/cli/src/v8/auth/logout.ts b/packages/cli/src/v8/auth/logout.ts index d951db5b..340deb7a 100644 --- a/packages/cli/src/v8/auth/logout.ts +++ b/packages/cli/src/v8/auth/logout.ts @@ -1,6 +1,8 @@ import { defineCommand, type Presentations } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; +import { environmentSessionInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; +import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; const SIGN_IN: NextAction = { kind: "run-command", @@ -13,7 +15,10 @@ export interface LogoutResult { readonly workspaceIds: readonly string[]; } -function presentationsFor(result: LogoutResult): Presentations { +function presentationsFor( + result: LogoutResult, + environmentInForce: boolean, +): Presentations { const summary = result.endedCount === 0 ? "No workspace sessions to end." @@ -28,6 +33,15 @@ function presentationsFor(result: LogoutResult): Presentations { }, { kind: "fields", rows }, { kind: "summary", tone: "ok", text: summary }, + ...(environmentInForce + ? [ + { + kind: "summary", + tone: "info", + text: ENVIRONMENT_SESSION_NOTICE, + } as const, + ] + : []), ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), next: () => [SIGN_IN], @@ -41,12 +55,17 @@ export const authLogoutCommand = defineCommand({ examples: ["auth logout"], }, handler: async (_args, ctx) => { - const sessions = await ctx.credentialManager.sessions(); + const stored = await ctx.credentialManager.sessions(); await ctx.credentialManager.endAllSessions(); const result: LogoutResult = { - endedCount: sessions.length, - workspaceIds: sessions.map((session) => session.workspaceId), + endedCount: stored.sessions.length, + workspaceIds: stored.sessions.map((session) => session.workspaceId), }; - return ok(ctx.present({ data: result }, presentationsFor(result))); + return ok( + ctx.present( + { data: result }, + presentationsFor(result, environmentSessionInForce(ctx.env)), + ), + ); }, }); diff --git a/packages/cli/src/v8/auth/session-card.ts b/packages/cli/src/v8/auth/session-card.ts index 4bf5dbc7..2f565c6c 100644 --- a/packages/cli/src/v8/auth/session-card.ts +++ b/packages/cli/src/v8/auth/session-card.ts @@ -1,38 +1,35 @@ -import type { Session } from "@prisma/cli-engine"; +import type { ActiveCredential, CredentialIdentity } from "@prisma/cli-engine"; import { SERVICE_TOKEN_ENV_VAR } from "../../auth"; -import { sessionLabel } from "./session-ref"; export interface FieldRow { readonly label: string; readonly value: string; } -/** The identity a session's own claims or the API can supply. */ -export interface SessionIdentity { - readonly id: string | null; - readonly email: string | null; - readonly name: string | null; -} - export const ENVIRONMENT_SESSION_NOTICE = `${SERVICE_TOKEN_ENV_VAR} supplies the session in force; unset it to use your stored workspace sessions.`; -/** The card rows for a session, or the signed-out row when there is - * none. */ -export function sessionFieldRows(spec: { - readonly session: Session | null; - readonly identity: SessionIdentity | null; +/** The card rows for the active credential, or the signed-out row when + * there is none. A credential nothing names — an environment token + * whose claims carry no workspace — has no workspace row at all. */ +export function credentialFieldRows(spec: { + readonly credential: ActiveCredential | null; + readonly identity: CredentialIdentity | null; }): readonly FieldRow[] { - const session = spec.session; - if (session === null) { + const credential = spec.credential; + if (credential === null) { return [{ label: "status", value: "signed out" }]; } const rows: FieldRow[] = [{ label: "status", value: "signed in" }]; - const user = spec.identity?.email ?? spec.identity?.name; - if (user) { - rows.push({ label: "user", value: user }); + if (spec.identity?.email !== undefined) { + rows.push({ label: "user", value: spec.identity.email }); + } + if (credential.workspaceId !== undefined) { + rows.push({ + label: "workspace", + value: credential.workspaceName ?? credential.workspaceId, + }); } - rows.push({ label: "workspace", value: sessionLabel(session) }); - if (session.source === "environment") { + if (credential.origin.source === "environment") { rows.push({ label: "source", value: SERVICE_TOKEN_ENV_VAR }); } return rows; diff --git a/packages/cli/src/v8/auth/session-ref.ts b/packages/cli/src/v8/auth/session-ref.ts index 4f531ab5..faf522c2 100644 --- a/packages/cli/src/v8/auth/session-ref.ts +++ b/packages/cli/src/v8/auth/session-ref.ts @@ -1,7 +1,10 @@ /** * Command-side resolution of a user-typed workspace reference against * the sessions the credential manager holds. The manager resolves no - * user input: the commands match the ref and pass the matched Session. + * user input: the commands match the ref and pass the matched session's + * workspace id. This is also where a workspace the user never had is + * caught, which is why removal being idempotent still leaves a mistyped + * ref with a useful error. */ import { noSessionForWorkspaceError, type Session } from "@prisma/cli-engine"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index 87ddf68b..47df955e 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -1,17 +1,15 @@ import { - type CommandContext, + type ActiveCredential, + type CredentialIdentity, defineCommand, type ManagementApiClient, type Presentations, - type Session, } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; -import { decodeClaims, environmentServiceToken } from "../../auth"; import { CLI_NAME } from "../../cli-name"; import { + credentialFieldRows, ENVIRONMENT_SESSION_NOTICE, - type SessionIdentity, - sessionFieldRows, } from "./session-card"; const TITLE = "Showing the current authenticated identity."; @@ -28,74 +26,63 @@ export interface WhoamiResult { readonly id: string; readonly name: string | null; } | null; - readonly user: SessionIdentity | null; + readonly user: { + readonly id: string | null; + readonly email: string | null; + readonly name: string | null; + } | null; readonly source: "stored" | "environment" | null; readonly expiresAt: string | null; } -function claimedIdentity(token: string): SessionIdentity | null { - const claims = decodeClaims(token); - if (claims === undefined) { - return null; - } - const read = (key: string): string | null => - typeof claims[key] === "string" ? (claims[key] as string) : null; - const identity = { - id: read("sub"), - email: read("email"), - name: read("name"), - }; - return identity.id === null && identity.email === null ? null : identity; -} - /** Best-effort online enrichment: whoami works offline, so any failure - * leaves the identity as whatever the session itself could supply. */ -async function enrichedIdentity( + * leaves the identity as whatever the credential's own claims said. */ +async function fetchedIdentity( api: ManagementApiClient, signal: AbortSignal, -): Promise { +): Promise { try { const { data } = await api.GET("/v1/me", { signal }); const user = data?.data?.user; if (!user) { - return null; + return undefined; } return { - id: user.id ?? null, - email: user.email ?? null, - name: user.name ?? null, + userId: user.id ?? undefined, + email: user.email ?? undefined, + name: user.name ?? undefined, }; } catch { signal.throwIfAborted(); - return null; + return undefined; } } -/** An env session's identity is the env token's own claims — decoded - * locally, never fetched. `/v1/me` is the stored-session path, whose - * token whoami cannot reach. */ -async function identityFor( - session: Session, - ctx: CommandContext, -): Promise { - if (session.source === "environment") { - const envToken = environmentServiceToken(ctx.env); - return envToken === undefined ? null : claimedIdentity(envToken); - } - return enrichedIdentity(ctx.api, ctx.signal); +/** `/v1/me` wins field by field where it disagrees with the claims; + * the claims are the offline fallback. */ +function mergedIdentity( + claimed: CredentialIdentity | undefined, + fetched: CredentialIdentity | undefined, +): CredentialIdentity | null { + const userId = fetched?.userId ?? claimed?.userId; + const email = fetched?.email ?? claimed?.email; + const name = fetched?.name ?? claimed?.name; + return userId === undefined && email === undefined && name === undefined + ? null + : { userId, email, name }; } function presentationsFor(spec: { - readonly session: Session | null; - readonly identity: SessionIdentity | null; + readonly credential: ActiveCredential | null; + readonly identity: CredentialIdentity | null; }): Presentations { - const rows = sessionFieldRows(spec); - const environmentSession = spec.session?.source === "environment"; + const rows = credentialFieldRows(spec); + const fromEnvironment = spec.credential?.origin.source === "environment"; return { human: () => [ { kind: "summary", tone: "info", text: TITLE }, { kind: "fields", rows }, - ...(environmentSession + ...(fromEnvironment ? [ { kind: "summary", @@ -106,7 +93,7 @@ function presentationsFor(spec: { : []), ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), - next: () => (spec.session === null ? [SIGN_IN] : []), + next: () => (spec.credential === null ? [SIGN_IN] : []), }; } @@ -116,20 +103,36 @@ export const authWhoamiCommand = defineCommand({ examples: ["auth whoami", "auth whoami --json"], }, handler: async (_args, ctx) => { - const session = await ctx.session(); - const identity = session === null ? null : await identityFor(session, ctx); + const credential = await ctx.activeCredential(); + const identity = + credential === null + ? null + : mergedIdentity( + credential.identity, + await fetchedIdentity(ctx.api, ctx.signal), + ); const result: WhoamiResult = { - authenticated: session !== null, + authenticated: credential !== null, workspace: - session === null + credential === null || credential.workspaceId === undefined + ? null + : { + id: credential.workspaceId, + name: credential.workspaceName ?? null, + }, + user: + identity === null ? null - : { id: session.workspaceId, name: session.workspaceName ?? null }, - user: identity, - source: session?.source ?? null, - expiresAt: session?.expiresAt?.toISOString() ?? null, + : { + id: identity.userId ?? null, + email: identity.email ?? null, + name: identity.name ?? null, + }, + source: credential?.origin.source ?? null, + expiresAt: credential?.expiresAt?.toISOString() ?? null, }; return ok( - ctx.present({ data: result }, presentationsFor({ session, identity })), + ctx.present({ data: result }, presentationsFor({ credential, identity })), ); }, }); diff --git a/packages/cli/src/v8/auth/workspace-list.ts b/packages/cli/src/v8/auth/workspace-list.ts index 48593cd8..f28aafc9 100644 --- a/packages/cli/src/v8/auth/workspace-list.ts +++ b/packages/cli/src/v8/auth/workspace-list.ts @@ -18,6 +18,7 @@ const LOGIN_NEXT_ACTION: NextAction = { export interface WorkspaceListResult { readonly sessions: readonly Session[]; + readonly selectedWorkspaceId: string | undefined; readonly environmentSessionInForce: boolean; } @@ -25,13 +26,12 @@ export function serializeWorkspaceList(result: WorkspaceListResult) { return { context: { environmentSessionInForce: result.environmentSessionInForce, - currentWorkspaceId: - result.sessions.find((session) => session.current)?.workspaceId ?? null, + currentWorkspaceId: result.selectedWorkspaceId ?? null, }, items: result.sessions.map((session) => ({ workspaceId: session.workspaceId, workspaceName: session.workspaceName ?? null, - current: session.current, + current: session.workspaceId === result.selectedWorkspaceId, expiresAt: session.expiresAt?.toISOString() ?? null, })), count: result.sessions.length, @@ -43,7 +43,7 @@ function listPresentations(result: WorkspaceListResult): Presentations { const rows = result.sessions.map((session) => [ sessionLabel(session), session.workspaceId, - session.current ? "current" : "", + session.workspaceId === result.selectedWorkspaceId ? "current" : "", ]); return { human: () => [ @@ -84,8 +84,10 @@ export const authWorkspaceListCommand = defineCommand({ examples: ["auth workspace list", "auth workspace list --json"], }, handler: async (_args, ctx) => { + const stored = await ctx.credentialManager.sessions(); const result: WorkspaceListResult = { - sessions: await ctx.credentialManager.sessions(), + sessions: stored.sessions, + selectedWorkspaceId: stored.selectedWorkspaceId, environmentSessionInForce: environmentSessionInForce(ctx.env), }; return ok(ctx.present({ data: result }, listPresentations(result))); diff --git a/packages/cli/src/v8/auth/workspace-logout.ts b/packages/cli/src/v8/auth/workspace-logout.ts index d2d515b3..dd1ce662 100644 --- a/packages/cli/src/v8/auth/workspace-logout.ts +++ b/packages/cli/src/v8/auth/workspace-logout.ts @@ -5,7 +5,9 @@ import { positional, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; +import { environmentSessionInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; +import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; import { requireSession, sessionLabel } from "./session-ref"; export interface WorkspaceLogoutResult { @@ -16,6 +18,7 @@ export interface WorkspaceLogoutResult { function logoutPresentations(spec: { readonly label: string; readonly wasCurrent: boolean; + readonly environmentSessionInForce: boolean; }): Presentations { const rows = [{ label: "workspace", value: spec.label }]; return { @@ -33,6 +36,15 @@ function logoutPresentations(spec: { ? "Ended the current workspace session; no replacement was selected." : "Ended the workspace session.", }, + ...(spec.environmentSessionInForce + ? [ + { + kind: "summary", + tone: "info", + text: ENVIRONMENT_SESSION_NOTICE, + } as const, + ] + : []), ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), next: () => [ @@ -69,22 +81,24 @@ export const authWorkspaceLogoutCommand = defineCommand({ examples: ["auth workspace logout my-workspace"], }, handler: async (args, ctx) => { - const sessions = await ctx.credentialManager.sessions(); - const session = requireSession(sessions, args.positionals.workspace); - await ctx.credentialManager.endSession(session); + const stored = await ctx.credentialManager.sessions(); + const session = requireSession(stored.sessions, args.positionals.workspace); + const wasCurrent = session.workspaceId === stored.selectedWorkspaceId; + await ctx.credentialManager.endSession(session.workspaceId); const result: WorkspaceLogoutResult = { workspace: { id: session.workspaceId, name: session.workspaceName ?? null, }, - wasCurrent: session.current, + wasCurrent, }; return ok( ctx.present( { data: result }, logoutPresentations({ label: sessionLabel(session), - wasCurrent: session.current, + wasCurrent, + environmentSessionInForce: environmentSessionInForce(ctx.env), }), ), ); diff --git a/packages/cli/src/v8/auth/workspace-use.ts b/packages/cli/src/v8/auth/workspace-use.ts index fa304add..690d8dc8 100644 --- a/packages/cli/src/v8/auth/workspace-use.ts +++ b/packages/cli/src/v8/auth/workspace-use.ts @@ -5,9 +5,12 @@ import { type Presentations, positional, type Session, + type StoredSessions, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { environmentSessionInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; +import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; import { requireSession, sessionLabel } from "./session-ref"; export interface WorkspaceUseResult { @@ -34,6 +37,7 @@ function noWorkspaceSessionsError(): CliStructuredError { function usePresentations(spec: { readonly session: Session; readonly previous: Session | undefined; + readonly environmentSessionInForce: boolean; }): Presentations { const rows = [ ...(spec.previous === undefined @@ -54,6 +58,15 @@ function usePresentations(spec: { tone: "ok", text: "Current workspace session updated.", }, + ...(spec.environmentSessionInForce + ? [ + { + kind: "summary", + tone: "info", + text: ENVIRONMENT_SESSION_NOTICE, + } as const, + ] + : []), ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), next: () => [ @@ -86,17 +99,21 @@ export const authWorkspaceUseCommand = defineCommand({ examples: ["auth workspace use", "auth workspace use my-workspace"], }, handler: async (args, ctx) => { - const sessions = await ctx.credentialManager.sessions(); - if (sessions.length === 0) { + const stored = await ctx.credentialManager.sessions(); + if (stored.sessions.length === 0) { throw noWorkspaceSessionsError(); } const ref = args.positionals.workspace?.trim(); const chosen = ref - ? requireSession(sessions, ref) - : await selectSession(sessions, ctx.prompt.select); - const previous = sessions.find((session) => session.current); + ? requireSession(stored.sessions, ref) + : await promptForSession(stored, ctx.prompt.select); + const previous = stored.sessions.find( + (session) => session.workspaceId === stored.selectedWorkspaceId, + ); - const session = await ctx.credentialManager.useSession(chosen); + const session = await ctx.credentialManager.selectSession( + chosen.workspaceId, + ); const result: WorkspaceUseResult = { workspace: { id: session.workspaceId, @@ -105,27 +122,36 @@ export const authWorkspaceUseCommand = defineCommand({ previousWorkspaceId: previous?.workspaceId ?? null, }; return ok( - ctx.present({ data: result }, usePresentations({ session, previous })), + ctx.present( + { data: result }, + usePresentations({ + session, + previous, + environmentSessionInForce: environmentSessionInForce(ctx.env), + }), + ), ); }, }); -async function selectSession( - sessions: readonly Session[], +async function promptForSession( + stored: StoredSessions, select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, ) => Promise, ): Promise { - if (sessions.length === 1) { - return sessions[0]; + if (stored.sessions.length === 1) { + return stored.sessions[0]; } const workspaceId = await select( "Select a workspace", - sessions.map((session) => ({ + stored.sessions.map((session) => ({ value: session.workspaceId, - label: `${sessionLabel(session)} (${session.workspaceId})${session.current ? " current" : ""}`, + label: `${sessionLabel(session)} (${session.workspaceId})${ + session.workspaceId === stored.selectedWorkspaceId ? " current" : "" + }`, })), ); - return requireSession(sessions, workspaceId); + return requireSession(stored.sessions, workspaceId); } diff --git a/packages/cli/src/v8/runtime.ts b/packages/cli/src/v8/runtime.ts index b75e48c9..aeed193f 100644 --- a/packages/cli/src/v8/runtime.ts +++ b/packages/cli/src/v8/runtime.ts @@ -13,7 +13,6 @@ import { fetchWorkspaceName, getApiBaseUrl, getAuthBaseUrl, - makeGetCredentials, resolveStateFilePath, STATE_FILE_ENV_VAR, } from "../auth"; @@ -92,7 +91,6 @@ export async function assembleRuntime(proc: HostProcess): Promise { exit: (code) => proc.exit(code), onSignal: makeOnSignal(proc), config: await loadConfig(proc.cwd()), - getCredentials: makeGetCredentials(proc.env), credentialManager: new FileCredentialManager({ env: proc.env, fetchWorkspaceName: fetchWorkspaceName(apiBaseUrl), diff --git a/packages/cli/tests/credential-manager-login.test.ts b/packages/cli/tests/credential-manager-login.test.ts index 7374d65e..959b2d5c 100644 --- a/packages/cli/tests/credential-manager-login.test.ts +++ b/packages/cli/tests/credential-manager-login.test.ts @@ -53,11 +53,11 @@ describe("performLogin", () => { expect(await readFile(stateFilePath, "utf8").catch(() => null)).toBeNull(); const manager = new FileCredentialManager({ env }); - expect(await manager.sessions()).toEqual([]); + expect((await manager.sessions()).sessions).toEqual([]); await manager.createSession(credential, WORKSPACE_A); expect( - (await manager.sessions()).map((session) => session.workspaceId), + (await manager.sessions()).sessions.map((session) => session.workspaceId), ).toEqual([WORKSPACE_A]); }); diff --git a/packages/cli/tests/credential-manager-migration.test.ts b/packages/cli/tests/credential-manager-migration.test.ts index 28ab2fb7..43241cb4 100644 --- a/packages/cli/tests/credential-manager-migration.test.ts +++ b/packages/cli/tests/credential-manager-migration.test.ts @@ -64,64 +64,78 @@ describe("adopting the legacy store", () => { workspaces: { [WORKSPACE_B]: { name: "Bravo" } }, }); - const sessions = await makeManager().sessions(); - expect(sessions.map((session) => session.workspaceId)).toEqual([ + const stored = await makeManager().sessions(); + expect(stored.sessions.map((session) => session.workspaceId)).toEqual([ WORKSPACE_A, WORKSPACE_B, ]); - expect(sessions.find((session) => session.current)?.workspaceId).toBe( - WORKSPACE_B, - ); - expect(sessions[1].workspaceName).toBe("Bravo"); + expect(stored.selectedWorkspaceId).toBe(WORKSPACE_B); + expect(stored.sessions[1].workspaceName).toBe("Bravo"); }); - it("adopts with no current session when the pointer dangles", async () => { + it("adopts with nothing selected when the pointer dangles", async () => { await writeLegacyStore([legacyEntry(WORKSPACE_A)]); await writeLegacyContext({ activeWorkspaceId: "wksp_gone", workspaces: {}, }); - const sessions = await makeManager().sessions(); - expect(sessions).toHaveLength(1); - expect(sessions.every((session) => !session.current)).toBe(true); + const stored = await makeManager().sessions(); + expect(stored.sessions).toHaveLength(1); + expect(stored.selectedWorkspaceId).toBeUndefined(); }); - it("adopts with no current session when the pointer is null", async () => { + it("adopts with nothing selected when the pointer is null", async () => { await writeLegacyStore([legacyEntry(WORKSPACE_A)]); await writeLegacyContext({ activeWorkspaceId: null, workspaces: {} }); expect( - (await makeManager().sessions()).every((session) => !session.current), - ).toBe(true); + (await makeManager().sessions()).selectedWorkspaceId, + ).toBeUndefined(); }); - it("makes the only entry current when there is no context file", async () => { + it("selects the only entry when there is no context file", async () => { await writeLegacyStore([legacyEntry(WORKSPACE_A)]); - const sessions = await makeManager().sessions(); - expect(sessions[0].current).toBe(true); + expect((await makeManager().sessions()).selectedWorkspaceId).toBe( + WORKSPACE_A, + ); }); - it("picks no current session when several entries have no context file", async () => { + it("selects nothing when several entries have no context file", async () => { await writeLegacyStore([ legacyEntry(WORKSPACE_A), legacyEntry(WORKSPACE_B), ]); expect( - (await makeManager().sessions()).every((session) => !session.current), - ).toBe(true); + (await makeManager().sessions()).selectedWorkspaceId, + ).toBeUndefined(); }); it("adopts nothing from a missing, unparseable, or wrong-shaped file", async () => { - expect(await makeManager().sessions()).toEqual([]); + const nothing = { sessions: [], selectedWorkspaceId: undefined }; + expect(await makeManager().sessions()).toEqual(nothing); await writeFile(authFilePath, "not json at all", "utf8"); - expect(await makeManager().sessions()).toEqual([]); + expect(await makeManager().sessions()).toEqual(nothing); await writeFile(authFilePath, JSON.stringify({ nope: true }), "utf8"); - expect(await makeManager().sessions()).toEqual([]); + expect(await makeManager().sessions()).toEqual(nothing); + }); + + /** Design §11.10, test 4: §7 adopts entries with no refresh token, and + * the engine tells "could never have been renewed" from a token set + * with no refresh token. This is where that state comes from. */ + it("hands the engine a token set with no refresh token for an adopted entry", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A)]); + const manager = makeManager(); + + await manager.activeCredential(); + const tokens = await (await manager.activeCredentialStorage()).getTokens(); + + expect(tokens).toMatchObject({ workspaceId: WORKSPACE_A }); + expect(tokens?.refreshToken).toBeUndefined(); }); it("keys on the workspace_id claim, keeps the last duplicate, and ignores undecodable entries", async () => { @@ -182,13 +196,10 @@ describe("adopting the legacy store", () => { const legacyBytes = await readFile(authFilePath, "utf8"); const manager = makeManager(); - await manager.currentSession(); + await manager.activeCredential(); expect(await readFile(authFilePath, "utf8")).toBe(legacyBytes); - const sessionB = (await manager.sessions()).find( - (session) => session.workspaceId === WORKSPACE_B, - ); - await manager.useSession(sessionB as never); + await manager.selectSession(WORKSPACE_B); const state = await readCredentialState(authFilePath); expect(state.sessions.map((session) => session.workspaceId)).toEqual([ diff --git a/packages/cli/tests/credential-manager-processes.test.ts b/packages/cli/tests/credential-manager-processes.test.ts index 8dc8516e..e1831bb8 100644 --- a/packages/cli/tests/credential-manager-processes.test.ts +++ b/packages/cli/tests/credential-manager-processes.test.ts @@ -298,10 +298,10 @@ describe("across processes", () => { await runWorker("create", WORKSPACE_B, mintToken(WORKSPACE_B), "refresh-b"); const manager = makeManager(); - expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_B); + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_B); await runWorker("use", WORKSPACE_A); - expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_B); + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_B); const fromNewProcess = JSON.parse(await runWorker("current")) as { workspaceId: string; diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index d98cbd24..f143d900 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -1,7 +1,7 @@ /** * The credential manager over its state file: the file format and its - * atomicity, process pinning, the env override rules, the TokenStorage - * write slices, and the legacy migration. + * atomicity, process pinning, idempotent removal, what an environment + * credential can and cannot reach, and the two token storages. */ import nodeFs from "node:fs"; import fsPromises, { @@ -14,6 +14,7 @@ import fsPromises, { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { TokenStorage } from "@prisma/cli-engine"; import { mintTestJwt } from "@prisma/cli-engine/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -91,6 +92,15 @@ async function readRawState(): Promise { return readFile(stateFilePath, "utf8").catch(() => null); } +/** The engine's order: resolve the active credential, then ask for the + * storage behind it. */ +async function storageFor( + manager: FileCredentialManager, +): Promise { + await manager.activeCredential(); + return manager.activeCredentialStorage(); +} + async function seedTwoSessions(): Promise { const manager = makeManager(); await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); @@ -163,14 +173,14 @@ describe("the state file", () => { const opens = vi.spyOn(fsPromises, "open"); try { const manager = makeManager(); - await manager.currentSession(); + await manager.activeCredential(); await manager.sessions(); - await manager.tokenStorage(WORKSPACE_A).getTokens(); + await (await manager.activeCredentialStorage()).getTokens(); const adopting = new FileCredentialManager({ env: { PRISMA_AUTH_FILE: legacyPath }, }); - await adopting.currentSession(); + await adopting.activeCredential(); await adopting.sessions(); for (const spy of spies) { @@ -373,34 +383,28 @@ describe("the state file", () => { const before = await readRawState(); const manager = makeManager(); - expect(await manager.currentSession()).toBeNull(); - expect(await manager.sessions()).toEqual([]); + expect(await manager.activeCredential()).toBeNull(); + expect(await manager.sessions()).toEqual({ + sessions: [], + selectedWorkspaceId: undefined, + }); expect(await readRawState()).toBe(before); }); }); describe("process pinning", () => { - it("pins the current session at the first read and keeps it when another process moves the marker", async () => { + it("pins the credential at the first read and keeps it when another process moves the selection", async () => { await seedTwoSessions(); const manager = makeManager(); - await makeManager().useSession( - (await manager.sessions()).find( - (session) => session.workspaceId === WORKSPACE_A, - ) as never, - ); + await makeManager().selectSession(WORKSPACE_A); - const pinned = await manager.currentSession(); + const pinned = await manager.activeCredential(); expect(pinned?.workspaceId).toBe(WORKSPACE_A); - const otherProcess = makeManager(); - await otherProcess.useSession( - (await otherProcess.sessions()).find( - (session) => session.workspaceId === WORKSPACE_B, - ) as never, - ); + await makeManager().selectSession(WORKSPACE_B); - expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_A); - expect((await makeManager().currentSession())?.workspaceId).toBe( + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_A); + expect((await makeManager().activeCredential())?.workspaceId).toBe( WORKSPACE_B, ); }); @@ -408,124 +412,182 @@ describe("process pinning", () => { it("moves the pin on this process's own mutations", async () => { await seedTwoSessions(); const manager = makeManager(); - expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_B); + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_B); - const sessionA = (await manager.sessions()).find( - (session) => session.workspaceId === WORKSPACE_A, - ); - await manager.useSession(sessionA as never); - expect((await manager.currentSession())?.workspaceId).toBe(WORKSPACE_A); + await manager.selectSession(WORKSPACE_A); + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_A); - await manager.endSession(sessionA as never); - await expect(manager.currentSession()).rejects.toMatchObject({ + await manager.endSession(WORKSPACE_A); + await expect(manager.activeCredential()).rejects.toMatchObject({ code: "CLI.CREDENTIALS_REQUIRED", }); }); - it("fails with the session-ended error when another process ends the pinned session", async () => { + it("reads the material through the file, so another process's replacement is visible", async () => { await seedTwoSessions(); const manager = makeManager(); - await manager.currentSession(); + expect((await manager.activeCredential())?.expiresAt).toBeUndefined(); - const otherProcess = makeManager(); - await otherProcess.endSession( - (await otherProcess.sessions()).find( - (session) => session.workspaceId === WORKSPACE_B, - ) as never, + await makeManager().createSession( + { + token: mintToken(WORKSPACE_B, { exp: 2_000_000_000 }), + refreshToken: "refresh-2", + expiresAt: undefined, + }, + WORKSPACE_B, ); - await expect(manager.currentSession()).rejects.toMatchObject({ + expect((await manager.activeCredential())?.expiresAt).toEqual( + new Date(2_000_000_000 * 1000), + ); + }); + + it("fails with the session-ended error when another process ends the pinned session", async () => { + await seedTwoSessions(); + const manager = makeManager(); + await manager.activeCredential(); + + await makeManager().endSession(WORKSPACE_B); + + await expect(manager.activeCredential()).rejects.toMatchObject({ code: "CLI.CREDENTIALS_REQUIRED", message: "The workspace session this command was using has ended.", }); }); - it("reports sessions held but none current", async () => { + it("reports sessions held but none selected", async () => { await seedTwoSessions(); - const manager = makeManager(); - const sessions = await manager.sessions(); - await manager.endSession( - sessions.find((session) => session.workspaceId === WORKSPACE_B) as never, - ); + await makeManager().endSession(WORKSPACE_B); - await expect(makeManager().currentSession()).rejects.toMatchObject({ + await expect(makeManager().activeCredential()).rejects.toMatchObject({ code: "CLI.CREDENTIALS_REQUIRED", why: "You have workspace sessions but none is current.", }); }); }); -describe("mutations under an environment session", () => { - /** §5's matrix: every mutation × {unset, set, blank, whitespace}. */ - const refusals = { - set: { - token: () => mintToken(WORKSPACE_B), - code: "AUTH.ENV_SESSION_IN_FORCE", - createSessionRefused: false, - }, - blank: { - token: () => "", - code: "AUTH.SERVICE_TOKEN_EMPTY", - createSessionRefused: true, - }, - whitespace: { - token: () => " ", - code: "AUTH.SERVICE_TOKEN_EMPTY", - createSessionRefused: true, - }, - } as const; +describe("removal is idempotent", () => { + /** Design §11.10, test 6. The rename is the last step of every write, + * so watching it says "wrote nothing" rather than "wrote the same + * bytes"; ending a session that exists is the positive control. */ + it("succeeds and writes nothing when the workspace has no session", async () => { + await seedTwoSessions(); + const before = await readRawState(); + const manager = makeManager(); + const renames = vi.spyOn(fsPromises, "rename"); - for (const [name, spec] of Object.entries(refusals)) { - it(`refuses useSession, endSession and endAllSessions with the env token ${name}`, async () => { - await seedTwoSessions(); - const stored = await makeManager().sessions(); - const before = await readRawState(); - const manager = makeManager({ - env: { PRISMA_SERVICE_TOKEN: spec.token() }, - }); + try { + await expect(manager.endSession(WORKSPACE_C)).resolves.toBeUndefined(); + expect(renames).not.toHaveBeenCalled(); - await expect( - manager.useSession(stored[0] as never), - ).rejects.toMatchObject({ code: spec.code }); - await expect( - manager.endSession(stored[0] as never), - ).rejects.toMatchObject({ code: spec.code }); - await expect(manager.endAllSessions()).rejects.toMatchObject({ - code: spec.code, - }); - expect(await readRawState()).toBe(before); + await manager.endSession(WORKSPACE_A); + expect(renames).toHaveBeenCalled(); + } finally { + renames.mockRestore(); + } + + expect(before).not.toBeNull(); + expect(await readRawState()).not.toBe(before); + }); + + it("succeeds when another process removed the session first", async () => { + await seedTwoSessions(); + const manager = makeManager(); + await makeManager().endSession(WORKSPACE_A); + + await expect(manager.endSession(WORKSPACE_A)).resolves.toBeUndefined(); + + expect( + (await readCredentialState(stateFilePath)).sessions.map( + (session) => session.workspaceId, + ), + ).toEqual([WORKSPACE_B]); + }); + + it("still refuses to select a workspace with no session", async () => { + await seedTwoSessions(); + + await expect( + makeManager().selectSession(WORKSPACE_C), + ).rejects.toMatchObject({ code: "AUTH.NO_SESSION_FOR_WORKSPACE" }); + }); +}); + +describe("mutations while an environment credential is in force", () => { + /** Design §11.7 and §11.10 test 8: the refusals are gone. Selecting or + * ending a stored session changes stored state; this process keeps + * authenticating as the environment credential either way. */ + it("lets every mutation through and leaves the pin on the environment credential", async () => { + await seedTwoSessions(); + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_C) }, }); + expect((await manager.activeCredential())?.origin.source).toBe( + "environment", + ); - it(`handles createSession with the env token ${name}`, async () => { - await seedTwoSessions(); - const before = await readRawState(); - const manager = makeManager({ - env: { PRISMA_SERVICE_TOKEN: spec.token() }, - }); - const created = manager.createSession( + await expect( + manager.createSession( credentialFor(WORKSPACE_A, "refresh-env"), WORKSPACE_A, - ); + ), + ).resolves.toMatchObject({ workspaceId: WORKSPACE_A }); + await expect(manager.selectSession(WORKSPACE_B)).resolves.toMatchObject({ + workspaceId: WORKSPACE_B, + }); + await expect(manager.endSession(WORKSPACE_B)).resolves.toBeUndefined(); + await expect(manager.endAllSessions()).resolves.toBeUndefined(); - if (spec.createSessionRefused) { - await expect(created).rejects.toMatchObject({ code: spec.code }); - expect(await readRawState()).toBe(before); - return; - } + expect(await readCredentialState(stateFilePath)).toMatchObject({ + sessions: [], + currentWorkspaceId: null, + }); + expect((await manager.activeCredential())?.origin.source).toBe( + "environment", + ); + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_C); + }); - await expect(created).resolves.toMatchObject({ - workspaceId: WORKSPACE_A, - }); - expect(await readRawState()).not.toBe(before); + it("moves the stored selection without moving the pin", async () => { + await seedTwoSessions(); + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_C) }, + }); + await manager.activeCredential(); + + await manager.selectSession(WORKSPACE_A); + + expect((await manager.sessions()).selectedWorkspaceId).toBe(WORKSPACE_A); + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_C); + }); + + for (const [name, token] of [ + ["blank", ""], + ["whitespace", " "], + ] as const) { + it(`refuses every mutation with the env token ${name}, changing nothing`, async () => { + await seedTwoSessions(); + const before = await readRawState(); + const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: token } }); + const blank = { code: "AUTH.SERVICE_TOKEN_EMPTY" }; + + await expect( + manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A), + ).rejects.toMatchObject(blank); + await expect(manager.selectSession(WORKSPACE_A)).rejects.toMatchObject( + blank, + ); + await expect(manager.endSession(WORKSPACE_A)).rejects.toMatchObject( + blank, + ); + await expect(manager.endAllSessions()).rejects.toMatchObject(blank); + expect(await readRawState()).toBe(before); }); } it("lets every mutation through with the env token unset", async () => { await seedTwoSessions(); const manager = makeManager(); - const sessionA = (await manager.sessions()).find( - (session) => session.workspaceId === WORKSPACE_A, - ); await expect( manager.createSession( @@ -533,12 +595,10 @@ describe("mutations under an environment session", () => { WORKSPACE_A, ), ).resolves.toMatchObject({ workspaceId: WORKSPACE_A }); - await expect(manager.useSession(sessionA as never)).resolves.toMatchObject({ + await expect(manager.selectSession(WORKSPACE_A)).resolves.toMatchObject({ workspaceId: WORKSPACE_A, }); - await expect( - manager.endSession(sessionA as never), - ).resolves.toBeUndefined(); + await expect(manager.endSession(WORKSPACE_A)).resolves.toBeUndefined(); await expect(manager.endAllSessions()).resolves.toBeUndefined(); expect(await readCredentialState(stateFilePath)).toMatchObject({ @@ -547,7 +607,7 @@ describe("mutations under an environment session", () => { }); }); - it("succeeds as a no-op when endAllSessions runs with no stored sessions", async () => { + it("writes no state file when endAllSessions has nothing to clear", async () => { const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_B) }, }); @@ -555,7 +615,7 @@ describe("mutations under an environment session", () => { expect(await readRawState()).toBeNull(); }); - it("still reaps the legacy context sidecar on the no-op", async () => { + it("still reaps the legacy context sidecar when there is nothing to clear", async () => { const sidecarPath = getAuthContextFilePath(stateFilePath); await writeFile( sidecarPath, @@ -571,24 +631,98 @@ describe("mutations under an environment session", () => { await expect(stat(sidecarPath)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("allows createSession while the env token is in force and leaves the pin on the env session", async () => { + it("raises the blank-token error from activeCredential", async () => { + const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: " " } }); + await expect(manager.activeCredential()).rejects.toMatchObject({ + code: "AUTH.SERVICE_TOKEN_EMPTY", + }); + }); +}); + +describe("the environment credential", () => { + /** Design §11.10, test 7: nothing manufactures an empty workspace id. */ + it("reports no workspace id when the token's claims name none", async () => { const manager = makeManager({ - env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_B) }, + env: { PRISMA_SERVICE_TOKEN: mintTestJwt({ sub: "usr_1" }) }, }); - expect((await manager.currentSession())?.source).toBe("environment"); - await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const active = await manager.activeCredential(); - expect((await manager.currentSession())?.source).toBe("environment"); - const state = await readCredentialState(stateFilePath); - expect(state.currentWorkspaceId).toBe(WORKSPACE_A); + expect(active).toMatchObject({ + workspaceName: undefined, + identity: { userId: "usr_1" }, + origin: { source: "environment" }, + }); + expect(active?.workspaceId).toBeUndefined(); }); - it("raises the blank-token error from currentSession", async () => { - const manager = makeManager({ env: { PRISMA_SERVICE_TOKEN: " " } }); - await expect(manager.currentSession()).rejects.toMatchObject({ - code: "AUTH.SERVICE_TOKEN_EMPTY", + it("reports the workspace a service token's sub names", async () => { + const manager = makeManager({ + env: { + PRISMA_SERVICE_TOKEN: mintTestJwt({ sub: `workspace:${WORKSPACE_B}` }), + }, }); + + expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_B); + }); + + /** Design §11.2 and §11.10 tests 2 and 3: the memory-backed storage + * closes over a local variable and is never given the file path, so + * no method of it can reach the stored sessions. */ + it("rotates in memory and leaves the state file byte-unchanged", async () => { + await seedTwoSessions(); + const before = await readRawState(); + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_A) }, + }); + + const storage = await storageFor(manager); + expect(await storage.getTokens()).toMatchObject({ + workspaceId: WORKSPACE_A, + accessToken: mintToken(WORKSPACE_A), + refreshToken: undefined, + }); + + const rotated = mintToken(WORKSPACE_A, { exp: 2_000_000_000 }); + await storage.setTokens({ + workspaceId: WORKSPACE_A, + accessToken: rotated, + refreshToken: "rotated-refresh", + }); + + expect(await storage.getTokens()).toMatchObject({ + accessToken: rotated, + refreshToken: "rotated-refresh", + }); + expect(await readRawState()).toBe(before); + }); + + it("cannot delete the stored session its workspace matches", async () => { + await seedTwoSessions(); + const before = await readRawState(); + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_A) }, + }); + + const storage = await storageFor(manager); + await storage.clearTokens(); + + expect(storage.clearTokensIfCurrent).toBeUndefined(); + expect(await storage.getTokens()).toBeNull(); + expect(await readRawState()).toBe(before); + expect( + (await manager.sessions()).sessions.map((session) => session.workspaceId), + ).toEqual([WORKSPACE_A, WORKSPACE_B]); + }); + + it("keys the token set by a fixed constant when the claims name no workspace", async () => { + const manager = makeManager({ + env: { PRISMA_SERVICE_TOKEN: mintTestJwt({ sub: "usr_1" }) }, + }); + + const tokens = await (await storageFor(manager)).getTokens(); + + expect(tokens?.workspaceId).toBe("(no workspace)"); }); }); @@ -663,12 +797,7 @@ describe("createSession", () => { }); await fetchStarted; - const otherProcess = makeManager(); - await otherProcess.endSession( - (await otherProcess.sessions()).find( - (session) => session.workspaceId === WORKSPACE_A, - ) as never, - ); + await makeManager().endSession(WORKSPACE_A); releaseFetch(); await vi.waitFor(async () => { @@ -698,7 +827,7 @@ describe("createSession", () => { }); }); -describe("the TokenStorage view", () => { +describe("the file-backed TokenStorage", () => { it("writes only the token fields on rotation and re-derives the expiry", async () => { const manager = makeManager({ fetchWorkspaceName: async () => "Workspace A", @@ -707,7 +836,7 @@ describe("the TokenStorage view", () => { await makeManager().createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); const rotated = mintToken(WORKSPACE_A, { exp: 2_000_000_000 }); - await manager.tokenStorage(WORKSPACE_A).setTokens({ + await (await storageFor(manager)).setTokens({ workspaceId: WORKSPACE_A, accessToken: rotated, refreshToken: "refresh-2", @@ -729,16 +858,12 @@ describe("the TokenStorage view", () => { it("refuses to resurrect a session ended during the rotation", async () => { const manager = makeManager(); await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const storage = await storageFor(manager); - const otherProcess = makeManager(); - await otherProcess.endSession( - (await otherProcess.sessions()).find( - (session) => session.workspaceId === WORKSPACE_A, - ) as never, - ); + await makeManager().endSession(WORKSPACE_A); await expect( - manager.tokenStorage(WORKSPACE_A).setTokens({ + storage.setTokens({ workspaceId: WORKSPACE_A, accessToken: mintToken(WORKSPACE_A), refreshToken: "refresh-2", @@ -752,7 +877,7 @@ describe("the TokenStorage view", () => { await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); await expect( - manager.tokenStorage(WORKSPACE_A).setTokens({ + (await storageFor(manager)).setTokens({ workspaceId: WORKSPACE_A, accessToken: mintToken(WORKSPACE_B), refreshToken: "refresh-2", @@ -764,7 +889,7 @@ describe("the TokenStorage view", () => { const manager = makeManager(); const credential = credentialFor(WORKSPACE_A); await manager.createSession(credential, WORKSPACE_A); - const storage = manager.tokenStorage(WORKSPACE_A); + const storage = await storageFor(manager); const stale = { workspaceId: WORKSPACE_A, @@ -785,9 +910,12 @@ describe("the TokenStorage view", () => { expect(state.currentWorkspaceId).toBeNull(); }); - it("clearTokens removes only the bound record", async () => { - await seedTwoSessions(); - await makeManager().tokenStorage(WORKSPACE_A).clearTokens(); + it("clearTokens removes only the pinned record", async () => { + const manager = makeManager(); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + await makeManager().createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); + + await (await storageFor(manager)).clearTokens(); const state = await readCredentialState(stateFilePath); expect(state.sessions.map((session) => session.workspaceId)).toEqual([ @@ -798,7 +926,8 @@ describe("the TokenStorage view", () => { it("serializes refreshes in this process", async () => { const manager = makeManager(); - const storage = manager.tokenStorage(WORKSPACE_A); + await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const storage = await storageFor(manager); const order: string[] = []; const first = storage.withRefreshLock?.(async () => { order.push("first-start"); @@ -816,7 +945,7 @@ describe("the TokenStorage view", () => { it("re-reads the file on every getTokens", async () => { const manager = makeManager(); await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); - const storage = manager.tokenStorage(WORKSPACE_A); + const storage = await storageFor(manager); expect((await storage.getTokens())?.refreshToken).toBe("refresh-1"); await makeManager().createSession( @@ -842,10 +971,9 @@ describe("token material never leaks", () => { expiresAt: undefined, }; await manager.createSession(credential, WORKSPACE_A); - await manager.currentSession(); + const storage = await storageFor(manager); const rotated = mintToken(WORKSPACE_A, { exp: 2_000_000_000 }); - const storage = manager.tokenStorage(WORKSPACE_A); await storage.setTokens({ workspaceId: WORKSPACE_A, accessToken: rotated, @@ -856,7 +984,20 @@ describe("token material never leaks", () => { accessToken: rotated, refreshToken: rotatedSecret, }); - await manager.tokenStorage(WORKSPACE_B).clearTokens(); + + const secondProcess = makeManager({ + env: { PRISMA_NEXT_DEBUG: "1" }, + debugWrite: (text) => debugLines.push(text), + }); + await secondProcess.createSession( + { + token: mintToken(WORKSPACE_B), + refreshToken: secret, + expiresAt: undefined, + }, + WORKSPACE_B, + ); + await (await storageFor(secondProcess)).clearTokens(); const errors = [ await manager @@ -889,6 +1030,7 @@ describe("rotation durability", () => { it("has the rotated pair on disk by the time setTokens resolves", async () => { const manager = makeManager(); await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const storage = await storageFor(manager); const rotated = mintToken(WORKSPACE_A, { exp: 2_000_000_000 }); let renamedBeforeResolve = false; @@ -900,7 +1042,7 @@ describe("rotation durability", () => { renamedBeforeResolve = true; }); try { - await manager.tokenStorage(WORKSPACE_A).setTokens({ + await storage.setTokens({ workspaceId: WORKSPACE_A, accessToken: rotated, refreshToken: "refresh-2", diff --git a/packages/cli/tests/helpers/credential-manager-worker.ts b/packages/cli/tests/helpers/credential-manager-worker.ts index 40faef06..e52d3b3d 100644 --- a/packages/cli/tests/helpers/credential-manager-worker.ts +++ b/packages/cli/tests/helpers/credential-manager-worker.ts @@ -3,7 +3,9 @@ * tests: it runs one manager operation (or holds the lock) against a * state file and prints the result as JSON. */ + import fs from "node:fs/promises"; +import type { TokenStorage } from "@prisma/cli-engine"; import { FileCredentialManager } from "../../src/auth/credential-manager"; const [stateFilePath, command, ...args] = process.argv.slice(2); @@ -14,6 +16,20 @@ function makeManager(env: Record = {}) { }); } +/** The engine's order: resolve the active credential, then ask for the + * storage behind it. The caller names the workspace it expects the + * selection to have pinned. */ +async function storageForSelected( + manager: FileCredentialManager, + workspaceId: string, +): Promise { + const active = await manager.activeCredential(); + if (active?.workspaceId !== workspaceId) { + throw new Error(`the selected session is not ${workspaceId}`); + } + return manager.activeCredentialStorage(); +} + async function run(): Promise { switch (command) { case "create": { @@ -25,28 +41,17 @@ async function run(): Promise { } case "use": { const [workspaceId] = args; - const manager = makeManager(); - const session = (await manager.sessions()).find( - (candidate) => candidate.workspaceId === workspaceId, - ); - if (session === undefined) throw new Error(`no session ${workspaceId}`); - return manager.useSession(session); + return makeManager().selectSession(workspaceId); } case "end": { const [workspaceId] = args; - const manager = makeManager(); - const session = (await manager.sessions()).find( - (candidate) => candidate.workspaceId === workspaceId, - ); - if (session === undefined) throw new Error(`no session ${workspaceId}`); - await manager.endSession(session); + await makeManager().endSession(workspaceId); return null; } case "rotate": { const [workspaceId, accessToken, refreshToken] = args; - await makeManager() - .tokenStorage(workspaceId) - .setTokens({ workspaceId, accessToken, refreshToken }); + const storage = await storageForSelected(makeManager(), workspaceId); + await storage.setTokens({ workspaceId, accessToken, refreshToken }); return null; } /** A REAL refresh: the SDK's refreshing client over the manager's @@ -62,13 +67,13 @@ async function run(): Promise { redirectUri: `${apiBaseUrl}/auth/callback`, apiBaseUrl, authBaseUrl, - tokenStorage: makeManager().tokenStorage(workspaceId), + tokenStorage: await storageForSelected(makeManager(), workspaceId), }); const { response } = await sdk.client.GET("/v1/workspaces", {}); return { status: response.status }; } case "current": - return makeManager().currentSession(); + return makeManager().activeCredential(); case "sessions": return makeManager().sessions(); case "crash-holding-the-lock": { diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index 9a619771..5974d857 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -7,6 +7,8 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { + type ActiveCredential, + type Credential, defineCommand, type ManagementApiClient, type Session, @@ -90,9 +92,16 @@ function apiReturning(body: unknown): ManagementApiClient { } as unknown as ManagementApiClient; } +/** The environment credential PRISMA_SERVICE_TOKEN supplies. One + * environment variable carries one bearer string, so it has no refresh + * token. */ +function environmentCredentialFor(token: string): Credential { + return { token, refreshToken: undefined, expiresAt: undefined }; +} + function makeCli(spec?: { readonly sessions?: readonly SessionRecord[]; - readonly currentWorkspaceId?: string; + readonly selectedWorkspaceId?: string; readonly environmentToken?: string; readonly client?: ManagementApiClient; readonly openUrl?: (url: string) => void; @@ -101,8 +110,11 @@ function makeCli(spec?: { commands: COMMANDS, groups: GROUPS, sessions: spec?.sessions ?? [], - currentWorkspaceId: spec?.currentWorkspaceId, - environmentToken: spec?.environmentToken, + selectedWorkspaceId: spec?.selectedWorkspaceId, + environmentCredential: + spec?.environmentToken === undefined + ? undefined + : environmentCredentialFor(spec.environmentToken), managementApi: { client: spec?.client ?? OFFLINE_API }, openUrl: spec?.openUrl, now: () => new Date(0), @@ -163,7 +175,7 @@ describe("auth login", () => { environmentSessionInForce: false, }); const state = cli.credentialManager?.state(); - expect(state?.currentWorkspaceId).toBe("ws_1"); + expect(state?.selectedWorkspaceId).toBe("ws_1"); expect(state?.sessions.map((session) => session.workspaceId)).toEqual([ "ws_1", ]); @@ -221,7 +233,7 @@ describe("auth logout", () => { it("ends every session and reports the count it ended", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc"), record("ws_2", "Globex")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", }); const result = await cli.run(["auth", "logout", "--json"]); @@ -233,7 +245,7 @@ describe("auth logout", () => { }); expect(cli.credentialManager?.state()).toEqual({ sessions: [], - currentWorkspaceId: null, + selectedWorkspaceId: undefined, }); }); @@ -244,18 +256,26 @@ describe("auth logout", () => { expect(resultOf(result)).toEqual({ endedCount: 0, workspaceIds: [] }); }); - it("refuses under an env override while stored sessions exist, changing nothing", async () => { + /** Design §11.7 and §11.10 test 8. */ + it("clears the store under an env override and says the env token stays in force", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", environmentToken: tokenFor("ws_env"), }); - const result = await cli.run(["auth", "logout", "--json"]); + const result = await cli.run(["auth", "logout"], { + isTty: { stdout: true }, + }); - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("AUTH.ENV_SESSION_IN_FORCE"); - expect(cli.credentialManager?.state().sessions).toHaveLength(1); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "PRISMA_SERVICE_TOKEN supplies the session in force", + ); + expect(cli.credentialManager?.state()).toEqual({ + sessions: [], + selectedWorkspaceId: undefined, + }); }); it("succeeds as a no-op under an env override with no stored sessions", async () => { @@ -285,10 +305,10 @@ describe("auth whoami", () => { }); }); - it("reports the pinned session offline, with no identity to show", async () => { + it("falls back offline to the identity the credential's own claims carry", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", }); const result = await cli.run(["auth", "whoami", "--json"]); @@ -297,18 +317,18 @@ describe("auth whoami", () => { expect(resultOf(result)).toMatchObject({ authenticated: true, workspace: { id: "ws_1", name: "Acme Inc" }, - user: null, + user: { id: "usr_456", email: "bob@example.com" }, source: "stored", }); }); - it("enriches the identity from the management API when online", async () => { + it("lets the management API win over the claims where they disagree", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", client: apiReturning({ data: { - user: { id: "usr_456", email: "bob@example.com", name: "Bob" }, + user: { id: "usr_456", email: "renamed@example.com", name: "Bob" }, }, }), }); @@ -316,14 +336,14 @@ describe("auth whoami", () => { const result = await cli.run(["auth", "whoami", "--json"]); expect(resultOf(result)).toMatchObject({ - user: { id: "usr_456", email: "bob@example.com", name: "Bob" }, + user: { id: "usr_456", email: "renamed@example.com" }, }); }); it("notes the env override and reads its identity from the token's claims", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", environmentToken: tokenFor("ws_env", { sub: "usr_env", email: "ci@example.com", @@ -343,7 +363,7 @@ describe("auth whoami", () => { }); }); -describe("sessions held with none current", () => { +describe("sessions held with none selected", () => { const needsCredentials = defineCommand({ help: { summary: "Requires a signed-in session" }, needs: { credentials: true }, @@ -369,7 +389,7 @@ describe("sessions held with none current", () => { }, }); - it("raises one identical error from ctx.session, the needs check, and a bare ctx.api touch", async () => { + it("raises one identical error from ctx.activeCredential, the needs check, and a bare ctx.api touch", async () => { const cli = createTestCli({ commands: { "auth whoami": authWhoamiCommand, @@ -402,7 +422,7 @@ describe("auth workspace list", () => { it("lists the sessions with the current one marked, nameless rows by id", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc"), record("ws_2", undefined)], - currentWorkspaceId: "ws_2", + selectedWorkspaceId: "ws_2", }); const result = await cli.run(["auth", "workspace", "list"], { @@ -416,7 +436,7 @@ describe("auth workspace list", () => { it("serializes the sessions and the current marker for json", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", }); const result = await cli.run(["auth", "workspace", "list", "--json"]); @@ -441,7 +461,7 @@ describe("auth workspace list", () => { it("states that the env session is in force", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", environmentToken: tokenFor("ws_env"), }); @@ -470,7 +490,7 @@ describe("auth workspace use", () => { const twoSessions = [record("ws_1", "Acme Inc"), record("ws_2", "Globex")]; it("selects by workspace id", async () => { - const cli = makeCli({ sessions: twoSessions, currentWorkspaceId: "ws_1" }); + const cli = makeCli({ sessions: twoSessions, selectedWorkspaceId: "ws_1" }); const result = await cli.run([ "auth", @@ -485,11 +505,11 @@ describe("auth workspace use", () => { workspace: { id: "ws_2", name: "Globex" }, previousWorkspaceId: "ws_1", }); - expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_2"); + expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); }); it("selects by workspace name, case-insensitively", async () => { - const cli = makeCli({ sessions: twoSessions, currentWorkspaceId: "ws_1" }); + const cli = makeCli({ sessions: twoSessions, selectedWorkspaceId: "ws_1" }); const result = await cli.run([ "auth", @@ -500,13 +520,13 @@ describe("auth workspace use", () => { ]); expect(result.exitCode).toBe(0); - expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_2"); + expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); }); it("refuses an ambiguous name, listing the workspaces that matched", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc"), record("ws_9", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", }); const result = await cli.run([ @@ -522,7 +542,7 @@ describe("auth workspace use", () => { code: "AUTH.WORKSPACE_AMBIGUOUS", meta: { workspaceIds: ["ws_1", "ws_9"] }, }); - expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_1"); + expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_1"); }); it("never opens a browser for a workspace it has no session for", async () => { @@ -552,24 +572,23 @@ describe("auth workspace use", () => { expect(openUrl).not.toHaveBeenCalled(); }); - it("refuses to switch under an env override", async () => { + /** Design §11.7 and §11.10 test 8. */ + it("switches under an env override and says the env token stays in force", async () => { const cli = makeCli({ sessions: twoSessions, - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", environmentToken: tokenFor("ws_env"), }); - const result = await cli.run([ - "auth", - "workspace", - "use", - "ws_2", - "--json", - ]); + const result = await cli.run(["auth", "workspace", "use", "ws_2"], { + isTty: { stdout: true }, + }); - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("AUTH.ENV_SESSION_IN_FORCE"); - expect(cli.credentialManager?.state().currentWorkspaceId).toBe("ws_1"); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "PRISMA_SERVICE_TOKEN supplies the session in force", + ); + expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); }); it("reports having nothing to select when no sessions are held", async () => { @@ -590,7 +609,7 @@ describe("auth workspace logout", () => { it("ends the named session and prints the workspace it ended", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc"), record("ws_2", "Globex")], - currentWorkspaceId: "ws_2", + selectedWorkspaceId: "ws_2", }); const result = await cli.run([ @@ -614,7 +633,7 @@ describe("auth workspace logout", () => { it("clears the current marker when the ended session was current", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", }); const result = await cli.run([ @@ -628,7 +647,7 @@ describe("auth workspace logout", () => { expect(resultOf(result)).toMatchObject({ wasCurrent: true }); expect(cli.credentialManager?.state()).toEqual({ sessions: [], - currentWorkspaceId: null, + selectedWorkspaceId: undefined, }); }); @@ -645,13 +664,44 @@ describe("auth workspace logout", () => { expect(errorOf(result).code).toBe("AUTH.NO_SESSION_FOR_WORKSPACE"); }); - it("refuses under an env override", async () => { + /** Design §11.7 and §11.10 test 8. */ + it("ends the session under an env override and says the env token stays in force", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", environmentToken: tokenFor("ws_env"), }); + const result = await cli.run(["auth", "workspace", "logout", "ws_1"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "PRISMA_SERVICE_TOKEN supplies the session in force", + ); + expect(cli.credentialManager?.state().sessions).toEqual([]); + }); + + /** Design §11.10, test 6: the ref resolves, then another process + * removes the session before the write lands. Removal is idempotent, + * so the command still exits 0 rather than saying something untrue. */ + it("exits 0 when another process removed the session mid-command", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc")], + selectedWorkspaceId: "ws_1", + }); + const manager = cli.credentialManager; + const readSessions = manager.sessions.bind(manager); + vi.spyOn(manager, "sessions").mockImplementation(async () => { + const stored = await readSessions(); + manager.overwriteStoredState({ + sessions: [], + selectedWorkspaceId: undefined, + }); + return stored; + }); + const result = await cli.run([ "auth", "workspace", @@ -660,13 +710,16 @@ describe("auth workspace logout", () => { "--json", ]); - expect(result.exitCode).toBe(2); - expect(errorOf(result).code).toBe("AUTH.ENV_SESSION_IN_FORCE"); - expect(cli.credentialManager?.state().sessions).toHaveLength(1); + expect(result.exitCode).toBe(0); + expect(resultOf(result)).toEqual({ + workspace: { id: "ws_1", name: "Acme Inc" }, + wasCurrent: true, + }); + expect(manager.state().sessions).toEqual([]); }); }); -describe("the env session never refreshes", () => { +describe("the environment credential carries no refresh token", () => { let server: Server | undefined; const paths: string[] = []; @@ -702,7 +755,9 @@ describe("the env session never refreshes", () => { return createTestCli({ commands: { ...COMMANDS, probe: touchesApi }, groups: GROUPS, - environmentToken: tokenFor("ws_env", { sub: "usr_env" }), + environmentCredential: environmentCredentialFor( + tokenFor("ws_env", { sub: "usr_env" }), + ), managementApiClientConfig: { clientId: "test-client-id", redirectUri: `${baseUrl}/auth/callback`, @@ -723,7 +778,10 @@ describe("the env session never refreshes", () => { expect(paths).toEqual(["/v1/me"]); }); - it("gives whoami the env token's own claims without any request", async () => { + /** §11.6: whoami does not branch on origin — it attempts the same + * online enrichment for an environment credential, and falls back to + * the token's own claims when the request fails. */ + it("falls back to the env token's own claims when the enrichment is rejected", async () => { const cli = await cliAgainstA401Server(); const result = await cli.run(["auth", "whoami", "--json"]); @@ -731,9 +789,9 @@ describe("the env session never refreshes", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toMatchObject({ source: "environment", - user: { id: "usr_env" }, + user: { id: "usr_env", email: null }, }); - expect(paths).toEqual([]); + expect(paths).toEqual(["/v1/me"]); }); }); @@ -745,7 +803,7 @@ describe("a blank service token is never an override", () => { it(`fails auth workspace list with the blank-token error (${name})`, async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", }); const result = await cli.run(["auth", "workspace", "list", "--json"], { @@ -774,12 +832,12 @@ describe("a blank service token is never an override", () => { } }); -describe("session shapes the commands hand back", () => { +describe("the shapes the commands hand back", () => { it("never lets token material reach the output", async () => { const secret = "refresh_ws_1"; const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], - currentWorkspaceId: "ws_1", + selectedWorkspaceId: "ws_1", }); const runs = [ @@ -793,14 +851,24 @@ describe("session shapes the commands hand back", () => { } }); - it("exposes no token on the Session shape the commands see", () => { + it("exposes no token on the shapes the commands see", () => { const session: Session = { workspaceId: "ws_1", workspaceName: "Acme Inc", expiresAt: undefined, - source: "stored", - current: true, + }; + const active: ActiveCredential = { + workspaceId: "ws_1", + workspaceName: "Acme Inc", + expiresAt: undefined, + identity: { + userId: "usr_456", + email: "bob@example.com", + name: undefined, + }, + origin: { source: "stored" }, }; expect(Object.keys(session)).not.toContain("token"); + expect(Object.keys(active)).not.toContain("token"); }); }); diff --git a/packages/cli/tests/v8-bin.test.ts b/packages/cli/tests/v8-bin.test.ts index 45ca6ce6..19ffaac0 100644 --- a/packages/cli/tests/v8-bin.test.ts +++ b/packages/cli/tests/v8-bin.test.ts @@ -1,10 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { - CLIENT_ID, - DEFAULT_REDIRECT_URI, - makeGetCredentials, -} from "../src/auth"; +import { CLIENT_ID, DEFAULT_REDIRECT_URI } from "../src/auth"; import { buildCli } from "../src/v8/cli"; import { main } from "../src/v8/main"; import { @@ -14,18 +10,6 @@ import { makeOnSignal, } from "../src/v8/runtime"; -vi.mock("../src/auth/token-storage", async (importOriginal) => ({ - ...(await importOriginal()), - FileTokenStorage: class { - getTokens() { - return Promise.resolve({ - accessToken: "stored_token", - workspaceId: "ws_1", - }); - } - }, -})); - function makeProcess(overrides?: { argv?: string[]; env?: NodeJS.ProcessEnv; @@ -140,32 +124,6 @@ describe("makeOnSignal", () => { }); }); -describe("makeGetCredentials", () => { - it("prefers a non-empty PRISMA_SERVICE_TOKEN over stored tokens", async () => { - const getCredentials = makeGetCredentials({ - PRISMA_SERVICE_TOKEN: " svc_token ", - } as NodeJS.ProcessEnv); - - expect(await getCredentials()).toEqual({ token: "svc_token" }); - }); - - it("reads the stored token when no service token is set", async () => { - const getCredentials = makeGetCredentials({} as NodeJS.ProcessEnv); - - expect(await getCredentials()).toEqual({ token: "stored_token" }); - }); - - it("fails when PRISMA_SERVICE_TOKEN is set but blank instead of falling back to stored tokens", async () => { - const getCredentials = makeGetCredentials({ - PRISMA_SERVICE_TOKEN: " ", - } as NodeJS.ProcessEnv); - - await expect(getCredentials()).rejects.toThrow( - "PRISMA_SERVICE_TOKEN is set but empty", - ); - }); -}); - describe("assembleRuntime", () => { it("assembles the runtime from the process-like host", async () => { const proc = makeProcess({ diff --git a/packages/cli/tests/v8-golden-rendering.test.ts b/packages/cli/tests/v8-golden-rendering.test.ts index 2359e40b..260d53cf 100644 --- a/packages/cli/tests/v8-golden-rendering.test.ts +++ b/packages/cli/tests/v8-golden-rendering.test.ts @@ -42,7 +42,7 @@ function makeCli(sessions: readonly SessionRecord[], current?: string) { "auth workspace": { brief: "Manage local workspace sessions" }, }, sessions, - currentWorkspaceId: current, + selectedWorkspaceId: current, now: () => new Date(0), }); } diff --git a/packages/cli/tests/v8-whoami.test.ts b/packages/cli/tests/v8-whoami.test.ts index 5b3fe1b9..75ef849e 100644 --- a/packages/cli/tests/v8-whoami.test.ts +++ b/packages/cli/tests/v8-whoami.test.ts @@ -56,7 +56,12 @@ const requiresCredentials = defineCommand({ function makeCli(options?: { readonly sessions?: readonly SessionRecord[]; - readonly currentWorkspaceId?: string; + readonly selectedWorkspaceId?: string; + readonly environmentCredential?: { + readonly token: string; + readonly refreshToken: string | undefined; + readonly expiresAt: Date | undefined; + }; readonly client?: ManagementApiClient; }) { return createTestCli({ @@ -66,7 +71,8 @@ function makeCli(options?: { }, groups: { auth: { brief: "Manage local authentication for the CLI" } }, sessions: options?.sessions ?? [], - currentWorkspaceId: options?.currentWorkspaceId, + selectedWorkspaceId: options?.selectedWorkspaceId, + environmentCredential: options?.environmentCredential, managementApi: { client: options?.client ?? OFFLINE_API }, now: EPOCH, }); @@ -75,7 +81,7 @@ function makeCli(options?: { function signedInCli() { return makeCli({ sessions: [SESSION], - currentWorkspaceId: "ws_123", + selectedWorkspaceId: "ws_123", client: IDENTIFIED_API, }); } @@ -152,6 +158,66 @@ describe("prisma-v8 auth whoami", () => { }); }); + /** Design §11.10, test 7: an environment token whose claims name no + * workspace has no workspace row and a null JSON workspace — never + * an empty string and never the literal "undefined". */ + it("omits the workspace entirely for a claimless environment token", async () => { + const cli = makeCli({ + environmentCredential: { + token: mintTestJwt({ sub: "usr_env" }), + refreshToken: undefined, + expiresAt: undefined, + }, + }); + + const human = await cli.run(["auth", "whoami"], { + isTty: { stdout: true }, + }); + + expect(human.exitCode).toBe(0); + expect(human.stdout).toBe( + "status: signed in\nsource: PRISMA_SERVICE_TOKEN\n", + ); + expect(human.stderr).not.toContain("workspace:"); + expect(human.stderr).not.toContain("undefined"); + + const json = await cli.run(["auth", "whoami", "--json"]); + const frame = json.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: true, + result: { + authenticated: true, + workspace: null, + user: { id: "usr_env", email: null, name: null }, + source: "environment", + }, + }); + expect(json.stdout).not.toContain('""'); + expect(json.stdout).not.toContain("undefined"); + }); + + it("falls back to the stored credential's own claims when /v1/me is unreachable", async () => { + const result = await makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + }).run(["auth", "whoami", "--json"]); + + expect(result.exitCode).toBe(0); + const frame = result.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + result: { + user: { id: "usr_456", email: null, name: null }, + source: "stored", + }, + }); + }); + it("renders the unchanged presentation under --quiet (a log-level alias)", async () => { const result = await signedInCli().run(["auth", "whoami", "--quiet"], { isTty: { stdout: true }, From fec6678f5c55679d1bca886a3f5eec24ea517529 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:10:56 +0200 Subject: [PATCH 60/67] fix(cli): close the stale-lock takeover race the Windows runner exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming the corpse aside instead of unlinking it narrowed the window but did not close it. Two waiters both see the lock as stale; the first renames it away and creates its own; the second then renames away THAT lock, and both believe they hold it. Windows produced the ordering naturally and macOS did not, which is how it reached CI green. Rename cannot be made conditional, so the takeover now confirms afterwards that what it moved aside is the corpse it examined, by comparing mtime. If it is not, the lock belongs to whoever created it and goes back — via `link`, which fails when the path is occupied, so restoring can never overwrite a third process's lock. The test forces the interleaving rather than leaving it to the scheduler: one hook holds both waiters until each has seen the lock as stale, a second holds the loser's removal until the winner's lock actually exists, signalled off the create rather than polled. Six runs pass with the fix and six fail without it, where before it passed either way on macOS. The assertion is "at most one takeover", because a winner that releases before the loser looks leaves no corpse and zero is also correct; two is the defect. Also guards the two 0600 assertions behind a POSIX check. Windows has no Unix permission bits and reports 0o666 whatever the file was created with, so those assertions could only ever fail there. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/auth/state-file.ts | 22 +++++- packages/cli/tests/credential-manager.test.ts | 70 +++++++++++++++++-- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/auth/state-file.ts b/packages/cli/src/auth/state-file.ts index aede3ece..584e4b01 100644 --- a/packages/cli/src/auth/state-file.ts +++ b/packages/cli/src/auth/state-file.ts @@ -288,9 +288,9 @@ async function takeOverStaleStateLock( lockPath: string, debug: DebugLog, ): Promise { - const stats = await fs.stat(lockPath).catch(() => null); - if (!stats) return true; - if (Date.now() - stats.mtimeMs <= LOCK_STALE_MS) return false; + const stale = await fs.stat(lockPath).catch(() => null); + if (stale === null) return true; + if (Date.now() - stale.mtimeMs <= LOCK_STALE_MS) return false; const takenPath = `${lockPath}.${randomUUID()}.stale`; try { @@ -300,6 +300,22 @@ async function takeOverStaleStateLock( // rather than reporting a takeover that did not happen. return false; } + + // Rename cannot be made conditional, so confirm afterwards that what + // we moved aside is the corpse we examined. Two waiters release + // together, and the slower one would otherwise rename away the lock + // the faster one had just created — both would then believe they + // held it and one mutation would be lost. + const taken = await fs.stat(takenPath).catch(() => null); + if (taken !== null && taken.mtimeMs !== stale.mtimeMs) { + // Whoever created this owns it. `link` fails when the path is + // occupied, so putting it back can never overwrite a third + // process's lock. + await fs.link(takenPath, lockPath).catch(() => {}); + await fs.unlink(takenPath).catch(() => {}); + return false; + } + await fs.unlink(takenPath).catch(() => {}); debug(`lock taken over from a crashed holder ${lockPath}`); return true; diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index f143d900..99a63d82 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -22,6 +22,15 @@ import { FileCredentialManager } from "../src/auth/credential-manager"; import { readCredentialState } from "../src/auth/state-file"; import { getAuthContextFilePath } from "../src/auth/token-storage"; +/** Windows has no Unix permission bits — `stat` reports 0o666 whatever + * the file was created with — so the mode assertions only mean + * something on a POSIX filesystem. */ +const POSIX_MODES = process.platform !== "win32"; + +function expectOwnerOnly(mode: number): void { + if (POSIX_MODES) expect(mode & 0o777).toBe(0o600); +} + function escapeForRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -125,7 +134,7 @@ describe("the state file", () => { currentWorkspaceId: WORKSPACE_A, sessions: [{ workspaceId: WORKSPACE_A, refreshToken: "refresh-1" }], }); - expect((await stat(stateFilePath)).mode & 0o777).toBe(0o600); + expectOwnerOnly((await stat(stateFilePath)).mode); }); it("tightens permissions looser than 0600", async () => { @@ -134,7 +143,7 @@ describe("the state file", () => { }); await makeManager().createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); - expect((await stat(stateFilePath)).mode & 0o777).toBe(0o600); + expectOwnerOnly((await stat(stateFilePath)).mode); }); it("reads never write", async () => { @@ -292,10 +301,13 @@ describe("the state file", () => { const longAgo = new Date(Date.now() - 60_000); await utimes(lockPath, longAgo, longAgo); - // Hold both waiters until each has seen the lock as stale. That is - // the interleaving the takeover has to survive: if clearing it is - // not atomic, the second waiter deletes the first waiter's fresh - // lock and both run their read-modify-write at once. + // Two hooks reproduce the worst interleaving deterministically, + // rather than leaving it to the scheduler. Windows produced it + // naturally and macOS did not, which is exactly the kind of race + // that regresses unnoticed on one platform. + // + // First: hold both waiters until each has seen the lock as stale, + // so both believe they may clear it. const bothSawItStale = barrierFor(2); const realStat = fsPromises.stat.bind(fsPromises); const stats = vi @@ -308,6 +320,45 @@ describe("the state file", () => { }, ); + // Second: hold the loser's removal until the winner has created its + // fresh lock. The loser is then about to remove a lock that is not + // the corpse it examined, which is the case the takeover has to + // detect. The winner's create is the signal, so watch for it + // directly rather than polling the filesystem. + let announceFreshLock: () => void = () => {}; + const freshLockExists = new Promise((resolve) => { + announceFreshLock = resolve; + }); + const realOpen = fsPromises.open.bind(fsPromises); + const opens = vi + .spyOn(fsPromises, "open") + .mockImplementation( + async (...args: Parameters) => { + const handle = await realOpen(...args); + if (String(args[0]).endsWith(".lock")) announceFreshLock(); + return handle; + }, + ); + + let removals = 0; + const realRename = fsPromises.rename.bind(fsPromises); + const renames = vi + .spyOn(fsPromises, "rename") + .mockImplementation(async (from, to) => { + if (String(to).endsWith(".stale")) { + removals += 1; + if (removals === 2) { + // Bounded: if the winner released before the loser looked, + // there is no fresh lock to wait for and no race to force. + await Promise.race([ + freshLockExists, + new Promise((resolve) => setTimeout(resolve, 250)), + ]); + } + } + return realRename(from, to); + }); + const debugLines: string[] = []; try { await Promise.all( @@ -320,10 +371,15 @@ describe("the state file", () => { ); } finally { stats.mockRestore(); + renames.mockRestore(); + opens.mockRestore(); } + // At most one: if the winner released before the loser looked, + // there was no corpse left to clear and zero is also correct. Two + // is the defect — both would have entered the critical section. const takeovers = debugLines.filter((line) => line.includes("taken over")); - expect(takeovers).toHaveLength(1); + expect(takeovers.length).toBeLessThanOrEqual(1); const state = await readCredentialState(stateFilePath); expect( [...state.sessions.map((session) => session.workspaceId)].sort(), From 06723a2fb3bb0c7b0c6af87c9d2c950bb025fe75 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:15:37 +0200 Subject: [PATCH 61/67] fix(cli): stop reporting a service token's workspace as its user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real service token's subject is `workspace:`, not a person. `claimedIdentity` read `sub` and called it a user id unconditionally, twenty lines below a function that existed precisely because the subject can name a workspace. So `PRISMA_SERVICE_TOKEN= prisma auth whoami --json` emitted `"user": {"id": "workspace:ws_abc"}` whenever /v1/me returned no user, which is the likely answer for a machine credential. The human card hid it, because it only prints a user row when there is an email. The reason no test caught it is the second half of this change. The four claim helpers were duplicated across the CLI's `claims.ts` and the engine's in-memory manager, and the copies had drifted: production knew about the `workspace:` subject and the harness did not, so a service token that works in production could not be represented in a test at all, and the harness reported no workspace where production reported one. There is now one implementation in the engine that both managers use, and the CLI keeps its existing names by delegating to it. The engine's public surface grows by the four helpers the CLI consumes. The raw decoder stays internal, and `decodeClaims` is dropped from the CLI's barrel — it was re-exported and never used. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/exports/index.ts | 6 ++ .../src/in-memory-credential-manager.ts | 48 ++--------- packages/cli-engine/src/token-claims.ts | 82 +++++++++++++++++++ packages/cli-engine/tests/engine.test.ts | 4 + packages/cli/src/auth/claims.ts | 72 +++------------- packages/cli/src/auth/index.ts | 2 +- packages/cli/tests/v8-whoami.test.ts | 29 +++++++ 7 files changed, 144 insertions(+), 99 deletions(-) create mode 100644 packages/cli-engine/src/token-claims.ts diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 72a64507..b5a07502 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -102,3 +102,9 @@ export { PRISMA_CONFIG_VERSION, type Runtime, } from "../runtime"; +export { + claimedExpiresAt, + claimedIdentity, + claimedWorkspaceId, + credentialWorkspaceId, +} from "../token-claims"; diff --git a/packages/cli-engine/src/in-memory-credential-manager.ts b/packages/cli-engine/src/in-memory-credential-manager.ts index 5373463f..feac2ca6 100644 --- a/packages/cli-engine/src/in-memory-credential-manager.ts +++ b/packages/cli-engine/src/in-memory-credential-manager.ts @@ -21,6 +21,12 @@ import type { StoredSessions, } from "./credential-manager"; import type { TokenStorage } from "./management-api"; +import { + claimedExpiresAt, + claimedIdentity, + claimedWorkspaceId, + credentialWorkspaceId, +} from "./token-claims"; type Tokens = NonNullable>>; @@ -66,43 +72,6 @@ export function mintTestJwt(claims: Readonly>): string { return `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.test-signature`; } -function decodeJwtClaims(token: string): Record | undefined { - const parts = token.split("."); - if (parts.length < 2) { - return undefined; - } - try { - const parsed: unknown = JSON.parse( - Buffer.from(parts[1], "base64url").toString("utf8"), - ); - return typeof parsed === "object" && parsed !== null - ? (parsed as Record) - : undefined; - } catch { - return undefined; - } -} - -function claimedWorkspaceId(token: string): string | undefined { - const claims = decodeJwtClaims(token); - const workspaceId = claims?.workspace_id; - return typeof workspaceId === "string" ? workspaceId : undefined; -} - -function claimedExpiresAt(token: string): Date | undefined { - const exp = decodeJwtClaims(token)?.exp; - return typeof exp === "number" ? new Date(exp * 1000) : undefined; -} - -function claimedIdentity(token: string): CredentialIdentity | undefined { - const claims = decodeJwtClaims(token); - const userId = typeof claims?.sub === "string" ? claims.sub : undefined; - const email = typeof claims?.email === "string" ? claims.email : undefined; - return userId === undefined && email === undefined - ? undefined - : { userId, email, name: undefined }; -} - function asSession(record: SessionRecord): Session { return { workspaceId: record.workspaceId, @@ -123,7 +92,7 @@ function storedActiveCredential(record: SessionRecord): ActiveCredential { function environmentActiveCredential(credential: Credential): ActiveCredential { return { - workspaceId: claimedWorkspaceId(credential.token), + workspaceId: credentialWorkspaceId(credential.token), workspaceName: undefined, expiresAt: claimedExpiresAt(credential.token) ?? credential.expiresAt, identity: claimedIdentity(credential.token), @@ -140,7 +109,8 @@ function environmentActiveCredential(credential: Credential): ActiveCredential { */ function memoryBackedStorage(credential: Credential): TokenStorage { let tokens: Tokens | null = { - workspaceId: claimedWorkspaceId(credential.token) ?? NO_WORKSPACE_CLAIMED, + workspaceId: + credentialWorkspaceId(credential.token) ?? NO_WORKSPACE_CLAIMED, accessToken: credential.token, refreshToken: credential.refreshToken, }; diff --git a/packages/cli-engine/src/token-claims.ts b/packages/cli-engine/src/token-claims.ts new file mode 100644 index 00000000..12deb8dd --- /dev/null +++ b/packages/cli-engine/src/token-claims.ts @@ -0,0 +1,82 @@ +/** + * Reading a credential's own claims. This lives in the engine and is + * shared rather than reimplemented, because both credential managers + * need it and the two copies had already drifted: one knew a service + * token names its workspace through `sub`, the other did not, so a + * token that worked in production could not be reproduced in a test. + * + * Decoding only. Nothing here verifies a signature — the claims are the + * issuer's word, used for display and for keying, never for authorizing. + */ +import { Buffer } from "node:buffer"; +import type { CredentialIdentity } from "./credential-manager"; + +/** A service token's subject is its workspace, not a person. */ +const WORKSPACE_SUBJECT_PREFIX = "workspace:"; + +export function decodeTokenClaims( + token: string, +): Record | undefined { + const payload = token.split(".")[1]; + if (payload === undefined) return undefined; + try { + const parsed: unknown = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function claimedString( + claims: Record | undefined, + name: string, +): string | undefined { + const value = claims?.[name]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** The `workspace_id` claim, which is what sessions are keyed by. */ +export function claimedWorkspaceId(token: string): string | undefined { + return claimedString(decodeTokenClaims(token), "workspace_id"); +} + +/** The workspace a credential names: its `workspace_id` claim, or the + * workspace its subject names when it is a service token. */ +export function credentialWorkspaceId(token: string): string | undefined { + const claims = decodeTokenClaims(token); + const fromClaim = claimedString(claims, "workspace_id"); + if (fromClaim !== undefined) return fromClaim; + + const subject = claimedString(claims, "sub"); + if (subject === undefined || !subject.startsWith(WORKSPACE_SUBJECT_PREFIX)) { + return undefined; + } + const derived = subject.slice(WORKSPACE_SUBJECT_PREFIX.length).trim(); + return derived.length > 0 ? derived : undefined; +} + +export function claimedExpiresAt(token: string): Date | undefined { + const exp = decodeTokenClaims(token)?.exp; + return typeof exp === "number" ? new Date(exp * 1000) : undefined; +} + +/** + * Who a credential belongs to, from its own claims. A service token's + * subject names a workspace rather than a person, so it yields no user + * — reporting `workspace:ws_1` as a user id was a real defect. + */ +export function claimedIdentity(token: string): CredentialIdentity | undefined { + const claims = decodeTokenClaims(token); + const subject = claimedString(claims, "sub"); + const userId = subject?.startsWith(WORKSPACE_SUBJECT_PREFIX) + ? undefined + : subject; + const email = claimedString(claims, "email"); + return userId === undefined && email === undefined + ? undefined + : { userId, email, name: undefined }; +} diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 117d6fc2..5639bfcd 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -18,8 +18,12 @@ describe("main export", () => { "PRESENTED", "PRISMA_CONFIG_VERSION", "authServiceError", + "claimedExpiresAt", + "claimedIdentity", + "claimedWorkspaceId", "createCli", "credentialRejectedError", + "credentialWorkspaceId", "credentialsRequiredError", "defineCommand", "defineCommandFamily", diff --git a/packages/cli/src/auth/claims.ts b/packages/cli/src/auth/claims.ts index 7b38fb7a..9c7d3484 100644 --- a/packages/cli/src/auth/claims.ts +++ b/packages/cli/src/auth/claims.ts @@ -1,59 +1,13 @@ -import { Buffer } from "node:buffer"; -import type { CredentialIdentity } from "@prisma/cli-engine"; - -const WORKSPACE_SUB_PREFIX = "workspace:"; - -export function decodeClaims( - token: string, -): Record | undefined { - const payload = token.split(".")[1]; - if (payload === undefined) return undefined; - try { - const parsed: unknown = JSON.parse( - Buffer.from(payload, "base64url").toString("utf8"), - ); - return typeof parsed === "object" && parsed !== null - ? (parsed as Record) - : undefined; - } catch { - return undefined; - } -} - -/** The `workspace_id` claim, which is what sessions are keyed by. */ -export function claimedWorkspaceId(token: string): string | undefined { - const workspaceId = decodeClaims(token)?.workspace_id; - return typeof workspaceId === "string" && workspaceId.length > 0 - ? workspaceId - : undefined; -} - -/** The workspace a service token names, for display only: its - * `workspace_id` claim, or the workspace its `sub` names. */ -export function serviceTokenWorkspaceId(token: string): string | undefined { - const fromWorkspaceClaim = claimedWorkspaceId(token); - if (fromWorkspaceClaim !== undefined) return fromWorkspaceClaim; - - const sub = decodeClaims(token)?.sub; - if (typeof sub !== "string" || !sub.startsWith(WORKSPACE_SUB_PREFIX)) { - return undefined; - } - const derived = sub.slice(WORKSPACE_SUB_PREFIX.length).trim(); - return derived.length > 0 ? derived : undefined; -} - -export function claimedExpiresAt(token: string): Date | undefined { - const exp = decodeClaims(token)?.exp; - return typeof exp === "number" ? new Date(exp * 1000) : undefined; -} - -/** Who a credential belongs to, from its own claims. The manager - * decodes it so no command ever holds a token to decode. */ -export function claimedIdentity(token: string): CredentialIdentity | undefined { - const claims = decodeClaims(token); - const userId = typeof claims?.sub === "string" ? claims.sub : undefined; - const email = typeof claims?.email === "string" ? claims.email : undefined; - return userId === undefined && email === undefined - ? undefined - : { userId, email, name: undefined }; -} +/** + * The credential's own claims. The implementation lives in the engine + * so both credential managers read a token the same way; these are the + * names the CLI already uses. + */ +export { + claimedExpiresAt, + claimedIdentity, + claimedWorkspaceId, + /** The workspace a service token names, whether through its + * `workspace_id` claim or its `workspace:`-prefixed subject. */ + credentialWorkspaceId as serviceTokenWorkspaceId, +} from "@prisma/cli-engine"; diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 0d34fe85..94c4b42e 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -1,4 +1,4 @@ -export { claimedWorkspaceId, decodeClaims } from "./claims"; +export { claimedWorkspaceId } from "./claims"; export { CLIENT_ID, DEFAULT_REDIRECT_URI, diff --git a/packages/cli/tests/v8-whoami.test.ts b/packages/cli/tests/v8-whoami.test.ts index 75ef849e..69a44f2d 100644 --- a/packages/cli/tests/v8-whoami.test.ts +++ b/packages/cli/tests/v8-whoami.test.ts @@ -199,6 +199,35 @@ describe("prisma-v8 auth whoami", () => { expect(json.stdout).not.toContain("undefined"); }); + /** A real service token's subject is its workspace, not a person. + * Reporting `workspace:ws_1` as the user's id would put a workspace + * in the user field of a machine-readable contract. */ + it("reads a service token's workspace subject as a workspace, not a user", async () => { + const cli = makeCli({ + environmentCredential: { + token: mintTestJwt({ sub: "workspace:ws_svc" }), + refreshToken: undefined, + expiresAt: undefined, + }, + }); + + const json = await cli.run(["auth", "whoami", "--json"]); + const frame = json.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: true, + result: { + authenticated: true, + workspace: { id: "ws_svc" }, + user: null, + source: "environment", + }, + }); + expect(json.stdout).not.toContain("workspace:ws_svc"); + }); + it("falls back to the stored credential's own claims when /v1/me is unreachable", async () => { const result = await makeCli({ sessions: [SESSION], From ed8e873a78c7acf313179683d4dce2c28dc0598b Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:17:32 +0200 Subject: [PATCH 62/67] fix(cli): give whoami's identity lookup a deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rev 6 stopped whoami branching on where the credential came from, so it now attempts the /v1/me enrichment for an environment credential too, where rev 5's path was local and instant. Nothing bounded that request: ctx.signal only fires on Ctrl-C, so a host that accepts the connection and never answers would hold the command for as long as the runtime's own timeouts allow — minutes behind a black-holing proxy, which is exactly the setting a service token gets used in. The enrichment now carries its own three-second deadline alongside ctx.signal. A cancellation still propagates, because the catch rethrows on the original signal; a timeout falls through to the claims, which is what whoami answers from anyway. The test points it at a server that accepts and never responds, and it completes in about three seconds with the claims intact. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/v8/auth/whoami.ts | 12 ++++++++- packages/cli/tests/v8-auth.test.ts | 43 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index 47df955e..f2f49c54 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -35,14 +35,24 @@ export interface WhoamiResult { readonly expiresAt: string | null; } +/** whoami answers from the credential's own claims, so the lookup is + * worth a moment and no more. ctx.signal only fires on Ctrl-C, and + * nothing else bounds a request: a host that accepts the connection + * and never answers would otherwise hold the command for minutes. */ +const ENRICHMENT_TIMEOUT_MS = 3_000; + /** Best-effort online enrichment: whoami works offline, so any failure * leaves the identity as whatever the credential's own claims said. */ async function fetchedIdentity( api: ManagementApiClient, signal: AbortSignal, ): Promise { + const bounded = AbortSignal.any([ + signal, + AbortSignal.timeout(ENRICHMENT_TIMEOUT_MS), + ]); try { - const { data } = await api.GET("/v1/me", { signal }); + const { data } = await api.GET("/v1/me", { signal: bounded }); const user = data?.data?.user; if (!user) { return undefined; diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index 5974d857..e22217cb 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -793,6 +793,49 @@ describe("the environment credential carries no refresh token", () => { }); expect(paths).toEqual(["/v1/me"]); }); + + /** A host that accepts the connection and never answers is the case + * ctx.signal cannot cover — it only fires on Ctrl-C. Without its own + * deadline the enrichment would hold whoami for as long as the + * runtime's own timeouts allow, which on a CI runner behind a + * black-holing proxy is minutes. */ + it("gives up on an enrichment that never answers and reports the claims", async () => { + server = createServer((request) => { + paths.push(request.url ?? ""); + // Accept, then never respond. + }); + await new Promise((resolve) => { + server?.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + const baseUrl = `http://127.0.0.1:${port}`; + const cli = createTestCli({ + commands: COMMANDS, + groups: GROUPS, + environmentCredential: environmentCredentialFor( + tokenFor("ws_env", { sub: "usr_env" }), + ), + managementApiClientConfig: { + clientId: "test-client-id", + redirectUri: `${baseUrl}/auth/callback`, + apiBaseUrl: baseUrl, + authBaseUrl: baseUrl, + }, + now: () => new Date(0), + }); + + const startedAt = Date.now(); + const result = await cli.run(["auth", "whoami", "--json"]); + const elapsed = Date.now() - startedAt; + + expect(result.exitCode).toBe(0); + expect(resultOf(result)).toMatchObject({ + source: "environment", + user: { id: "usr_env" }, + }); + expect(paths).toEqual(["/v1/me"]); + expect(elapsed).toBeLessThan(10_000); + }, 20_000); }); describe("a blank service token is never an override", () => { From 8290f60f062b4b63307ba23bf4f93339fc7c65a2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:21:57 +0200 Subject: [PATCH 63/67] fix(cli-engine): make the in-memory manager obey the rules it claims to share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two full implementations of one contract, and every command test in this slice and the next two runs against the in-memory one — so where they disagree, the tests assert behaviour the product does not have. Three disagreements left after the claim helpers were unified. The workspace-mismatch refusal threw a plain harness error where the file-backed manager throws a structured one. The constructor moves to the engine and both raise it, so a test of that refusal now sees what production raises. Same for the rotation-time re-scope check. withRefreshLock was `fn => fn()`. Section 6 requires the hook to serialise within a process, and the file-backed manager runs a real queue, so two concurrent refreshes would pass a harness test and fail in production. The in-memory manager now runs the same queue. The blank-PRISMA_SERVICE_TOKEN refusal genuinely cannot live here — this manager is handed a credential and never reads the variable — so the header now says so instead of claiming parity it does not have. That rule is covered end to end through the real environment in the CLI's tests. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/credential-errors.ts | 25 +++++++++ packages/cli-engine/src/exports/index.ts | 1 + .../src/in-memory-credential-manager.ts | 51 +++++++++++++------ .../tests/credential-manager.test.ts | 4 +- packages/cli-engine/tests/engine.test.ts | 1 + packages/cli/src/auth/credential-manager.ts | 20 +------- 6 files changed, 67 insertions(+), 35 deletions(-) diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts index 64c1234d..0afcbbfe 100644 --- a/packages/cli-engine/src/credential-errors.ts +++ b/packages/cli-engine/src/credential-errors.ts @@ -96,6 +96,31 @@ export function credentialRejectedError( : credentialsRequiredError("expired"); } +/** + * A credential's workspace_id claim disagrees with the workspace it is + * being stored under, or a rotated token would re-scope a session. + * Raised by every CredentialManager, so a test sees what production + * raises. + */ +export function credentialWorkspaceMismatchError( + workspaceId: string, +): CliStructuredError { + return new CliStructuredError( + "AUTH.CREDENTIAL_WORKSPACE_MISMATCH", + "That credential belongs to a different workspace.", + { + why: `The token's workspace_id claim does not name workspace '${workspaceId}'.`, + nextActions: [ + { + kind: "run-command", + label: "Sign in again and pick the workspace you want", + command: "prisma auth login", + }, + ], + }, + ); +} + /** * The env var that supplies a session is set to a blank value. The one * structured error for it, raised identically by currentSession(), the diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index b5a07502..294d3d88 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -59,6 +59,7 @@ export { type CredentialsRequiredReason, credentialRejectedError, credentialsRequiredError, + credentialWorkspaceMismatchError, emptyServiceTokenError, noSessionForWorkspaceError, serviceTokenRejectedError, diff --git a/packages/cli-engine/src/in-memory-credential-manager.ts b/packages/cli-engine/src/in-memory-credential-manager.ts index feac2ca6..e689f98d 100644 --- a/packages/cli-engine/src/in-memory-credential-manager.ts +++ b/packages/cli-engine/src/in-memory-credential-manager.ts @@ -1,15 +1,22 @@ /** * A complete CredentialManager whose state lives in memory rather than * in a file. It implements the same rules as the file-backed one — the - * pinned decision, upsert by workspace, idempotent removal, and the two - * token storages — and adds a seed and a state read-back. Tests are - * what it is mostly used for, which is why it ships from the ./testing - * subpath alongside the JWT minter that produces tokens to seed it, but - * nothing about it is a stub. + * pinned decision, upsert by workspace, idempotent removal, in-process + * single-flight refresh, and the two token storages — and adds a seed + * and a state read-back. Tests are what it is mostly used for, which is + * why it ships from the ./testing subpath alongside the JWT minter that + * produces tokens to seed it, but nothing about it is a stub. + * + * One rule it cannot carry: the blank-PRISMA_SERVICE_TOKEN refusal + * belongs to the file-backed manager, which reads the variable. This + * one is handed a credential, so a blank value has no representation + * here. That rule is covered end to end through the real environment + * in the CLI's own tests. */ import { Buffer } from "node:buffer"; import { credentialsRequiredError, + credentialWorkspaceMismatchError, noSessionForWorkspaceError, } from "./credential-errors"; import type { @@ -107,7 +114,10 @@ function environmentActiveCredential(credential: Credential): ActiveCredential { * credential whose workspace matches a stored session therefore cannot * delete that session. */ -function memoryBackedStorage(credential: Credential): TokenStorage { +function memoryBackedStorage( + credential: Credential, + singleFlight: (fn: () => Promise) => Promise, +): TokenStorage { let tokens: Tokens | null = { workspaceId: credentialWorkspaceId(credential.token) ?? NO_WORKSPACE_CLAIMED, @@ -122,7 +132,7 @@ function memoryBackedStorage(credential: Credential): TokenStorage { clearTokens: async () => { tokens = null; }, - withRefreshLock: (fn) => fn(), + withRefreshLock: (fn) => singleFlight(fn), }; } @@ -143,6 +153,10 @@ type Pin = * own tests. */ export class InMemoryCredentialManager implements CredentialManager { + /** §6 requires the refresh hook to serialise within a process. The + * file-backed manager runs this same queue; a harness that let two + * refreshes through would pass a test production would fail. */ + #refreshLock: Promise = Promise.resolve(); private storedSessions: SessionRecord[]; private selection: string | undefined; private readonly environmentCredential: Credential | undefined; @@ -164,6 +178,15 @@ export class InMemoryCredentialManager implements CredentialManager { } } + #singleFlight(fn: () => Promise): Promise { + const queued = this.#refreshLock.then(fn, fn); + this.#refreshLock = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } + state(): InMemoryCredentialManagerState { return { sessions: [...this.storedSessions], @@ -244,7 +267,9 @@ export class InMemoryCredentialManager implements CredentialManager { private buildActiveStorage(): TokenStorage { const pin = this.pin; if (pin.kind === "environment") { - return memoryBackedStorage(this.requireEnvironmentCredential()); + return memoryBackedStorage(this.requireEnvironmentCredential(), (fn) => + this.#singleFlight(fn), + ); } if (pin.kind === "session") { return this.storedSessionStorage(pin.workspaceId); @@ -281,9 +306,7 @@ export class InMemoryCredentialManager implements CredentialManager { } const claimed = claimedWorkspaceId(tokens.accessToken); if (claimed !== undefined && claimed !== workspaceId) { - throw new Error( - "@prisma/cli-engine/testing: a refreshed token's workspace_id claim disagrees with the pinned workspace — refresh cannot re-scope", - ); + throw credentialWorkspaceMismatchError(workspaceId); } this.storedSessions = this.storedSessions.map((stored) => stored.workspaceId === workspaceId @@ -313,7 +336,7 @@ export class InMemoryCredentialManager implements CredentialManager { } this.removeRecord(workspaceId); }, - withRefreshLock: (fn) => fn(), + withRefreshLock: (fn) => this.#singleFlight(fn), }; } @@ -387,9 +410,7 @@ export class InMemoryCredentialManager implements CredentialManager { ): Session { const claimed = claimedWorkspaceId(credential.token); if (claimed !== undefined && claimed !== workspaceId) { - throw new Error( - "@prisma/cli-engine/testing: createSession's workspaceId argument disagrees with the credential's workspace_id claim", - ); + throw credentialWorkspaceMismatchError(workspaceId); } const existing = this.storedSessions.find( (stored) => stored.workspaceId === workspaceId, diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 260974da..f5de84ae 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -370,7 +370,9 @@ describe("session mutations and state read-back", () => { userCredential({ workspaceId: "workspace-1" }), "workspace-2", ), - ).rejects.toThrow(/disagrees with the credential's workspace_id claim/); + // The same structured error the file-backed manager raises, so a + // test of this refusal sees what production does. + ).rejects.toMatchObject({ code: "AUTH.CREDENTIAL_WORKSPACE_MISMATCH" }); }); test("selectSession switches the selection and refuses a workspace with no session", async () => { diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 5639bfcd..1b9cb11b 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -24,6 +24,7 @@ describe("main export", () => { "createCli", "credentialRejectedError", "credentialWorkspaceId", + "credentialWorkspaceMismatchError", "credentialsRequiredError", "defineCommand", "defineCommandFamily", diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index 2a08754a..6219eea2 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -10,6 +10,7 @@ import type { } from "@prisma/cli-engine"; import { credentialsRequiredError, + credentialWorkspaceMismatchError, noSessionForWorkspaceError, } from "@prisma/cli-engine"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; @@ -66,25 +67,6 @@ type Pin = type ResolvedPin = Exclude; -function credentialWorkspaceMismatchError( - workspaceId: string, -): CliStructuredError { - return new CliStructuredError( - "AUTH.CREDENTIAL_WORKSPACE_MISMATCH", - "That credential belongs to a different workspace.", - { - why: `The token's workspace_id claim does not name workspace '${workspaceId}'.`, - nextActions: [ - { - kind: "run-command", - label: "Sign in again and pick the workspace you want", - command: "prisma auth login", - }, - ], - }, - ); -} - /** * The memory-backed storage, for a credential with no home record: a * free function closing over one local variable. It is never given the From f646194ab66171e8740c2ca48b93639475a68904 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:26:12 +0200 Subject: [PATCH 64/67] refactor(cli): stop calling the environment credential a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision 6 is titled "the environment credential is not a session", and the command layer went on calling it one — in the notice every mutation prints, in the helper that reports it, in two json field names, and in the module that builds whoami's card. The retired model was what the user read. ENVIRONMENT_SESSION_NOTICE becomes ENVIRONMENT_CREDENTIAL_NOTICE and says "supplies the credential in force". environmentSessionInForce becomes environmentCredentialInForce, including the json fields on `auth workspace list`'s context and `auth login`'s result. session-card becomes credential-card, since it builds rows for an ActiveCredential. Also the leftover "current" outside the deliberate exceptions: `wasCurrent` in workspace logout's result becomes `wasSelected`, whoami's title says "active authenticated identity", and `auth logout` says it is clearing your stored workspace sessions rather than "the current CLI session" — it clears every one of them, so the old wording was inaccurate as well as retired. The on-disk currentWorkspaceId, the `auth workspace use` command name, and `auth workspace list`'s json `currentWorkspaceId` and per-item `current` keep the word on purpose: they are contracts, and the divergence document now says so for the renamed field too. The legacy shell's own wording is untouched; it goes in S2d. serviceTokenRejectedError leaves the public exports. Wording that differs by origin belongs in credentialRejectedError alone, and an export was a second door into the environment-specific text. The divergence document described a whoami shape the code does not produce — it claimed `user.userId` and that `user.name` had no successor, where the code emits `user: {id, email, name}` — and said no json result carried the environment flag when two do. Both corrected, and `auth workspace logout`'s result is now described at all. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/s2/parity-divergences.md | 17 +++++----- packages/cli-engine/src/credential-errors.ts | 6 +++- packages/cli-engine/src/exports/index.ts | 1 - packages/cli-engine/tests/engine.test.ts | 1 - packages/cli/src/auth/index.ts | 2 +- packages/cli/src/auth/service-token.ts | 2 +- .../{session-card.ts => credential-card.ts} | 2 +- packages/cli/src/v8/auth/login.ts | 18 +++++------ packages/cli/src/v8/auth/logout.ts | 10 +++--- packages/cli/src/v8/auth/whoami.ts | 8 ++--- packages/cli/src/v8/auth/workspace-list.ts | 14 ++++----- packages/cli/src/v8/auth/workspace-logout.ts | 26 ++++++++-------- packages/cli/src/v8/auth/workspace-use.ts | 12 +++---- .../credential-manager-processes.test.ts | 4 +-- .../helpers/credential-manager-worker.ts | 2 +- packages/cli/tests/v8-auth.test.ts | 31 ++++++++++--------- .../cli/tests/v8-golden-rendering.test.ts | 2 +- packages/cli/tests/v8-whoami.test.ts | 8 ++--- 18 files changed, 87 insertions(+), 79 deletions(-) rename packages/cli/src/v8/auth/{session-card.ts => credential-card.ts} (88%) diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index b32065c5..b2fdebb5 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -46,14 +46,15 @@ The legacy result was `AuthStateResult` (`authenticated`/`provider`/`user`/`work ```json { "authenticated": true, "workspace": { "id": "…", "name": "…" }, - "user": { "userId": "…", "email": "…" }, + "user": { "id": "…", "email": "…", "name": "…" }, "source": "stored", "expiresAt": null } ``` - **`provider` has NO successor.** Nothing in the model records which identity provider minted a credential, so the field is gone rather than renamed. - `credential` is gone: the type/id/name of the credential is not a user-facing concept here. - `source` is new (`"stored"` | `"environment"`) and comes from the credential's origin; `expiresAt` is the credential's expiry. -- **`user.name` has no successor, and `user.id` is now `user.userId`.** There is one identity type for both the claimed and the fetched identity (design §11.6), and it carries a user id and an email only. The human card's `user` row therefore shows the email, or is omitted when there is none. +- `user` keeps `id`, `email` and `name`. There is one identity type for both the claimed and the fetched identity (design §11.6); a token's claims carry an id and an email, and only the online lookup supplies a name, so `name` is null offline. The human card's `user` row shows the email, or is omitted when there is none. +- **A service token reports no user at all.** Its subject names a workspace rather than a person, so `user` is null and the workspace is read from that subject. Reporting `workspace:` as a user id was a defect. - Identity display: the credential manager decodes the credential's own claims, and `/v1/me` is a best-effort online enrichment that wins field by field where it disagrees. whoami does not branch on the origin — it attempts the enrichment for an environment credential too, and falls back to the claims when the request fails. **This restores legacy behaviour that rev 5 had dropped:** a stored session offline now shows the claim-derived user again, where rev 5 showed the workspace and no user. - **A credential nothing names renders no workspace at all.** An environment token whose claims carry no workspace reports `"workspace": null` and omits the workspace row from the human card. It is never an empty string and never the literal `undefined` — rev 5 wrote `workspaceId: ""` in that case. - Signed out still exits 0. @@ -70,12 +71,16 @@ The variable supplies the credential this process authenticates as. It is not a | `auth login` | **succeeds** — a new session is stored and selected | | every read (`whoami`, `workspace list`) | works normally | -Each of the four mutations prints the same one-line notice in human output: the environment token remains in force until the variable is unset. The notice is human-only; no json result gained a field for it, and `auth workspace list`'s `context.environmentSessionInForce` remains the machine-readable signal. +Each of the four mutations prints the same one-line notice in human output: the environment credential remains in force until the variable is unset. The notice itself is human-only. Two json results carry the fact as a field — `auth workspace list`'s `context.environmentCredentialInForce` and `auth login`'s `environmentCredentialInForce` — and those are the machine-readable signal. This is the second change here. Legacy refused workspace switching with `WORKSPACE_SWITCH_UNAVAILABLE` and let `auth logout` clear stored state. Rev 5 of the design refused `workspace use`, `workspace logout` and `auth logout` with `AUTH.ENV_SESSION_IN_FORCE`, carving out an empty store so CI teardowns would not fail. **`AUTH.ENV_SESSION_IN_FORCE` no longer exists**, and neither does the carve-out. The net effect against legacy is that workspace switching now works while the variable is set, where legacy refused it. A blank or whitespace-only `PRISMA_SERVICE_TOKEN` is unchanged: it is never an override, and every command — mutations included — fails with `AUTH.SERVICE_TOKEN_EMPTY`. +### `auth workspace logout` — json shape + +The result is `{ workspace: { id, name }, wasSelected }`. `wasSelected` says whether the session that was removed had been the selected one; when it was, nothing is promoted in its place and the next actions offer `auth workspace list` and `auth workspace use`. + ### Ending a session is idempotent `auth workspace logout ` resolves the ref against the sessions you hold, so a workspace you never had is still `AUTH.NO_SESSION_FOR_WORKSPACE`, exit 2. What changed is the race: if another `prisma` process removes that session between the resolution and the write, the command now exits 0 rather than exit 2 with a message that is no longer true. The postcondition — no session for that workspace — holds either way. Selecting is not idempotent and still refuses a workspace with no session. @@ -83,7 +88,7 @@ A blank or whitespace-only `PRISMA_SERVICE_TOKEN` is unchanged: it is never an o ### `auth workspace list` - Rows are the sessions the manager holds: `name`, `id`, `status`, where status is `current` (legacy: `active`). The legacy `source` column and the `auth source` line are gone — the environment credential never appears as a row. -- While `PRISMA_SERVICE_TOKEN` is set the listing STATES that the environment credential is in force; the stored selection is still shown as current. The json context carries `environmentSessionInForce: true` alongside `currentWorkspaceId`, which keeps naming the stored selection, not the environment credential's workspace. Both json field names keep the word "current" deliberately: they are an output contract, where the code says "selected" (design §11.1). +- While `PRISMA_SERVICE_TOKEN` is set the listing STATES that the environment credential is in force; the stored selection is still shown as current. The json context carries `environmentCredentialInForce: true` alongside `currentWorkspaceId`, which keeps naming the stored selection, not the environment credential's workspace. `currentWorkspaceId` and the per-item `current` keep the word "current" deliberately: they are an output contract, where the code says "selected" (design §11.1). `environmentCredentialInForce` was renamed from `environmentSessionInForce` — the thing it describes is not a session, which is the whole point of §11. - The json shape is new (`context`/`items`/`count` with `workspaceId`/`workspaceName`/`current`/`expiresAt`); the legacy fields `credentialWorkspaceId`, `switchable`, `lastSeenAt` and @@ -144,9 +149,7 @@ on every `whoami`/`list` and wrote them back. Accepted and stated. - The interactive paste-fallback prompt and instruction prose inside `performLogin` still write to the process's own stdin/stderr; unchanged from legacy. -- The json result is `{ workspace: { id, name }, - environmentSessionInForce }` — the workspace the session was created - for, not an auth-state snapshot. +- The json result is `{ workspace: { id, name }, environmentCredentialInForce }` — the workspace the session was created for, not an auth-state snapshot. - Agent-setup tip: legacy suppressed it under `--json`, `--quiet`, CI (unless `--interactive`), and non-TTY stderr. In v8 CI suppression is kept (`ctx.env.CI`); the tip LINE renders only in the human diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts index 0afcbbfe..b11be2e8 100644 --- a/packages/cli-engine/src/credential-errors.ts +++ b/packages/cli-engine/src/credential-errors.ts @@ -173,10 +173,14 @@ export function noSessionForWorkspaceError( } /** + * Module-private on purpose: `credentialRejectedError` is the one place + * wording differs by origin (§11.1), and exporting this would be a + * second door into the environment-specific text that bypasses it. + * * The management API rejected the env-supplied service token (401). * There is no refresh for it and nothing stored is cleared. */ -export function serviceTokenRejectedError(spec: { +function serviceTokenRejectedError(spec: { readonly envVar: string; }): CliStructuredError { return new CliStructuredError( diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 294d3d88..aa19e441 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -62,7 +62,6 @@ export { credentialWorkspaceMismatchError, emptyServiceTokenError, noSessionForWorkspaceError, - serviceTokenRejectedError, } from "../credential-errors"; export type { ActiveCredential, diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 1b9cb11b..a63b8016 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -37,7 +37,6 @@ describe("main export", () => { "loadConfig", "noSessionForWorkspaceError", "positional", - "serviceTokenRejectedError", ]); }); diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 94c4b42e..c36eca57 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -30,8 +30,8 @@ export { resolveRecipientWorkspaceSession, } from "./recipient"; export { + environmentCredentialInForce, environmentServiceToken, - environmentSessionInForce, } from "./service-token"; export { DEPRECATED_STATE_FILE_ENV_VAR, diff --git a/packages/cli/src/auth/service-token.ts b/packages/cli/src/auth/service-token.ts index fd28b4a1..c128d91e 100644 --- a/packages/cli/src/auth/service-token.ts +++ b/packages/cli/src/auth/service-token.ts @@ -19,7 +19,7 @@ export function environmentServiceToken( } /** Whether the env session overrides the stored ones. Blank raises. */ -export function environmentSessionInForce( +export function environmentCredentialInForce( env: Readonly>, ): boolean { return environmentServiceToken(env) !== undefined; diff --git a/packages/cli/src/v8/auth/session-card.ts b/packages/cli/src/v8/auth/credential-card.ts similarity index 88% rename from packages/cli/src/v8/auth/session-card.ts rename to packages/cli/src/v8/auth/credential-card.ts index 2f565c6c..1ad4642c 100644 --- a/packages/cli/src/v8/auth/session-card.ts +++ b/packages/cli/src/v8/auth/credential-card.ts @@ -6,7 +6,7 @@ export interface FieldRow { readonly value: string; } -export const ENVIRONMENT_SESSION_NOTICE = `${SERVICE_TOKEN_ENV_VAR} supplies the session in force; unset it to use your stored workspace sessions.`; +export const ENVIRONMENT_CREDENTIAL_NOTICE = `${SERVICE_TOKEN_ENV_VAR} supplies the credential in force; unset it to use your stored workspace sessions.`; /** The card rows for the active credential, or the signed-out row when * there is none. A credential nothing names — an environment token diff --git a/packages/cli/src/v8/auth/login.ts b/packages/cli/src/v8/auth/login.ts index bd331c8d..4032b901 100644 --- a/packages/cli/src/v8/auth/login.ts +++ b/packages/cli/src/v8/auth/login.ts @@ -10,12 +10,12 @@ import { } from "@prisma/cli-engine/protocol"; import { claimedWorkspaceId, - environmentSessionInForce, + environmentCredentialInForce, performLogin, } from "../../auth"; import { CLI_NAME } from "../../cli-name"; import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; -import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; +import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; import { sessionLabel } from "./session-ref"; const TITLE = "Starting an authenticated CLI session."; @@ -23,7 +23,7 @@ const LOGIN_STEP = "Sign in via your browser"; export interface LoginResult { readonly workspace: { readonly id: string; readonly name: string | null }; - readonly environmentSessionInForce: boolean; + readonly environmentCredentialInForce: boolean; } /** The minted credential names no workspace, so no session can be @@ -73,7 +73,7 @@ function nextActionsFor( function presentationsFor(spec: { readonly session: Session; - readonly environmentSessionInForce: boolean; + readonly environmentCredentialInForce: boolean; readonly agentSetupTipCommand: string | null; }): Presentations { const rows = [ @@ -84,12 +84,12 @@ function presentationsFor(spec: { human: () => [ { kind: "summary", tone: "info", text: TITLE }, { kind: "fields", rows }, - ...(spec.environmentSessionInForce + ...(spec.environmentCredentialInForce ? [ { kind: "summary", tone: "info", - text: ENVIRONMENT_SESSION_NOTICE, + text: ENVIRONMENT_CREDENTIAL_NOTICE, } as const, ] : []), @@ -117,7 +117,7 @@ export const authLoginCommand = defineCommand({ handler: async (_args, ctx) => { // A blank service token is the single blank-token error, raised // before the browser opens rather than after a credential is minted. - const environmentSession = environmentSessionInForce(ctx.env); + const environmentSession = environmentCredentialInForce(ctx.env); ctx.report({ kind: "step-started", step: LOGIN_STEP }); let session: Session; try { @@ -149,14 +149,14 @@ export const authLoginCommand = defineCommand({ id: session.workspaceId, name: session.workspaceName ?? null, }, - environmentSessionInForce: environmentSession, + environmentCredentialInForce: environmentSession, }; return ok( ctx.present( { data: result }, presentationsFor({ session, - environmentSessionInForce: environmentSession, + environmentCredentialInForce: environmentSession, agentSetupTipCommand, }), ), diff --git a/packages/cli/src/v8/auth/logout.ts b/packages/cli/src/v8/auth/logout.ts index 340deb7a..5884c876 100644 --- a/packages/cli/src/v8/auth/logout.ts +++ b/packages/cli/src/v8/auth/logout.ts @@ -1,8 +1,8 @@ import { defineCommand, type Presentations } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; -import { environmentSessionInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; -import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; +import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; const SIGN_IN: NextAction = { kind: "run-command", @@ -29,7 +29,7 @@ function presentationsFor( { kind: "summary", tone: "info", - text: "Clearing the current CLI session.", + text: "Clearing your stored workspace sessions.", }, { kind: "fields", rows }, { kind: "summary", tone: "ok", text: summary }, @@ -38,7 +38,7 @@ function presentationsFor( { kind: "summary", tone: "info", - text: ENVIRONMENT_SESSION_NOTICE, + text: ENVIRONMENT_CREDENTIAL_NOTICE, } as const, ] : []), @@ -64,7 +64,7 @@ export const authLogoutCommand = defineCommand({ return ok( ctx.present( { data: result }, - presentationsFor(result, environmentSessionInForce(ctx.env)), + presentationsFor(result, environmentCredentialInForce(ctx.env)), ), ); }, diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index f2f49c54..78bc7bcd 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -9,10 +9,10 @@ import { type NextAction, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { credentialFieldRows, - ENVIRONMENT_SESSION_NOTICE, -} from "./session-card"; + ENVIRONMENT_CREDENTIAL_NOTICE, +} from "./credential-card"; -const TITLE = "Showing the current authenticated identity."; +const TITLE = "Showing the active authenticated identity."; const SIGN_IN: NextAction = { kind: "run-command", @@ -97,7 +97,7 @@ function presentationsFor(spec: { { kind: "summary", tone: "info", - text: ENVIRONMENT_SESSION_NOTICE, + text: ENVIRONMENT_CREDENTIAL_NOTICE, } as const, ] : []), diff --git a/packages/cli/src/v8/auth/workspace-list.ts b/packages/cli/src/v8/auth/workspace-list.ts index f28aafc9..40d094a6 100644 --- a/packages/cli/src/v8/auth/workspace-list.ts +++ b/packages/cli/src/v8/auth/workspace-list.ts @@ -5,9 +5,9 @@ import { type Session, } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; -import { environmentSessionInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; -import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; +import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; import { sessionLabel } from "./session-ref"; const LOGIN_NEXT_ACTION: NextAction = { @@ -19,13 +19,13 @@ const LOGIN_NEXT_ACTION: NextAction = { export interface WorkspaceListResult { readonly sessions: readonly Session[]; readonly selectedWorkspaceId: string | undefined; - readonly environmentSessionInForce: boolean; + readonly environmentCredentialInForce: boolean; } export function serializeWorkspaceList(result: WorkspaceListResult) { return { context: { - environmentSessionInForce: result.environmentSessionInForce, + environmentCredentialInForce: result.environmentCredentialInForce, currentWorkspaceId: result.selectedWorkspaceId ?? null, }, items: result.sessions.map((session) => ({ @@ -52,12 +52,12 @@ function listPresentations(result: WorkspaceListResult): Presentations { tone: "info", text: "Listing your workspace sessions on this machine.", }, - ...(result.environmentSessionInForce + ...(result.environmentCredentialInForce ? [ { kind: "summary", tone: "info", - text: ENVIRONMENT_SESSION_NOTICE, + text: ENVIRONMENT_CREDENTIAL_NOTICE, } as const, ] : []), @@ -88,7 +88,7 @@ export const authWorkspaceListCommand = defineCommand({ const result: WorkspaceListResult = { sessions: stored.sessions, selectedWorkspaceId: stored.selectedWorkspaceId, - environmentSessionInForce: environmentSessionInForce(ctx.env), + environmentCredentialInForce: environmentCredentialInForce(ctx.env), }; return ok(ctx.present({ data: result }, listPresentations(result))); }, diff --git a/packages/cli/src/v8/auth/workspace-logout.ts b/packages/cli/src/v8/auth/workspace-logout.ts index dd1ce662..e582f72d 100644 --- a/packages/cli/src/v8/auth/workspace-logout.ts +++ b/packages/cli/src/v8/auth/workspace-logout.ts @@ -5,20 +5,20 @@ import { positional, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { environmentSessionInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; -import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; +import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; import { requireSession, sessionLabel } from "./session-ref"; export interface WorkspaceLogoutResult { readonly workspace: { readonly id: string; readonly name: string | null }; - readonly wasCurrent: boolean; + readonly wasSelected: boolean; } function logoutPresentations(spec: { readonly label: string; - readonly wasCurrent: boolean; - readonly environmentSessionInForce: boolean; + readonly wasSelected: boolean; + readonly environmentCredentialInForce: boolean; }): Presentations { const rows = [{ label: "workspace", value: spec.label }]; return { @@ -32,16 +32,16 @@ function logoutPresentations(spec: { { kind: "summary", tone: "ok", - text: spec.wasCurrent + text: spec.wasSelected ? "Ended the current workspace session; no replacement was selected." : "Ended the workspace session.", }, - ...(spec.environmentSessionInForce + ...(spec.environmentCredentialInForce ? [ { kind: "summary", tone: "info", - text: ENVIRONMENT_SESSION_NOTICE, + text: ENVIRONMENT_CREDENTIAL_NOTICE, } as const, ] : []), @@ -53,7 +53,7 @@ function logoutPresentations(spec: { label: "List your workspace sessions", command: `${CLI_NAME} auth workspace list`, }, - ...(spec.wasCurrent + ...(spec.wasSelected ? [ { kind: "run-command", @@ -83,22 +83,22 @@ export const authWorkspaceLogoutCommand = defineCommand({ handler: async (args, ctx) => { const stored = await ctx.credentialManager.sessions(); const session = requireSession(stored.sessions, args.positionals.workspace); - const wasCurrent = session.workspaceId === stored.selectedWorkspaceId; + const wasSelected = session.workspaceId === stored.selectedWorkspaceId; await ctx.credentialManager.endSession(session.workspaceId); const result: WorkspaceLogoutResult = { workspace: { id: session.workspaceId, name: session.workspaceName ?? null, }, - wasCurrent, + wasSelected, }; return ok( ctx.present( { data: result }, logoutPresentations({ label: sessionLabel(session), - wasCurrent, - environmentSessionInForce: environmentSessionInForce(ctx.env), + wasSelected, + environmentCredentialInForce: environmentCredentialInForce(ctx.env), }), ), ); diff --git a/packages/cli/src/v8/auth/workspace-use.ts b/packages/cli/src/v8/auth/workspace-use.ts index 690d8dc8..abff6782 100644 --- a/packages/cli/src/v8/auth/workspace-use.ts +++ b/packages/cli/src/v8/auth/workspace-use.ts @@ -8,9 +8,9 @@ import { type StoredSessions, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; -import { environmentSessionInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth"; import { CLI_NAME } from "../../cli-name"; -import { ENVIRONMENT_SESSION_NOTICE } from "./session-card"; +import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; import { requireSession, sessionLabel } from "./session-ref"; export interface WorkspaceUseResult { @@ -37,7 +37,7 @@ function noWorkspaceSessionsError(): CliStructuredError { function usePresentations(spec: { readonly session: Session; readonly previous: Session | undefined; - readonly environmentSessionInForce: boolean; + readonly environmentCredentialInForce: boolean; }): Presentations { const rows = [ ...(spec.previous === undefined @@ -58,12 +58,12 @@ function usePresentations(spec: { tone: "ok", text: "Current workspace session updated.", }, - ...(spec.environmentSessionInForce + ...(spec.environmentCredentialInForce ? [ { kind: "summary", tone: "info", - text: ENVIRONMENT_SESSION_NOTICE, + text: ENVIRONMENT_CREDENTIAL_NOTICE, } as const, ] : []), @@ -127,7 +127,7 @@ export const authWorkspaceUseCommand = defineCommand({ usePresentations({ session, previous, - environmentSessionInForce: environmentSessionInForce(ctx.env), + environmentCredentialInForce: environmentCredentialInForce(ctx.env), }), ), ); diff --git a/packages/cli/tests/credential-manager-processes.test.ts b/packages/cli/tests/credential-manager-processes.test.ts index e1831bb8..07cd4b09 100644 --- a/packages/cli/tests/credential-manager-processes.test.ts +++ b/packages/cli/tests/credential-manager-processes.test.ts @@ -303,7 +303,7 @@ describe("across processes", () => { await runWorker("use", WORKSPACE_A); expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_B); - const fromNewProcess = JSON.parse(await runWorker("current")) as { + const fromNewProcess = JSON.parse(await runWorker("active")) as { workspaceId: string; }; expect(fromNewProcess.workspaceId).toBe(WORKSPACE_A); @@ -323,7 +323,7 @@ describe("across processes", () => { await runWorker("create", WORKSPACE_A, seedAccessToken, "s3cret-refresh"); const printed = [ await runWorker("sessions"), - await runWorker("current"), + await runWorker("active"), await runWorker( "refresh", WORKSPACE_A, diff --git a/packages/cli/tests/helpers/credential-manager-worker.ts b/packages/cli/tests/helpers/credential-manager-worker.ts index e52d3b3d..9342e746 100644 --- a/packages/cli/tests/helpers/credential-manager-worker.ts +++ b/packages/cli/tests/helpers/credential-manager-worker.ts @@ -72,7 +72,7 @@ async function run(): Promise { const { response } = await sdk.client.GET("/v1/workspaces", {}); return { status: response.status }; } - case "current": + case "active": return makeManager().activeCredential(); case "sessions": return makeManager().sessions(); diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index e22217cb..79301afb 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -172,7 +172,7 @@ describe("auth login", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: null }, - environmentSessionInForce: false, + environmentCredentialInForce: false, }); const state = cli.credentialManager?.state(); expect(state?.selectedWorkspaceId).toBe("ws_1"); @@ -203,7 +203,7 @@ describe("auth login", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toContain( - "PRISMA_SERVICE_TOKEN supplies the session in force", + "PRISMA_SERVICE_TOKEN supplies the credential in force", ); expect(cli.credentialManager?.state().sessions).toHaveLength(1); }); @@ -270,7 +270,7 @@ describe("auth logout", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toContain( - "PRISMA_SERVICE_TOKEN supplies the session in force", + "PRISMA_SERVICE_TOKEN supplies the credential in force", ); expect(cli.credentialManager?.state()).toEqual({ sessions: [], @@ -358,7 +358,7 @@ describe("auth whoami", () => { expect(result.stdout).toContain("workspace: ws_env"); expect(result.stdout).toContain("source: PRISMA_SERVICE_TOKEN"); expect(result.stderr).toContain( - "PRISMA_SERVICE_TOKEN supplies the session in force", + "PRISMA_SERVICE_TOKEN supplies the credential in force", ); }); }); @@ -443,7 +443,7 @@ describe("auth workspace list", () => { expect(resultOf(result)).toEqual({ context: { - environmentSessionInForce: false, + environmentCredentialInForce: false, currentWorkspaceId: "ws_1", }, items: [ @@ -458,7 +458,7 @@ describe("auth workspace list", () => { }); }); - it("states that the env session is in force", async () => { + it("states that the environment credential is in force", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], selectedWorkspaceId: "ws_1", @@ -468,7 +468,10 @@ describe("auth workspace list", () => { const result = await cli.run(["auth", "workspace", "list", "--json"]); expect(resultOf(result)).toMatchObject({ - context: { environmentSessionInForce: true, currentWorkspaceId: "ws_1" }, + context: { + environmentCredentialInForce: true, + currentWorkspaceId: "ws_1", + }, }); }); @@ -586,7 +589,7 @@ describe("auth workspace use", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toContain( - "PRISMA_SERVICE_TOKEN supplies the session in force", + "PRISMA_SERVICE_TOKEN supplies the credential in force", ); expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); }); @@ -623,7 +626,7 @@ describe("auth workspace logout", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: "Acme Inc" }, - wasCurrent: false, + wasSelected: false, }); expect( cli.credentialManager?.state().sessions.map((s) => s.workspaceId), @@ -644,7 +647,7 @@ describe("auth workspace logout", () => { "--json", ]); - expect(resultOf(result)).toMatchObject({ wasCurrent: true }); + expect(resultOf(result)).toMatchObject({ wasSelected: true }); expect(cli.credentialManager?.state()).toEqual({ sessions: [], selectedWorkspaceId: undefined, @@ -678,7 +681,7 @@ describe("auth workspace logout", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toContain( - "PRISMA_SERVICE_TOKEN supplies the session in force", + "PRISMA_SERVICE_TOKEN supplies the credential in force", ); expect(cli.credentialManager?.state().sessions).toEqual([]); }); @@ -713,7 +716,7 @@ describe("auth workspace logout", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: "Acme Inc" }, - wasCurrent: true, + wasSelected: true, }); expect(manager.state().sessions).toEqual([]); }); @@ -855,8 +858,8 @@ describe("a blank service token is never an override", () => { expect(result.exitCode).toBe(2); expect(errorOf(result).code).toBe("AUTH.SERVICE_TOKEN_EMPTY"); - expect(result.stdout).not.toContain("supplies the session in force"); - expect(result.stderr).not.toContain("supplies the session in force"); + expect(result.stdout).not.toContain("supplies the credential in force"); + expect(result.stderr).not.toContain("supplies the credential in force"); }); it(`fails auth login with the blank-token error before the browser opens (${name})`, async () => { diff --git a/packages/cli/tests/v8-golden-rendering.test.ts b/packages/cli/tests/v8-golden-rendering.test.ts index 260d53cf..9fb23102 100644 --- a/packages/cli/tests/v8-golden-rendering.test.ts +++ b/packages/cli/tests/v8-golden-rendering.test.ts @@ -56,7 +56,7 @@ describe("v8 golden rendering", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe( - "ℹ Clearing the current CLI session.\n" + + "ℹ Clearing your stored workspace sessions.\n" + "ended: 1\n" + "✔ Ended 1 workspace session.\n" + "→ Sign in: prisma-cli auth login\n", diff --git a/packages/cli/tests/v8-whoami.test.ts b/packages/cli/tests/v8-whoami.test.ts index 69a44f2d..7446ee17 100644 --- a/packages/cli/tests/v8-whoami.test.ts +++ b/packages/cli/tests/v8-whoami.test.ts @@ -95,7 +95,7 @@ describe("prisma-v8 auth whoami", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe("status: signed out\n"); expect(result.stderr).toBe( - "ℹ Showing the current authenticated identity.\n" + + "ℹ Showing the active authenticated identity.\n" + "status: signed out\n" + "→ Sign in: prisma-cli auth login\n", ); @@ -111,7 +111,7 @@ describe("prisma-v8 auth whoami", () => { "status: signed in\nuser: bob@example.com\nworkspace: Acme Inc\n", ); expect(result.stderr).toBe( - "ℹ Showing the current authenticated identity.\n" + + "ℹ Showing the active authenticated identity.\n" + "status: signed in\n" + "user: bob@example.com\n" + "workspace: Acme Inc\n", @@ -257,7 +257,7 @@ describe("prisma-v8 auth whoami", () => { "status: signed in\nuser: bob@example.com\nworkspace: Acme Inc\n", ); expect(result.stderr).toBe( - "ℹ Showing the current authenticated identity.\n" + + "ℹ Showing the active authenticated identity.\n" + "status: signed in\n" + "user: bob@example.com\n" + "workspace: Acme Inc\n", @@ -279,7 +279,7 @@ describe("needs.credentials early failure", () => { ); }); - it("runs the handler when a session is current", async () => { + it("runs the handler when a session is selected", async () => { const result = await signedInCli().run(["auth", "locked"], { isTty: { stdout: true }, }); From c4f21ce75bb5286ede7259596be7ecc7230d4317 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:31:35 +0200 Subject: [PATCH 65/67] fix(cli): the smaller findings from the implementation review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things, none of which changes a green suite on its own. The stored-state mapping could tell an environment credential that its session had expired or ended. Both are statements about a stored session, and an environment credential has none, so neither can be the answer for it however its refresh fails. It gets the credential-rejected error for a rejected refresh token and the transient error for a transient failure — never an explanation whose remedy does nothing while the variable is set. Unreachable today, because one environment variable carries one bearer string and so there is no refresh token to fail, but §11.2 keeps the uniform path deliberately and the discrimination now travels with it. whoami's identity merge could report a person who does not exist. The claims and the /v1/me lookup are read at different moments, so another process replacing the session in between leaves them describing two different users; filling a gap in one from the other then produces one user's id beside another's email. Field-by-field merging is right where both describe the same person and is now limited to that; otherwise the lookup is taken whole. The active credential's storage was memoized, but every mutation moves the pin afterwards, so a command that mutated and then reached for ctx.api would have been handed storage for the credential it used to be acting as. Moving the pin now discards it. Nothing hits this today — auth login never touches ctx.api — but the next command that mutates would have. Also the two comments naming ctx.session and currentSession(), both deleted in rev 6 and both flagged twice; and the blank-token check whose body was a discarded getter call with nothing at the call site to say that reading is what raises. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/credential-errors.ts | 8 +-- .../cli-engine/src/execution/api-client.ts | 31 +++++++++-- .../cli-engine/tests/management-api.test.ts | 55 +++++++++++++++++++ packages/cli/src/auth/credential-manager.ts | 25 +++++++-- packages/cli/src/auth/service-token.ts | 6 +- packages/cli/src/v8/auth/whoami.ts | 31 ++++++++--- packages/cli/tests/v8-whoami.test.ts | 27 +++++++++ 7 files changed, 159 insertions(+), 24 deletions(-) diff --git a/packages/cli-engine/src/credential-errors.ts b/packages/cli-engine/src/credential-errors.ts index b11be2e8..bf07bd01 100644 --- a/packages/cli-engine/src/credential-errors.ts +++ b/packages/cli-engine/src/credential-errors.ts @@ -20,8 +20,8 @@ export type CredentialsRequiredReason = /** * The single constructor of CLI.CREDENTIALS_REQUIRED. Raised - * identically by the needs check, ctx.session, and the engine's - * request path. + * identically by the needs check, ctx.activeCredential, and the + * engine's request path. */ export function credentialsRequiredError( reason: CredentialsRequiredReason = "unauthenticated", @@ -123,8 +123,8 @@ export function credentialWorkspaceMismatchError( /** * The env var that supplies a session is set to a blank value. The one - * structured error for it, raised identically by currentSession(), the - * needs check, and the engine's request path. + * structured error for it, raised identically by activeCredential(), + * the needs check, and the engine's request path. */ export function emptyServiceTokenError(spec: { readonly envVar: string; diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 18239959..180b3f69 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -227,17 +227,38 @@ async function mapRequestFailure( )} error=${endpointVerdict(authError.message)}`, ); } - if (authError.refreshTokenInvalid === true) { - return credentialsRequiredError("expired"); - } const manager = invocation.runtime.credentialManager; if (manager === undefined || pinned === undefined) { - return authServiceError(); + return authError.refreshTokenInvalid === true + ? credentialsRequiredError("expired") + : authServiceError(); + } + // "Expired" and "ended" are both statements about a stored session, + // so neither can be true of a credential that has no home record. + // Today the uniform refresh path cannot reach here with one — an + // environment credential carries no refresh token — but §11.2 keeps + // that path deliberately, so the discrimination travels with it. + const homeRecord = hasHomeRecord(pinned.active); + if (authError.refreshTokenInvalid === true) { + return homeRecord + ? credentialsRequiredError("expired") + : credentialRejectedError(pinned.active.origin, SERVICE_TOKEN_ENV_VAR); } if (cameFromRefresh && (await couldNeverHaveBeenRenewed(pinned.storage))) { return credentialRejectedError(pinned.active.origin, SERVICE_TOKEN_ENV_VAR); } - return mapAgainstStoredState(manager, pinned.active, cause); + // Everything left is the auth service failing transiently. Only a + // stored session can additionally have been ended underneath us. + return homeRecord + ? mapAgainstStoredState(manager, pinned.active, cause) + : authServiceError(); +} + +/** Whether this credential is backed by a stored session. The origin + * is the only thing that answers it, which is why the credential- + * rejected error is allowed to read it (§11.1). */ +function hasHomeRecord(active: ActiveCredential): boolean { + return active.origin.source === "stored"; } /** diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index f758ad13..99a81039 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -744,6 +744,61 @@ describe("the environment credential", () => { expect(calls[0].authorization).toBe(`Bearer ${environmentToken}`); }); + /** "Expired" and "ended" describe a stored session. An environment + * credential has none, so neither can be the answer for it however + * its refresh fails. Unreachable today — one environment variable + * carries one bearer string, so there is no refresh token — but + * §11.2 keeps the uniform path on purpose. */ + test("with a refresh token: invalid_grant is the credential-rejected error, not an expired session", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(400, { error: "invalid_grant" }) + : jsonResponse(401, { message: "unauthorized" }), + ); + const cli = createTestCli({ + commands: { toy: callApi }, + environmentCredential: { + token: environmentToken, + refreshToken: "refresh-env", + expiresAt: undefined, + }, + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + expect(json.find((frame) => frame.kind === "result")).toMatchObject({ + envelope: { + ok: false, + error: { code: "AUTH.SERVICE_TOKEN_REJECTED" }, + }, + }); + }); + + test("with a refresh token: a transient refresh failure stays transient, and is never reported as an ended session", async () => { + scriptFetch((url) => + url === TOKEN_ENDPOINT + ? jsonResponse(503, { message: "boom" }) + : jsonResponse(401, { message: "unauthorized" }), + ); + const cli = createTestCli({ + commands: { toy: callApi }, + environmentCredential: { + token: environmentToken, + refreshToken: "refresh-env", + expiresAt: undefined, + }, + managementApiClientConfig: CLIENT_CONFIG, + }); + const { exitCode, json } = await cli.run(["toy", "--json"]); + expect(exitCode).toBe(2); + expect(json.find((frame) => frame.kind === "result")).toMatchObject({ + envelope: { + ok: false, + error: { code: "CLI.AUTH_SERVICE_ERROR" }, + }, + }); + }); + test("a successful request passes its data through and carries the environment token", async () => { const calls = scriptFetch(() => jsonResponse(200, { workspaces: ["env"] })); let seen: unknown; diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index 6219eea2..a0212d23 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -108,6 +108,10 @@ export class FileCredentialManager implements CredentialManager { readonly #debug: DebugLog; readonly #fetchWorkspaceName: FetchWorkspaceName | undefined; #pin: Pin = { kind: "unresolved" }; + /** Built for one pinned credential. Every mutation that moves the + * pin discards it, so a command that mutates and then reaches for + * ctx.api cannot be handed storage for the credential it used to be + * acting as. */ #activeStorage: TokenStorage | undefined; #refreshLock: Promise = Promise.resolve(); @@ -190,7 +194,7 @@ export class FileCredentialManager implements CredentialManager { }); if (!environmentInForce) { - this.#pin = { kind: "session", workspaceId }; + this.#repin({ kind: "session", workspaceId }); } const name = await this.#lookUpWorkspaceName(credential, workspaceId); @@ -224,7 +228,7 @@ export class FileCredentialManager implements CredentialManager { return { state: next, result: toSession(record) }; }); if (!environmentInForce) { - this.#pin = { kind: "session", workspaceId }; + this.#repin({ kind: "session", workspaceId }); } return selected; } @@ -241,7 +245,7 @@ export class FileCredentialManager implements CredentialManager { ); if (this.#pin.kind === "session" && this.#pin.workspaceId === workspaceId) { - this.#pin = { kind: "none" }; + this.#repin({ kind: "none" }); } } @@ -256,7 +260,7 @@ export class FileCredentialManager implements CredentialManager { await this.#reapLegacyContextFile(); await this.#reapOrphanedWrites(); if (!environmentInForce) { - this.#pin = { kind: "none" }; + this.#repin({ kind: "none" }); } } @@ -409,6 +413,13 @@ export class FileCredentialManager implements CredentialManager { return pin; } + /** Moves the pin after a mutation, discarding storage built for the + * credential this process was acting as before. */ + #repin(pin: ResolvedPin): void { + this.#pin = pin; + this.#activeStorage = undefined; + } + #environmentToken(): string | undefined { return environmentServiceToken(this.#env); } @@ -424,8 +435,12 @@ export class FileCredentialManager implements CredentialManager { /** A blank env token is an error state everywhere the environment * credential would be consulted, including the mutations that no * longer care whether a valid one is set. */ + /** A blank PRISMA_SERVICE_TOKEN is an error state everywhere the + * environment credential would be consulted, including the two + * mutations that do not otherwise read it. Reading is what raises; + * the value is deliberately unused. */ #refuseBlankEnvironmentToken(): void { - this.#environmentToken(); + void this.#environmentToken(); } /** endAllSessions clears everything, including the legacy context diff --git a/packages/cli/src/auth/service-token.ts b/packages/cli/src/auth/service-token.ts index c128d91e..46b7b152 100644 --- a/packages/cli/src/auth/service-token.ts +++ b/packages/cli/src/auth/service-token.ts @@ -5,7 +5,7 @@ import { SERVICE_TOKEN_ENV_VAR } from "./client"; * The env-supplied service token, trimmed — or undefined when the var * is not set. A blank or whitespace value is never "not set" and never * an override: it raises the single blank-token error, identically - * everywhere the env session would be consulted. + * everywhere the environment credential would be consulted. */ export function environmentServiceToken( env: Readonly>, @@ -18,7 +18,9 @@ export function environmentServiceToken( return raw.trim(); } -/** Whether the env session overrides the stored ones. Blank raises. */ +/** Whether the environment credential is the one this process + * authenticates as. It does not change stored state (design §11.7) — + * this is a display fact. Blank raises. */ export function environmentCredentialInForce( env: Readonly>, ): boolean { diff --git a/packages/cli/src/v8/auth/whoami.ts b/packages/cli/src/v8/auth/whoami.ts index 78bc7bcd..003a03bd 100644 --- a/packages/cli/src/v8/auth/whoami.ts +++ b/packages/cli/src/v8/auth/whoami.ts @@ -68,18 +68,33 @@ async function fetchedIdentity( } } -/** `/v1/me` wins field by field where it disagrees with the claims; - * the claims are the offline fallback. */ +/** + * `/v1/me` wins field by field where it disagrees with the claims, and + * the claims are the offline fallback — but only while both describe + * the same person. The two are read at different moments, so another + * process replacing the session in between can leave the claims + * describing one user and the lookup another; filling a gap in one from + * the other would then invent a person who does not exist. When the two + * name different users, the lookup is taken whole. + */ function mergedIdentity( claimed: CredentialIdentity | undefined, fetched: CredentialIdentity | undefined, ): CredentialIdentity | null { - const userId = fetched?.userId ?? claimed?.userId; - const email = fetched?.email ?? claimed?.email; - const name = fetched?.name ?? claimed?.name; - return userId === undefined && email === undefined && name === undefined - ? null - : { userId, email, name }; + if (fetched === undefined) return claimed ?? null; + if (claimed === undefined) return fetched; + + const samePerson = + fetched.userId === undefined || + claimed.userId === undefined || + fetched.userId === claimed.userId; + if (!samePerson) return fetched; + + return { + userId: fetched.userId ?? claimed.userId, + email: fetched.email ?? claimed.email, + name: fetched.name ?? claimed.name, + }; } function presentationsFor(spec: { diff --git a/packages/cli/tests/v8-whoami.test.ts b/packages/cli/tests/v8-whoami.test.ts index 7446ee17..af8bdacc 100644 --- a/packages/cli/tests/v8-whoami.test.ts +++ b/packages/cli/tests/v8-whoami.test.ts @@ -228,6 +228,33 @@ describe("prisma-v8 auth whoami", () => { expect(json.stdout).not.toContain("workspace:ws_svc"); }); + /** The claims and the lookup are read at different moments, so a + * session replaced in between can have them describing two different + * people. Filling a gap in one from the other would report a user + * who does not exist. */ + it("does not blend two identities when the lookup names a different user", async () => { + const differentUser = { + GET: async () => ({ + data: { data: { user: { id: "usr_999", email: null, name: null } } }, + response: { status: 200 }, + }), + } as unknown as ManagementApiClient; + const result = await makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + client: differentUser, + }).run(["auth", "whoami", "--json"]); + + const frame = result.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: true, + result: { user: { id: "usr_999", email: null, name: null } }, + }); + }); + it("falls back to the stored credential's own claims when /v1/me is unreachable", async () => { const result = await makeCli({ sessions: [SESSION], From c0e7d06f3a12949bba4954a4e9d38760c29772ec Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:37:47 +0200 Subject: [PATCH 66/67] refactor(cli): delete the auth barrels, import from the modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review comment on packages/cli/src/auth/index.ts: separate exports from src, and if it is not a package entrypoint, delete it. It is not — packages/cli ships a binary and its package.json exposes only ./package.json — so the barrel goes and its twenty-two importers now name the module they want. biome's noBarrelFile was already flagging it; the rule is exempted for the two src/exports directories, which is the pattern this file was not following. The claims module went the same way. It had just become a re-export of the engine's token-claims, so it was a second barrel by the time it was flagged; its three importers now use the engine directly, and `serviceTokenWorkspaceId` loses its alias in favour of the one name the engine gives it. Test mocks that stubbed the barrel now name the module holding the function they replace, which also makes it visible which module each test is actually faking. Repo-wide barrel count is zero. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/auth/claims.ts | 13 ----- packages/cli/src/auth/credential-manager.ts | 14 +++-- packages/cli/src/auth/index.ts | 52 ------------------- packages/cli/src/auth/legacy-state.ts | 2 +- packages/cli/src/auth/operations.ts | 2 +- packages/cli/src/controllers/app-env.ts | 2 +- packages/cli/src/controllers/app.ts | 11 ++-- packages/cli/src/controllers/auth.ts | 10 ++-- packages/cli/src/controllers/branch.ts | 2 +- packages/cli/src/controllers/bucket.ts | 2 +- packages/cli/src/controllers/build.ts | 2 +- packages/cli/src/controllers/database.ts | 2 +- packages/cli/src/controllers/project.ts | 10 ++-- packages/cli/src/shell/command-runner.ts | 2 +- packages/cli/src/v8/auth/credential-card.ts | 2 +- packages/cli/src/v8/auth/login.ts | 8 ++- packages/cli/src/v8/auth/logout.ts | 2 +- packages/cli/src/v8/auth/workspace-list.ts | 2 +- packages/cli/src/v8/auth/workspace-logout.ts | 2 +- packages/cli/src/v8/auth/workspace-use.ts | 2 +- packages/cli/src/v8/runtime.ts | 10 ++-- .../cli/tests/app-branch-database.test.ts | 6 +-- packages/cli/tests/app-controller.test.ts | 6 +-- packages/cli/tests/app-env-vars.test.ts | 6 +-- packages/cli/tests/app-env.test.ts | 6 +-- packages/cli/tests/auth-real-mode.test.ts | 8 +-- packages/cli/tests/auth.test.ts | 2 +- packages/cli/tests/branch-controller.test.ts | 6 +-- packages/cli/tests/project-controller.test.ts | 18 +++---- packages/cli/tests/project-real-mode.test.ts | 46 ++++++++-------- packages/cli/tests/v8-auth.test.ts | 6 +-- packages/cli/tests/v8-bin.test.ts | 2 +- 32 files changed, 100 insertions(+), 166 deletions(-) delete mode 100644 packages/cli/src/auth/claims.ts delete mode 100644 packages/cli/src/auth/index.ts diff --git a/packages/cli/src/auth/claims.ts b/packages/cli/src/auth/claims.ts deleted file mode 100644 index 9c7d3484..00000000 --- a/packages/cli/src/auth/claims.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * The credential's own claims. The implementation lives in the engine - * so both credential managers read a token the same way; these are the - * names the CLI already uses. - */ -export { - claimedExpiresAt, - claimedIdentity, - claimedWorkspaceId, - /** The workspace a service token names, whether through its - * `workspace_id` claim or its `workspace:`-prefixed subject. */ - credentialWorkspaceId as serviceTokenWorkspaceId, -} from "@prisma/cli-engine"; diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index a0212d23..8a85bb8c 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -9,17 +9,15 @@ import type { TokenStorage, } from "@prisma/cli-engine"; import { + claimedExpiresAt, + claimedIdentity, + claimedWorkspaceId, credentialsRequiredError, + credentialWorkspaceId, credentialWorkspaceMismatchError, noSessionForWorkspaceError, } from "@prisma/cli-engine"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { - claimedExpiresAt, - claimedIdentity, - claimedWorkspaceId, - serviceTokenWorkspaceId, -} from "./claims"; import { environmentServiceToken } from "./service-token"; import { type CredentialState, @@ -80,7 +78,7 @@ function memoryBackedStorage( ): TokenStorage { let tokens: Tokens | null = { workspaceId: - serviceTokenWorkspaceId(credential.token) ?? NO_WORKSPACE_CLAIMED, + credentialWorkspaceId(credential.token) ?? NO_WORKSPACE_CLAIMED, accessToken: credential.token, refreshToken: credential.refreshToken, }; @@ -571,7 +569,7 @@ function storedCredential(record: StoredSession): ActiveCredential { * workspace id — never the empty string. */ function environmentCredential(token: string): ActiveCredential { return { - workspaceId: serviceTokenWorkspaceId(token), + workspaceId: credentialWorkspaceId(token), workspaceName: undefined, expiresAt: claimedExpiresAt(token), identity: claimedIdentity(token), diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts deleted file mode 100644 index c36eca57..00000000 --- a/packages/cli/src/auth/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -export { claimedWorkspaceId } from "./claims"; -export { - CLIENT_ID, - DEFAULT_REDIRECT_URI, - getApiBaseUrl, - getAuthBaseUrl, - SERVICE_TOKEN_ENV_VAR, -} from "./client"; -export { - type FetchWorkspaceName, - FileCredentialManager, -} from "./credential-manager"; -export { - workspaceAmbiguousError, - workspaceNotAuthenticatedError, - workspaceSwitchUnavailableError, -} from "./errors"; -export { authenticatedManagementApiClient } from "./guard"; -export { - EmptyServiceTokenError, - isEmptyServiceTokenError, - performLogin, - performLogout, - readAuthState, - storeLegacyCredential, -} from "./operations"; -export { - RecipientSessionInvalidError, - type RecipientWorkspaceSession, - resolveRecipientWorkspaceSession, -} from "./recipient"; -export { - environmentCredentialInForce, - environmentServiceToken, -} from "./service-token"; -export { - DEPRECATED_STATE_FILE_ENV_VAR, - resolveStateFilePath, - STATE_FILE_ENV_VAR, -} from "./state-file"; -export { - FileTokenStorage, - type StoredAuthWorkspace, - WorkspaceSelectionError, -} from "./token-storage"; -export { fetchWorkspaceName } from "./workspace-name"; -export { - listAuthWorkspaces, - logoutAuthWorkspace, - switchAuthWorkspace, - type WorkspaceOperationContext, -} from "./workspaces"; diff --git a/packages/cli/src/auth/legacy-state.ts b/packages/cli/src/auth/legacy-state.ts index 19203f39..d51173bd 100644 --- a/packages/cli/src/auth/legacy-state.ts +++ b/packages/cli/src/auth/legacy-state.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { claimedExpiresAt, claimedWorkspaceId } from "./claims"; +import { claimedExpiresAt, claimedWorkspaceId } from "@prisma/cli-engine"; import type { CredentialState, StoredSession } from "./state-file"; import { getAuthContextFilePath } from "./token-storage"; diff --git a/packages/cli/src/auth/operations.ts b/packages/cli/src/auth/operations.ts index a4fb2f83..82416866 100644 --- a/packages/cli/src/auth/operations.ts +++ b/packages/cli/src/auth/operations.ts @@ -1,11 +1,11 @@ import type { Credential } from "@prisma/cli-engine"; +import { claimedExpiresAt, claimedWorkspaceId } from "@prisma/cli-engine"; import type { ManagementApiClient, TokenStorage, Tokens, } from "@prisma/management-api-sdk"; import type { AuthStateResult } from "../types/auth"; -import { claimedExpiresAt, claimedWorkspaceId } from "./claims"; import { SERVICE_TOKEN_ENV_VAR } from "./client"; import { authenticatedManagementApiClient } from "./guard"; import { AuthError, login } from "./login"; diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 8a391d86..0b5a70cd 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/performance/noAwaitInLoops: API pagination loops are intentionally sequential. import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { authenticatedManagementApiClient } from "../auth"; +import { authenticatedManagementApiClient } from "../auth/guard"; import { type EnvScope, type EnvVarRole, diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 88252759..f27993b7 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -17,13 +17,10 @@ import { detectComputeAppFromDirectory } from "@prisma/compute-sdk/config/direct import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { matchError, Result } from "better-result"; import open from "open"; -import { - authenticatedManagementApiClient, - FileTokenStorage, - getApiBaseUrl, - readAuthState, - SERVICE_TOKEN_ENV_VAR, -} from "../auth"; +import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../auth/client"; +import { authenticatedManagementApiClient } from "../auth/guard"; +import { readAuthState } from "../auth/operations"; +import { FileTokenStorage } from "../auth/token-storage"; import { type AppRecord, createAppProvider, diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index 629df36a..06896de7 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -1,14 +1,16 @@ +import { SERVICE_TOKEN_ENV_VAR } from "../auth/client"; import { - listAuthWorkspaces, - logoutAuthWorkspace, performLogin, performLogout, readAuthState, - SERVICE_TOKEN_ENV_VAR, storeLegacyCredential, +} from "../auth/operations"; +import { + listAuthWorkspaces, + logoutAuthWorkspace, switchAuthWorkspace, type WorkspaceOperationContext, -} from "../auth"; +} from "../auth/workspaces"; import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; import { PRISMA_AGENT_INSTALL_ARGS } from "../lib/agent/constants"; import { diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 8eb6c37c..a920db7a 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Branch pagination requests must run sequentially. import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { authenticatedManagementApiClient } from "../auth"; +import { authenticatedManagementApiClient } from "../auth/guard"; import { projectResolutionErrorToCliError, resolveProjectTarget, diff --git a/packages/cli/src/controllers/bucket.ts b/packages/cli/src/controllers/bucket.ts index 38a7bf52..6805e630 100644 --- a/packages/cli/src/controllers/bucket.ts +++ b/packages/cli/src/controllers/bucket.ts @@ -1,4 +1,4 @@ -import { authenticatedManagementApiClient } from "../auth"; +import { authenticatedManagementApiClient } from "../auth/guard"; import { type BucketProvider, createManagementBucketProvider, diff --git a/packages/cli/src/controllers/build.ts b/packages/cli/src/controllers/build.ts index 5cf1b997..ddc0f311 100644 --- a/packages/cli/src/controllers/build.ts +++ b/packages/cli/src/controllers/build.ts @@ -1,4 +1,4 @@ -import { authenticatedManagementApiClient } from "../auth"; +import { authenticatedManagementApiClient } from "../auth/guard"; import { authRequiredError, CliError } from "../shell/errors"; import { writeJsonEvent } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts index 9bd094c9..837250ad 100644 --- a/packages/cli/src/controllers/database.ts +++ b/packages/cli/src/controllers/database.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { authenticatedManagementApiClient } from "../auth"; +import { authenticatedManagementApiClient } from "../auth/guard"; import { type PrismaCliPackageCommandFormatter, resolvePrismaCliPackageCommandFormatterSync, diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index ccbc7a1f..79da7c88 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -10,14 +10,16 @@ import { parseGitHubRepositoryUrl, readGitOriginRemote, } from "../adapters/git"; +import { SERVICE_TOKEN_ENV_VAR } from "../auth/client"; +import { authenticatedManagementApiClient } from "../auth/guard"; import { - authenticatedManagementApiClient, - FileTokenStorage, RecipientSessionInvalidError, resolveRecipientWorkspaceSession, - SERVICE_TOKEN_ENV_VAR, +} from "../auth/recipient"; +import { + FileTokenStorage, WorkspaceSelectionError, -} from "../auth"; +} from "../auth/token-storage"; import { type PrismaCliPackageCommandFormatter, resolvePrismaCliPackageCommandFormatterSync, diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index 36f33eac..df3ebbf8 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -1,5 +1,5 @@ import { AuthError as SDKAuthError } from "@prisma/management-api-sdk"; -import { isEmptyServiceTokenError } from "../auth"; +import { isEmptyServiceTokenError } from "../auth/operations"; import { collectCommandDiagnostics } from "../lib/diagnostics"; import type { CommandDescriptor } from "./command-meta"; import { getCommandDescriptor } from "./command-meta"; diff --git a/packages/cli/src/v8/auth/credential-card.ts b/packages/cli/src/v8/auth/credential-card.ts index 1ad4642c..35b0f0cb 100644 --- a/packages/cli/src/v8/auth/credential-card.ts +++ b/packages/cli/src/v8/auth/credential-card.ts @@ -1,5 +1,5 @@ import type { ActiveCredential, CredentialIdentity } from "@prisma/cli-engine"; -import { SERVICE_TOKEN_ENV_VAR } from "../../auth"; +import { SERVICE_TOKEN_ENV_VAR } from "../../auth/client"; export interface FieldRow { readonly label: string; diff --git a/packages/cli/src/v8/auth/login.ts b/packages/cli/src/v8/auth/login.ts index 4032b901..e164e1bd 100644 --- a/packages/cli/src/v8/auth/login.ts +++ b/packages/cli/src/v8/auth/login.ts @@ -1,4 +1,5 @@ import { + claimedWorkspaceId, defineCommand, type Presentations, type Session, @@ -8,11 +9,8 @@ import { type NextAction, ok, } from "@prisma/cli-engine/protocol"; -import { - claimedWorkspaceId, - environmentCredentialInForce, - performLogin, -} from "../../auth"; +import { performLogin } from "../../auth/operations"; +import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; diff --git a/packages/cli/src/v8/auth/logout.ts b/packages/cli/src/v8/auth/logout.ts index 5884c876..41aa89b0 100644 --- a/packages/cli/src/v8/auth/logout.ts +++ b/packages/cli/src/v8/auth/logout.ts @@ -1,6 +1,6 @@ import { defineCommand, type Presentations } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; -import { environmentCredentialInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; diff --git a/packages/cli/src/v8/auth/workspace-list.ts b/packages/cli/src/v8/auth/workspace-list.ts index 40d094a6..8796663c 100644 --- a/packages/cli/src/v8/auth/workspace-list.ts +++ b/packages/cli/src/v8/auth/workspace-list.ts @@ -5,7 +5,7 @@ import { type Session, } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; -import { environmentCredentialInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; import { sessionLabel } from "./session-ref"; diff --git a/packages/cli/src/v8/auth/workspace-logout.ts b/packages/cli/src/v8/auth/workspace-logout.ts index e582f72d..c03c2c50 100644 --- a/packages/cli/src/v8/auth/workspace-logout.ts +++ b/packages/cli/src/v8/auth/workspace-logout.ts @@ -5,7 +5,7 @@ import { positional, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; -import { environmentCredentialInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; import { requireSession, sessionLabel } from "./session-ref"; diff --git a/packages/cli/src/v8/auth/workspace-use.ts b/packages/cli/src/v8/auth/workspace-use.ts index abff6782..6698efbd 100644 --- a/packages/cli/src/v8/auth/workspace-use.ts +++ b/packages/cli/src/v8/auth/workspace-use.ts @@ -8,7 +8,7 @@ import { type StoredSessions, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; -import { environmentCredentialInForce } from "../../auth"; +import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; import { requireSession, sessionLabel } from "./session-ref"; diff --git a/packages/cli/src/v8/runtime.ts b/packages/cli/src/v8/runtime.ts index aeed193f..b84f938a 100644 --- a/packages/cli/src/v8/runtime.ts +++ b/packages/cli/src/v8/runtime.ts @@ -8,14 +8,16 @@ import open from "open"; import { CLIENT_ID, DEFAULT_REDIRECT_URI, - DEPRECATED_STATE_FILE_ENV_VAR, - FileCredentialManager, - fetchWorkspaceName, getApiBaseUrl, getAuthBaseUrl, +} from "../auth/client"; +import { FileCredentialManager } from "../auth/credential-manager"; +import { + DEPRECATED_STATE_FILE_ENV_VAR, resolveStateFilePath, STATE_FILE_ENV_VAR, -} from "../auth"; +} from "../auth/state-file"; +import { fetchWorkspaceName } from "../auth/workspace-name"; export type SignalProcess = Pick; diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index acf5c529..b5de8d25 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -14,8 +14,8 @@ beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -37,7 +37,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/auth"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.doUnmock("../src/lib/app/branch-database"); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index e2b4b62a..dd49a487 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -17,8 +17,8 @@ beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -40,7 +40,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/auth"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/controllers/agent"); vi.doUnmock("../src/lib/app/app-provider"); diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index cb7b2cbb..05872573 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -8,8 +8,8 @@ beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -31,7 +31,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/auth"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index 24f2577c..0679cbc8 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -15,7 +15,7 @@ afterEach(() => { delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; - vi.doUnmock("../src/auth"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); @@ -108,8 +108,8 @@ async function loadControllers(client: MockClient, projectId: string) { vi.resetModules(); void projectId; - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index 4a51f055..28f81509 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -234,7 +234,7 @@ describe("real auth mode", () => { const { createTempCwd, createTestCommandContext } = await import( "./helpers" ); - const { FileTokenStorage } = await import("../src/auth"); + const { FileTokenStorage } = await import("../src/auth/token-storage"); const { runAuthWorkspaceList } = await import("../src/controllers/auth"); const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -307,7 +307,7 @@ describe("real auth mode", () => { }), })); - const { FileTokenStorage } = await import("../src/auth"); + const { FileTokenStorage } = await import("../src/auth/token-storage"); const { createTempCwd, createTestCommandContext } = await import( "./helpers" ); @@ -383,7 +383,7 @@ describe("real auth mode", () => { }), })); - const { FileTokenStorage } = await import("../src/auth"); + const { FileTokenStorage } = await import("../src/auth/token-storage"); const { createTempCwd, createTestCommandContext } = await import( "./helpers" ); @@ -471,7 +471,7 @@ describe("real auth mode", () => { }), })); - const { FileTokenStorage } = await import("../src/auth"); + const { FileTokenStorage } = await import("../src/auth/token-storage"); const { createTempCwd, executeCli } = await import("./helpers"); const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index bfd3d2ec..ef25e2cc 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import stripAnsi from "strip-ansi"; import { describe, expect, it } from "vitest"; -import { FileTokenStorage } from "../src/auth"; +import { FileTokenStorage } from "../src/auth/token-storage"; import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index 788affed..fd3bec3d 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTempCwd, createTestCommandContext } from "./helpers"; afterEach(() => { - vi.doUnmock("../src/auth"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("../src/auth/guard"); vi.resetModules(); vi.restoreAllMocks(); @@ -102,8 +102,8 @@ function expectedBranchVerboseContext() { async function loadController(client: ReturnType) { vi.resetModules(); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 6bebfd74..5acee8f9 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -7,7 +7,7 @@ import { createTempCwd, createTestCommandContext } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); afterEach(() => { - vi.doUnmock("../src/auth"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("../src/auth/guard"); vi.doUnmock("../src/lib/app/app-provider"); vi.resetModules(); @@ -164,8 +164,8 @@ describe("project controller", () => { name: "New Dashboard", }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -237,8 +237,8 @@ describe("project controller", () => { defaultRegion: "us-east-1", }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -308,8 +308,8 @@ describe("project controller", () => { name: "Interactive Project", }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, @@ -386,8 +386,8 @@ describe("project controller", () => { .fn() .mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: vi.fn().mockResolvedValue({ authenticated: true, provider: null, diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index 536d5e14..88dcd03a 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -11,7 +11,7 @@ type ApiGetMock = Mock< type ApiMutationMock = Mock<(pathName: string, request?: unknown) => unknown>; afterEach(() => { - vi.doUnmock("../src/auth"); + vi.doUnmock("../src/auth/operations"); vi.doUnmock("../src/auth/guard"); vi.doUnmock("open"); vi.resetModules(); @@ -261,8 +261,8 @@ describe("real project mode", () => { .fn() .mockResolvedValue(mockClient()); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState, performLogin: vi.fn(), performLogout: vi.fn(), @@ -328,8 +328,8 @@ describe("real project mode", () => { }); it("resolves an explicit project in real mode", async () => { - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -404,8 +404,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -489,8 +489,8 @@ describe("real project mode", () => { }); const post = vi.fn(); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -574,8 +574,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -686,8 +686,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -779,8 +779,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -914,8 +914,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -984,8 +984,8 @@ describe("real project mode", () => { }); const post = vi.fn(); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -1060,8 +1060,8 @@ describe("real project mode", () => { }); const post = vi.fn(); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), @@ -1134,8 +1134,8 @@ describe("real project mode", () => { throw new Error(`Unexpected path ${pathName}`); }); - vi.doMock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), + vi.doMock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), diff --git a/packages/cli/tests/v8-auth.test.ts b/packages/cli/tests/v8-auth.test.ts index 79301afb..624ec878 100644 --- a/packages/cli/tests/v8-auth.test.ts +++ b/packages/cli/tests/v8-auth.test.ts @@ -21,7 +21,7 @@ import { } from "@prisma/cli-engine/testing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { performLogin, storeLegacyCredential } from "../src/auth"; +import { performLogin, storeLegacyCredential } from "../src/auth/operations"; import { authLoginCommand } from "../src/v8/auth/login"; import { authLogoutCommand } from "../src/v8/auth/logout"; import { authWhoamiCommand } from "../src/v8/auth/whoami"; @@ -29,8 +29,8 @@ import { authWorkspaceListCommand } from "../src/v8/auth/workspace-list"; import { authWorkspaceLogoutCommand } from "../src/v8/auth/workspace-logout"; import { authWorkspaceUseCommand } from "../src/v8/auth/workspace-use"; -vi.mock("../src/auth", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../src/auth/operations", async (importOriginal) => ({ + ...(await importOriginal()), performLogin: vi.fn(), storeLegacyCredential: vi.fn(), })); diff --git a/packages/cli/tests/v8-bin.test.ts b/packages/cli/tests/v8-bin.test.ts index 19ffaac0..1e889d81 100644 --- a/packages/cli/tests/v8-bin.test.ts +++ b/packages/cli/tests/v8-bin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { CLIENT_ID, DEFAULT_REDIRECT_URI } from "../src/auth"; +import { CLIENT_ID, DEFAULT_REDIRECT_URI } from "../src/auth/client"; import { buildCli } from "../src/v8/cli"; import { main } from "../src/v8/main"; import { From 9bc47d0ef6fe75110b32b93a3029fc5fd62fd24a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 10 Aug 2026 23:40:17 +0200 Subject: [PATCH 67/67] refactor(cli): the remaining review comments on the auth surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The telemetry group said "Inspect and change anonymous CLI telemetry". You do not change telemetry; you change whether the CLI sends it. It now says so. Three files built an SDK client with `http://localhost:0/auth/callback` written out by hand. The SDK's config demands a redirect URI even for a client that only ever calls the API with tokens it already has, so the value exists to satisfy a type and no browser is ever sent to it. It is now one named constant next to the real one, with a comment saying why port zero is the honest choice. In the interface draft: the two inline `import('…')` type positions become a top-level `import type`, matching what the shipped management-api module already does. And the telemetry snapshot's description now says what it means — which command ran and which flags were given, never what any of them was set to — instead of naming itself "value-free" and leaving the reader to work it out. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/engine/engine-interface-draft.ts | 15 +++++++++++---- packages/cli/src/auth/client.ts | 7 +++++++ packages/cli/src/auth/guard.ts | 9 +++++++-- packages/cli/src/auth/recipient.ts | 4 ++-- packages/cli/src/auth/workspaces.ts | 9 +++++++-- packages/cli/src/v8/cli.ts | 2 +- packages/cli/tests/v8-telemetry.test.ts | 4 +++- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts index bdcd6f24..15a9aba9 100644 --- a/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts +++ b/.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts @@ -531,8 +531,13 @@ export interface CredentialManager { /** The SDK's typed client and token-storage contract, re-exported by * the engine so consumers never import @prisma/management-api-sdk * directly. */ -export type ManagementApiClient = import('@prisma/management-api-sdk').ManagementApiClient -export type TokenStorage = import('@prisma/management-api-sdk').TokenStorage +import type { + ManagementApiClient as SdkClient, + TokenStorage as SdkTokenStorage, +} from '@prisma/management-api-sdk' + +export type ManagementApiClient = SdkClient +export type TokenStorage = SdkTokenStorage /** SDK client construction config, injected by the bin beside the * manager (§10). All four fields: the SDK's refreshing fetch @@ -1192,8 +1197,10 @@ export interface RunSummary { } /** - * The value-free command snapshot recorded at parse time. NO VALUES, - * EVER: command-path segments, flag names with their value source, + * What telemetry records about an invocation, captured when argv is + * parsed. It says which command ran and which flags were given, and + * never what any of them was set to: the command-path segments, the + * flag NAMES with where each value came from, * and a bare count of positionals. Flag `source` derives from what * the engine knows at parse time: flags explicitly present on argv * are 'cli'; the engine reads no flags from the environment today, diff --git a/packages/cli/src/auth/client.ts b/packages/cli/src/auth/client.ts index 79ec7a1b..a77e8e67 100644 --- a/packages/cli/src/auth/client.ts +++ b/packages/cli/src/auth/client.ts @@ -14,6 +14,13 @@ export const AUTH_FILE_ENV_VAR = "PRISMA_COMPUTE_AUTH_FILE"; */ export const DEFAULT_REDIRECT_URI = "http://localhost/auth/callback"; +/** + * The SDK's config demands a redirect URI even for a client that only + * ever makes API calls with tokens it already has. Port 0 is the + * honest value: no browser is ever sent here, and nothing listens. + */ +export const UNUSED_REDIRECT_URI = "http://localhost:0/auth/callback"; + export function getApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string { return env.PRISMA_MANAGEMENT_API_URL?.trim() || DEFAULT_API_BASE_URL; } diff --git a/packages/cli/src/auth/guard.ts b/packages/cli/src/auth/guard.ts index ca107846..be7c1adc 100644 --- a/packages/cli/src/auth/guard.ts +++ b/packages/cli/src/auth/guard.ts @@ -4,7 +4,12 @@ import { type ManagementApiClient, } from "@prisma/management-api-sdk"; -import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +import { + CLIENT_ID, + getApiBaseUrl, + SERVICE_TOKEN_ENV_VAR, + UNUSED_REDIRECT_URI, +} from "./client"; import { FileTokenStorage } from "./token-storage"; /** @@ -51,7 +56,7 @@ export async function authenticatedManagementApiClient( const sdk = createManagementApiSdk({ clientId: CLIENT_ID, - redirectUri: "http://localhost:0/auth/callback", + redirectUri: UNUSED_REDIRECT_URI, tokenStorage, apiBaseUrl: getApiBaseUrl(env), }); diff --git a/packages/cli/src/auth/recipient.ts b/packages/cli/src/auth/recipient.ts index de8ea9e3..d3122494 100644 --- a/packages/cli/src/auth/recipient.ts +++ b/packages/cli/src/auth/recipient.ts @@ -1,6 +1,6 @@ import { createManagementApiSdk } from "@prisma/management-api-sdk"; -import { CLIENT_ID, getApiBaseUrl } from "./client"; +import { CLIENT_ID, getApiBaseUrl, UNUSED_REDIRECT_URI } from "./client"; import { FileTokenStorage, type StoredAuthWorkspace } from "./token-storage"; export interface RecipientWorkspaceSession { @@ -44,7 +44,7 @@ export async function resolveRecipientWorkspaceSession( const sdk = createManagementApiSdk({ clientId: CLIENT_ID, - redirectUri: "http://localhost:0/auth/callback", + redirectUri: UNUSED_REDIRECT_URI, tokenStorage: pinnedStorage, apiBaseUrl: getApiBaseUrl(env), }); diff --git a/packages/cli/src/auth/workspaces.ts b/packages/cli/src/auth/workspaces.ts index a5bc4f40..00474ae3 100644 --- a/packages/cli/src/auth/workspaces.ts +++ b/packages/cli/src/auth/workspaces.ts @@ -9,7 +9,12 @@ import type { AuthWorkspaceLogoutResult, AuthWorkspaceUseResult, } from "../types/auth"; -import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; +import { + CLIENT_ID, + getApiBaseUrl, + SERVICE_TOKEN_ENV_VAR, + UNUSED_REDIRECT_URI, +} from "./client"; import { workspaceAmbiguousError, workspaceNotAuthenticatedError, @@ -242,7 +247,7 @@ async function resolveOAuthWorkspaceMetadata( ); const sdk = createManagementApiSdk({ clientId: CLIENT_ID, - redirectUri: "http://localhost:0/auth/callback", + redirectUri: UNUSED_REDIRECT_URI, tokenStorage, apiBaseUrl: getApiBaseUrl(context.env), }); diff --git a/packages/cli/src/v8/cli.ts b/packages/cli/src/v8/cli.ts index e8795699..19aaf81a 100644 --- a/packages/cli/src/v8/cli.ts +++ b/packages/cli/src/v8/cli.ts @@ -31,7 +31,7 @@ export function buildCli(): Cli { auth: { brief: "Manage local authentication for the CLI" }, "auth workspace": { brief: "Manage local workspace sessions" }, telemetry: { - brief: "Inspect and change anonymous CLI telemetry", + brief: "Show or change whether the CLI sends anonymous usage data", description: "Show telemetry status, or enable / disable anonymous CLI usage data.\n" + `Telemetry is on by default (opt-out); see ${CLI_DOCS_URL}\n` + diff --git a/packages/cli/tests/v8-telemetry.test.ts b/packages/cli/tests/v8-telemetry.test.ts index 97ca40e6..b4a04205 100644 --- a/packages/cli/tests/v8-telemetry.test.ts +++ b/packages/cli/tests/v8-telemetry.test.ts @@ -28,7 +28,9 @@ function makeCli() { "telemetry disable": telemetryDisableCommand, }, groups: { - telemetry: { brief: "Inspect and change anonymous CLI telemetry" }, + telemetry: { + brief: "Show or change whether the CLI sends anonymous usage data", + }, }, now: () => new Date(0), });