From 3dffc419ee14a38b0e35d93e8e43d4ffd9045120 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:12:04 +0200 Subject: [PATCH 01/14] docs(drive): slice spec for the dev url, dev stop and emulator list commands Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/s3-commands/spec.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .drive/projects/emulator-commands/slices/s3-commands/spec.md diff --git a/.drive/projects/emulator-commands/slices/s3-commands/spec.md b/.drive/projects/emulator-commands/slices/s3-commands/spec.md new file mode 100644 index 000000000..914e89098 --- /dev/null +++ b/.drive/projects/emulator-commands/slices/s3-commands/spec.md @@ -0,0 +1,76 @@ +# S3 — `prisma dev url`, `prisma dev stop` and `prisma emulator list` + +Repo: prisma/composer. Branch `feat/emulator-commands`, stacked on S1b (`feat/emulator-data-instances`, prisma/composer#356). + +## At a glance + +```bash +$ prisma dev module.ts # terminal 1 +$ prisma dev url gateway # terminal 2 +http://localhost:3001 +$ prisma dev url catalog --json +{"kind":"result","envelope":{"ok":true,"result":{"address":"catalog","kind":"database","url":"postgres://…:51300/…","state":"running"},…}} +$ prisma dev stop # stops this directory's app: services and Postgres servers; data stays +$ prisma emulator list # every emulator on this machine and every instance on it, with its labels +``` + +## Chosen design + +**Operations first (ADR-0043: commands render operations).** +- `@prisma/composer/control` gains two operations. `devUrl` takes a config, a cwd and an address, and returns `{ address, kind, url, state }`. `devStop` takes a config and a cwd, and stops the current app recorded in that directory. +- They go through the core local-target seam, so the framework stays target-agnostic. +- `LocalTargetDescriptor` gains what they need without an app name. Taking the dev state directory alone, it resolves an address and stops the directory's current app. The Prisma Cloud target implements both from `emulator-instances.json`. +- Core never learns the record's format. +- `emulator list` needs no project and no config, so it can't go through the seam. Its operation lives in the Prisma Cloud domain and is exported from `@prisma/composer-prisma-cloud/local-target`. It lists each emulator (`service`, `database`, `bucket`): whether it is running, its admin URL, and its instances with id, kind, URL, state and labels. +- It honours `PRISMA_COMPOSER_EMULATORS_DIR`, like everything else. + +**Commands.** +- The family exports three new commands. +- The host decides the mount paths (prisma-cli requirement R12). The intended paths are `dev url`, `dev stop` and `emulator list`; prisma-cli's engine now allows `dev` to be both a command and a group (prisma/prisma-cli#349). +- `dev url` and `dev stop` declare the `composer` config section. `emulator list` declares none. +- `emulator list` is defined in the Prisma Cloud domain, or wherever keeps layering legal. `@prisma/composer-cli`, in `9-public`, which may import both domains, assembles it into the family. +- The family's static import graph stays free of alchemy and effect values (`scripts/check-family-static-graph.mjs`). +- `family.test.ts`'s pinned command list is updated. + +**Output.** +- `dev url`, human format: the URL as the command's data line, so `--format human` works inside `$(…)`, plus a minimal human block. The JSON `result` is `{ address, kind, url, state }`, where `kind` is `service | database | bucket`. +- `state` is the service status; for a database `running | stopped`; for a bucket whether its emulator runs. +- URLs: + - **Service:** `http://localhost:`. + - **Database:** its connection string. + - **Bucket:** the bucket emulator's S3 endpoint plus `/` plus the bucket's id. Access keys are never printed. +- `dev stop` returns what it stopped. +- `emulator list` JSON `result`: `{ emulators: [{ kind, running, url, instances: [{ id, kind, url, state, labels }] }] }`. Human output: one block per emulator, and its instances with their address and directory from the labels when present. +- Follow prisma-cli's `docs/product/output-conventions.md` and `cli-style-guide.md` where they apply. The two-level list departs from the list convention; record that in the ADR. + +**Errors (ADR-0044; record every new code in its registry in this change).** +- No record in this directory → `DEV.NOT_STARTED_HERE`. Fix: run `prisma dev ` here. +- Unknown address → `DEV.ADDRESS_UNKNOWN`, listing the known addresses. +- The node's emulator isn't running → `DEV.EMULATOR_UNREACHABLE`. +- The node exists but isn't running → `DEV.NOT_RUNNING`. It names the state and points to `prisma dev`. +- `emulator list` with nothing running → ok, with every emulator `running: false`. No error. + +**Help.** Every summary, description and flag brief meets prisma-cli's `docs/product/cli-help-standard.md`: the reader has never used Prisma, and each term is defined in place, "emulator" included. The `dev` command's description must define what its children rely on (prisma-cli `cli-style-guide.md`, "Groups With Their Own Command"). + +**Docs.** +- A new ADR amends ADR-0050 (the family is no longer exactly `deploy` and `dev`) and ADR-0043 (the new operations), and lists the alternatives from `design-notes.md`. +- Also update `local-dev.md`, `deploy-cli.md`'s scope, the running-locally guide and the core-concepts skill, including that `prisma emulator start` and `stop` are planned. +- The guide shows the `jq` form for scripts: `curl "$(prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url')"`. + +## Scope + +In: the operations, the seam additions, the commands, the family assembly, tests (bun:test; real emulators in temporary directories, skipped on Windows), the ADR, ADR-0044 and the docs. + +Out: mounting in prisma-cli (S4); prisma/web (S5); `emulator start`, `emulator stop` and removing instances. + +## Slice-specific done conditions + +- [ ] With an app running from a temporary directory on real emulators, `dev url` returns a URL for a service, a database and a bucket that each answer: an HTTP request, a Postgres `select 1`, and an S3 listing with the app's credentials. +- [ ] `dev stop` leaves no process or Postgres server of that app running. +- [ ] `emulator list` shows the instances of two directories with their labels. + +## Dispatch plan + +1. **Operations and seam:** `devUrl`, `devStop` and the emulator listing, with the errors and tests. *Hands to 2.* +2. **Commands and family assembly:** help text, output, family tests and the static-graph check. *Hands to 3.* +3. **Docs:** the ADR, ADR-0044, `local-dev.md`, `deploy-cli.md`, the guide and the skill. *Hands to:* the PR. From 50d7368dfe8c8fd446a42bf79f86234948ace417 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:19:25 +0200 Subject: [PATCH 02/14] feat: devUrl and devStop operations, and the emulator listing `@prisma/composer/control` gains two operations that act on the app `prisma dev` last ran from a directory, read from its dev state alone: - `devUrl({ config, address, cwd })` returns `{ address, kind, url, state }` for a running node: a service's URL, a database's connection string, or a bucket's S3 endpoint plus `/`. - `devStop({ config, cwd })` stops the app's service processes and database servers and returns what it stopped. Data and ports stay. They fail with DEV.NOT_STARTED_HERE (no record in the directory), DEV.ADDRESS_UNKNOWN (listing the known addresses), DEV.EMULATOR_UNREACHABLE (the node's emulator is not running) and DEV.NOT_RUNNING (naming the node's state). The core local-target seam gains an optional `current({ devDir })`, which returns the recorded app's name, its addresses, `locate(address)` and `stop()`. Core never reads the record; the Prisma Cloud target implements it from emulator-instances.json. `listEmulators()` in the Prisma Cloud domain, exported from `@prisma/composer-prisma-cloud/local-target`, lists each emulator (`service`, `database`, `bucket`), whether it runs, its admin URL, and its instances with id, kind, URL, state and labels. Nothing running is a valid answer. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/core/src/control/app-config.ts | 46 ++++ .../1-core/core/src/exports/local-target.ts | 4 + .../3-tooling/cli/src/exports/control.ts | 4 +- .../operations/__tests__/dev-current.test.ts | 189 ++++++++++++++++ .../cli/src/operations/dev-current.ts | 69 ++++++ .../cli/src/operations/execute-dev-current.ts | 117 ++++++++++ .../3-tooling/cli/src/operations/shared.ts | 2 +- .../src/__tests__/emulator-list.test.ts | 55 +++++ .../dev-emulators/src/emulator-list.ts | 127 +++++++++++ .../dev-emulators/src/exports/index.ts | 7 + .../local-target/src/current-app.ts | 70 ++++++ .../local-target/src/exports/index.ts | 1 + .../1-extensions/target/package.json | 1 + .../__tests__/local-target-current.test.ts | 213 ++++++++++++++++++ .../target/src/exports/local-target.ts | 10 +- .../target/src/local-target/descriptor.ts | 3 +- pnpm-lock.yaml | 3 + 17 files changed, 917 insertions(+), 4 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/dev-current.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts create mode 100644 packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts create mode 100644 packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index 6db23a473..b211e106a 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -253,6 +253,52 @@ export interface LocalTargetDescriptor { attach(input: LocalTargetAttachInput): Promise; /** `--fresh`: remove every local trace of the dev instance — emulator instances, state, data. */ teardown?(input: TeardownInput): Promise; + /** + * The app `prisma dev` last ran from a directory, read from its dev state + * directory alone — no entry, no graph, no container — so a command can + * find or stop a running app without running the pipeline. Undefined when + * this extension recorded nothing there. + */ + current?(input: LocalTargetCurrentInput): Promise; +} + +export interface LocalTargetCurrentInput { + /** Absolute path of the dev state directory (`/.prisma-composer/dev`). */ + readonly devDir: string; +} + +/** The app `prisma dev` last ran from a directory, as one extension recorded it. */ +export interface LocalTargetCurrentApp { + readonly app: string; + /** Every node address recorded for the app. */ + readonly addresses: readonly string[]; + /** Where the node at `address` runs locally; undefined for an address not recorded. */ + locate(address: string): Promise; + /** Stops everything the target runs for the app (emulators and data persist); returns what was recorded as running locally. */ + stop(): Promise; +} + +/** A node's local whereabouts. `kind` is the target's word for the node (for Prisma Cloud: `service`, `database` or `bucket`). */ +export type LocalNodeLocation = + | { + readonly address: string; + readonly kind: string; + readonly emulator: 'running'; + /** Absent when the emulator no longer has the node's instance. */ + readonly url?: string; + /** `running` when the node answers at `url`; otherwise the target's word for its state. */ + readonly state: string; + } + | { + readonly address: string; + readonly kind: string; + /** The emulator that runs the node is not running, so it has no URL or state to report. */ + readonly emulator: 'not-running'; + }; + +export interface LocalNodeStopped { + readonly address: string; + readonly kind: string; } export interface LocalTargetProvidersInput { diff --git a/packages/0-framework/1-core/core/src/exports/local-target.ts b/packages/0-framework/1-core/core/src/exports/local-target.ts index 91a474c36..a03d56f6c 100644 --- a/packages/0-framework/1-core/core/src/exports/local-target.ts +++ b/packages/0-framework/1-core/core/src/exports/local-target.ts @@ -9,8 +9,12 @@ export { DEV_DIR, + type LocalNodeLocation, + type LocalNodeStopped, type LocalTargetAttachInput, type LocalTargetAttachment, + type LocalTargetCurrentApp, + type LocalTargetCurrentInput, type LocalTargetDescriptor, type LocalTargetEmulatorsInput, type LocalTargetProvidersInput, diff --git a/packages/0-framework/3-tooling/cli/src/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts index 6ad41d1ef..8641ded0e 100644 --- a/packages/0-framework/3-tooling/cli/src/exports/control.ts +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -1,6 +1,6 @@ /** * Public surface (the `./control` subpath): the programmatic - * deploy/destroy/dev/log operations. Implementation lives in ../operations/. + * deploy/destroy/dev/devUrl/devStop/log operations. Implementation lives in ../operations/. * Importing it executes nothing — the heavy pipeline loads lazily inside * each operation. Distinct from an EXTENSION's `/control` * entry (ADR-0017's control-plane descriptors, importable only from @@ -33,6 +33,8 @@ export type { export { destroy } from '../operations/destroy.ts'; export type { DevEvent, DevInput, DevSession } from '../operations/dev.ts'; export { dev } from '../operations/dev.ts'; +export type { DevStopInput, DevStopped, DevUrl, DevUrlInput } from '../operations/dev-current.ts'; +export { devStop, devUrl } from '../operations/dev-current.ts'; export type { LogAttached, LogEvent, LogInput, LogLine } from '../operations/log.ts'; export { log } from '../operations/log.ts'; export type { ExecutionDiagnostics, ServiceEndpoint } from '../operations/shared.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts new file mode 100644 index 000000000..97655ac25 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts @@ -0,0 +1,189 @@ +/** + * `devUrl` and `devStop` against a fake local target: how the operations + * turn what a target recorded into results and structured failures. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { PrismaAppConfig } from '@internal/core/config'; +import type { + LocalNodeLocation, + LocalTargetCurrentApp, + LocalTargetDescriptor, +} from '@internal/core/local-target'; +import * as Layer from 'effect/Layer'; +import { devStop, devUrl } from '../dev-current.ts'; + +const unused = () => { + throw new Error('not used by devUrl or devStop'); +}; + +let cwd: string; +let stopped: string[]; + +beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dev-current-')); + fs.writeFileSync(path.join(cwd, 'prisma.config.ts'), ''); + stopped = []; +}); + +afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +function recordedApp(locations: readonly LocalNodeLocation[]): LocalTargetCurrentApp { + return { + app: 'shop', + addresses: locations.map((l) => l.address), + locate: async (address) => locations.find((l) => l.address === address), + stop: async () => { + stopped.push('shop'); + return locations.map(({ address, kind }) => ({ address, kind })); + }, + }; +} + +function configWith(current: LocalTargetDescriptor['current']): PrismaAppConfig { + const descriptor: LocalTargetDescriptor = { + providers: () => Layer.empty, + container: { + ensure: unused, + locate: unused, + remove: unused, + deserialize: unused, + }, + attach: unused, + ...(current !== undefined ? { current } : {}), + }; + return { + extensions: [ + { + id: 'fixture', + nodes: { + svc: { + kind: 'service', + provision: unused, + serialize: unused, + package: unused, + deploy: unused, + }, + }, + localTarget: () => Promise.resolve(descriptor), + }, + ], + state: { extension: 'fixture', create: unused }, + }; +} + +function config(current: LocalTargetDescriptor['current']) { + return { value: configWith(current), file: 'prisma.config.ts' }; +} + +const running: LocalNodeLocation[] = [ + { + address: 'gateway', + kind: 'service', + emulator: 'running', + url: 'http://localhost:3001', + state: 'running', + }, + { + address: 'catalog', + kind: 'database', + emulator: 'running', + url: 'postgres://x', + state: 'stopped', + }, + { address: 'uploads', kind: 'bucket', emulator: 'not-running' }, +]; + +describe.skipIf(process.platform === 'win32')('devUrl', () => { + test("returns a running node's URL, kind and state", async () => { + const result = await devUrl({ + config: config(async () => recordedApp(running)), + address: 'gateway', + cwd, + }); + + expect(result).toMatchObject({ + ok: true, + value: { + address: 'gateway', + kind: 'service', + url: 'http://localhost:3001', + state: 'running', + }, + }); + }); + + test('a directory where `prisma dev` never ran fails with DEV.NOT_STARTED_HERE', async () => { + const result = await devUrl({ config: config(async () => undefined), address: 'gateway', cwd }); + + expect(result).toMatchObject({ ok: false, failure: { code: 'DEV.NOT_STARTED_HERE' } }); + }); + + test('a target that records nothing counts as nothing recorded', async () => { + const result = await devUrl({ config: config(undefined), address: 'gateway', cwd }); + + expect(result).toMatchObject({ ok: false, failure: { code: 'DEV.NOT_STARTED_HERE' } }); + }); + + test('an address the app does not have fails with DEV.ADDRESS_UNKNOWN, listing the known ones', async () => { + const result = await devUrl({ + config: config(async () => recordedApp(running)), + address: 'quotes', + cwd, + }); + + expect(result).toMatchObject({ + ok: false, + failure: { code: 'DEV.ADDRESS_UNKNOWN', meta: { known: ['catalog', 'gateway', 'uploads'] } }, + }); + }); + + test('a node whose emulator is not running fails with DEV.EMULATOR_UNREACHABLE', async () => { + const result = await devUrl({ + config: config(async () => recordedApp(running)), + address: 'uploads', + cwd, + }); + + expect(result).toMatchObject({ ok: false, failure: { code: 'DEV.EMULATOR_UNREACHABLE' } }); + }); + + test('a node that is not running fails with DEV.NOT_RUNNING, naming its state', async () => { + const result = await devUrl({ + config: config(async () => recordedApp(running)), + address: 'catalog', + cwd, + }); + + expect(result).toMatchObject({ + ok: false, + failure: { code: 'DEV.NOT_RUNNING', meta: { state: 'stopped' } }, + }); + if (!result.ok) expect(result.failure.message).toContain('stopped'); + }); +}); + +describe.skipIf(process.platform === 'win32')('devStop', () => { + test('stops the recorded app and returns what it stopped', async () => { + const result = await devStop({ config: config(async () => recordedApp(running)), cwd }); + + expect(stopped).toEqual(['shop']); + expect(result).toMatchObject({ + ok: true, + value: { + app: 'shop', + stopped: running.map(({ address, kind }) => ({ address, kind })), + }, + }); + }); + + test('a directory where `prisma dev` never ran fails with DEV.NOT_STARTED_HERE', async () => { + const result = await devStop({ config: config(async () => undefined), cwd }); + + expect(result).toMatchObject({ ok: false, failure: { code: 'DEV.NOT_STARTED_HERE' } }); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/operations/dev-current.ts b/packages/0-framework/3-tooling/cli/src/operations/dev-current.ts new file mode 100644 index 000000000..a89d29e1b --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/dev-current.ts @@ -0,0 +1,69 @@ +/** + * The programmatic `devUrl` and `devStop` operations + * (`@prisma/composer/control`): they act on the app `prisma dev` last ran + * from a directory, read from that directory's dev state alone — no entry + * module, no build. The executor loads lazily, so importing this module + * executes nothing. + */ +import type { CliStructuredError } from '@internal/foundation/errors'; +import { notOk, ok, type Result } from '@internal/foundation/result'; +import type { ComposerConfigSource } from '../composer-config.ts'; +import { executorLoadFailure, type OperationName } from './shared.ts'; + +export interface DevUrlInput { + /** Composer's configuration: the `composer` section of `prisma.config.ts` and that file. It names the extensions whose local targets recorded the app. */ + readonly config: ComposerConfigSource; + /** A node's dotted address in the app, such as `gateway` or `catalog.database`. */ + readonly address: string; + /** The directory `prisma dev` ran from. Defaults to the process's working directory. */ + readonly cwd?: string | undefined; +} + +export interface DevUrl { + readonly address: string; + /** What the node is, in its target's words; Prisma Cloud's are `service`, `database` and `bucket`. */ + readonly kind: string; + /** A service's URL, a database's connection string, or a bucket's S3 endpoint plus `/`. */ + readonly url: string; + /** Always `running`: a node that is not running is a failure. */ + readonly state: string; +} + +export interface DevStopInput { + readonly config: ComposerConfigSource; + /** The directory `prisma dev` ran from. Defaults to the process's working directory. */ + readonly cwd?: string | undefined; +} + +export interface DevStopped { + /** The app that was stopped: the one `prisma dev` last ran from the directory. */ + readonly app: string; + /** Each node whose process or server was stopped. Data and ports are kept. */ + readonly stopped: readonly { readonly address: string; readonly kind: string }[]; +} + +async function loadExecutor( + operation: OperationName, +): Promise> { + try { + return ok(await import('./execute-dev-current.ts')); + } catch (error) { + return notOk(executorLoadFailure(operation, error)); + } +} + +/** Where the node at `input.address` of the directory's current app runs locally. */ +export async function devUrl(input: DevUrlInput): Promise> { + const executor = await loadExecutor('dev url'); + if (!executor.ok) return executor; + return executor.value.executeDevUrl(input, input.cwd ?? process.cwd()); +} + +/** Stops the directory's current app: its service processes and database servers. Data and ports stay. */ +export async function devStop( + input: DevStopInput, +): Promise> { + const executor = await loadExecutor('dev stop'); + if (!executor.ok) return executor; + return executor.value.executeDevStop(input, input.cwd ?? process.cwd()); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts new file mode 100644 index 000000000..001f0c6ed --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts @@ -0,0 +1,117 @@ +/** + * The `devUrl` and `devStop` executor: config → each extension's local + * target → the app it recorded in the directory's dev state. Reached only by + * lazy import from dev-current.ts — resolving the local targets loads their + * descriptors, whose graphs reach alchemy's provider tree. + */ +import * as path from 'node:path'; +import type { LocalTargetCurrentApp } from '@internal/core/local-target'; +import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; +import { CliStructuredError } from '@internal/foundation/errors'; +import { notOk, ok, type Result } from '@internal/foundation/result'; +import { checkConfigSource } from '../pipeline.ts'; +import type { DevStopInput, DevStopped, DevUrl, DevUrlInput } from './dev-current.ts'; +import { toStructured } from './shared.ts'; + +const RUN_DEV = 'Run `prisma dev ` in this directory.'; + +function platformUnsupported(): CliStructuredError { + return new CliStructuredError( + 'DEV.PLATFORM_UNSUPPORTED', + 'local dev is not supported on Windows yet.', + ); +} + +/** Every extension's record of the app `prisma dev` last ran from `cwd`; fails when none recorded one. */ +async function currentApps( + input: { readonly config: DevUrlInput['config'] }, + cwd: string, +): Promise { + const { value } = checkConfigSource(input.config, cwd); + const devDir = path.join(cwd, DEV_DIR); + const apps: LocalTargetCurrentApp[] = []; + for (const target of (await resolveLocalTargets(value)).values()) { + const current = await target.current?.({ devDir }); + if (current !== undefined) apps.push(current); + } + if (apps.length === 0) { + throw new CliStructuredError('DEV.NOT_STARTED_HERE', `\`prisma dev\` has not run in ${cwd}.`, { + why: `The local services, databases and buckets of an app are recorded in ${devDir} when \`prisma dev\` runs there, and there is no such record.`, + fix: RUN_DEV, + meta: { cwd }, + }); + } + return apps; +} + +async function locate(input: DevUrlInput, cwd: string): Promise { + const apps = await currentApps(input, cwd); + const app = apps.find((candidate) => candidate.addresses.includes(input.address)); + if (app === undefined) { + const known = apps.flatMap((candidate) => candidate.addresses).sort(); + throw new CliStructuredError( + 'DEV.ADDRESS_UNKNOWN', + `"${apps.map((a) => a.app).join('", "')}" has no node at "${input.address}".`, + { + why: `The addresses recorded for the app \`prisma dev\` last ran here are: ${known.join(', ')}.`, + fix: 'Pass one of those addresses. A node in a nested module has a dotted address, such as `catalog.database`.', + meta: { address: input.address, known }, + }, + ); + } + const location = await app.locate(input.address); + if (location === undefined) + throw new Error(`no location for recorded address "${input.address}"`); + if (location.emulator === 'not-running') { + throw new CliStructuredError( + 'DEV.EMULATOR_UNREACHABLE', + `The local ${location.kind} emulator that runs "${input.address}" is not running.`, + { + why: 'The local emulators run your services, databases and buckets on this machine; `prisma dev` starts the ones an app needs.', + fix: RUN_DEV, + meta: { address: input.address, kind: location.kind }, + }, + ); + } + if (location.state !== 'running' || location.url === undefined) { + throw new CliStructuredError( + 'DEV.NOT_RUNNING', + `The ${location.kind} "${input.address}" is not running (${location.state}).`, + { + why: '`prisma dev` stops the app when it ends, and a stopped node has no URL that answers.', + fix: RUN_DEV, + meta: { address: input.address, kind: location.kind, state: location.state }, + }, + ); + } + return { address: input.address, kind: location.kind, url: location.url, state: location.state }; +} + +export async function executeDevUrl( + input: DevUrlInput, + cwd: string, +): Promise> { + if (process.platform === 'win32') return notOk(platformUnsupported()); + try { + return ok(await locate(input, cwd)); + } catch (error) { + // An emulator's own failure (not "unreachable", which arrives + // structured) reaches here plain. + return notOk(toStructured('DEV.EMULATOR_FAILED', error)); + } +} + +export async function executeDevStop( + input: DevStopInput, + cwd: string, +): Promise> { + if (process.platform === 'win32') return notOk(platformUnsupported()); + try { + const apps = await currentApps(input, cwd); + const stopped = []; + for (const app of apps) stopped.push(...(await app.stop())); + return ok({ app: apps.map((a) => a.app).join(', '), stopped }); + } catch (error) { + return notOk(toStructured('DEV.EMULATOR_FAILED', error)); + } +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts index 73ff39ce5..f71e36ed4 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -115,7 +115,7 @@ export function toStructured(code: `${string}.${string}`, error: unknown): CliSt /** The operation whose executor failed to load — named in the failure so a * host driving several operations can tell which one broke. */ -export type OperationName = 'deploy' | 'destroy' | 'dev' | 'log'; +export type OperationName = 'deploy' | 'destroy' | 'dev' | 'dev url' | 'dev stop' | 'log'; /** A failed executor import: an environmental failure of the consumer's * installed tree, not a bug here. The import error rides as the cause. */ diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts new file mode 100644 index 000000000..8e8a3e189 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts @@ -0,0 +1,55 @@ +/** The emulator listing with nothing running, and with an emulator running. */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { computeClient } from '../client.ts'; +import { stopDaemon } from '../daemon.ts'; +import { listEmulators } from '../emulator-list.ts'; +import { ensureFreshDaemon, tempDir } from './helpers.ts'; + +let registryRoot: string; + +beforeEach(() => { + registryRoot = tempDir('emulator-list-registry'); +}); + +afterEach(async () => { + await stopDaemon('compute', { registryRoot }).catch(() => undefined); +}); + +test('with nothing running, every emulator is listed as not running, with no instances', async () => { + const result = await listEmulators({ registryRoot }); + + expect(result).toMatchObject({ + ok: true, + value: { + emulators: [ + { kind: 'service', running: false, instances: [] }, + { kind: 'database', running: false, instances: [] }, + { kind: 'bucket', running: false, instances: [] }, + ], + }, + }); +}); + +test("a running emulator is listed with its admin URL and its instances' labels", async () => { + await ensureFreshDaemon('compute', registryRoot); + const client = computeClient({ registryRoot }); + const instance = await client.createInstance({ labels: { 'composer.address': 'gateway' } }); + + const result = await listEmulators({ registryRoot }); + + if (!result.ok) throw new Error(result.failure.message); + expect(result.value.emulators[0]).toEqual({ + kind: 'service', + running: true, + url: client.baseUrl, + instances: [ + { + id: instance.id, + kind: 'service', + url: instance.url, + state: 'stopped', + labels: { 'composer.address': 'gateway' }, + }, + ], + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts new file mode 100644 index 000000000..dc92a9b24 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts @@ -0,0 +1,127 @@ +/** + * Every local emulator on this machine and every instance on it — the + * operation behind `prisma emulator list`. It needs no project: it reads the + * emulators' registry and asks each running emulator for its instances. + */ +import { CliStructuredError } from '@internal/foundation/errors'; +import { notOk, ok, type Result } from '@internal/foundation/result'; +import { bucketsClient, computeClient, postgresClient } from './client.ts'; +import { + type DaemonName, + type DaemonRootOptions, + defaultRegistryRoot, + isPidAlive, + readRegistryEntry, +} from './daemon.ts'; + +/** What an emulator's instances are. */ +export type EmulatorKind = 'service' | 'database' | 'bucket'; + +export const EMULATOR_KINDS: readonly EmulatorKind[] = ['service', 'database', 'bucket']; + +const DAEMON_OF: Record = { + service: 'compute', + database: 'postgres', + bucket: 'buckets', +}; + +export interface EmulatorInstance { + readonly id: string; + readonly kind: EmulatorKind; + /** A service's URL, a database's connection string, or a bucket's S3 endpoint plus `/`. */ + readonly url: string; + /** A service's status, a database's `running` or `stopped`, or `running` for a bucket. */ + readonly state: string; + readonly labels: Readonly>; +} + +export interface EmulatorListing { + readonly kind: EmulatorKind; + readonly running: boolean; + /** The emulator's admin URL; absent when it is not running. */ + readonly url?: string; + /** Empty when the emulator is not running. */ + readonly instances: readonly EmulatorInstance[]; +} + +export interface EmulatorList { + readonly emulators: readonly EmulatorListing[]; +} + +/** Whether the emulator for `kind` is registered with a live process. */ +export async function isEmulatorRunning( + kind: EmulatorKind, + opts: DaemonRootOptions = {}, +): Promise { + const entry = await readRegistryEntry( + opts.registryRoot ?? defaultRegistryRoot(), + DAEMON_OF[kind], + ); + return entry !== undefined && isPidAlive(entry.pid); +} + +async function instancesOf( + kind: EmulatorKind, + opts: DaemonRootOptions, +): Promise<{ readonly url: string; readonly instances: EmulatorInstance[] }> { + if (kind === 'service') { + const client = computeClient(opts); + const instances = await client.listInstances(); + return { + url: client.baseUrl, + instances: instances.map(({ id, url, status, labels }) => ({ + id, + kind, + url, + state: status, + labels, + })), + }; + } + if (kind === 'database') { + const client = postgresClient(opts); + const instances = await client.listInstances(); + return { + url: client.baseUrl, + instances: instances.map(({ id, url, status, labels }) => ({ + id, + kind, + url, + state: status, + labels, + })), + }; + } + const client = bucketsClient(opts); + const instances = await client.listInstances(); + return { + url: client.baseUrl, + instances: instances.map(({ id, labels }) => ({ + id, + kind, + url: `${client.baseUrl}/${id}`, + state: 'running', + labels, + })), + }; +} + +/** Every emulator, whether it runs, and its instances. Nothing running is a valid answer, not a failure. */ +export async function listEmulators( + opts: DaemonRootOptions = {}, +): Promise> { + try { + const emulators: EmulatorListing[] = []; + for (const kind of EMULATOR_KINDS) { + if (!(await isEmulatorRunning(kind, opts))) { + emulators.push({ kind, running: false, instances: [] }); + continue; + } + emulators.push({ kind, running: true, ...(await instancesOf(kind, opts)) }); + } + return ok({ emulators }); + } catch (error) { + if (CliStructuredError.is(error)) return notOk(error); + throw error; + } +} diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts index bffef9981..78b20f407 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts @@ -32,3 +32,10 @@ export { registryFilePath, stopDaemon, } from '../daemon.ts'; +export type { + EmulatorInstance, + EmulatorKind, + EmulatorList, + EmulatorListing, +} from '../emulator-list.ts'; +export { EMULATOR_KINDS, isEmulatorRunning, listEmulators } from '../emulator-list.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts new file mode 100644 index 000000000..af4003b57 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts @@ -0,0 +1,70 @@ +/** + * The app `prisma dev` last ran from a directory, read from the directory's + * record alone: where each of its nodes runs, and stopping it. Behind + * `prisma dev url` and `prisma dev stop`. + */ +import type { + LocalNodeLocation, + LocalNodeStopped, + LocalTargetCurrentApp, +} from '@internal/core/config'; +import { + bucketsClient, + computeClient, + type EmulatorKind, + isEmulatorRunning, + postgresClient, +} from '@internal/dev-emulators'; +import { + directoryOf, + type NodeInstance, + readEmulatorInstances, + stopAppInstances, +} from './emulator-instances.ts'; + +/** A node's state when its emulator no longer has the instance the record names. */ +const MISSING = 'missing'; + +async function locate(address: string, node: NodeInstance): Promise { + const kind: EmulatorKind = node.kind; + if (!(await isEmulatorRunning(kind))) return { address, kind, emulator: 'not-running' }; + if (node.kind === 'service') { + const instance = await computeClient().getInstance(node.id); + return instance + ? { address, kind, emulator: 'running', url: instance.url, state: instance.status } + : { address, kind, emulator: 'running', state: MISSING }; + } + if (node.kind === 'database') { + const instance = await postgresClient().getInstance(node.id); + return instance + ? { address, kind, emulator: 'running', url: instance.url, state: instance.status } + : { address, kind, emulator: 'running', state: MISSING }; + } + const client = bucketsClient(); + const instance = await client.getInstance(node.id); + return instance + ? { address, kind, emulator: 'running', url: `${client.baseUrl}/${node.id}`, state: 'running' } + : { address, kind, emulator: 'running', state: MISSING }; +} + +/** The directory's current app; undefined when `prisma dev` never ran there. */ +export async function currentApp(devDir: string): Promise { + const record = await readEmulatorInstances(directoryOf(devDir)); + if (record === undefined) return undefined; + const app = record.current; + const nodes = record.apps[app]?.nodes ?? {}; + return { + app, + addresses: Object.keys(nodes), + locate: async (address) => { + const node = nodes[address]; + return node === undefined ? undefined : locate(address, node); + }, + stop: async (): Promise => { + await stopAppInstances({ devDir, app }); + return Object.entries(nodes).flatMap(([address, node]) => + node.kind === 'bucket' ? [] : [{ address, kind: node.kind }], + ); + }, + }; +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts index ed99e85c3..c174c1b21 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts @@ -4,6 +4,7 @@ export * from '../app-instances.ts'; export * from '../app-name.ts'; export * from '../bucket.ts'; export * from '../compute.ts'; +export * from '../current-app.ts'; export * from '../dev-store.ts'; export * from '../emulator-instances.ts'; export * from '../postgres.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index dd081af8a..e6a82a728 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -38,6 +38,7 @@ "pg": "8.22.0" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1085.0", "@distilled.cloud/prisma": "1.0.0-rc.12", "@internal/tsdown-config": "workspace:0.29.1", "@prisma/cli-engine": "0.7.0", diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts new file mode 100644 index 000000000..e6044c041 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts @@ -0,0 +1,213 @@ +/** + * The directory's current app, read from its record alone — what + * `prisma dev url` and `prisma dev stop` act on — and the emulator listing, + * against real Compute, Postgres and bucket emulators in a temporary + * emulators directory. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ListObjectsV2Command, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import type { LocalTargetCurrentApp } from '@internal/core/local-target'; +import { + bucketsClient, + computeClient, + type DaemonName, + EMULATORS_DIR_ENV, + ensureDaemon, + listEmulators, + postgresClient, + stopDaemon, +} from '@internal/dev-emulators'; +import { + type AppNode, + deleteAppInstances, + ensureAppInstances, + readEmulatorInstances, +} from '@internal/local-target'; +import pg from 'pg'; +import { localTargetDescriptor } from '../local-target/descriptor.ts'; + +const APP = 'current-app'; +const NODES: readonly AppNode[] = [ + { address: 'gateway', kind: 'service' }, + { address: 'catalog', kind: 'database' }, + { address: 'uploads', kind: 'bucket' }, +]; +const DAEMONS: readonly DaemonName[] = ['compute', 'postgres', 'buckets']; +const SERVING_BOOTSTRAP = + "Bun.serve({ port: Number(process.env['PORT']), fetch: () => new Response('hello from gateway') });\n"; +const savedEmulatorsDir = process.env[EMULATORS_DIR_ENV]; + +function acceptsConnection(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: '127.0.0.1' }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); +} + +// Local dev is not supported on Windows, where the emulators cannot run. +describe.skipIf(process.platform === 'win32')("the directory's current app", () => { + let root: string; + let registryRoot: string; + let directories: string[]; + + beforeEach(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'target-current-')); + registryRoot = path.join(root, 'emulators'); + process.env[EMULATORS_DIR_ENV] = registryRoot; + for (const name of DAEMONS) { + const entry = fileURLToPath(import.meta.resolve(`@internal/dev-emulators/${name}-main`)); + await ensureDaemon(name, entry, { registryRoot }); + } + directories = []; + }); + + afterEach(async () => { + for (const directory of directories) { + const devDir = path.join(directory, '.prisma-composer', 'dev'); + await deleteAppInstances({ devDir, app: APP }).catch(() => undefined); + } + for (const name of DAEMONS) await stopDaemon(name, { registryRoot }).catch(() => undefined); + fs.rmSync(root, { recursive: true, force: true }); + if (savedEmulatorsDir === undefined) delete process.env[EMULATORS_DIR_ENV]; + else process.env[EMULATORS_DIR_ENV] = savedEmulatorsDir; + }); + + /** Runs the app's nodes from a new directory, as `prisma dev` leaves them: the service deployed and serving, the bucket with a key. */ + async function runApp(name: string): Promise<{ directory: string; devDir: string }> { + const directory = path.join(root, name); + directories.push(directory); + const devDir = path.join(directory, '.prisma-composer', 'dev'); + await ensureAppInstances({ devDir, app: APP }, NODES); + const nodes = (await readEmulatorInstances(directory))?.apps[APP]?.nodes ?? {}; + const gateway = nodes['gateway']; + const uploads = nodes['uploads']; + if (gateway?.kind !== 'service' || uploads === undefined) throw new Error('not recorded'); + const artifactDir = path.join(root, `${name}-artifact`); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.writeFileSync(path.join(artifactDir, 'bootstrap.js'), SERVING_BOOTSTRAP); + await computeClient().putDeployment(gateway.id, { + artifactDir, + artifactHash: 'v1', + env: { PATH: process.env['PATH'] ?? '', PORT: String(gateway.port) }, + }); + await bucketsClient().putCredentials(uploads.id, `AKIA${name.toUpperCase()}`, 'secret'); + return { directory, devDir }; + } + + async function currentOf(devDir: string): Promise { + const current = await localTargetDescriptor().current?.({ devDir }); + if (current === undefined) throw new Error('no current app'); + return current; + } + + async function urlOf(current: LocalTargetCurrentApp, address: string): Promise { + const location = await current.locate(address); + if (location?.emulator !== 'running' || location.url === undefined) { + throw new Error(`"${address}" is not running`); + } + expect(location.state).toBe('running'); + return location.url; + } + + test('gives a URL that answers for a service, a database and a bucket', async () => { + const { devDir } = await runApp('a'); + const current = await currentOf(devDir); + + expect(current.app).toBe(APP); + expect([...current.addresses].sort()).toEqual(['catalog', 'gateway', 'uploads']); + + const service = await urlOf(current, 'gateway'); + expect(await (await fetch(service)).text()).toBe('hello from gateway'); + + const database = new pg.Client({ connectionString: await urlOf(current, 'catalog') }); + await database.connect(); + expect((await database.query('select 1 as one')).rows).toEqual([{ one: 1 }]); + await database.end(); + + const bucketUrl = new URL(await urlOf(current, 'uploads')); + const bucket = bucketUrl.pathname.slice(1); + const s3 = new S3Client({ + region: 'auto', + endpoint: bucketUrl.origin, + forcePathStyle: true, + credentials: { accessKeyId: 'AKIAA', secretAccessKey: 'secret' }, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + maxAttempts: 1, + }); + await s3.send(new PutObjectCommand({ Bucket: bucket, Key: 'hello.txt', Body: 'hi' })); + const listed = await s3.send(new ListObjectsV2Command({ Bucket: bucket })); + expect(listed.Contents?.map((o) => o.Key)).toEqual(['hello.txt']); + }, 90_000); + + test("stopping leaves none of the app's processes or Postgres servers running", async () => { + const { directory, devDir } = await runApp('a'); + const current = await currentOf(devDir); + const nodes = (await readEmulatorInstances(directory))?.apps[APP]?.nodes ?? {}; + const gateway = nodes['gateway']; + const catalog = nodes['catalog']; + if (gateway?.kind !== 'service' || catalog?.kind !== 'database') + throw new Error('not recorded'); + + const stopped = await current.stop(); + + expect([...stopped].sort((a, b) => a.address.localeCompare(b.address))).toEqual([ + { address: 'catalog', kind: 'database' }, + { address: 'gateway', kind: 'service' }, + ]); + expect(await computeClient().getInstance(gateway.id)).toMatchObject({ status: 'stopped' }); + expect(await postgresClient().getInstance(catalog.id)).toMatchObject({ status: 'stopped' }); + expect(await acceptsConnection(gateway.port)).toBe(false); + expect(await acceptsConnection(catalog.port)).toBe(false); + expect(await current.locate('catalog')).toMatchObject({ state: 'stopped' }); + }, 90_000); + + test('a directory where `prisma dev` never ran has no current app', async () => { + const devDir = path.join(root, 'never', '.prisma-composer', 'dev'); + + expect(await localTargetDescriptor().current?.({ devDir })).toBeUndefined(); + }); + + test('a node whose emulator is not running says so', async () => { + const { devDir } = await runApp('a'); + await stopDaemon('buckets', { registryRoot }); + + expect(await (await currentOf(devDir)).locate('uploads')).toEqual({ + address: 'uploads', + kind: 'bucket', + emulator: 'not-running', + }); + }, 60_000); + + test('the emulator listing shows the instances of two directories with their labels', async () => { + const a = await runApp('a'); + const b = await runApp('b'); + + const result = await listEmulators(); + + if (!result.ok) throw new Error(result.failure.message); + const { emulators } = result.value; + expect(emulators.map((e) => [e.kind, e.running])).toEqual([ + ['service', true], + ['database', true], + ['bucket', true], + ]); + for (const emulator of emulators) { + const directoriesOf = emulator.instances.map((i) => i.labels['composer.directory']).sort(); + expect(directoriesOf).toEqual([a.directory, b.directory].sort()); + for (const instance of emulator.instances) { + expect(instance.kind).toBe(emulator.kind); + expect(instance.labels['composer.app']).toBe(APP); + } + } + }, 90_000); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts b/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts index f68ffafbd..8446072b7 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts @@ -3,10 +3,18 @@ * entry from `./control`. `control/extension.ts` loads `localTargetDescriptor` * through its lazy `localTarget` reference; the descriptor lives in * `../local-target/descriptor.ts`. The entry also exposes the emulator - * registry root and the variable that sets it, from `@internal/dev-emulators`. + * registry root, the variable that sets it, and the `listEmulators` + * operation behind `prisma emulator list`, from `@internal/dev-emulators`. */ +export type { + EmulatorInstance, + EmulatorKind, + EmulatorList, + EmulatorListing, +} from '@internal/dev-emulators'; export { defaultRegistryRoot as emulatorRegistryRoot, EMULATORS_DIR_ENV, + listEmulators, } from '@internal/dev-emulators'; export { localTargetDescriptor } from '../local-target/descriptor.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/descriptor.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/descriptor.ts index 7edc520c0..f0253ac77 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/descriptor.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/descriptor.ts @@ -12,7 +12,7 @@ */ import type { LocalTargetDescriptor } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; -import { localTargetProviders } from '@internal/local-target'; +import { currentApp, localTargetProviders } from '@internal/local-target'; import * as Prisma from '@internal/lowering'; import * as Layer from 'effect/Layer'; import { GeneratedParamProvider } from '../generated-param-resource.ts'; @@ -56,5 +56,6 @@ export function localTargetDescriptor(): LocalTargetDescriptor { emulators: (input) => runDevEmulators(input), attach: (input) => devAttach(input), teardown: (input) => runDevTeardown(input), + current: (input) => currentApp(input.devDir), }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52171578e..448d4dfa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1032,6 +1032,9 @@ importers: specifier: 8.22.0 version: 8.22.0 devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.1085.0 + version: 3.1087.0 '@distilled.cloud/prisma': specifier: 1.0.0-rc.12 version: 1.0.0-rc.12(effect@4.0.0-rc.115) From 169c381206e36135a4f84d9cef6deeafd56939e5 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:23:34 +0200 Subject: [PATCH 03/14] feat(cli): `dev url` and `dev stop` commands The family ships two more commands, keyed by the path they are meant to mount at; the host decides where each one mounts. - `dev url
` prints a node's local URL. In human format the URL alone goes to stdout, so `$(prisma dev url gateway --format human)` is the URL; the JSON result is `{ address, kind, url, state }`. - `dev stop` stops the app `prisma dev` last ran from the directory and returns `{ app, stopped }`. Both declare the `composer` config section. `createComposerFamily` takes extra `commands`, so the published family can add `emulator list`, and the operations double covers `devUrl` and `devStop`. The `dev` command's description now defines what the commands beneath it rely on: an emulator, a node's address, and that the app is recorded in the directory. The family's tests mount `deploy` and `dev` explicitly, since the engine they build against (0.7.0) cannot mount commands beneath a command. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/family/__tests__/command-at.ts | 11 ++ .../cli/src/family/__tests__/deploy.test.ts | 6 +- .../__tests__/dev-current-commands.test.ts | 104 ++++++++++++++++++ .../__tests__/dev-emulators-dir.test.ts | 3 +- .../family/__tests__/dev-interrupt.test.ts | 3 +- .../cli/src/family/__tests__/family.test.ts | 19 +++- .../cli/src/family/commands/dev-stop.ts | 57 ++++++++++ .../cli/src/family/commands/dev-url.ts | 62 +++++++++++ .../3-tooling/cli/src/family/commands/dev.ts | 8 +- .../3-tooling/cli/src/family/family.ts | 19 +++- .../cli/src/testing/operations-double.ts | 38 ++++++- 11 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/family/__tests__/command-at.ts create mode 100644 packages/0-framework/3-tooling/cli/src/family/__tests__/dev-current-commands.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/family/commands/dev-stop.ts create mode 100644 packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/command-at.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/command-at.ts new file mode 100644 index 000000000..6704de2c5 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/command-at.ts @@ -0,0 +1,11 @@ +import type { CommandFamily } from '@prisma/cli-engine'; + +/** The family's command at `key`; a test that names a command the family lacks fails here. */ +export function commandAt( + family: CommandFamily, + key: string, +): CommandFamily['commands'][string] & {} { + const command = family.commands[key]; + if (command === undefined) throw new Error(`the family has no "${key}" command`); + return command; +} diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts index 832898fd8..6ed080eea 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts @@ -13,6 +13,7 @@ import type { ScriptedChildProgram, TestCli } from '@prisma/cli-engine/testing'; import { createTestCli, mintTestJwt } from '@prisma/cli-engine/testing'; import { createOperationsDouble, type OperationsDouble } from '../../testing/operations-double.ts'; import { createComposerFamily } from '../family.ts'; +import { commandAt } from './command-at.ts'; import { validComposerSection } from './fixtures/composer-section.ts'; type Fixtures = Parameters[0]; @@ -70,7 +71,10 @@ function composerCli( const family = createComposerFamily({ operations: double.operations }); const cli = createTestCli({ commandFamilies: [family], - commands: family.commands, + // The two product verbs, at the root. `dev url` and `dev stop` mount + // beneath `dev`, which needs an engine that lets a command also be a + // group; their own tests mount them alone. + commands: { deploy: commandAt(family, 'deploy'), dev: commandAt(family, 'dev') }, config: { composer: validComposerSection() }, ...(spec.signedIn === false ? {} : { credential: activeCredential() }), ...(spec.spawnScript === undefined ? {} : { spawnScript: spec.spawnScript }), diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-current-commands.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-current-commands.test.ts new file mode 100644 index 000000000..bcce22307 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-current-commands.test.ts @@ -0,0 +1,104 @@ +/** + * `dev url` and `dev stop` through the engine, over the operations double: + * the URL as the human data line, the JSON results, and failures carrying + * their code and fix. + */ +import { describe, expect, test } from 'bun:test'; +import { CliStructuredError } from '@internal/foundation/errors'; +import { notOk } from '@internal/foundation/result'; +import { createTestCli } from '@prisma/cli-engine/testing'; +import { + createOperationsDouble, + type OperationsDoubleFixtures, +} from '../../testing/operations-double.ts'; +import { createComposerFamily } from '../family.ts'; +import { commandAt } from './command-at.ts'; + +function composerConfig() { + return { + extensions: [{ id: 'ext-a', nodes: {}, providers: () => 'layer' }], + state: { extension: 'ext-a', create: () => 'state' }, + }; +} + +function cliWith(fixtures: OperationsDoubleFixtures = {}) { + const double = createOperationsDouble(fixtures); + const family = createComposerFamily({ operations: double.operations }); + const cli = createTestCli({ + commandFamilies: [family], + commands: { + 'dev url': commandAt(family, 'dev url'), + 'dev stop': commandAt(family, 'dev stop'), + }, + groups: { dev: { brief: 'Run the app on this machine.' } }, + config: { composer: composerConfig() }, + }); + return { cli, calls: double.calls }; +} + +describe('dev url', () => { + test('prints the URL alone on stdout in human format', async () => { + const { cli, calls } = cliWith(); + + const result = await cli.run(['dev', 'url', 'gateway', '--format', 'human']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('http://localhost:3000\n'); + expect(calls.devUrl.map((input) => input.address)).toEqual(['gateway']); + }); + + test('returns address, kind, url and state as the JSON result', async () => { + const { cli } = cliWith({ + devUrl: { + ok: true, + value: { address: 'catalog', kind: 'database', url: 'postgres://x', state: 'running' }, + } as never, + }); + + const result = await cli.run(['dev', 'url', 'catalog', '--json']); + + expect(result.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: true, + result: { address: 'catalog', kind: 'database', url: 'postgres://x', state: 'running' }, + }, + }); + }); + + test('a failure keeps its code, and its fix becomes the next action', async () => { + const { cli } = cliWith({ + devUrl: notOk( + new CliStructuredError('DEV.ADDRESS_UNKNOWN', 'no node at "quotes".', { + fix: 'Pass one of those addresses.', + }), + ), + }); + + const result = await cli.run(['dev', 'url', 'quotes', '--json']); + + expect(result.exitCode).not.toBe(0); + expect(result.json.at(-1)).toMatchObject({ + envelope: { + ok: false, + error: { code: 'DEV.ADDRESS_UNKNOWN' }, + nextActions: [{ kind: 'user-choice', label: 'Pass one of those addresses.' }], + }, + }); + }); +}); + +describe('dev stop', () => { + test('returns what it stopped as the JSON result and writes nothing to stdout in human format', async () => { + const stopped = { app: 'shop', stopped: [{ address: 'gateway', kind: 'service' }] }; + const { cli, calls } = cliWith({ devStop: { ok: true, value: stopped } as never }); + + const json = await cli.run(['dev', 'stop', '--json']); + const human = await cli.run(['dev', 'stop', '--format', 'human']); + + expect(json.json.at(-1)).toMatchObject({ envelope: { ok: true, result: stopped } }); + expect(human.exitCode).toBe(0); + expect(human.stdout).toBe(''); + expect(calls.devStop).toHaveLength(2); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-emulators-dir.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-emulators-dir.test.ts index 9192a6110..a7cfda8cb 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-emulators-dir.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-emulators-dir.test.ts @@ -17,6 +17,7 @@ import { createTestCli } from '@prisma/cli-engine/testing'; import * as Layer from 'effect/Layer'; import { devWithDeps } from '../../operations/dev.ts'; import { createComposerFamily, realOperations } from '../family.ts'; +import { commandAt } from './command-at.ts'; const coreIndex = path.resolve(import.meta.dir, '../../../../../1-core/core/src/exports/index.ts'); @@ -127,7 +128,7 @@ describe.skipIf(process.platform === 'win32')( }); const cli = createTestCli({ commandFamilies: [family], - commands: { ...family.commands }, + commands: { dev: commandAt(family, 'dev') }, config: { composer: configWhoseEmulatorsThrow() }, }); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-interrupt.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-interrupt.test.ts index 86666049f..8feacd3f3 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-interrupt.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-interrupt.test.ts @@ -14,6 +14,7 @@ import type { EngineEvent } from '@prisma/cli-engine'; import { createTestCli } from '@prisma/cli-engine/testing'; import { createOperationsDouble } from '../../testing/operations-double.ts'; import { createComposerFamily } from '../family.ts'; +import { commandAt } from './command-at.ts'; import { validComposerSection } from './fixtures/composer-section.ts'; const AS_TTY = { isTty: { stdout: true, stderr: true } } as const; @@ -26,7 +27,7 @@ describe('dev, interrupted', () => { const family = createComposerFamily({ operations: double.operations }); const cli = createTestCli({ commandFamilies: [family], - commands: { ...family.commands }, + commands: { dev: commandAt(family, 'dev') }, config: { composer: validComposerSection() }, }); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/family.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/family.test.ts index c8f50ed7c..54556be6f 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/family.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/family.test.ts @@ -16,8 +16,21 @@ describe('createComposerFamily()', () => { expect(createComposerFamily().configSection).toBe(composerSection); }); - test('the family mounts deploy and dev only — destroy and log are retired', () => { - expect(Object.keys(createComposerFamily().commands).sort()).toEqual(['deploy', 'dev']); + test('the family ships deploy, dev, dev url and dev stop — destroy and log are operations, not commands', () => { + expect(Object.keys(createComposerFamily().commands).sort()).toEqual([ + 'deploy', + 'dev', + 'dev stop', + 'dev url', + ]); + }); + + test('a host may add commands to the family', () => { + const extra = createComposerFamily().commands['dev url']; + if (extra === undefined) throw new Error('no dev url command'); + expect( + Object.keys(createComposerFamily({ commands: { 'emulator list': extra } }).commands).sort(), + ).toEqual(['deploy', 'dev', 'dev stop', 'dev url', 'emulator list']); }); test('the family ships no redirects for the retired spellings', () => { @@ -27,6 +40,8 @@ describe('createComposerFamily()', () => { test('the operations seam defaults to the real control operations', () => { expect(realOperations.deploy).toBeInstanceOf(Function); expect(realOperations.dev).toBeInstanceOf(Function); + expect(realOperations.devUrl).toBeInstanceOf(Function); + expect(realOperations.devStop).toBeInstanceOf(Function); }); test('a host may substitute the operations', () => { diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev-stop.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev-stop.ts new file mode 100644 index 000000000..38be10a6e --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev-stop.ts @@ -0,0 +1,57 @@ +/** + * `dev stop` — stops the app `prisma dev` last ran from this directory, as + * ending `prisma dev` would: its service processes and database servers. + * For an app whose `prisma dev` was killed without the chance to stop it. + */ +import { defineCommand } from '@prisma/cli-engine'; +import { notOk, ok } from '@prisma/cli-engine/protocol'; +import type { ComposerOperations } from '../family.ts'; +import { composerSection } from '../section.ts'; +import { toEngineError } from '../translate-error.ts'; + +export const createDevStopCommand = (operations: ComposerOperations) => + defineCommand({ + help: { + summary: + 'Stop the app `prisma dev` last ran from this directory: its service processes and ' + + 'local database servers. Data and ports are kept for the next `prisma dev`.', + description: + 'Ending `prisma dev` with Ctrl-C does the same. Run this when `prisma dev` was ' + + 'killed without a chance to stop the app, or before deleting the directory. The ' + + 'emulators keep running; other directories may be using them.', + examples: ['{bin} dev stop'], + }, + args: {}, + needs: { config: composerSection }, + handler: async (_args, ctx) => { + const result = await operations.devStop({ config: ctx.config, cwd: ctx.cwd }); + if (!result.ok) return notOk(toEngineError(result.failure)); + const { app, stopped } = result.value; + return ok( + ctx.present( + { data: result.value }, + { + human: (ui) => [ + { + kind: 'summary', + status: 'ok', + text: `Stopped ${ui.emphasize(app)}. Its data and ports are kept for the next \`prisma dev\`.`, + }, + ...(stopped.length === 0 + ? [] + : [ + { + kind: 'table' as const, + columns: ['Address', 'Kind'], + rows: stopped.map(({ address, kind }) => [address, kind]), + }, + ]), + ], + stdout: () => [], + json: () => result.value, + next: () => [], + }, + ), + ); + }, + }); diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts new file mode 100644 index 000000000..4aaee380d --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts @@ -0,0 +1,62 @@ +/** + * `dev url
` — where a node of the app `prisma dev` runs from this + * directory answers locally. A data command: the URL is its stdout line, so + * `$(prisma dev url gateway --format human)` is the URL alone. + */ +import { defineCommand, positional } from '@prisma/cli-engine'; +import { notOk, ok } from '@prisma/cli-engine/protocol'; +import type { ComposerOperations } from '../family.ts'; +import { composerSection } from '../section.ts'; +import { toEngineError } from '../translate-error.ts'; + +export const createDevUrlCommand = (operations: ComposerOperations) => + defineCommand({ + help: { + summary: + 'Print the local URL of a node of the app `prisma dev` runs from this directory: a ' + + "service's HTTP URL, a database's connection string, or a bucket's S3 address.", + description: + 'Reads what the last `prisma dev` in this directory recorded and starts nothing, so ' + + 'run it while `prisma dev` is running; a node that is not running is an error. With ' + + '--format human the URL alone goes to stdout. In a script, the JSON result holds it: ' + + `prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url'.`, + examples: ['{bin} dev url gateway', '{bin} dev url catalog.database --json'], + }, + args: { + positionals: { + address: positional.string({ + brief: + "The node's address: its name in the app, dotted for a node inside a module, such " + + 'as catalog.database.', + placeholder: 'address', + }), + }, + }, + needs: { config: composerSection }, + handler: async (args, ctx) => { + const result = await operations.devUrl({ + config: ctx.config, + address: args.positionals.address, + cwd: ctx.cwd, + }); + if (!result.ok) return notOk(toEngineError(result.failure)); + const node = result.value; + return ok( + ctx.present( + { data: node }, + { + human: (ui) => [ + { + kind: 'summary', + status: 'ok', + text: `The ${node.kind} ${ui.emphasize(node.address)} is ${node.state} at ${node.url}`, + }, + ], + stdout: () => [node.url], + json: () => node, + next: () => [], + }, + ), + ); + }, + }); diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts index 32c2bbb85..9ead059ef 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts @@ -183,7 +183,13 @@ export const createDevCommand = (operations: ComposerOperations) => "Bring up the application whose root node is 's default export, entirely on " + 'this machine.', description: - 'Runs credential-free and watches the app for changes, reconverging on every edit.', + 'Runs credential-free and watches the app for changes, reconverging on every edit. ' + + 'Each node of the app runs on a local emulator: a program on this machine that stands ' + + 'in for Prisma Cloud and runs services, databases or S3-compatible buckets. ' + + "A node's address is its name in the app, dotted for a node inside a module " + + '(catalog.database). The app and where each node runs are recorded in this directory, ' + + 'so the commands beneath work from here while `prisma dev` runs, and after it ends. ' + + 'Ending `prisma dev` stops its services and database servers; data and ports are kept.', examples: ['{bin} dev module.ts', '{bin} dev module.ts --fresh'], }, args: { diff --git a/packages/0-framework/3-tooling/cli/src/family/family.ts b/packages/0-framework/3-tooling/cli/src/family/family.ts index 7f5e473fe..f95d69ad7 100644 --- a/packages/0-framework/3-tooling/cli/src/family/family.ts +++ b/packages/0-framework/3-tooling/cli/src/family/family.ts @@ -15,14 +15,20 @@ * mount this family against the test double and exercise real grammar, real * arg validation and real handlers with no alchemy behind them. * - * The family ships `deploy` and `dev` only. `destroy` and `log` are - * programmatic operations in `@prisma/composer/control`, not commands. + * The family ships `deploy`, `dev`, `dev url` and `dev stop`, keyed by the + * path they are meant to mount at; the host decides where each one mounts. + * A host may add commands of its own through `commands` — the published + * family adds `emulator list`, which needs no project. `destroy` and `log` + * are programmatic operations in `@prisma/composer/control`, not commands. */ import { type CommandFamily, defineCommandFamily } from '@prisma/cli-engine'; import { deployWithDeps } from '../operations/deploy.ts'; import { devWithDeps } from '../operations/dev.ts'; +import { devStop, devUrl } from '../operations/dev-current.ts'; import { createDeployCommand } from './commands/deploy.ts'; import { createDevCommand } from './commands/dev.ts'; +import { createDevStopCommand } from './commands/dev-stop.ts'; +import { createDevUrlCommand } from './commands/dev-url.ts'; import { composerSection } from './section.ts'; /** @@ -36,16 +42,22 @@ import { composerSection } from './section.ts'; export interface ComposerOperations { readonly deploy: typeof deployWithDeps; readonly dev: typeof devWithDeps; + readonly devUrl: typeof devUrl; + readonly devStop: typeof devStop; } export const realOperations: ComposerOperations = { deploy: deployWithDeps, dev: devWithDeps, + devUrl, + devStop, }; export interface CreateComposerFamilyOptions { /** Defaults to the real control operations. */ readonly operations?: ComposerOperations | undefined; + /** More commands to ship in the family, keyed by the path they are meant to mount at. */ + readonly commands?: CommandFamily['commands'] | undefined; } /** The `prisma` CLI's error reference; the engine appends `/` to it for every error envelope. */ @@ -58,6 +70,9 @@ export function createComposerFamily(options: CreateComposerFamilyOptions = {}): commands: { deploy: createDeployCommand(operations), dev: createDevCommand(operations), + 'dev url': createDevUrlCommand(operations), + 'dev stop': createDevStopCommand(operations), + ...options.commands, }, docsBaseUrl: CLI_DOCS_BASE, }); diff --git a/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts b/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts index 44fe614b8..1139622f7 100644 --- a/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts +++ b/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts @@ -1,6 +1,7 @@ /** - * The published operations double (`@prisma/composer-cli/testing`): the two - * operations the command family calls, `deploy` and `dev`, with their real + * The published operations double (`@prisma/composer-cli/testing`): the + * operations the command family calls, `deploy`, `dev`, `devUrl` and + * `devStop`, with their real * signatures and Result shapes, backed by fixtures instead of alchemy, * containers, or a network. It does not cover `destroy` and `log`, which no * command calls. @@ -21,6 +22,7 @@ import { notOk, ok, type Result } from '@internal/foundation/result'; import type { ComposerOperations } from '../family/family.ts'; import type { DeployEvent, DeployInput, DeploySuccess } from '../operations/deploy.ts'; import type { DevInput, DevSession } from '../operations/dev.ts'; +import type { DevStopInput, DevStopped, DevUrl, DevUrlInput } from '../operations/dev-current.ts'; import type { OperationDeps, ServiceEndpoint } from '../operations/shared.ts'; import { type AlchemyInvocation, reproduceCommand } from '../run-alchemy.ts'; @@ -36,6 +38,10 @@ export interface OperationsDoubleFixtures { readonly dev?: Result | undefined; /** The session's front door; emitted as the initial 'ready' event, like the real operation's. */ readonly devEndpoints?: readonly ServiceEndpoint[] | undefined; + /** Returned as-is. Default: the address as a running service at http://localhost:3000. */ + readonly devUrl?: Result | undefined; + /** Returned as-is. Default: an app named `app` with nothing to stop. */ + readonly devStop?: Result | undefined; } /** @@ -53,6 +59,8 @@ export interface OperationsDoubleDeps { export interface OperationsDoubleCalls { readonly deploy: readonly DeployInput[]; readonly dev: readonly DevInput[]; + readonly devUrl: readonly DevUrlInput[]; + readonly devStop: readonly DevStopInput[]; readonly deps: OperationsDoubleDeps; } @@ -154,7 +162,15 @@ export function createOperationsDouble(fixtures: OperationsDoubleFixtures = {}): const deployDeps: OperationDeps[] = []; const devDeps: OperationDeps[] = []; const deps = { deploy: deployDeps, dev: devDeps }; - const calls = { deploy: deployCalls, dev: devCalls, deps }; + const devUrlCalls: DevUrlInput[] = []; + const devStopCalls: DevStopInput[] = []; + const calls = { + deploy: deployCalls, + dev: devCalls, + devUrl: devUrlCalls, + devStop: devStopCalls, + deps, + }; const operations: ComposerOperations = { deploy: async (input, operationDeps) => { @@ -176,6 +192,22 @@ export function createOperationsDouble(fixtures: OperationsDoubleFixtures = {}): deps.dev.push(operationDeps); return fixtures.dev ?? ok(devSessionDouble(input, fixtures.devEndpoints ?? [])); }, + devUrl: async (input) => { + devUrlCalls.push(input); + return ( + fixtures.devUrl ?? + ok({ + address: input.address, + kind: 'service', + url: 'http://localhost:3000', + state: 'running', + }) + ); + }, + devStop: async (input) => { + devStopCalls.push(input); + return fixtures.devStop ?? ok({ app: 'app', stopped: [] }); + }, }; return { operations, calls }; From 01e5c979d9927fcfab4562cdbaa00f695ef4300f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:25:59 +0200 Subject: [PATCH 04/14] feat(composer-cli): `emulator list` command in the published family `@prisma/composer-cli/family` assembles `emulator list` into the family, beside the framework's commands; it needs no project and no config. The listing comes from the Prisma Cloud domain, which this public package may import and the framework may not, and loads on first use, so the family's static graph stays light. Human output is one tree: each emulator, whether it runs and its admin URL, and under it each instance with its URL, state, and the node and directory that started it. The JSON result is `{ emulators: [{ kind, running, url, instances: [{ id, kind, url, state, labels }] }] }`. `createComposerFamily({ listEmulators })` lets a host substitute the listing in its tests. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- architecture.config.json | 6 ++ packages/9-public/composer-cli/package.json | 6 +- .../composer-cli/src/exports/family.ts | 29 ++++++ .../composer-cli/src/family/emulator-list.ts | 87 +++++++++++++++++ .../composer-cli/test/emulator-list.test.ts | 96 +++++++++++++++++++ packages/9-public/composer-cli/tsconfig.json | 5 +- pnpm-lock.yaml | 9 ++ 7 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 packages/9-public/composer-cli/src/family/emulator-list.ts create mode 100644 packages/9-public/composer-cli/test/emulator-list.test.ts diff --git a/architecture.config.json b/architecture.config.json index 90866ed2f..78093fbf0 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -720,6 +720,12 @@ "layer": "public", "plane": "control" }, + { + "glob": "packages/9-public/composer-cli/src/family/**", + "domain": "public", + "layer": "public", + "plane": "control" + }, { "glob": "packages/9-public/composer-cli/src/exports/testing.ts", "domain": "public", diff --git a/packages/9-public/composer-cli/package.json b/packages/9-public/composer-cli/package.json index 66cbf2e37..3ca3aa977 100644 --- a/packages/9-public/composer-cli/package.json +++ b/packages/9-public/composer-cli/package.json @@ -2,7 +2,7 @@ "name": "@prisma/composer-cli", "version": "0.29.1", "type": "module", - "description": "Prisma Composer's command family, which the `prisma` CLI mounts as `deploy` and `dev`.", + "description": "Prisma Composer's command family, which the `prisma` CLI mounts as `deploy`, `dev`, `dev url`, `dev stop` and `emulator list`.", "exports": { "./family": "./dist/family.mjs", "./testing": "./dist/testing.mjs", @@ -15,6 +15,7 @@ "scripts": { "build": "tsdown", "clean": "rm -rf dist", + "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -29,7 +30,10 @@ }, "devDependencies": { "@internal/cli": "workspace:0.29.1", + "@internal/dev-emulators": "workspace:0.29.1", + "@internal/foundation": "workspace:0.29.1", "@internal/tsdown-config": "workspace:0.29.1", + "@types/bun": "^1.3.13", "@prisma/cli-engine": "0.7.0", "@types/node": "^26.0.1", "tsdown": "^0.22.7", diff --git a/packages/9-public/composer-cli/src/exports/family.ts b/packages/9-public/composer-cli/src/exports/family.ts index 2658e4d03..660b0d079 100644 --- a/packages/9-public/composer-cli/src/exports/family.ts +++ b/packages/9-public/composer-cli/src/exports/family.ts @@ -11,4 +11,33 @@ * Its static graph is alchemy-free and effect-free, so importing it costs a * host nothing until a composer command actually runs. */ + +import { + createComposerFamily as createFrameworkFamily, + type CreateComposerFamilyOptions as FrameworkFamilyOptions, +} from '@internal/cli/family'; +import type { CommandFamily } from '@prisma/cli-engine'; +import { + createEmulatorListCommand, + type ListEmulators, + listEmulators, +} from '../family/emulator-list.ts'; + export * from '@internal/cli/family'; +export type { ListEmulators } from '../family/emulator-list.ts'; + +export interface CreateComposerFamilyOptions extends FrameworkFamilyOptions { + /** Defaults to the real listing of this machine's emulators. */ + readonly listEmulators?: ListEmulators | undefined; +} + +/** Composer's command family: `deploy`, `dev`, `dev url`, `dev stop` and `emulator list`, keyed by the path each is meant to mount at. */ +export function createComposerFamily(options: CreateComposerFamilyOptions = {}): CommandFamily { + return createFrameworkFamily({ + ...options, + commands: { + 'emulator list': createEmulatorListCommand(options.listEmulators ?? listEmulators), + ...options.commands, + }, + }); +} diff --git a/packages/9-public/composer-cli/src/family/emulator-list.ts b/packages/9-public/composer-cli/src/family/emulator-list.ts new file mode 100644 index 000000000..af55c6f9a --- /dev/null +++ b/packages/9-public/composer-cli/src/family/emulator-list.ts @@ -0,0 +1,87 @@ +/** + * `emulator list` — every local emulator on this machine and every + * instance it holds. It needs no project and no config. The listing comes + * from the Prisma Cloud domain, so the command is assembled into the + * family here, where both domains may be imported. + */ + +import { toEngineError } from '@internal/cli/family'; +import type { EmulatorList } from '@internal/dev-emulators'; +import type { CliStructuredError as ComposerError } from '@internal/foundation/errors'; +import type { Result } from '@internal/foundation/result'; +import { defineCommand } from '@prisma/cli-engine'; +import { notOk, ok } from '@prisma/cli-engine/protocol'; + +export type ListEmulators = () => Promise>; + +/** Loaded on first use, so nothing of the emulators' clients loads with the family. */ +export const listEmulators: ListEmulators = async () => + (await import('@internal/dev-emulators')).listEmulators(); + +const EMULATOR_NAMES = { + service: 'Service emulator', + database: 'Database emulator', + bucket: 'Bucket emulator', +} as const; + +/** The node and directory that started an instance, when Composer's labels say. */ +function startedBy(labels: Readonly>): string | undefined { + const address = labels['composer.address']; + const directory = labels['composer.directory']; + if (address === undefined) return undefined; + return directory === undefined ? address : `${address} in ${directory}`; +} + +export const createEmulatorListCommand = (list: ListEmulators) => + defineCommand({ + help: { + summary: + 'List the local emulators on this machine — the programs `prisma dev` starts to run ' + + 'services, databases and S3-compatible buckets without Prisma Cloud — with every ' + + 'instance each one holds.', + description: + 'Needs no project: it shows every directory’s instances. An emulator that is not ' + + 'running is listed with no instances. Each instance’s labels say which directory, ' + + 'app and node started it. A stopped instance keeps its port and data until it is ' + + 'deleted.', + examples: ['{bin} emulator list', '{bin} emulator list --json'], + }, + args: {}, + handler: async (_args, ctx) => { + const result = await list(); + if (!result.ok) return notOk(toEngineError(result.failure)); + const { emulators } = result.value; + return ok( + ctx.present( + { data: result.value }, + { + human: (ui) => [ + { + kind: 'tree', + roots: emulators.map((emulator) => ({ + label: emulator.running + ? `${EMULATOR_NAMES[emulator.kind]} at ${emulator.url ?? ''}` + : `${EMULATOR_NAMES[emulator.kind]} ${ui.dim('(not running)')}`, + status: emulator.running ? ('ok' as const) : ('info' as const), + children: emulator.instances.map((instance) => { + const by = startedBy(instance.labels); + return { + label: [ + instance.id, + instance.url, + instance.state, + ...(by ? [ui.dim(by)] : []), + ].join(' '), + }; + }), + })), + }, + ], + stdout: () => [], + json: () => result.value, + next: () => [], + }, + ), + ); + }, + }); diff --git a/packages/9-public/composer-cli/test/emulator-list.test.ts b/packages/9-public/composer-cli/test/emulator-list.test.ts new file mode 100644 index 000000000..443c8e6e2 --- /dev/null +++ b/packages/9-public/composer-cli/test/emulator-list.test.ts @@ -0,0 +1,96 @@ +/** `emulator list` through the engine, over a fake listing, and its place in the published family. */ +import { describe, expect, test } from 'bun:test'; +import type { EmulatorList } from '@internal/dev-emulators'; +import { CliStructuredError } from '@internal/foundation/errors'; +import { notOk, ok } from '@internal/foundation/result'; +import { createTestCli } from '@prisma/cli-engine/testing'; +import { createComposerFamily } from '../src/exports/family.ts'; + +const listing: EmulatorList = { + emulators: [ + { + kind: 'service', + running: true, + url: 'http://127.0.0.1:4300', + instances: [ + { + id: 'svc_0123456789abcdef', + kind: 'service', + url: 'http://localhost:3000', + state: 'running', + labels: { + 'composer.directory': '/work/shop', + 'composer.app': 'shop', + 'composer.address': 'gateway', + }, + }, + ], + }, + { kind: 'database', running: false, instances: [] }, + { kind: 'bucket', running: false, instances: [] }, + ], +}; + +function cliWith(list: Parameters[0]) { + const family = createComposerFamily(list); + const command = family.commands['emulator list']; + if (command === undefined) throw new Error('no emulator list command'); + return createTestCli({ + commandFamilies: [family], + commands: { 'emulator list': command }, + groups: { emulator: { brief: 'The local emulators on this machine.' } }, + }); +} + +describe('emulator list', () => { + test('the published family ships it beside the framework commands', () => { + expect(Object.keys(createComposerFamily().commands).sort()).toEqual([ + 'deploy', + 'dev', + 'dev stop', + 'dev url', + 'emulator list', + ]); + }); + + test('returns every emulator and its instances as the JSON result, with no config', async () => { + const cli = cliWith({ listEmulators: async () => ok(listing) }); + + const result = await cli.run(['emulator', 'list', '--json']); + + expect(result.exitCode).toBe(0); + expect(result.json.at(-1)).toMatchObject({ envelope: { ok: true, result: listing } }); + }); + + test('shows each emulator and, under it, its instances with the node and directory that started them', async () => { + const cli = cliWith({ listEmulators: async () => ok(listing) }); + + const result = await cli.run(['emulator', 'list', '--format', 'human']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Service emulator at http://127.0.0.1:4300'); + expect(result.stderr).toContain('svc_0123456789abcdef'); + expect(result.stderr).toContain('gateway in /work/shop'); + expect(result.stderr).toContain('Database emulator'); + expect(result.stderr).toContain('not running'); + }); + + test('a failure keeps its code', async () => { + const cli = cliWith({ + listEmulators: async () => + notOk( + new CliStructuredError('DEV.EMULATORS_DIR_INVALID', 'not absolute', { + fix: 'Set it to an absolute path.', + }), + ), + }); + + const result = await cli.run(['emulator', 'list', '--json']); + + expect(result.exitCode).not.toBe(0); + expect(result.json.at(-1)).toMatchObject({ + envelope: { ok: false, error: { code: 'DEV.EMULATORS_DIR_INVALID' } }, + }); + }); +}); diff --git a/packages/9-public/composer-cli/tsconfig.json b/packages/9-public/composer-cli/tsconfig.json index 34e61011b..e8fad01c8 100644 --- a/packages/9-public/composer-cli/tsconfig.json +++ b/packages/9-public/composer-cli/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../../tsconfig.base.json", - "include": ["src", "tsdown.config.ts"] + "compilerOptions": { + "types": ["bun-types"] + }, + "include": ["src", "test", "tsdown.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 448d4dfa5..5046f0d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1375,12 +1375,21 @@ importers: '@internal/cli': specifier: workspace:0.29.1 version: link:../../0-framework/3-tooling/cli + '@internal/dev-emulators': + specifier: workspace:0.29.1 + version: link:../../1-prisma-cloud/0-lowering/dev-emulators + '@internal/foundation': + specifier: workspace:0.29.1 + version: link:../../0-framework/0-foundation/foundation '@internal/tsdown-config': specifier: workspace:0.29.1 version: link:../../0-framework/0-foundation/tsdown-config '@prisma/cli-engine': specifier: 0.7.0 version: 0.7.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5) + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 '@types/node': specifier: ^26.0.1 version: 26.1.1 From 4df07a974e0c64b0d0c94001a4f06a046f3037d2 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:28:52 +0200 Subject: [PATCH 05/14] docs: prisma dev url, prisma dev stop and prisma emulator list ADR-0053 records the three commands, the devUrl and devStop operations, the core local-target current() seam and the DEV errors it raises. It amends ADR-0050 (the family was exactly deploy and dev) and ADR-0043 (the control subpath had four operations). The running-locally guide, the deploying guide, local-dev.md, deploy-cli.md and the core-concepts skill describe the commands, the jq form for scripts, and that prisma emulator start and stop are planned. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/deploy-cli.md | 5 +- docs/design/10-domains/local-dev.md | 32 +++++++++- ...path-is-the-programmatic-deploy-surface.md | 2 + ...l-envelopes-with-dotted-namespace-codes.md | 4 +- ...er-runs-as-prisma-deploy-and-prisma-dev.md | 2 + ...0053-dev-url-dev-stop-and-emulator-list.md | 62 +++++++++++++++++++ docs/design/90-decisions/README.md | 5 +- docs/guides/deploying.md | 11 ++-- docs/guides/running-locally.md | 41 ++++++++++++ skills/prisma-composer-core-concepts/SKILL.md | 18 +++++- 10 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index e273177aa..a462098c3 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -27,7 +27,10 @@ and ## Scope Two commands, mounted by the `prisma` CLI from `@prisma/composer-cli`'s -command family, plus one operation with no command: +command family, plus one operation with no command. The family also carries +`dev url`, `dev stop` and `emulator list`, which act on the local app and the +local emulators; [local-dev.md](local-dev.md) covers them +([ADR-0053](../90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md)). - **`prisma deploy `** — deploy the application whose root node is `entry`'s default export, to a stage (default: production). diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index 98ba0853d..7f99b69df 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -11,7 +11,8 @@ this doc is the mechanics. ## Scope -One command and one operation: +Three commands about the app, one about the machine, and one operation with no +command: - **`prisma dev `** — bring up the application whose root node is `entry`'s default export, entirely on the local machine, and keep it up: @@ -27,6 +28,31 @@ One command and one operation: return first (default 0, which returns live output only). It only reads the running app's logs — it neither builds, provisions, starts, nor stops anything. +- **`prisma dev url
`** — where a node of the app `prisma dev` last + ran from this directory answers: a service's `http://localhost:`, a + database's connection string (local credentials included), or a bucket's S3 + endpoint plus `/`. JSON result `{ address, kind, url, state }`; in + human format the URL alone goes to stdout. It is the `devUrl` operation + ([ADR-0053](../90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md)). +- **`prisma dev stop`** — stop that app's service processes and database + servers, as ending `prisma dev` does, for a `dev` that was killed without the + chance. Data and ports stay. It is the `devStop` operation, returning + `{ app, stopped }`. +- **`prisma emulator list`** — every local emulator on this machine, whether it + runs, its admin URL, and its instances with id, kind, URL, state and labels. + It needs no project or configuration. The operation is `listEmulators` from + `@prisma/composer-prisma-cloud/local-target`. `prisma emulator start` and + `prisma emulator stop` are planned. + +`dev url` and `dev stop` take no entry and build nothing. The configuration +does not name the app, so they act on the app the directory's record names as +current. Core reaches it through the local-target seam: a +`LocalTargetDescriptor` may implement `current({ devDir })`, returning the +recorded app's name, its addresses, `locate(address)` and `stop()`. The Prisma +Cloud target implements it from `emulator-instances.json`; core never reads the +record. `dev url` reports a node only while it is running, so every URL it +prints answers. + Flags: `dev` takes `--name` (override the root's name, as on deploy) and `--fresh` (destroy the dev stack and wipe the dev state directory before starting). Stages do not apply — a working directory has @@ -440,6 +466,10 @@ Deploy's rule holds: every failure names its fix. | a service exits before it listens, or does not listen within the readiness timeout | the address, the port, the exit code if it exited, and the log path; `dev` stops the app's services before it returns the failure | | an emulator does not accept connections (retried briefly; an error response from the emulator is never retried) | `DEV.EMULATOR_UNREACHABLE`, with the emulator's URL, from `dev` and `log` alike | | `PRISMA_COMPOSER_SERVICE_READY_TIMEOUT_MS` is not a positive whole number | `DEV.SERVICE_READY_TIMEOUT_INVALID`, with the value | +| `dev url` or `dev stop` in a directory where no extension recorded an app | `DEV.NOT_STARTED_HERE`: run `prisma dev` here first | +| `dev url` with an address the app does not have | `DEV.ADDRESS_UNKNOWN`, listing the addresses it has | +| `dev url` for a node whose emulator is not running | `DEV.EMULATOR_UNREACHABLE` | +| `dev url` for a node that exists but is not running | `DEV.NOT_RUNNING`, naming its state | | service crash-loops | the address, exit code, and the last stderr lines, as a standing message | ## Out of scope (designed around) diff --git a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md index 1fffbfd4b..abaa0e2ec 100644 --- a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -2,6 +2,8 @@ Amended by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md): each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired. Amended by [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md): the `prisma-composer` binary is retired; the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`, and `destroy` and `log` are called from scripts. The surface below is otherwise unchanged. +Amended by [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md): two more operations, `devUrl` and `devStop`, act on the app `prisma dev` last ran from a directory. + ## Decision Composer's deploy pipeline is drivable in-process through one published subpath, **`@prisma/composer/control`**. It exposes four typed operations — `deploy`, `destroy`, `dev`, `log` — that take structured inputs and return structured results. They never parse argv and never call `process.exit`; the operations print nothing — the spawned alchemy child streams its own output to the terminal. The `prisma-composer` CLI is a thin renderer over these same operations, so the command-line surface and the programmatic surface cannot drift apart. *(Amended by [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md): the `prisma-composer` binary is retired. The `prisma` CLI renders two of the operations, as `prisma deploy` and `prisma dev`; `destroy` and `log` have no command and are called from scripts.)* diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index 746cf7bfc..bd4619680 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -72,7 +72,7 @@ vocabulary, not import edges. | `COMPOSE` | topology loading: entry, root node, graph shape, naming | pipeline.ts (load-entry.ts, core's graph modules) | | `ASSEMBLE` | assembling each service's deploy artifact | `@internal/assemble`'s assemble-services.ts | | `DEPLOY` | target selection, stage, containers, preflight, engine, teardown, stack write | operations/execute-deploy-destroy.ts (main.ts, validate-stage.ts, run-alchemy.ts) | -| `DEV` | local dev pipeline and session | operations/execute-dev.ts (core/control/local-target.ts) | +| `DEV` | local dev pipeline and session, and the commands on the app it recorded | operations/execute-dev.ts (core/control/local-target.ts, operations/execute-dev-current.ts, `@internal/dev-emulators`) | | `LOG` | log attach and tail | operations/execute-log.ts | | `DEPS` | the consumer's installed dependency tree | operations/shared.ts | @@ -93,7 +93,7 @@ Closed subcode registry: BUILD_REQUIRED, ALCHEMY_BIN_MISSING, NODE_MISSING, STACK_WRITE_FAILED. `prisma dev` starts the Alchemy child the same way `deploy` does, so it raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. -- `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. +- `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory), ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named) are raised by `devUrl` and `devStop` ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)); `emulator list` raises EMULATORS_DIR_INVALID and EMULATOR_UNREACHABLE and no code of its own. - `LOG` — PLATFORM_UNSUPPORTED, ATTACH_FAILED, ADDRESS_UNKNOWN - `DEPS` — EXECUTOR_UNLOADABLE *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))* diff --git a/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md b/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md index e3a26dff8..7a0d26ade 100644 --- a/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md +++ b/docs/design/90-decisions/ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md @@ -1,5 +1,7 @@ # ADR-0050: Composer runs as `prisma deploy` and `prisma dev`; it has no binary of its own +Amended by [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md): the family also ships `dev url`, `dev stop` and `emulator list`, so `prisma dev` is both a command and a group. + ## Decision Composer has no command-line binary of its own. The `prisma` CLI is its only command line, and it mounts exactly two Composer commands: diff --git a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md new file mode 100644 index 000000000..4785bd4a4 --- /dev/null +++ b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md @@ -0,0 +1,62 @@ +# ADR-0053: `prisma dev url`, `prisma dev stop` and `prisma emulator list` + +## Decision + +Composer's command family ships three more commands, and `@prisma/composer/control` two more operations behind them: + +```bash +$ prisma dev module.ts # terminal 1: runs the app +$ prisma dev url gateway # terminal 2: where a node of that app answers +http://localhost:3001 +$ curl "$(prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url')" +$ prisma dev stop # stops this directory's app: services and database servers; data stays +$ prisma emulator list # every local emulator on this machine and every instance on it +``` + +- **`dev url
`** prints where a node of the app `prisma dev` runs from the current directory answers: a service's `http://localhost:`, a database's connection string, or a bucket's S3 endpoint plus `/`. Its JSON result is `{ address, kind, url, state }`. In human format the URL alone goes to stdout, so `$(prisma dev url gateway --format human)` is the URL. It is the `devUrl` operation. +- **`dev stop`** stops that app's service processes and database servers, as ending `prisma dev` does, for a `prisma dev` that was killed without the chance. Data and ports stay. It is the `devStop` operation, and returns `{ app, stopped }`. +- **`emulator list`** lists the local emulators — the programs `prisma dev` starts to run services, databases and buckets on this machine — whether each runs, its admin URL, and its instances with id, kind, URL, state and labels. It needs no project. Its JSON result is `{ emulators: [{ kind, running, url, instances: [{ id, kind, url, state, labels }] }] }`. The operation is `listEmulators`, exported from `@prisma/composer-prisma-cloud/local-target`. + +The family keys each command by the path it is meant to mount at (`dev url`, `dev stop`, `emulator list`); the `prisma` CLI decides where it mounts them. This amends [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md), under which the family was exactly `deploy` and `dev`, and [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md), whose `control` subpath had four operations. + +## Reasoning + +Docs, scripts and agents need to reach a locally running node without assuming its port. Ports are assigned in dependency order from 3000 and kept per directory ([ADR-0052](ADR-0052-composer-maps-its-nodes-to-emulator-instances-per-directory.md)), so `localhost:3001` is right for one app in one directory and wrong for the next. The directory's record already says which instance runs each node; these commands read it. + +`dev url` and `dev stop` act on the app `prisma dev` *last ran from the directory*, without an entry or a build. The configuration does not name the app ([ADR-0003](ADR-0003-deploy-derives-everything-from-the-root-node.md)), so the record is the only source. They still go through the core local-target seam, so core stays target-agnostic: `LocalTargetDescriptor` gains an optional `current({ devDir })`, which returns the recorded app's name, its addresses, `locate(address)` and `stop()`. The Prisma Cloud target implements it from `emulator-instances.json`; core never reads the record. Core turns what a target returns into the failures a user can act on: + +| Situation | Code | +| --- | --- | +| No extension recorded an app in the directory | `DEV.NOT_STARTED_HERE` | +| The app has no node at the address (the known ones are listed) | `DEV.ADDRESS_UNKNOWN` | +| The emulator the node runs on is not running | `DEV.EMULATOR_UNREACHABLE` | +| The node exists but is not running (its state is named) | `DEV.NOT_RUNNING` | + +`dev url` reports a node only when it is running, so every URL it prints answers. That relies on a service being `running` only once its port accepts connections. + +`emulator list` is a question about the machine, not a project: it needs no `prisma.config.ts`, so it cannot go through the seam, which starts from the configuration. Its operation lives in the Prisma Cloud domain beside the emulator clients, and `@prisma/composer-cli`, a public package that may import both domains, adds the command to the family. Nothing running is a valid answer: every emulator is listed with `running: false`. + +## Consequences + +- `@prisma/composer/control` has six operations: `deploy`, `destroy`, `dev`, `devUrl`, `devStop`, `log`. +- `prisma dev` is both a command and a group. The CLI engine supports that from `@prisma/cli-engine` 0.8.0; the family itself is built against 0.7.0, because it only exports commands and does not mount paths. The `dev` command's description defines the terms its children rely on: an emulator, a node's address, and that the app is recorded in the directory. +- `emulator list`'s JSON departs from the list convention (`{ context, items, count }` with `{ name, id, status }` items): it nests two levels, emulators and their instances, because an instance is meaningless without the emulator that holds it. +- Database URLs are connection strings with local credentials, printed on purpose; a bucket's access keys are never printed. +- `prisma emulator start` and `prisma emulator stop`, and removing instances left by deleted directories, are planned and not part of this decision. + +## Alternatives considered + +- **`prisma emulator url
`.** The same behaviour, but `url` asks a question about this directory's app, which belongs under `dev`; `emulator` commands act on the machine. +- **A top-level `prisma url`.** Ambiguous between a local node and a deployed one. +- **Renaming the run command to `prisma dev start`.** Avoids a command that is also a group, but breaks every `prisma dev module.ts` and reverses ADR-0050's root verb. +- **Naming the app on the command line (`url my-app gateway`) or with `--app`.** Addresses are relative to the directory's app, and app names collide across directories. +- **`emulator list` through the core seam.** Would require a project and a configuration for a question about the machine. +- **Printing a bare URL even when stdout is piped.** Would change the CLI's rule that a piped stdout carries JSON frames; scripts use `jq` or `--format human` instead. + +## Related + +- [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — the commands Composer ships; amended here. +- [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md) — the programmatic operations; amended here. +- [ADR-0052](ADR-0052-composer-maps-its-nodes-to-emulator-instances-per-directory.md) — the per-directory record these commands read. +- [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md) — the codes above are registered there. +- [local-dev.md](../10-domains/local-dev.md) — the mechanics. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index dc0f53033..f9ea35ca0 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -64,13 +64,14 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0040](ADR-0040-the-orm-binding-carries-the-url-and-a-lazy-client.md) — `postgres(contract)`'s dependency binding is `{ url, client }`: the raw connection string plus the typed client, constructed lazily and memoized on first `client` access — `hydrate` builds nothing. The contract remains the compatibility interface (hash check and deploy-time migration unchanged, ADR-0022); the binding becomes a strict superset of plain `rawPostgres()`'s `{ url }`, so an app that owns its database client still gets framework-run migrations. Contract validation cost and failure move from `load()` (where one bad input poisoned every input, unattributed) to the first `client` access. Cross-kind satisfaction (`'postgres'` satisfying `'postgres'`) rejected in its favor. - [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `localTarget` field of `ExtensionDescriptor` (a lazy thunk resolving a `LocalTargetDescriptor`; subpaths `@prisma/composer/local-target` and `@prisma/composer-prisma-cloud/local-target` — "dev" names only the user-facing command/prefix/state dir) (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres the Postgres emulator hosts one `@prisma/dev` server per database instance. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`OrmMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam). *(Amended by ADR-0050: the command is `prisma dev `; logs are the `log` operation, not a command. Amended by ADR-0052: emulator instances are keyed by emulator-generated ids, not the app name, Composer maps each app's nodes to them per directory, and `prisma dev` stops the app's database servers when it ends.)* - [ADR-0042](ADR-0042-service-input-is-one-standard-schema.md) — A compute service declares its entire incoming configuration — config and secrets together — as one Standard Schema (`input`), read back through one typed accessor; `params`/`secrets` and `config()`/`secrets()` are replaced. The framework never introspects the schema (validate-only, per the spec): the operator's binding is the traversable structure (sourcing: literals, `envParam`, `envSecret`), the schema is the black-box judge of legality (invoked at deploy over the resolved binding with secrets as opaque `SecretString` boxes, and again at boot), and secretness is a leaf *type* enforced by validation in both directions. The wire format is one self-describing JSON document row per service with `$secret` pointers to platform variables; an env-bound key whose variable is unset resolves to key-omitted and the schema arbitrates absence — subsuming optional secrets and conditional config (`stripeId` only when `stripeEnabled`) without a framework DSL. -- [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md) — `@prisma/composer/control` is the programmatic deploy surface: typed `deploy`/`destroy`/`dev`/`log` operations (structured inputs/results, no argv/console/exit) implemented in `@internal/cli`'s `src/operations/` and re-exported per ADR-0035; the CLI is a thin renderer over them. The entry's static graph stays import-light — each operation lazily imports its executor, so importing the subpath executes nothing, and a tree that cannot load the deploy stack surfaces as a structured `pipeline` failure — and `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` carries the deploy result across the process boundary: the alchemy child's report hook writes a serializable `DeploymentSummary` to the named file, the operation reads it back best-effort (absent/malformed = undefined summary, never a failure). Distinct from an extension's ADR-0017 `/control` entry. *(Amended by ADR-0049: each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired.)* *(Amended by ADR-0050: the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`; `destroy` and `log` are called from scripts.)* +- [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md) — `@prisma/composer/control` is the programmatic deploy surface: typed `deploy`/`destroy`/`dev`/`log` operations (structured inputs/results, no argv/console/exit) implemented in `@internal/cli`'s `src/operations/` and re-exported per ADR-0035; the CLI is a thin renderer over them. The entry's static graph stays import-light — each operation lazily imports its executor, so importing the subpath executes nothing, and a tree that cannot load the deploy stack surfaces as a structured `pipeline` failure — and `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` carries the deploy result across the process boundary: the alchemy child's report hook writes a serializable `DeploymentSummary` to the named file, the operation reads it back best-effort (absent/malformed = undefined summary, never a failure). Distinct from an extension's ADR-0017 `/control` entry. *(Amended by ADR-0049: each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired.)* *(Amended by ADR-0050: the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`; `destroy` and `log` are called from scripts.)* *(Amended by ADR-0053: `devUrl` and `devStop` join the operations.)* - [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md) — Errors are structural envelopes with dotted `NAMESPACE.SUBCODE` codes (the shared prisma/prisma foundation, duplicated pending extraction): structured at origin with why/fix splits, no catch-all codes, bugs carry no code (exit 1 + report hint), recognition is structural (`CliStructuredError.is()`), operation results ride the shared `Result` `ok` discriminator, expected failures exit 2 — with the alchemy child-status passthrough as the documented exception. *(Amended by ADR-0049: `DEPS.EFFECT_VERSION_CONFLICT` is retired.)* - [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) — Deploy state lives behind the platform state API (the Management API implements Alchemy's stock `HttpStateApi` wire contract per Branch; composer's state layer is Alchemy's stock HTTP client), and deploys hold a server-side per-`(stack, stage)` lease (TTL 60s, heartbeated, released on exit; contention fails fast naming the holder; state operations without a live lease fail 409). Supersedes ADR-0010 (lock → lease) and the storage half of ADR-0034 (Branch scoping and lifetime stand; the visible per-stage database is gone); closes ADR-0012 as obsolete. No migration: legacy stages are refused until destroyed or deleted. - [ADR-0046](ADR-0046-the-orm-facade-is-a-peer-dependency.md) — `@prisma/composer-prisma-cloud` takes the Prisma ORM postgres facade (`@prisma/orm-postgres`) as a **peer** dependency at one exact version, not a regular dependency: Composer registers an extension pack against the application's copy of the target, and two copies of a shell in one tree means two codec/operation registries and two class identities — a value from one is rejected by the other, silently. As a peer, that combination fails at install instead. Every `@prisma/orm-*` spec in the workspace is one exact version and all name the same one (`scripts/lint-orm-pins.mjs`). `@prisma/orm-toolchain`, which Composer drives rather than extends, stays a regular dependency. Replaces ADR-0022's consequence bullet on how the ORM is installed. - [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md) — Compute assembly traces runtime files from the author-declared Node entry without rebundling app code, preserves only symlinks whose resolved targets remain inside the staged bundle, and installs narrowly runtime-gated bootstrap compatibility when a framework needs Node semantics. Each local mechanism is removed once the upstream Alchemy Compute provider owns the equivalent guarantee. Supersedes ADR-0005's blanket ban on symlinks and its assumption that directory output is already self-contained. - [ADR-0048](ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md) — The six Management-API resource families (project, database, connection, app, deployment, environment variable) are the upstream `alchemy/Prisma` provider's classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where the upstream provider has no support yet (buckets) or no Management API exists behind them. Compute binds the low-level App/Deployment/EnvironmentVariable trio (the `COMPOSER_*_ORIGIN` self-edge and ADR-0005 rule out composite `Compute`); the env→deployment ordering edge rides the deployment's `app` prop, and a deployment is replaced exactly when its artifact or environment fingerprint changes — unchanged services are reused. Legacy rows in the platform state API (retired type-ids and shapes) migrate on read — the older retired SQL state stores stay destroy-only under ADR-0045 — branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. - [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md) — Composer's configuration is the `composer` section of `prisma.config.ts`, loaded by the Prisma CLI engine. The validator checks only the fields that identify extension and state descriptors, passes descriptors through as the file's own objects, rejects unknown keys, and takes the nearest declaring file's section whole so the generated stack imports exactly one file. The old `prisma-composer.config.ts` and the `configPath` pointer are refused with `CONFIG.SECTION_MISSING`, `CONFIG.FIELD_RETIRED` and `CONFIG.FILE_RETIRED`, never migrated. The validator returns the section with its declaring file, and the programmatic operations take the same `ComposerConfigSource` (`{ value, file }`) and run the same checks. The Alchemy child evaluates the whole `prisma.config.ts`. The effect pre-flight and `DEPS.EFFECT_VERSION_CONFLICT` are retired: a broken tree fails evaluation of `prisma.config.ts` with the engine's `CLI.CONFIG_UNREADABLE`. *(Amended: the alternatives section lists the rejected ways to keep a Composer-owned effect check.)* -- [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy ` and `prisma dev `, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. +- [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy ` and `prisma dev `, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. *(Amended by ADR-0053: the family also ships `dev url`, `dev stop` and `emulator list`.)* - [ADR-0051](ADR-0051-a-nodes-address-is-its-logical-id-on-the-platform.md) — A node's address is its identity on Prisma Cloud: Composer writes it, byte for byte, as the `logicalId` of the node's one platform row (Project, App, Database, Bucket) and as the node's `logicalId` in the application topology, so the platform joins topology nodes to rows, and one node across branches, by string equality. A provision ID defaults to the node's name, so changing a name changes identity. The value is never an Alchemy resource ID, a display name or a platform `id`; App, Database and Bucket rows get it as a prop on the upstream resource that creates them, and the Project from container resolution; no separate resource writes it. Changing an address changes the entity's identity; changing a display name changes nothing. - [ADR-0052](ADR-0052-composer-maps-its-nodes-to-emulator-instances-per-directory.md) — The local emulators know nothing about Composer: the Compute, Postgres and bucket emulators key their instances by ids they generate and store labels they never read. Composer records per working directory, in `.prisma-composer/dev/emulator-instances.json`, which instance backs each node of each app run there, and nothing else maps Composer's names to instances, so two directories running one app get separate services, databases and buckets. An instance keeps its port until it is deleted, because the port is in the deployment's environment and its dependents' URLs and a warm start does not redeploy; a remembered port is requested, as a preference, only when an instance is re-created. When `prisma dev` ends it stops the app's services and database servers, keeping their data. Running a different app from a directory stops the previous app's instances and keeps them; `--fresh` deletes only the current app's. Amends ADR-0041's app-name local identity. +- [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md) — Composer's command family ships `dev url
` (where a node of the app `prisma dev` runs from this directory answers: a service's URL, a database's connection string, a bucket's S3 endpoint; the URL alone on stdout in human format), `dev stop` (stops that app's services and database servers, data kept) and `emulator list` (every local emulator and its instances, with labels; needs no project). `devUrl` and `devStop` are `@prisma/composer/control` operations that go through a new optional `current({ devDir })` on the local-target seam, so core never reads the target's record; `listEmulators` lives in the Prisma Cloud domain and `@prisma/composer-cli` adds its command. New codes: `DEV.NOT_STARTED_HERE`, `DEV.ADDRESS_UNKNOWN`, `DEV.NOT_RUNNING`. Amends ADR-0050 and ADR-0043. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 9de707d1a..bda775da7 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -473,10 +473,11 @@ With the graph empty, planning auto-baselines from the ref: it authors empty → ## Driving deploys from code -`@prisma/composer/control` exports typed `deploy`, `destroy`, `dev`, and `log` -operations that return structured results instead of printing and exiting. -`prisma deploy` and `prisma dev` are thin renderers over the `deploy` and -`dev` operations, so the two surfaces can't drift. `destroy` and `log` have +`@prisma/composer/control` exports typed `deploy`, `destroy`, `dev`, `devUrl`, +`devStop` and `log` operations that return structured results instead of +printing and exiting. `prisma deploy`, `prisma dev`, `prisma dev url` and +`prisma dev stop` are thin renderers over the `deploy`, `dev`, `devUrl` and +`devStop` operations, so the two surfaces can't drift. `destroy` and `log` have no command; these operations are how you run them. They read their credentials from the environment (see [Credentials](#credentials)). @@ -509,7 +510,7 @@ if (result.ok) { What to know before embedding it: -- **You pass the config.** `deploy`, `destroy`, `dev` and `log` each take a +- **You pass the config.** Each operation takes a required `config: { value, file }` (the `ComposerConfigSource` type): the `composer` section of your `prisma.config.ts` and the path of the file that declares it. A relative `file` resolves against `cwd`, not against your diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 17e94026c..88e3cfb9b 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -108,6 +108,47 @@ A stopped service or database keeps its port reserved, and a database its data, so the instances of a checkout you delete keep theirs. There is no command to remove them yet. +### Finding a running node, and stopping the app + +Ports differ from one directory to the next, so don't hard-code +`localhost:3001`. From another terminal in the same directory, ask where a +node of the running app answers, by its address: + +```bash +prisma dev url gateway +``` + +A service gives `http://localhost:`, a database its connection string +(with its local credentials), and a bucket its S3 endpoint followed by +`/`. Nested nodes have dotted addresses, such as `catalog.database`. + +When stdout is a terminal you see the URL. When it is piped, the CLI writes +JSON frames, so a script picks the URL out with `jq`: + +```bash +curl "$(prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url')" +``` + +`--format human` prints the URL alone even when piped. `dev url` only reports +a node that is running, so the URL it prints answers. Otherwise it fails with +a code a script can branch on: + +- `DEV.NOT_STARTED_HERE`: `prisma dev` never ran in this directory. +- `DEV.ADDRESS_UNKNOWN`: the app has no node at that address; the message + lists the ones it has. +- `DEV.EMULATOR_UNREACHABLE`: the emulator the node runs on is not running. +- `DEV.NOT_RUNNING`: the node exists but is stopped, usually because + `prisma dev` has ended. + +`prisma dev stop` stops the app's service processes and database servers, as +`Ctrl-C` does. Use it when `prisma dev` was killed without the chance to stop +them. Data and ports are kept, so the next `prisma dev` is still a warm start. + +`prisma emulator list` shows every local emulator on the machine, whether it +is running, and every instance on it, with the directory, app and address it +belongs to. It works from any directory. `prisma emulator start` and +`prisma emulator stop` are planned. + `--fresh` is also the fix when a framework upgrade leaves stale rows in this app's local dev state — the symptom is a plan-time error naming an unregistered resource type (for example diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 1ec37e683..d7c97e79d 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -409,8 +409,9 @@ authored the connection or an extension on one side. **Driving deploys from code.** A script imports only `@prisma/composer/control`; the extensions' `/control` entries are imported only by `prisma.config.ts` (ADR-0017). `@prisma/composer/control` exposes typed -`deploy`, `destroy`, `dev`, and `log` returning structured results; -`prisma deploy` and `prisma dev` render `deploy` and `dev`. Each takes +`deploy`, `destroy`, `dev`, `devUrl`, `devStop`, and `log` returning structured +results; `prisma deploy`, `prisma dev`, `prisma dev url` and `prisma dev stop` +render `deploy`, `dev`, `devUrl` and `devStop`. Each takes a required `config: { value, file }` (`ComposerConfigSource`): the `composer` export of your `prisma.config.ts` and that file's path; a relative `file` resolves against `cwd`, so build it from `import.meta.url`. The operations @@ -444,6 +445,19 @@ that surprise: const attached = await log({ entry: 'module.ts', config, tail: 20, signal }); if (attached.ok) for await (const { service, line } of attached.value.lines) console.log(service, line); ``` + Don't hard-code a local port; ports differ per directory. `prisma dev url +
` (run in the same directory as `dev`) prints where a running node + answers: a service's `http://localhost:`, a database's connection + string, a bucket's S3 endpoint plus `/`. Piped stdout carries JSON + frames, so scripts use + `curl "$(prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url')"` + or `--format human`. It fails with `DEV.NOT_STARTED_HERE` (no `dev` ran + here), `DEV.ADDRESS_UNKNOWN` (lists the known addresses), + `DEV.EMULATOR_UNREACHABLE`, or `DEV.NOT_RUNNING` (the node is stopped). + `prisma dev stop` stops the app's services and database servers when `dev` + was killed without doing it; data and ports stay. `prisma emulator list` + shows every local emulator and its instances, from any directory. + `prisma emulator start` and `stop` are planned. 4. `dev` reads `prisma.config.ts` once, at start, and watches it: after an edit it says so and pauses rebuilds until you restart `dev`. 5. An unset secret doesn't block a local run: it becomes a placeholder plus a From c91b6e00b1d3977318dc994e8d83c73c572e3f77 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:49:11 +0200 Subject: [PATCH 06/14] fix: dev url and dev stop wrap only target failures, and say why a node is down The executor gave every error, bugs included, the DEV.EMULATOR_FAILED code. Now only failures from the target's current, locate and stop calls get it; other errors are rethrown, and a recorded address the target cannot locate is an InternalError. DEV.NOT_RUNNING now chooses its reason and fix from the node's condition, which the target reports: a stopped node needs `prisma dev`, a crash-looping service needs its log read, and a node its emulator lost is created again by `prisma dev`. dev stop reports only the nodes whose emulator was running. Stopping no longer swallows errors other than "the emulator is not running", so an invalid emulators directory fails with DEV.EMULATORS_DIR_INVALID. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/core/src/control/app-config.ts | 15 ++- .../1-core/core/src/exports/local-target.ts | 1 + .../operations/__tests__/dev-current.test.ts | 96 ++++++++++++++++++- .../cli/src/operations/execute-dev-current.ts | 77 ++++++++++----- .../local-target/src/current-app.ts | 60 ++++++++---- .../local-target/src/emulator-instances.ts | 47 +++++---- .../__tests__/local-target-current.test.ts | 28 +++++- .../__tests__/local-target-emulators.test.ts | 1 + .../target/src/exports/local-target.ts | 10 +- .../target/src/local-target/attach.ts | 4 +- 10 files changed, 266 insertions(+), 73 deletions(-) diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index b211e106a..dc5b3cebb 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -274,7 +274,7 @@ export interface LocalTargetCurrentApp { readonly addresses: readonly string[]; /** Where the node at `address` runs locally; undefined for an address not recorded. */ locate(address: string): Promise; - /** Stops everything the target runs for the app (emulators and data persist); returns what was recorded as running locally. */ + /** Stops everything the target runs for the app (emulators and data persist); returns the nodes it stopped. */ stop(): Promise; } @@ -286,8 +286,11 @@ export type LocalNodeLocation = readonly emulator: 'running'; /** Absent when the emulator no longer has the node's instance. */ readonly url?: string; - /** `running` when the node answers at `url`; otherwise the target's word for its state. */ + /** The target's word for the node's state, shown to the user. */ readonly state: string; + readonly condition: LocalNodeCondition; + /** Where the node's own output is written, when the target keeps it in a file. */ + readonly logPath?: string; } | { readonly address: string; @@ -296,6 +299,14 @@ export type LocalNodeLocation = readonly emulator: 'not-running'; }; +/** + * What a node's state means for reaching it: `running` answers at its URL; + * `stopped` was stopped and starts with the next `prisma dev`; `crashing` + * exited and is being restarted or was held after repeated crashes; + * `missing` is recorded but its emulator no longer has it. + */ +export type LocalNodeCondition = 'running' | 'stopped' | 'crashing' | 'missing'; + export interface LocalNodeStopped { readonly address: string; readonly kind: string; diff --git a/packages/0-framework/1-core/core/src/exports/local-target.ts b/packages/0-framework/1-core/core/src/exports/local-target.ts index a03d56f6c..24c741dcf 100644 --- a/packages/0-framework/1-core/core/src/exports/local-target.ts +++ b/packages/0-framework/1-core/core/src/exports/local-target.ts @@ -9,6 +9,7 @@ export { DEV_DIR, + type LocalNodeCondition, type LocalNodeLocation, type LocalNodeStopped, type LocalTargetAttachInput, diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts index 97655ac25..f788d9b56 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts @@ -12,6 +12,7 @@ import type { LocalTargetCurrentApp, LocalTargetDescriptor, } from '@internal/core/local-target'; +import { CliStructuredError, InternalError } from '@internal/foundation/errors'; import * as Layer from 'effect/Layer'; import { devStop, devUrl } from '../dev-current.ts'; @@ -87,6 +88,7 @@ const running: LocalNodeLocation[] = [ emulator: 'running', url: 'http://localhost:3001', state: 'running', + condition: 'running', }, { address: 'catalog', @@ -94,6 +96,23 @@ const running: LocalNodeLocation[] = [ emulator: 'running', url: 'postgres://x', state: 'stopped', + condition: 'stopped', + }, + { + address: 'quotes', + kind: 'service', + emulator: 'running', + url: 'http://localhost:3002', + state: 'backoff', + condition: 'crashing', + logPath: '/logs/quotes.log', + }, + { + address: 'orders', + kind: 'service', + emulator: 'running', + state: 'missing', + condition: 'missing', }, { address: 'uploads', kind: 'bucket', emulator: 'not-running' }, ]; @@ -132,13 +151,16 @@ describe.skipIf(process.platform === 'win32')('devUrl', () => { test('an address the app does not have fails with DEV.ADDRESS_UNKNOWN, listing the known ones', async () => { const result = await devUrl({ config: config(async () => recordedApp(running)), - address: 'quotes', + address: 'reviews', cwd, }); expect(result).toMatchObject({ ok: false, - failure: { code: 'DEV.ADDRESS_UNKNOWN', meta: { known: ['catalog', 'gateway', 'uploads'] } }, + failure: { + code: 'DEV.ADDRESS_UNKNOWN', + meta: { known: ['catalog', 'gateway', 'orders', 'quotes', 'uploads'] }, + }, }); }); @@ -163,7 +185,75 @@ describe.skipIf(process.platform === 'win32')('devUrl', () => { ok: false, failure: { code: 'DEV.NOT_RUNNING', meta: { state: 'stopped' } }, }); - if (!result.ok) expect(result.failure.message).toContain('stopped'); + if (!result.ok) { + expect(result.failure.message).toContain('stopped'); + expect(result.failure.fix).toContain('prisma dev'); + } + }); + + test('a crash-looping service says so and points at its log, not at `prisma dev`', async () => { + const result = await devUrl({ + config: config(async () => recordedApp(running)), + address: 'quotes', + cwd, + }); + + expect(result).toMatchObject({ + ok: false, + failure: { code: 'DEV.NOT_RUNNING', meta: { state: 'backoff', logPath: '/logs/quotes.log' } }, + }); + if (!result.ok) { + expect(result.failure.why).toContain('restarted'); + expect(result.failure.fix).toContain('/logs/quotes.log'); + expect(result.failure.fix).not.toContain('prisma dev'); + } + }); + + test('a node its emulator lost says so, and that `prisma dev` creates it again', async () => { + const result = await devUrl({ + config: config(async () => recordedApp(running)), + address: 'orders', + cwd, + }); + + expect(result).toMatchObject({ ok: false, failure: { code: 'DEV.NOT_RUNNING' } }); + if (!result.ok) { + expect(result.failure.why).toContain('no longer has it'); + expect(result.failure.fix).toContain('creates the service again'); + } + }); + + test("a target's failure with no code becomes DEV.EMULATOR_FAILED, and a coded one keeps its code", async () => { + const plain = await devUrl({ + config: config(async () => ({ + ...recordedApp(running), + locate: async () => { + throw new Error('request to the local dev emulator failed (500)'); + }, + })), + address: 'gateway', + cwd, + }); + const coded = await devUrl({ + config: config(async () => { + throw new CliStructuredError('DEV.EMULATORS_DIR_INVALID', 'not absolute'); + }), + address: 'gateway', + cwd, + }); + + expect(plain).toMatchObject({ ok: false, failure: { code: 'DEV.EMULATOR_FAILED' } }); + expect(coded).toMatchObject({ ok: false, failure: { code: 'DEV.EMULATORS_DIR_INVALID' } }); + }); + + test('a target that cannot locate an address it recorded is a bug, not a failure with a code', async () => { + const promise = devUrl({ + config: config(async () => ({ ...recordedApp(running), locate: async () => undefined })), + address: 'gateway', + cwd, + }); + + await expect(promise).rejects.toBeInstanceOf(InternalError); }); }); diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts index 001f0c6ed..b286c264a 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts @@ -5,9 +5,9 @@ * descriptors, whose graphs reach alchemy's provider tree. */ import * as path from 'node:path'; -import type { LocalTargetCurrentApp } from '@internal/core/local-target'; +import type { LocalNodeLocation, LocalTargetCurrentApp } from '@internal/core/local-target'; import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; -import { CliStructuredError } from '@internal/foundation/errors'; +import { CliStructuredError, InternalError } from '@internal/foundation/errors'; import { notOk, ok, type Result } from '@internal/foundation/result'; import { checkConfigSource } from '../pipeline.ts'; import type { DevStopInput, DevStopped, DevUrl, DevUrlInput } from './dev-current.ts'; @@ -22,6 +22,15 @@ function platformUnsupported(): CliStructuredError { ); } +/** A call into an extension's local target; its failures that carry no code are the emulator's. */ +async function fromTarget(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + throw toStructured('DEV.EMULATOR_FAILED', error); + } +} + /** Every extension's record of the app `prisma dev` last ran from `cwd`; fails when none recorded one. */ async function currentApps( input: { readonly config: DevUrlInput['config'] }, @@ -31,7 +40,7 @@ async function currentApps( const devDir = path.join(cwd, DEV_DIR); const apps: LocalTargetCurrentApp[] = []; for (const target of (await resolveLocalTargets(value)).values()) { - const current = await target.current?.({ devDir }); + const current = await fromTarget(async () => target.current?.({ devDir })); if (current !== undefined) apps.push(current); } if (apps.length === 0) { @@ -54,14 +63,15 @@ async function locate(input: DevUrlInput, cwd: string): Promise { `"${apps.map((a) => a.app).join('", "')}" has no node at "${input.address}".`, { why: `The addresses recorded for the app \`prisma dev\` last ran here are: ${known.join(', ')}.`, - fix: 'Pass one of those addresses. A node in a nested module has a dotted address, such as `catalog.database`.', + fix: 'Pass one of those addresses. A nested node has a dotted address, such as `catalog.database`.', meta: { address: input.address, known }, }, ); } - const location = await app.locate(input.address); - if (location === undefined) - throw new Error(`no location for recorded address "${input.address}"`); + const location = await fromTarget(() => app.locate(input.address)); + if (location === undefined) { + throw new InternalError(`the local target recorded "${input.address}" but cannot locate it`); + } if (location.emulator === 'not-running') { throw new CliStructuredError( 'DEV.EMULATOR_UNREACHABLE', @@ -73,20 +83,43 @@ async function locate(input: DevUrlInput, cwd: string): Promise { }, ); } - if (location.state !== 'running' || location.url === undefined) { - throw new CliStructuredError( - 'DEV.NOT_RUNNING', - `The ${location.kind} "${input.address}" is not running (${location.state}).`, - { - why: '`prisma dev` stops the app when it ends, and a stopped node has no URL that answers.', - fix: RUN_DEV, - meta: { address: input.address, kind: location.kind, state: location.state }, - }, - ); + if (location.condition !== 'running' || location.url === undefined) { + throw notRunning(input.address, location); } return { address: input.address, kind: location.kind, url: location.url, state: location.state }; } +function notRunning( + address: string, + location: Extract, +): CliStructuredError { + const { kind, state, condition, logPath } = location; + const meta = { address, kind, state, ...(logPath === undefined ? {} : { logPath }) }; + const message = `The ${kind} "${address}" is not running (${state}).`; + if (condition === 'crashing') { + return new CliStructuredError('DEV.NOT_RUNNING', message, { + why: 'It exited after it started, and it is being restarted or was held after repeated crashes.', + fix: + logPath === undefined + ? 'Read its output in the `prisma dev` terminal, fix the cause, and rebuild.' + : `Read its log at ${logPath}, fix the cause, and rebuild.`, + meta, + }); + } + if (condition === 'missing') { + return new CliStructuredError('DEV.NOT_RUNNING', message, { + why: 'The local emulator no longer has it, for example because a Composer upgrade discarded the emulator’s state.', + fix: `${RUN_DEV} It creates the ${kind} again, empty.`, + meta, + }); + } + return new CliStructuredError('DEV.NOT_RUNNING', message, { + why: '`prisma dev` stops the app when it ends, and a stopped node has no URL that answers.', + fix: RUN_DEV, + meta, + }); +} + export async function executeDevUrl( input: DevUrlInput, cwd: string, @@ -95,9 +128,8 @@ export async function executeDevUrl( try { return ok(await locate(input, cwd)); } catch (error) { - // An emulator's own failure (not "unreachable", which arrives - // structured) reaches here plain. - return notOk(toStructured('DEV.EMULATOR_FAILED', error)); + if (CliStructuredError.is(error)) return notOk(error); + throw error; } } @@ -109,9 +141,10 @@ export async function executeDevStop( try { const apps = await currentApps(input, cwd); const stopped = []; - for (const app of apps) stopped.push(...(await app.stop())); + for (const app of apps) stopped.push(...(await fromTarget(() => app.stop()))); return ok({ app: apps.map((a) => a.app).join(', '), stopped }); } catch (error) { - return notOk(toStructured('DEV.EMULATOR_FAILED', error)); + if (CliStructuredError.is(error)) return notOk(error); + throw error; } } diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts index af4003b57..5fc09a206 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/current-app.ts @@ -4,6 +4,7 @@ * `prisma dev url` and `prisma dev stop`. */ import type { + LocalNodeCondition, LocalNodeLocation, LocalNodeStopped, LocalTargetCurrentApp, @@ -14,6 +15,7 @@ import { type EmulatorKind, isEmulatorRunning, postgresClient, + type ServiceInstanceStatus, } from '@internal/dev-emulators'; import { directoryOf, @@ -22,29 +24,56 @@ import { stopAppInstances, } from './emulator-instances.ts'; -/** A node's state when its emulator no longer has the instance the record names. */ -const MISSING = 'missing'; +const CONDITION_OF: Record = { + running: 'running', + stopped: 'stopped', + backoff: 'crashing', + held: 'crashing', +}; + +/** A node whose emulator no longer has the instance the record names. */ +function missing(address: string, kind: EmulatorKind): LocalNodeLocation { + return { address, kind, emulator: 'running', state: 'missing', condition: 'missing' }; +} async function locate(address: string, node: NodeInstance): Promise { const kind: EmulatorKind = node.kind; if (!(await isEmulatorRunning(kind))) return { address, kind, emulator: 'not-running' }; if (node.kind === 'service') { const instance = await computeClient().getInstance(node.id); - return instance - ? { address, kind, emulator: 'running', url: instance.url, state: instance.status } - : { address, kind, emulator: 'running', state: MISSING }; + if (instance === undefined) return missing(address, kind); + return { + address, + kind, + emulator: 'running', + url: instance.url, + state: instance.status, + condition: CONDITION_OF[instance.status], + logPath: instance.logPath, + }; } if (node.kind === 'database') { const instance = await postgresClient().getInstance(node.id); - return instance - ? { address, kind, emulator: 'running', url: instance.url, state: instance.status } - : { address, kind, emulator: 'running', state: MISSING }; + if (instance === undefined) return missing(address, kind); + return { + address, + kind, + emulator: 'running', + url: instance.url, + state: instance.status, + condition: instance.status, + }; } const client = bucketsClient(); - const instance = await client.getInstance(node.id); - return instance - ? { address, kind, emulator: 'running', url: `${client.baseUrl}/${node.id}`, state: 'running' } - : { address, kind, emulator: 'running', state: MISSING }; + if ((await client.getInstance(node.id)) === undefined) return missing(address, kind); + return { + address, + kind, + emulator: 'running', + url: `${client.baseUrl}/${node.id}`, + state: 'running', + condition: 'running', + }; } /** The directory's current app; undefined when `prisma dev` never ran there. */ @@ -60,11 +89,6 @@ export async function currentApp(devDir: string): Promise => { - await stopAppInstances({ devDir, app }); - return Object.entries(nodes).flatMap(([address, node]) => - node.kind === 'bucket' ? [] : [{ address, kind: node.kind }], - ); - }, + stop: (): Promise => stopAppInstances({ devDir, app }), }; } diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/emulator-instances.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/emulator-instances.ts index 40d5ac4ee..970fa9ced 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/emulator-instances.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/emulator-instances.ts @@ -12,6 +12,7 @@ import { bucketsClient, computeClient, type DatabaseInstanceInfo, + isEmulatorRunning, postgresClient, type ServiceInstanceInfo, } from '@internal/dev-emulators'; @@ -227,33 +228,47 @@ function idsOf(nodes: Readonly>, kind: NodeKind): s return Object.values(nodes).flatMap((node) => (node.kind === kind ? [node.id] : [])); } +/** A node `stopNodes` stopped. */ +export interface StoppedNode { + readonly address: string; + readonly kind: 'service' | 'database'; +} + /** * Stops the service processes and database servers of `nodes`, keeping - * their data and ports. A kind whose emulator is not running has nothing - * running to stop. + * their data and ports, and returns the nodes it stopped. A kind whose + * emulator is not running has nothing running to stop. */ -async function stopNodes(nodes: Readonly>): Promise { +async function stopNodes( + nodes: Readonly>, +): Promise { const services = idsOf(nodes, 'service'); const databases = idsOf(nodes, 'database'); // Both are attempted, so a failed service stop never leaves the database // servers running; the first failure is reported after both. const results = await Promise.allSettled([ - services.length > 0 ? whenRunning(() => computeClient().stopInstances(services)) : undefined, - databases.length > 0 ? whenRunning(() => postgresClient().stopInstances(databases)) : undefined, + services.length > 0 && whenRunning('service', () => computeClient().stopInstances(services)), + databases.length > 0 && + whenRunning('database', () => postgresClient().stopInstances(databases)), ]); const failure = results.find((result) => result.status === 'rejected'); if (failure) throw failure.reason; + const stoppedKinds = new Set(); + if (results[0].status === 'fulfilled' && results[0].value === true) stoppedKinds.add('service'); + if (results[1].status === 'fulfilled' && results[1].value === true) stoppedKinds.add('database'); + return Object.entries(nodes).flatMap(([address, node]) => + node.kind !== 'bucket' && stoppedKinds.has(node.kind) ? [{ address, kind: node.kind }] : [], + ); } -/** Runs `action` against an emulator, treating "the emulator is not running" as nothing to do. */ -async function whenRunning(action: () => Promise): Promise { - let run: Promise; - try { - run = action(); - } catch { - return; - } - await run; +/** Runs `action` when the emulator for `kind` is running; returns whether it ran. */ +async function whenRunning( + kind: 'service' | 'database', + action: () => Promise, +): Promise { + if (!(await isEmulatorRunning(kind))) return false; + await action(); + return true; } /** @@ -445,8 +460,8 @@ export async function deleteAppInstances(scope: AppScope): Promise { } /** When `prisma dev` ends: stops the app's service processes and database servers, keeping their data and ports. */ -export async function stopAppInstances(scope: AppScope): Promise { - await stopNodes(await appNodes(scope)); +export async function stopAppInstances(scope: AppScope): Promise { + return stopNodes(await appNodes(scope)); } /** diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts index e6044c041..db8d7e4e7 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts @@ -114,7 +114,7 @@ describe.skipIf(process.platform === 'win32')("the directory's current app", () if (location?.emulator !== 'running' || location.url === undefined) { throw new Error(`"${address}" is not running`); } - expect(location.state).toBe('running'); + expect(location).toMatchObject({ state: 'running', condition: 'running' }); return location.url; } @@ -168,7 +168,31 @@ describe.skipIf(process.platform === 'win32')("the directory's current app", () expect(await postgresClient().getInstance(catalog.id)).toMatchObject({ status: 'stopped' }); expect(await acceptsConnection(gateway.port)).toBe(false); expect(await acceptsConnection(catalog.port)).toBe(false); - expect(await current.locate('catalog')).toMatchObject({ state: 'stopped' }); + expect(await current.locate('catalog')).toMatchObject({ + state: 'stopped', + condition: 'stopped', + }); + }, 90_000); + + test('stopping reports only the nodes whose emulator was running', async () => { + const { devDir } = await runApp('a'); + await stopDaemon('compute', { registryRoot }); + + const stopped = await (await currentOf(devDir)).stop(); + + expect(stopped).toEqual([{ address: 'catalog', kind: 'database' }]); + }, 90_000); + + test('stopping with an emulators directory that is not absolute fails with its code, and stops nothing', async () => { + const { devDir } = await runApp('a'); + const current = await currentOf(devDir); + process.env[EMULATORS_DIR_ENV] = 'relative/emulators'; + + const stopping = current.stop(); + + await expect(stopping).rejects.toMatchObject({ code: 'DEV.EMULATORS_DIR_INVALID' }); + process.env[EMULATORS_DIR_ENV] = registryRoot; + expect(await current.locate('gateway')).toMatchObject({ state: 'running' }); }, 90_000); test('a directory where `prisma dev` never ran has no current app', async () => { diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts index 2ad1a2437..25e51dc20 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts @@ -44,6 +44,7 @@ mock.module('@internal/local-target', () => ({ ), stopAppInstances: fake(RealLocalTarget.stopAppInstances, async ({ app }: { app: string }) => { events.push(`stop ${app}`); + return []; }), })); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts b/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts index 8446072b7..f68ffafbd 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts @@ -3,18 +3,10 @@ * entry from `./control`. `control/extension.ts` loads `localTargetDescriptor` * through its lazy `localTarget` reference; the descriptor lives in * `../local-target/descriptor.ts`. The entry also exposes the emulator - * registry root, the variable that sets it, and the `listEmulators` - * operation behind `prisma emulator list`, from `@internal/dev-emulators`. + * registry root and the variable that sets it, from `@internal/dev-emulators`. */ -export type { - EmulatorInstance, - EmulatorKind, - EmulatorList, - EmulatorListing, -} from '@internal/dev-emulators'; export { defaultRegistryRoot as emulatorRegistryRoot, EMULATORS_DIR_ENV, - listEmulators, } from '@internal/dev-emulators'; export { localTargetDescriptor } from '../local-target/descriptor.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts index fd6e43629..ce731a9d8 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts @@ -131,6 +131,8 @@ export async function devAttach(input: LocalTargetAttachInput): Promise mergedLogs(scope, signal, opts?.tail ?? 0), // Service processes and database servers; their data and ports are kept. - stopApp: () => stopAppInstances(scope), + stopApp: async () => { + await stopAppInstances(scope); + }, }; } From 0997f2998f5573113516d40d20538853bd7a9145 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:49:13 +0200 Subject: [PATCH 07/14] fix: emulator list reports each emulator on its own An emulator that does not answer, or answers with an error, no longer fails the whole listing. Its entry carries `error: { code, message }`, with DEV.EMULATOR_UNREACHABLE or DEV.EMULATOR_FAILED, and the others are still listed. `url` is null for an emulator that is not running. Only an invalid emulators directory fails the command. The listEmulators re-export from @prisma/composer-prisma-cloud/local-target is removed: nothing used it, and the command uses the client bundled into @prisma/composer-cli. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/emulator-list.test.ts | 143 ++++++++++++++---- .../dev-emulators/src/emulator-list.ts | 111 +++++++++----- .../composer-cli/src/family/emulator-list.ts | 25 ++- .../composer-cli/test/emulator-list.test.ts | 14 +- 4 files changed, 217 insertions(+), 76 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts index 8e8a3e189..81e2578bc 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/emulator-list.test.ts @@ -1,10 +1,13 @@ -/** The emulator listing with nothing running, and with an emulator running. */ -import { afterEach, beforeEach, expect, test } from 'bun:test'; +/** The emulator listing: nothing running, an emulator running, and emulators that cannot be listed. */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; import { computeClient } from '../client.ts'; -import { stopDaemon } from '../daemon.ts'; +import { registryFilePath, stopDaemon } from '../daemon.ts'; import { listEmulators } from '../emulator-list.ts'; import { ensureFreshDaemon, tempDir } from './helpers.ts'; +const FAKE_VERSION = 'fake-registration'; let registryRoot: string; beforeEach(() => { @@ -12,44 +15,126 @@ beforeEach(() => { }); afterEach(async () => { + // A fake registration names this test's own process; stopping it would end the run. + for (const name of ['compute', 'buckets'] as const) { + const file = registryFilePath(registryRoot, name); + if (fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(FAKE_VERSION)) + fs.rmSync(file); + } await stopDaemon('compute', { registryRoot }).catch(() => undefined); }); +/** A port nothing listens on: bound once to pick it, then released. */ +async function closedPort(): Promise { + const server = net.createServer(); + 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('no port'); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +/** Registers an emulator as running at `port`, with this test's own live process as its PID. */ +function register(name: 'compute' | 'buckets', port: number): void { + fs.mkdirSync(registryRoot, { recursive: true }); + fs.writeFileSync( + registryFilePath(registryRoot, name), + JSON.stringify({ pid: process.pid, port, version: FAKE_VERSION, logPath: '/dev/null' }), + ); +} + test('with nothing running, every emulator is listed as not running, with no instances', async () => { const result = await listEmulators({ registryRoot }); - expect(result).toMatchObject({ - ok: true, - value: { - emulators: [ - { kind: 'service', running: false, instances: [] }, - { kind: 'database', running: false, instances: [] }, - { kind: 'bucket', running: false, instances: [] }, - ], - }, + if (!result.ok) throw new Error(result.failure.message); + expect(result.value).toEqual({ + emulators: [ + { kind: 'service', running: false, url: null, instances: [], error: null }, + { kind: 'database', running: false, url: null, instances: [], error: null }, + { kind: 'bucket', running: false, url: null, instances: [], error: null }, + ], }); }); -test("a running emulator is listed with its admin URL and its instances' labels", async () => { - await ensureFreshDaemon('compute', registryRoot); - const client = computeClient({ registryRoot }); - const instance = await client.createInstance({ labels: { 'composer.address': 'gateway' } }); +test('an emulator that does not answer is reported in its own entry, and the others are still listed', async () => { + const port = await closedPort(); + register('compute', port); const result = await listEmulators({ registryRoot }); if (!result.ok) throw new Error(result.failure.message); - expect(result.value.emulators[0]).toEqual({ - kind: 'service', - running: true, - url: client.baseUrl, - instances: [ - { - id: instance.id, - kind: 'service', - url: instance.url, - state: 'stopped', - labels: { 'composer.address': 'gateway' }, - }, - ], + expect(result.value.emulators).toEqual([ + { + kind: 'service', + running: true, + url: `http://127.0.0.1:${port}`, + instances: [], + error: { code: 'DEV.EMULATOR_UNREACHABLE', message: expect.any(String) }, + }, + { kind: 'database', running: false, url: null, instances: [], error: null }, + { kind: 'bucket', running: false, url: null, instances: [], error: null }, + ]); +}); + +test('an emulator that answers with an error is reported as DEV.EMULATOR_FAILED in its own entry', async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response('boom', { status: 500 }) }); + const port = server.port; + if (port === undefined) throw new Error('the server has no port'); + try { + register('buckets', port); + + const result = await listEmulators({ registryRoot }); + + if (!result.ok) throw new Error(result.failure.message); + expect(result.value.emulators[2]).toEqual({ + kind: 'bucket', + running: true, + url: `http://127.0.0.1:${port}`, + instances: [], + error: { code: 'DEV.EMULATOR_FAILED', message: expect.stringContaining('500') }, + }); + } finally { + await server.stop(true); + } +}); + +test('an emulators directory that is not absolute fails the whole listing', async () => { + const saved = process.env['PRISMA_COMPOSER_EMULATORS_DIR']; + process.env['PRISMA_COMPOSER_EMULATORS_DIR'] = 'relative/emulators'; + try { + const result = await listEmulators(); + + expect(result).toMatchObject({ ok: false, failure: { code: 'DEV.EMULATORS_DIR_INVALID' } }); + } finally { + if (saved === undefined) delete process.env['PRISMA_COMPOSER_EMULATORS_DIR']; + else process.env['PRISMA_COMPOSER_EMULATORS_DIR'] = saved; + } +}); + +// Local dev is not supported on Windows, where the emulators cannot run. +describe.skipIf(process.platform === 'win32')('with a real emulator', () => { + test("a running emulator is listed with its admin URL and its instances' labels", async () => { + await ensureFreshDaemon('compute', registryRoot); + const client = computeClient({ registryRoot }); + const instance = await client.createInstance({ labels: { 'composer.address': 'gateway' } }); + + const result = await listEmulators({ registryRoot }); + + if (!result.ok) throw new Error(result.failure.message); + expect(result.value.emulators[0]).toEqual({ + kind: 'service', + running: true, + url: client.baseUrl, + instances: [ + { + id: instance.id, + kind: 'service', + url: instance.url, + state: 'stopped', + labels: { 'composer.address': 'gateway' }, + }, + ], + error: null, + }); }); }); diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts index dc92a9b24..044bbe8d1 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/emulator-list.ts @@ -35,13 +35,21 @@ export interface EmulatorInstance { readonly labels: Readonly>; } +/** Why a running emulator could not be listed: it did not answer, or it answered with an error. */ +export interface EmulatorListingError { + readonly code: string; + readonly message: string; +} + export interface EmulatorListing { readonly kind: EmulatorKind; readonly running: boolean; - /** The emulator's admin URL; absent when it is not running. */ - readonly url?: string; - /** Empty when the emulator is not running. */ + /** The emulator's admin URL; null when it is not running. */ + readonly url: string | null; + /** Empty when the emulator is not running or could not be listed. */ readonly instances: readonly EmulatorInstance[]; + /** Null unless the emulator runs but could not be listed. */ + readonly error: EmulatorListingError | null; } export interface EmulatorList { @@ -60,65 +68,90 @@ export async function isEmulatorRunning( return entry !== undefined && isPidAlive(entry.pid); } -async function instancesOf( - kind: EmulatorKind, - opts: DaemonRootOptions, -): Promise<{ readonly url: string; readonly instances: EmulatorInstance[] }> { +interface InstanceLister { + readonly url: string; + list(): Promise; +} + +function listerOf(kind: EmulatorKind, opts: DaemonRootOptions): InstanceLister { if (kind === 'service') { const client = computeClient(opts); - const instances = await client.listInstances(); return { url: client.baseUrl, - instances: instances.map(({ id, url, status, labels }) => ({ - id, - kind, - url, - state: status, - labels, - })), + list: async () => + (await client.listInstances()).map(({ id, url, status, labels }) => ({ + id, + kind, + url, + state: status, + labels, + })), }; } if (kind === 'database') { const client = postgresClient(opts); - const instances = await client.listInstances(); return { url: client.baseUrl, - instances: instances.map(({ id, url, status, labels }) => ({ - id, - kind, - url, - state: status, - labels, - })), + list: async () => + (await client.listInstances()).map(({ id, url, status, labels }) => ({ + id, + kind, + url, + state: status, + labels, + })), }; } const client = bucketsClient(opts); - const instances = await client.listInstances(); return { url: client.baseUrl, - instances: instances.map(({ id, labels }) => ({ - id, - kind, - url: `${client.baseUrl}/${id}`, - state: 'running', - labels, - })), + list: async () => + (await client.listInstances()).map(({ id, labels }) => ({ + id, + kind, + url: `${client.baseUrl}/${id}`, + state: 'running', + labels, + })), }; } -/** Every emulator, whether it runs, and its instances. Nothing running is a valid answer, not a failure. */ +async function listingOf(kind: EmulatorKind, opts: DaemonRootOptions): Promise { + if (!(await isEmulatorRunning(kind, opts))) { + return { kind, running: false, url: null, instances: [], error: null }; + } + const lister = listerOf(kind, opts); + try { + return { kind, running: true, url: lister.url, instances: await lister.list(), error: null }; + } catch (error) { + const failure = CliStructuredError.is(error) + ? error + : new CliStructuredError( + 'DEV.EMULATOR_FAILED', + error instanceof Error ? error.message : String(error), + { cause: error }, + ); + return { + kind, + running: true, + url: lister.url, + instances: [], + error: { code: failure.code, message: failure.message }, + }; + } +} + +/** + * Every emulator, whether it runs, and its instances. Nothing running is a + * valid answer, and an emulator that cannot be listed is reported in its own + * entry; only an invalid emulators directory fails the whole listing. + */ export async function listEmulators( opts: DaemonRootOptions = {}, ): Promise> { try { const emulators: EmulatorListing[] = []; - for (const kind of EMULATOR_KINDS) { - if (!(await isEmulatorRunning(kind, opts))) { - emulators.push({ kind, running: false, instances: [] }); - continue; - } - emulators.push({ kind, running: true, ...(await instancesOf(kind, opts)) }); - } + for (const kind of EMULATOR_KINDS) emulators.push(await listingOf(kind, opts)); return ok({ emulators }); } catch (error) { if (CliStructuredError.is(error)) return notOk(error); diff --git a/packages/9-public/composer-cli/src/family/emulator-list.ts b/packages/9-public/composer-cli/src/family/emulator-list.ts index af55c6f9a..4323dd14e 100644 --- a/packages/9-public/composer-cli/src/family/emulator-list.ts +++ b/packages/9-public/composer-cli/src/family/emulator-list.ts @@ -6,7 +6,7 @@ */ import { toEngineError } from '@internal/cli/family'; -import type { EmulatorList } from '@internal/dev-emulators'; +import type { EmulatorList, EmulatorListing } from '@internal/dev-emulators'; import type { CliStructuredError as ComposerError } from '@internal/foundation/errors'; import type { Result } from '@internal/foundation/result'; import { defineCommand } from '@prisma/cli-engine'; @@ -32,6 +32,20 @@ function startedBy(labels: Readonly>): string | undefined return directory === undefined ? address : `${address} in ${directory}`; } +function labelOf(emulator: EmulatorListing, dim: (text: string) => string): string { + const name = EMULATOR_NAMES[emulator.kind]; + if (!emulator.running || emulator.url === null) return `${name} ${dim('(not running)')}`; + if (emulator.error !== null) { + return `${name} at ${emulator.url}: could not list its instances (${emulator.error.code}: ${emulator.error.message})`; + } + return `${name} at ${emulator.url}`; +} + +function statusOf(emulator: EmulatorListing): 'ok' | 'info' | 'error' { + if (emulator.error !== null) return 'error'; + return emulator.running ? 'ok' : 'info'; +} + export const createEmulatorListCommand = (list: ListEmulators) => defineCommand({ help: { @@ -41,7 +55,8 @@ export const createEmulatorListCommand = (list: ListEmulators) => 'instance each one holds.', description: 'Needs no project: it shows every directory’s instances. An emulator that is not ' + - 'running is listed with no instances. Each instance’s labels say which directory, ' + + 'running is listed with no instances, and one that runs but cannot be listed is ' + + 'shown with the error. Each instance’s labels say which directory, ' + 'app and node started it. A stopped instance keeps its port and data until it is ' + 'deleted.', examples: ['{bin} emulator list', '{bin} emulator list --json'], @@ -59,10 +74,8 @@ export const createEmulatorListCommand = (list: ListEmulators) => { kind: 'tree', roots: emulators.map((emulator) => ({ - label: emulator.running - ? `${EMULATOR_NAMES[emulator.kind]} at ${emulator.url ?? ''}` - : `${EMULATOR_NAMES[emulator.kind]} ${ui.dim('(not running)')}`, - status: emulator.running ? ('ok' as const) : ('info' as const), + label: labelOf(emulator, ui.dim), + status: statusOf(emulator), children: emulator.instances.map((instance) => { const by = startedBy(instance.labels); return { diff --git a/packages/9-public/composer-cli/test/emulator-list.test.ts b/packages/9-public/composer-cli/test/emulator-list.test.ts index 443c8e6e2..1d7d773d7 100644 --- a/packages/9-public/composer-cli/test/emulator-list.test.ts +++ b/packages/9-public/composer-cli/test/emulator-list.test.ts @@ -25,9 +25,16 @@ const listing: EmulatorList = { }, }, ], + error: null, + }, + { kind: 'database', running: false, url: null, instances: [], error: null }, + { + kind: 'bucket', + running: true, + url: 'http://127.0.0.1:4301', + instances: [], + error: { code: 'DEV.EMULATOR_UNREACHABLE', message: 'connection refused' }, }, - { kind: 'database', running: false, instances: [] }, - { kind: 'bucket', running: false, instances: [] }, ], }; @@ -74,6 +81,9 @@ describe('emulator list', () => { expect(result.stderr).toContain('gateway in /work/shop'); expect(result.stderr).toContain('Database emulator'); expect(result.stderr).toContain('not running'); + expect(result.stderr).toContain( + 'Bucket emulator at http://127.0.0.1:4301: could not list its instances (DEV.EMULATOR_UNREACHABLE: connection refused)', + ); }); test('a failure keeps its code', async () => { From 8d5cc13b8eaeefc1915083a49f9029d96ca0af23 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:49:25 +0200 Subject: [PATCH 08/14] fix(cli): define a node in the dev help, and stop claiming dev url works after dev ends The dev card now defines a node as one service, database or bucket in the app, and says that `prisma dev url` works while `prisma dev` runs and `prisma dev stop` also works after it was killed. The dev url summary defines a node in place, the address brief drops "module", and the jq example uses -e so a failure does not print a URL. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/family/commands/dev-url.ts | 11 ++++++----- .../3-tooling/cli/src/family/commands/dev.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts index 4aaee380d..f53531ccb 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev-url.ts @@ -13,21 +13,22 @@ export const createDevUrlCommand = (operations: ComposerOperations) => defineCommand({ help: { summary: - 'Print the local URL of a node of the app `prisma dev` runs from this directory: a ' + - "service's HTTP URL, a database's connection string, or a bucket's S3 address.", + 'Print the local URL of a node (a service, database or bucket in your app) that ' + + "`prisma dev` runs from this directory: a service's HTTP URL, a database's connection " + + "string, or a bucket's S3 address.", description: 'Reads what the last `prisma dev` in this directory recorded and starts nothing, so ' + 'run it while `prisma dev` is running; a node that is not running is an error. With ' + '--format human the URL alone goes to stdout. In a script, the JSON result holds it: ' + - `prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url'.`, + `prisma dev url gateway | jq -er 'select(.kind=="result").envelope.result.url'.`, examples: ['{bin} dev url gateway', '{bin} dev url catalog.database --json'], }, args: { positionals: { address: positional.string({ brief: - "The node's address: its name in the app, dotted for a node inside a module, such " + - 'as catalog.database.', + "The node's address: its name in the app, with dots for a nested node, such as " + + 'catalog.database.', placeholder: 'address', }), }, diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts index 9ead059ef..f092f31bb 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts @@ -184,11 +184,12 @@ export const createDevCommand = (operations: ComposerOperations) => 'this machine.', description: 'Runs credential-free and watches the app for changes, reconverging on every edit. ' + - 'Each node of the app runs on a local emulator: a program on this machine that stands ' + - 'in for Prisma Cloud and runs services, databases or S3-compatible buckets. ' + - "A node's address is its name in the app, dotted for a node inside a module " + - '(catalog.database). The app and where each node runs are recorded in this directory, ' + - 'so the commands beneath work from here while `prisma dev` runs, and after it ends. ' + + 'A node is one service, database or bucket in the app. Each node runs on a local ' + + 'emulator: a program on this machine that stands in for Prisma Cloud and runs ' + + "services, databases or S3-compatible buckets. A node's address is its name in the " + + 'app, with dots for a nested node (catalog.database). The app and where each node ' + + 'runs are recorded in this directory: `prisma dev url` works from here while ' + + '`prisma dev` runs, and `prisma dev stop` also works after `prisma dev` was killed. ' + 'Ending `prisma dev` stops its services and database servers; data and ports are kept.', examples: ['{bin} dev module.ts', '{bin} dev module.ts --fresh'], }, From f2ce61ac98176608e7a73e64428fa35335c378a5 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:49:28 +0200 Subject: [PATCH 09/14] test: devUrl and devStop against the real Prisma Cloud target through a real config The target's own emulators hook records the integration fixture's app in a temporary directory on real emulators in a temporary emulators directory. devUrl, called with the package's prisma.config.ts, gives a URL the service answers at and the database's connection string; devStop stops the services and the database server, their ports close, and devUrl then fails with DEV.NOT_RUNNING. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/control.dev-current.test.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/integration/test/control.dev-current.test.ts diff --git a/test/integration/test/control.dev-current.test.ts b/test/integration/test/control.dev-current.test.ts new file mode 100644 index 000000000..d9ee7a165 --- /dev/null +++ b/test/integration/test/control.dev-current.test.ts @@ -0,0 +1,150 @@ +/** + * `devUrl` and `devStop` from `@prisma/composer/control` against the real + * Prisma Cloud target, through a real configuration: the target's own + * `emulators` hook records the app in a temporary directory and starts real + * emulators in a temporary emulators directory, then the operations find the + * app from that directory alone. The service's deployment and the emulators' + * registry use the emulators' documented wire protocol and files. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Load } from '@prisma/composer'; +import type { ContainerInstance, LocalTargetDescriptor } from '@prisma/composer/config'; +import { DEV_DIR } from '@prisma/composer/config'; +import { devStop, devUrl } from '@prisma/composer/control'; +import { prismaCloud } from '@prisma/composer-prisma-cloud/control'; +import { EMULATORS_DIR_ENV } from '@prisma/composer-prisma-cloud/local-target'; +import prismaConfig from '../prisma.config.ts'; +import appModule from './fixtures/local-dev/module.ts'; + +const APP = 'devcurrentfixture'; +const integrationDir = path.resolve(import.meta.dir, '..'); +const config = { + value: prismaConfig.composer, + file: path.join(integrationDir, 'prisma.config.ts'), +}; +const SERVING_BOOTSTRAP = + "Bun.serve({ port: Number(process.env['PORT']), fetch: () => new Response('hello from web') });\n"; + +interface RecordedNode { + readonly kind: string; + readonly id: string; + readonly port?: number; +} + +function acceptsConnection(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: '127.0.0.1' }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); +} + +// Local dev is not supported on Windows, where the emulators cannot run. +describe.skipIf(process.platform === 'win32')('devUrl and devStop on the real target', () => { + const savedEmulatorsDir = process.env[EMULATORS_DIR_ENV]; + const originalCwd = process.cwd(); + let root: string; + let cwd: string; + let emulatorsDir: string; + let target: LocalTargetDescriptor; + let container: ContainerInstance; + let nodes: Readonly>; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'control-dev-current-')); + cwd = path.join(root, 'app'); + fs.mkdirSync(cwd); + emulatorsDir = path.join(root, 'emulators'); + process.env[EMULATORS_DIR_ENV] = emulatorsDir; + + const localTarget = prismaCloud().localTarget; + if (localTarget === undefined) throw new Error('prismaCloud() has no local target'); + target = await localTarget(); + container = await target.container.ensure({ appName: APP, stage: undefined }); + const devDir = path.join(cwd, DEV_DIR); + await target.emulators?.({ graph: Load(appModule, { id: APP }), container, devDir }); + + const record = JSON.parse( + fs.readFileSync(path.join(devDir, 'emulator-instances.json'), 'utf8'), + ) as { apps: Record }> }; + nodes = record.apps[APP]?.nodes ?? {}; + const web = nodes['web']; + if (web?.port === undefined) throw new Error('web is not recorded with a port'); + + const artifactDir = path.join(root, 'web-artifact'); + fs.mkdirSync(artifactDir); + fs.writeFileSync(path.join(artifactDir, 'bootstrap.js'), SERVING_BOOTSTRAP); + const compute = JSON.parse(fs.readFileSync(path.join(emulatorsDir, 'compute.json'), 'utf8')); + const deployed = await fetch( + `http://127.0.0.1:${compute.port}/instances/${web.id}/deployment`, + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + artifactDir, + artifactHash: 'v1', + env: { PATH: process.env['PATH'] ?? '', PORT: String(web.port) }, + }), + }, + ); + if (!deployed.ok) throw new Error(`deploying web failed: ${await deployed.text()}`); + }, 120_000); + + afterAll(async () => { + process.chdir(cwd); + await target?.teardown?.({ container, stage: undefined }).catch(() => undefined); + process.chdir(originalCwd); + for (const name of ['compute', 'buckets', 'postgres']) { + const file = path.join(emulatorsDir, `${name}.json`); + if (!fs.existsSync(file)) continue; + try { + process.kill(JSON.parse(fs.readFileSync(file, 'utf8')).pid, 'SIGTERM'); + } catch { + // already gone + } + } + fs.rmSync(root, { recursive: true, force: true }); + if (savedEmulatorsDir === undefined) delete process.env[EMULATORS_DIR_ENV]; + else process.env[EMULATORS_DIR_ENV] = savedEmulatorsDir; + }, 60_000); + + test("devUrl gives the URL a service answers at, and a database's connection string", async () => { + const web = await devUrl({ config, cwd, address: 'web' }); + const appdb = await devUrl({ config, cwd, address: 'appdb' }); + + if (!web.ok) throw new Error(web.failure.message); + expect(web.value).toMatchObject({ address: 'web', kind: 'service', state: 'running' }); + expect(await (await fetch(web.value.url)).text()).toBe('hello from web'); + if (!appdb.ok) throw new Error(appdb.failure.message); + expect(appdb.value.url).toStartWith('postgres'); + }, 30_000); + + test('devStop stops the service and the database server, and their ports close', async () => { + const web = nodes['web']; + const appdb = nodes['appdb']; + if (web?.port === undefined || appdb?.port === undefined) throw new Error('not recorded'); + + const result = await devStop({ config, cwd }); + + if (!result.ok) throw new Error(result.failure.message); + expect(result.value.app).toBe(APP); + expect([...result.value.stopped].sort((a, b) => a.address.localeCompare(b.address))).toEqual([ + { address: 'appdb', kind: 'database' }, + { address: 'bkg', kind: 'service' }, + { address: 'web', kind: 'service' }, + ]); + expect(await acceptsConnection(web.port)).toBe(false); + expect(await acceptsConnection(appdb.port)).toBe(false); + expect(await devUrl({ config, cwd, address: 'web' })).toMatchObject({ + ok: false, + failure: { code: 'DEV.NOT_RUNNING' }, + }); + }, 60_000); +}); From d639a4a0ca1c9f6449256cb7cebfc445ba39868f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:49:30 +0200 Subject: [PATCH 10/14] docs: emulator list's errors and bundled client, NOT_RUNNING's reasons, and the safe jq form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0053 and local-dev.md say emulator list uses the client bundled into @prisma/composer-cli, record that the emulators' registry files and list routes are now read across Composer versions, and give the per-emulator error and null url in its JSON. ADR-0044 says devStop raises only NOT_STARTED_HERE. The guide, the skill and the ADR use `url=$(… | jq -er …) && curl "$url"`, because jq -r alone prints null on failure and curl runs anyway. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/local-dev.md | 19 +++++++++++++------ ...l-envelopes-with-dotted-namespace-codes.md | 2 +- ...0053-dev-url-dev-stop-and-emulator-list.md | 10 ++++++---- docs/guides/running-locally.md | 15 +++++++++++---- skills/prisma-composer-core-concepts/SKILL.md | 14 +++++++++----- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index 7f99b69df..5355c5742 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -37,12 +37,18 @@ command: - **`prisma dev stop`** — stop that app's service processes and database servers, as ending `prisma dev` does, for a `dev` that was killed without the chance. Data and ports stay. It is the `devStop` operation, returning - `{ app, stopped }`. + `{ app, stopped }`; `stopped` lists only the nodes whose emulator was + running. - **`prisma emulator list`** — every local emulator on this machine, whether it - runs, its admin URL, and its instances with id, kind, URL, state and labels. - It needs no project or configuration. The operation is `listEmulators` from - `@prisma/composer-prisma-cloud/local-target`. `prisma emulator start` and - `prisma emulator stop` are planned. + runs, its admin URL (`null` when it is not running), and its instances with + id, kind, URL, state and labels. An emulator that runs but cannot be listed + is reported in its own entry with an `error` of `{ code, message }`, and the + others are still listed. It needs no project or configuration, so it uses + the emulator client bundled into `@prisma/composer-cli` rather than the + project's `@prisma/composer-prisma-cloud`. The emulators' registry files and + instance-list routes are therefore read across Composer versions and must + stay backward compatible. `prisma emulator start` and `prisma emulator stop` + are planned. `dev url` and `dev stop` take no entry and build nothing. The configuration does not name the app, so they act on the app the directory's record names as @@ -469,7 +475,8 @@ Deploy's rule holds: every failure names its fix. | `dev url` or `dev stop` in a directory where no extension recorded an app | `DEV.NOT_STARTED_HERE`: run `prisma dev` here first | | `dev url` with an address the app does not have | `DEV.ADDRESS_UNKNOWN`, listing the addresses it has | | `dev url` for a node whose emulator is not running | `DEV.EMULATOR_UNREACHABLE` | -| `dev url` for a node that exists but is not running | `DEV.NOT_RUNNING`, naming its state | +| `dev url` for a node that exists but is not running | `DEV.NOT_RUNNING`, naming its state; for a stopped node the fix is `prisma dev`, for a crash-looping service it is the service's log, for a node its emulator lost it is `prisma dev`, which creates it again | +| the local target fails without a code (for example an emulator's error response) in `dev url` or `dev stop` | `DEV.EMULATOR_FAILED` | | service crash-loops | the address, exit code, and the last stderr lines, as a standing message | ## Out of scope (designed around) diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index bd4619680..d5a4179c3 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -93,7 +93,7 @@ Closed subcode registry: BUILD_REQUIRED, ALCHEMY_BIN_MISSING, NODE_MISSING, STACK_WRITE_FAILED. `prisma dev` starts the Alchemy child the same way `deploy` does, so it raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. -- `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory), ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named) are raised by `devUrl` and `devStop` ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)); `emulator list` raises EMULATORS_DIR_INVALID and EMULATOR_UNREACHABLE and no code of its own. +- `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory) is raised by `devUrl` and `devStop`; ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named, and the reason and fix depend on it) by `devUrl` alone ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). Both operations raise EMULATOR_FAILED when the local target fails without a code. `emulator list` fails only with EMULATORS_DIR_INVALID; an emulator it cannot list is reported in that emulator's entry with EMULATOR_UNREACHABLE or EMULATOR_FAILED. - `LOG` — PLATFORM_UNSUPPORTED, ATTACH_FAILED, ADDRESS_UNKNOWN - `DEPS` — EXECUTOR_UNLOADABLE *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))* diff --git a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md index 4785bd4a4..2e6ed24f3 100644 --- a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md +++ b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md @@ -8,14 +8,14 @@ Composer's command family ships three more commands, and `@prisma/composer/contr $ prisma dev module.ts # terminal 1: runs the app $ prisma dev url gateway # terminal 2: where a node of that app answers http://localhost:3001 -$ curl "$(prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url')" +$ url=$(prisma dev url gateway | jq -er 'select(.kind=="result").envelope.result.url') && curl "$url" $ prisma dev stop # stops this directory's app: services and database servers; data stays $ prisma emulator list # every local emulator on this machine and every instance on it ``` - **`dev url
`** prints where a node of the app `prisma dev` runs from the current directory answers: a service's `http://localhost:`, a database's connection string, or a bucket's S3 endpoint plus `/`. Its JSON result is `{ address, kind, url, state }`. In human format the URL alone goes to stdout, so `$(prisma dev url gateway --format human)` is the URL. It is the `devUrl` operation. - **`dev stop`** stops that app's service processes and database servers, as ending `prisma dev` does, for a `prisma dev` that was killed without the chance. Data and ports stay. It is the `devStop` operation, and returns `{ app, stopped }`. -- **`emulator list`** lists the local emulators — the programs `prisma dev` starts to run services, databases and buckets on this machine — whether each runs, its admin URL, and its instances with id, kind, URL, state and labels. It needs no project. Its JSON result is `{ emulators: [{ kind, running, url, instances: [{ id, kind, url, state, labels }] }] }`. The operation is `listEmulators`, exported from `@prisma/composer-prisma-cloud/local-target`. +- **`emulator list`** lists the local emulators — the programs `prisma dev` starts to run services, databases and buckets on this machine — whether each runs, its admin URL, and its instances with id, kind, URL, state and labels. It needs no project. Its JSON result is `{ emulators: [{ kind, running, url, instances: [{ id, kind, url, state, labels }], error }] }`. `url` is `null` for an emulator that is not running. `error` is `null` unless the emulator runs but could not be listed; then it is `{ code, message }`, with `DEV.EMULATOR_UNREACHABLE` when the emulator did not answer and `DEV.EMULATOR_FAILED` when it answered with an error, and the other emulators are still listed. The command uses the emulator client bundled into `@prisma/composer-cli`, because it must work without a project. The family keys each command by the path it is meant to mount at (`dev url`, `dev stop`, `emulator list`); the `prisma` CLI decides where it mounts them. This amends [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md), under which the family was exactly `deploy` and `dev`, and [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md), whose `control` subpath had four operations. @@ -30,17 +30,19 @@ Docs, scripts and agents need to reach a locally running node without assuming i | No extension recorded an app in the directory | `DEV.NOT_STARTED_HERE` | | The app has no node at the address (the known ones are listed) | `DEV.ADDRESS_UNKNOWN` | | The emulator the node runs on is not running | `DEV.EMULATOR_UNREACHABLE` | -| The node exists but is not running (its state is named) | `DEV.NOT_RUNNING` | +| The node exists but is not running (its state is named; the reason and fix depend on it: a stopped node needs `prisma dev`, a crash-looping service needs its log read, a node its emulator lost is created again by `prisma dev`) | `DEV.NOT_RUNNING` | +| The target fails without a code of its own | `DEV.EMULATOR_FAILED` | `dev url` reports a node only when it is running, so every URL it prints answers. That relies on a service being `running` only once its port accepts connections. -`emulator list` is a question about the machine, not a project: it needs no `prisma.config.ts`, so it cannot go through the seam, which starts from the configuration. Its operation lives in the Prisma Cloud domain beside the emulator clients, and `@prisma/composer-cli`, a public package that may import both domains, adds the command to the family. Nothing running is a valid answer: every emulator is listed with `running: false`. +`emulator list` is a question about the machine, not a project: it needs no `prisma.config.ts`, so it cannot go through the seam, which starts from the configuration, and it cannot load the project's `@prisma/composer-prisma-cloud`, because there may be no project. Its operation lives in the Prisma Cloud domain beside the emulator clients, and `@prisma/composer-cli`, a public package that may import both domains, adds the command to the family and bundles that client. Nothing running is a valid answer: every emulator is listed with `running: false`. ## Consequences - `@prisma/composer/control` has six operations: `deploy`, `destroy`, `dev`, `devUrl`, `devStop`, `log`. - `prisma dev` is both a command and a group. The CLI engine supports that from `@prisma/cli-engine` 0.8.0; the family itself is built against 0.7.0, because it only exports commands and does not mount paths. The `dev` command's description defines the terms its children rely on: an emulator, a node's address, and that the app is recorded in the directory. - `emulator list`'s JSON departs from the list convention (`{ context, items, count }` with `{ name, id, status }` items): it nests two levels, emulators and their instances, because an instance is meaningless without the emulator that holds it. +- The emulators are started by the project's `@prisma/composer-prisma-cloud`, but `emulator list` reads them with the client bundled into the `prisma` CLI's `@prisma/composer-cli`, which can be a different Composer version. The emulators' registry files (`/.json`) and their instance-list routes (`GET /instances` on the Compute and Postgres emulators, `GET /_pcdev/instances` on the bucket emulator) are therefore a contract across Composer versions: a change to them must stay backward compatible. - Database URLs are connection strings with local credentials, printed on purpose; a bucket's access keys are never printed. - `prisma emulator start` and `prisma emulator stop`, and removing instances left by deleted directories, are planned and not part of this decision. diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 88e3cfb9b..ac56eaf51 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -126,9 +126,12 @@ When stdout is a terminal you see the URL. When it is piped, the CLI writes JSON frames, so a script picks the URL out with `jq`: ```bash -curl "$(prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url')" +url=$(prisma dev url gateway | jq -er 'select(.kind=="result").envelope.result.url') && curl "$url" ``` +On failure the JSON result has no URL, so `jq -r` alone would print `null` and +`curl` would run anyway; `-e` makes `jq` exit non-zero, and `&&` stops there. +The failure's code is in `.envelope.error.code`. `--format human` prints the URL alone even when piped. `dev url` only reports a node that is running, so the URL it prints answers. Otherwise it fails with a code a script can branch on: @@ -137,8 +140,10 @@ a code a script can branch on: - `DEV.ADDRESS_UNKNOWN`: the app has no node at that address; the message lists the ones it has. - `DEV.EMULATOR_UNREACHABLE`: the emulator the node runs on is not running. -- `DEV.NOT_RUNNING`: the node exists but is stopped, usually because - `prisma dev` has ended. +- `DEV.NOT_RUNNING`: the node exists but is not running. Usually `prisma dev` + has ended. A service that keeps crashing is reported this way too, with the + path of its log; and a node the emulator lost is created again, empty, by the + next `prisma dev`. `prisma dev stop` stops the app's service processes and database servers, as `Ctrl-C` does. Use it when `prisma dev` was killed without the chance to stop @@ -146,7 +151,9 @@ them. Data and ports are kept, so the next `prisma dev` is still a warm start. `prisma emulator list` shows every local emulator on the machine, whether it is running, and every instance on it, with the directory, app and address it -belongs to. It works from any directory. `prisma emulator start` and +belongs to. It works from any directory. An emulator that is running but does +not answer, or answers with an error, is shown with that error, and the +others are still listed. `prisma emulator start` and `prisma emulator stop` are planned. `--fresh` is also the fix when a framework upgrade leaves stale rows in this diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index d7c97e79d..e7fbd2a61 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -450,13 +450,17 @@ that surprise: answers: a service's `http://localhost:`, a database's connection string, a bucket's S3 endpoint plus `/`. Piped stdout carries JSON frames, so scripts use - `curl "$(prisma dev url gateway | jq -r 'select(.kind=="result").envelope.result.url')"` - or `--format human`. It fails with `DEV.NOT_STARTED_HERE` (no `dev` ran - here), `DEV.ADDRESS_UNKNOWN` (lists the known addresses), - `DEV.EMULATOR_UNREACHABLE`, or `DEV.NOT_RUNNING` (the node is stopped). + `url=$(prisma dev url gateway | jq -er 'select(.kind=="result").envelope.result.url') && curl "$url"` + or `--format human`. Keep `-e`: on failure there is no URL, `jq -r` alone + prints `null`, and the code is in `.envelope.error.code`. It fails with + `DEV.NOT_STARTED_HERE` (no `dev` ran here), `DEV.ADDRESS_UNKNOWN` (lists + the known addresses), `DEV.EMULATOR_UNREACHABLE`, or `DEV.NOT_RUNNING` (the + node is stopped, a service is crash-looping and its log is named, or the + emulator lost the node and the next `dev` creates it again). `prisma dev stop` stops the app's services and database servers when `dev` was killed without doing it; data and ports stay. `prisma emulator list` - shows every local emulator and its instances, from any directory. + shows every local emulator and its instances, from any directory; one that + runs but cannot be listed carries an `error` in its own entry. `prisma emulator start` and `stop` are planned. 4. `dev` reads `prisma.config.ts` once, at start, and watches it: after an edit it says so and pauses rebuilds until you restart `dev`. From 63cca11f0cadb006ff62b954b3bfb5a17cdadf3f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:49:52 +0200 Subject: [PATCH 11/14] docs(drive): record deferred items from the commands slice Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/emulator-commands/deferred.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.drive/projects/emulator-commands/deferred.md b/.drive/projects/emulator-commands/deferred.md index 0796f1d18..b8c48ba6f 100644 --- a/.drive/projects/emulator-commands/deferred.md +++ b/.drive/projects/emulator-commands/deferred.md @@ -6,3 +6,5 @@ - **A deployment that arrives while its instance is being deleted can start a process the emulator no longer tracks.** Same race as #353's app delete. - **`env.json` and `secrets.json` in `.prisma-composer/dev/` are shared by every app run from a directory, and `--fresh` wipes them.** One app's `--fresh` can drop rows another app in the same directory needs on its next redeploy. Pre-existing. Making them per app, like bucket objects (S1b moved those to `buckets///`), would fix it. - **A service that never listens fails `prisma dev` under prisma/composer#353** (the cron scheduler; `examples/store`). Raised with Will; the fix belongs in #353. +- **`prisma dev url` doesn't retry an emulator that is briefly unreachable.** Retry policy is #353's (`withEmulatorRetry`); apply it here once that settles. +- **`prisma dev url` loads the config's extensions, and so Alchemy, to read one JSON file.** It only matters when a script calls it in a loop. From 7c217f69d0bdc1f431359e695de371fddb44eec5 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 18:56:26 +0200 Subject: [PATCH 12/14] fix(cli): a running node with no URL is an internal error, not DEV.NOT_RUNNING The local-target seam reserves an absent URL for an instance the emulator lost, which reports the `missing` condition. A target that reports a node as running with no URL broke that contract, and devUrl printed "is not running (running)". It now raises an InternalError, as it does for a recorded address the target cannot locate. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/__tests__/dev-current.test.ts | 20 +++++++++++++++++++ .../cli/src/operations/execute-dev-current.ts | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts index f788d9b56..315b8aea3 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/dev-current.test.ts @@ -246,6 +246,26 @@ describe.skipIf(process.platform === 'win32')('devUrl', () => { expect(coded).toMatchObject({ ok: false, failure: { code: 'DEV.EMULATORS_DIR_INVALID' } }); }); + test('a target that reports a running node with no URL is a bug, not a failure with a code', async () => { + const promise = devUrl({ + config: config(async () => + recordedApp([ + { + address: 'gateway', + kind: 'service', + emulator: 'running', + state: 'running', + condition: 'running', + }, + ]), + ), + address: 'gateway', + cwd, + }); + + await expect(promise).rejects.toBeInstanceOf(InternalError); + }); + test('a target that cannot locate an address it recorded is a bug, not a failure with a code', async () => { const promise = devUrl({ config: config(async () => ({ ...recordedApp(running), locate: async () => undefined })), diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts index b286c264a..f31162a35 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev-current.ts @@ -83,8 +83,9 @@ async function locate(input: DevUrlInput, cwd: string): Promise { }, ); } - if (location.condition !== 'running' || location.url === undefined) { - throw notRunning(input.address, location); + if (location.condition !== 'running') throw notRunning(input.address, location); + if (location.url === undefined) { + throw new InternalError(`the local target reports "${input.address}" running with no URL`); } return { address: input.address, kind: location.kind, url: location.url, state: location.state }; } From c3af20d1183ed2c79aac9fbc51a99441b4cf4520 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 13 Oct 2026 19:15:31 +0200 Subject: [PATCH 13/14] test: keep the dev url tests' services off the ports other test files use Each test file runs its own Compute emulator, and each emulator gives service ports from 3000. A port is free when the emulator gives it, but another file's service can bind it first, and then the readiness probe and the test's fetch reach that file's service: on macOS CI the gateway answered "first", the body compute-deployment.test.ts serves. The two files that fetch a service they deployed now fill the ports below a start of their own with placeholder instances first: 3050 for local-target-current.test.ts and 3100 for control.dev-current.test.ts. Each asserts its service's port is at or above that start, and the listing test ignores the placeholders. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../__tests__/local-target-current.test.ts | 24 +++++++- .../test/control.dev-current.test.ts | 57 ++++++++++++++----- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts index db8d7e4e7..328f482ee 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-current.test.ts @@ -41,6 +41,21 @@ const DAEMONS: readonly DaemonName[] = ['compute', 'postgres', 'buckets']; const SERVING_BOOTSTRAP = "Bun.serve({ port: Number(process.env['PORT']), fetch: () => new Response('hello from gateway') });\n"; const savedEmulatorsDir = process.env[EMULATORS_DIR_ENV]; +/** + * Where this file's services start. Other test files run their own Compute + * emulators at the same time, each giving ports from 3000, and a service + * that answers on a port proves nothing about whose process it is. Ports + * below this one go to placeholder instances, so this file's services never + * share a port with theirs. + */ +const FIRST_SERVICE_PORT = 3050; + +async function reserveServicePortsBelow(port: number): Promise { + const client = computeClient(); + while ((await client.createInstance({ labels: { purpose: 'port-skip' } })).port < port - 1) { + // each placeholder takes the lowest free port + } +} function acceptsConnection(port: number): Promise { return new Promise((resolve) => { @@ -67,8 +82,9 @@ describe.skipIf(process.platform === 'win32')("the directory's current app", () const entry = fileURLToPath(import.meta.resolve(`@internal/dev-emulators/${name}-main`)); await ensureDaemon(name, entry, { registryRoot }); } + await reserveServicePortsBelow(FIRST_SERVICE_PORT); directories = []; - }); + }, 60_000); afterEach(async () => { for (const directory of directories) { @@ -126,6 +142,7 @@ describe.skipIf(process.platform === 'win32')("the directory's current app", () expect([...current.addresses].sort()).toEqual(['catalog', 'gateway', 'uploads']); const service = await urlOf(current, 'gateway'); + expect(Number(new URL(service).port)).toBeGreaterThanOrEqual(FIRST_SERVICE_PORT); expect(await (await fetch(service)).text()).toBe('hello from gateway'); const database = new pg.Client({ connectionString: await urlOf(current, 'catalog') }); @@ -226,9 +243,10 @@ describe.skipIf(process.platform === 'win32')("the directory's current app", () ['bucket', true], ]); for (const emulator of emulators) { - const directoriesOf = emulator.instances.map((i) => i.labels['composer.directory']).sort(); + const instances = emulator.instances.filter((i) => i.labels['purpose'] !== 'port-skip'); + const directoriesOf = instances.map((i) => i.labels['composer.directory']).sort(); expect(directoriesOf).toEqual([a.directory, b.directory].sort()); - for (const instance of emulator.instances) { + for (const instance of instances) { expect(instance.kind).toBe(emulator.kind); expect(instance.labels['composer.app']).toBe(APP); } diff --git a/test/integration/test/control.dev-current.test.ts b/test/integration/test/control.dev-current.test.ts index d9ee7a165..0bf1531e8 100644 --- a/test/integration/test/control.dev-current.test.ts +++ b/test/integration/test/control.dev-current.test.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs'; import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; -import { Load } from '@prisma/composer'; +import { Load, module } from '@prisma/composer'; import type { ContainerInstance, LocalTargetDescriptor } from '@prisma/composer/config'; import { DEV_DIR } from '@prisma/composer/config'; import { devStop, devUrl } from '@prisma/composer/control'; @@ -29,6 +29,15 @@ const config = { const SERVING_BOOTSTRAP = "Bun.serve({ port: Number(process.env['PORT']), fetch: () => new Response('hello from web') });\n"; +/** + * Where this file's services start. Other test files run their own Compute + * emulators at the same time, each giving ports from 3000, and a service + * that answers on a port proves nothing about whose process it is. Ports + * below this one go to placeholder instances, so this file's services never + * share a port with theirs. + */ +const FIRST_SERVICE_PORT = 3100; + interface RecordedNode { readonly kind: string; readonly id: string; @@ -57,6 +66,10 @@ describe.skipIf(process.platform === 'win32')('devUrl and devStop on the real ta let container: ContainerInstance; let nodes: Readonly>; + function readRegistryPort(name: string): number { + return JSON.parse(fs.readFileSync(path.join(emulatorsDir, `${name}.json`), 'utf8')).port; + } + beforeAll(async () => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'control-dev-current-')); cwd = path.join(root, 'app'); @@ -69,6 +82,25 @@ describe.skipIf(process.platform === 'win32')('devUrl and devStop on the real ta target = await localTarget(); container = await target.container.ensure({ appName: APP, stage: undefined }); const devDir = path.join(cwd, DEV_DIR); + + // An app with no nodes starts the Compute emulator, so the placeholders can + // take the ports below FIRST_SERVICE_PORT before the fixture's services are created. + const placeholderApp = module('portskip', () => {}); + await target.emulators?.({ + graph: Load(placeholderApp, { id: 'portskip' }), + container: await target.container.ensure({ appName: 'portskip', stage: undefined }), + devDir: path.join(root, 'placeholders', DEV_DIR), + }); + const computeAdmin = `http://127.0.0.1:${readRegistryPort('compute')}`; + for (let port = 0; port < FIRST_SERVICE_PORT - 1; ) { + const created = await fetch(`${computeAdmin}/instances`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ labels: { purpose: 'port-skip' } }), + }); + port = ((await created.json()) as { port: number }).port; + } + await target.emulators?.({ graph: Load(appModule, { id: APP }), container, devDir }); const record = JSON.parse( @@ -81,19 +113,15 @@ describe.skipIf(process.platform === 'win32')('devUrl and devStop on the real ta const artifactDir = path.join(root, 'web-artifact'); fs.mkdirSync(artifactDir); fs.writeFileSync(path.join(artifactDir, 'bootstrap.js'), SERVING_BOOTSTRAP); - const compute = JSON.parse(fs.readFileSync(path.join(emulatorsDir, 'compute.json'), 'utf8')); - const deployed = await fetch( - `http://127.0.0.1:${compute.port}/instances/${web.id}/deployment`, - { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - artifactDir, - artifactHash: 'v1', - env: { PATH: process.env['PATH'] ?? '', PORT: String(web.port) }, - }), - }, - ); + const deployed = await fetch(`${computeAdmin}/instances/${web.id}/deployment`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + artifactDir, + artifactHash: 'v1', + env: { PATH: process.env['PATH'] ?? '', PORT: String(web.port) }, + }), + }); if (!deployed.ok) throw new Error(`deploying web failed: ${await deployed.text()}`); }, 120_000); @@ -121,6 +149,7 @@ describe.skipIf(process.platform === 'win32')('devUrl and devStop on the real ta if (!web.ok) throw new Error(web.failure.message); expect(web.value).toMatchObject({ address: 'web', kind: 'service', state: 'running' }); + expect(Number(new URL(web.value.url).port)).toBeGreaterThanOrEqual(FIRST_SERVICE_PORT); expect(await (await fetch(web.value.url)).text()).toBe('hello from web'); if (!appdb.ok) throw new Error(appdb.failure.message); expect(appdb.value.url).toStartWith('postgres'); From 9329213759f6191c70fcf4df043ee733ca925439 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 17:50:14 +0200 Subject: [PATCH 14/14] test(integration): a failed placeholder instance fails the dev url test at its cause The loop that fills ports below 3100 read .port from any response. It now throws with the emulator's status and body when creating a placeholder fails. local-target-current.test.ts creates its placeholders through the compute client, which already throws on an error response. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/control.dev-current.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/integration/test/control.dev-current.test.ts b/test/integration/test/control.dev-current.test.ts index 0bf1531e8..6c45dd607 100644 --- a/test/integration/test/control.dev-current.test.ts +++ b/test/integration/test/control.dev-current.test.ts @@ -98,6 +98,11 @@ describe.skipIf(process.platform === 'win32')('devUrl and devStop on the real ta headers: { 'content-type': 'application/json' }, body: JSON.stringify({ labels: { purpose: 'port-skip' } }), }); + if (!created.ok) { + throw new Error( + `creating a placeholder instance failed (${String(created.status)}): ${await created.text()}`, + ); + } port = ((await created.json()) as { port: number }).port; }