diff --git a/architecture.config.json b/architecture.config.json index 68eaf521..b50867ae 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -690,6 +690,12 @@ "layer": "public", "plane": "control" }, + { + "glob": "packages/9-public/composer/src/exports/control.ts", + "domain": "public", + "layer": "public", + "plane": "control" + }, { "glob": "packages/9-public/composer/src/exports/config.ts", "domain": "public", diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index 16434b01..d84a7762 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -193,8 +193,12 @@ every node already carries: - **`@internal/assemble`** owns the orchestration this seam drives: routing every service node in the loaded graph to its registry's assemble entry (one bundle per full address — the root is always a Module). The CLI is - its first consumer; the future - programmatic deploy API is its second — so its public surface carries no CLI + its first consumer; the programmatic control API is its second — + `@prisma/composer/control`'s typed `deploy`/`destroy`/`dev`/`log` + operations, implemented in `@internal/cli`'s `src/operations/` with the CLI + as a thin renderer over them + ([ADR-0043](../90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md)). + So assemble's public surface carries no CLI concepts (no `CliError`, no argv/usage anything). It throws its own `AssembleError`; the CLI's `main.ts` maps it (the existing destroy-path wrapping already does, since `AssembleError extends Error`). 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 new file mode 100644 index 00000000..0b2972d3 --- /dev/null +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -0,0 +1,83 @@ +# ADR-0043: `@prisma/composer/control` is the programmatic deploy surface + +## 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. + +A host — another CLI embedding Composer, a CI tool, a test — uses it like this: + +```ts +import { deploy } from '@prisma/composer/control'; + +const result = await deploy({ entry: 'module.ts', stage: 'feat-auth' }); + +if (result.outcome === 'deployed') { + for (const node of result.summary?.nodes ?? []) { + console.log(node.address, node.entities); + } +} else { + switch (result.failure.kind) { + case 'invalid-input': // e.g. a stage name git would reject + case 'pipeline': // loading the deploy stack through assembly failed + case 'execution': // alchemy ran and exited nonzero + report(result.failure.message); + } +} +``` + +Failures are values, not exceptions: every way a deploy can go wrong comes back as a discriminated `failure` the caller can branch on, carrying the same human-readable message the CLI prints plus the original error as `cause`. An `execution` failure may also carry an optional `diagnostics` object (exit code, generated stack-file path, reproduce command, cwd) — details of the current execution mechanism, useful for printing a hint but deliberately outside the durable contract. + +## Why a programmatic surface + +`@internal/assemble` is deliberately CLI-free — its design names a programmatic deploy API as its second consumer, alongside the CLI ([deploy-cli.md § Contracts](../10-domains/deploy-cli.md)). A program embedding Composer needs results it can branch on, not stdout to scrape: subprocess invocation couples the caller to output formatting, loses error types, and turns every failure into string parsing. `@prisma/composer/control` is that second consumer surface. + +Because the CLI's commands are renderers over the same operations, there is exactly one implementation of deploy orchestration. A fix or feature in the operation is a fix or feature in both surfaces; neither can gain behavior the other lacks. + +## Importing the subpath executes nothing + +The `./control` entry's static import graph is import-light: types, the result definitions, and two small helpers. Each operation lazily `import()`s the executor that reaches the pipeline and alchemy, so importing the subpath executes nothing — consistent with the repo's no-import-side-effects stance — and a host pays for the deploy stack only when it calls an operation. The property is pinned structurally: a test imports the entry in a fresh process with every heavy module poisoned and fails if the static graph ever reaches one. + +A dependency tree that cannot load that stack — for example, a mismatched `effect` version that makes alchemy's modules throw at import time — surfaces when an operation runs, as a structured `pipeline` failure whose message names the problem (the operation diagnoses the failed load with the same check the CLI's `bin.ts` runs at start-up). The host stays alive and gets a result it can branch on, never an import-time crash. + +## The deploy result crosses a process boundary + +Deploy execution happens in a **spawned alchemy child process** driving a generated stack file (ADR-0007). The full `DeploymentResult` only materializes inside that child, in the stack file's `report:` hook — and its `DeployedNode` entries hold live references to the graph nodes themselves, which cannot be serialized across processes (ADR-0033 defines the type for in-process readers). + +The operation therefore uses the narrowest channel that works: + +- `deployment-summary.ts` owns the protocol whole: **`DeploymentSummary`** — the serializable projection of a result (the app name and, per node, its `address` and deployed `entities`) — plus the env var, the writer, and the reader. +- When the environment variable `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` names a file, the report hook writes the summary there as JSON (best-effort — a write failure never fails a converged deploy), in addition to its normal console rendering. The variable is internal to the two halves; it is not exported from `./control`. +- The deploy operation points that variable at a file whose name is unique per run — concurrent deploys sharing a working directory cannot read or delete each other's summary — reads it back after a zero exit, and deletes it. + +The child's own stdout/stderr still stream to the host's terminal (`stdio: 'inherit'`) — the user watches alchemy work exactly as they would from the CLI, and the result file rides alongside rather than being scraped out of that stream. The file lives under the tool-owned `.prisma-composer/` directory (ADR-0004). + +The summary is **best-effort by contract**: an absent or unparseable file yields `summary: undefined` on a deploy that still reports `outcome: 'deployed'`. A deploy that converged is never reported as failed for lacking a convenience payload. Writer and reader share the one `DeploymentSummary` shape; changing it is a cross-process protocol change and must stay backward-tolerant — the reader treats anything unrecognizable as absent. + +## Where the code lives, and the name + +The operations live in `@internal/cli` (`src/operations/`), re-exported through `src/exports/control.ts` shims on both `@internal/cli` and `@prisma/composer` (the ADR-0035 entrypoint pattern). There is no new workspace package: the operations orchestrate the same pipeline modules the CLI uses, and only `packages/9-public/` publishes (ADR-0027/ADR-0028), so a separate internal package would add a boundary with nothing on the other side. + +The subpath is named `control` because that is the architecture plane these sources occupy in `architecture.config.json`, matching the existing control-plane subpaths (`@prisma/composer/node/control`, `/nextjs/control`, `@prisma/composer-prisma-cloud/control`). Note the distinct consumer classes: an *extension's* `/control` entry is a control-plane descriptor importable only from `prisma-composer.config.ts` (ADR-0017), while `@prisma/composer/control` is for external hosts. The shim's doc comment records the distinction. + +## Consequences + +- **The failure taxonomy is deliberately coarse at the pipeline stage.** One `pipeline` kind spans everything from loading the deploy stack and config discovery through assembly and container preparation; `invalid-input`, `unsupported-platform`, and `execution` are distinct. Callers needing to distinguish pipeline sub-failures must parse messages until a finer taxonomy exists. +- **`dev` returns a session handle** (`endpoints`, `stop()`, `closed`, an event callback) and **never touches process signal handlers**. Signal ownership — including evicting alchemy's import-time SIGINT/SIGTERM listeners — belongs to the host; the CLI adapter shows the pattern. +- **`log` returns the running services plus an `AsyncIterable` of lines** ended by a caller-owned `AbortSignal`; one stream failing surfaces as an event without ending the others. Zero running services is a valid, non-failure result with an already-finished iterable. +- **The alchemy child's output is not capturable through this API.** The current mechanism streams the deploy engine's output to the host's own stdio; capturing or redirecting it needs a new option on the operations, not a workaround. The mechanism itself (a spawned child, `stdio: 'inherit'`) is how composer deploys today, not a promise the surface makes — which is also why the spawn-shaped failure fields live in the optional `diagnostics` object rather than on the failure itself. +- **`@internal/cli` now contains a surface that is not a CLI.** The package name is narrower than its contents: the operations are control-plane orchestration that the CLI also happens to render. That is the accepted cost of not creating a package with nothing on the other side of its boundary. + +## Alternatives considered + +- **A new workspace package for the operations.** Everything the operations need already lives in `@internal/cli`, and only `packages/9-public/` publishes — a new internal package would add a boundary with nothing on the other side. +- **Naming the subpath `./operations` or `./pilot`.** The plane name the sources already carry won; the overlap with extension `/control` entries is a documentation concern, not a naming-precision one, and the entry's doc comment resolves it. +- **Parsing the summary from the child's stdout.** Stdout is the user's live view of alchemy working; scraping it would couple the cross-process protocol to presentation strings. +- **Failing the deploy when the result file is missing after exit 0.** Whether the report hook runs on a no-op converge is not guaranteed, and the summary is a convenience — a converged deploy must not be reported as failed for lacking one. + +## Related + +- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — the spawned child the result-file contract crosses into. +- [ADR-0017](ADR-0017-control-plane-loads-through-the-app-config.md) — the *other* `/control`: extension control-plane entries. +- [ADR-0027](ADR-0027-two-packages-compose-and-compose-prisma-cloud.md) / [ADR-0028](ADR-0028-numbered-domains-and-layers-enforced-by-dependency-cruiser.md) — why no new package, and where publishable code lives. +- [ADR-0033](ADR-0033-lowering-types-are-defined-by-their-readers.md) — the result/render split the summary projection extends across the process boundary. +- [ADR-0035](ADR-0035-public-entrypoints-live-in-src-exports.md) — the `src/exports/` shim pattern both new entries follow. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index e47f641a..96cd729d 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -64,3 +64,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0040](ADR-0040-the-pn-binding-carries-the-url-and-a-lazy-client.md) — `pnPostgres(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 `postgres()`'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 (`'prisma-next'` 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 runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` 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). - [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. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 546cd4cc..bec25efc 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -296,6 +296,63 @@ next deploy recreates everything under fresh state — either: Recreated apps get new generated URLs; anything pointing at the old ones needs updating. +## Driving deploys from code + +Everything the CLI does is also callable in-process, from +`@prisma/composer/control`: typed `deploy`, `destroy`, `dev`, and `log` +operations that return structured results instead of printing and exiting. +The `prisma-composer` commands are thin renderers over these same operations, +so the two surfaces can't drift. + +```ts +import { deploy } from '@prisma/composer/control'; + +const result = await deploy({ entry: 'module.ts', stage: 'pr-42' }); +if (result.outcome === 'deployed') { + // result.summary — the deployed topology (app name + each node's + // address and entities), when the deploy engine reported one. +} else { + console.error(result.failure.message); // same fix-naming text the CLI prints +} +``` + +What to know before embedding it: + +- **Inputs mirror the flags, but typed.** A bare `deploy` targets production, + exactly like the CLI. `destroy` takes a discriminated target — + `{ kind: 'production' }` or `{ kind: 'stage', stage }` — so there is no + silent default to production and no flag-combination footgun. +- **Failures are results, not throws.** Every operation resolves to either + its success shape or `{ outcome: 'failed', failure }`, where + `failure.kind` is one of `invalid-input`, `unsupported-platform`, `pipeline` + (anything between loading the deploy stack and the deploy engine — including + the [effect version conflict](#when-a-deploy-stops-on-an-effect-version-conflict), + reported with the same fix-naming message the CLI prints), or `execution` + (the engine ran and failed). An `execution` failure's optional + `diagnostics` object carries the exit code and an exact reproduce command — + details of the current execution mechanism, handy for printing a hint but + not something to build on; branch on `message`/`cause` for anything + durable. Importing the module executes nothing until you call an operation. +- **`summary` is best-effort.** It rides a result file the deploy engine's + child process writes; a deploy that converged without writing one still + succeeds, with `summary: undefined`. +- **The engine's own output still streams to your process's stdio.** That is + the current mechanism, not a promise: the operations return structured + results but don't capture the live deploy output; run them where that + output belongs, or with stdio redirected. Capturing it would be a new + option on the operations. +- **`dev` resolves to `{ outcome: 'started', session }` or a failure** — + never an exit code. The session is `{ endpoints, stop(), closed }`, with + progress (`ready`, `converge-failed`, `watch-error`, …) delivered through + `onEvent`. The operation never installs signal handlers; wiring Ctrl-C to + `session.stop()` is yours. +- **`log` resolves to `{ outcome: 'attached', appName, services, lines }` or + a failure.** `lines` is an `AsyncIterable` ended by an `AbortSignal` you + own (stopping early — `break`, `lines.return()` — also ends it cleanly). + Zero running services is a valid result (empty `services`, finished + stream), not an error. A consumer that falls behind loses oldest lines + past a bounded queue and is told via a `lines-dropped` event. + ## The full picture [`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md) diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index d187e65c..4baf4bb1 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -5,6 +5,7 @@ "description": "The `prisma-composer` deploy CLI.", "exports": { ".": "./dist/index.mjs", + "./control": "./dist/control.mjs", "./report": "./dist/report.mjs", "./package.json": "./package.json" }, diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts index d42f79e2..3c1e0f18 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, 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 { service } from '@internal/core'; import type { DeployedNode, DeploymentResult } from '@internal/core/deploy'; +import { DEPLOYMENT_RESULT_FILE_ENV, toDeploymentSummary } from '../deployment-summary.ts'; import { deploymentReport, renderDeployment } from '../render-deployment.ts'; /** @@ -165,8 +169,50 @@ describe('renderDeployment', () => { }); }); +describe('toDeploymentSummary', () => { + test('projects app + per-node address/entities, dropping the in-process node', () => { + const input = result('app', [ + deployed('auth.api', [ + { kind: 'compute-service', id: 'cps_1', url: 'https://a.example' }, + { kind: 'postgres-database', id: 'pdb_1' }, + ]), + deployed('db', []), + ]); + + const summary = toDeploymentSummary(input); + + expect(summary).toEqual({ + app: 'app', + nodes: [ + { + address: 'auth.api', + entities: [ + { kind: 'compute-service', id: 'cps_1', url: 'https://a.example' }, + { kind: 'postgres-database', id: 'pdb_1' }, + ], + }, + { address: 'db', entities: [] }, + ], + }); + expect(JSON.parse(JSON.stringify(summary))).toEqual(summary); + for (const node of summary.nodes) { + expect('node' in node).toBe(false); + } + }); +}); + describe('deploymentReport', () => { + const envKeeper = process.env[DEPLOYMENT_RESULT_FILE_ENV]; + afterEach(() => { + if (envKeeper === undefined) { + delete process.env[DEPLOYMENT_RESULT_FILE_ENV]; + } else { + process.env[DEPLOYMENT_RESULT_FILE_ENV] = envKeeper; + } + }); + test('prints a leading blank line then the rendered tree', () => { + delete process.env[DEPLOYMENT_RESULT_FILE_ENV]; const lines: unknown[] = []; const original = console.log; console.log = (value?: unknown) => { @@ -182,4 +228,41 @@ describe('deploymentReport', () => { expect(lines).toEqual(['', 'app\n└─ db postgres-database pdb_1']); }); + + test(`writes the JSON summary to the file named by ${DEPLOYMENT_RESULT_FILE_ENV}, printing the same output`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-report-')); + const file = path.join(dir, 'deployment-result.json'); + process.env[DEPLOYMENT_RESULT_FILE_ENV] = file; + const input = result('app', [deployed('db', [{ kind: 'postgres-database', id: 'pdb_1' }])]); + const lines: unknown[] = []; + const original = console.log; + console.log = (value?: unknown) => { + lines.push(value); + }; + try { + deploymentReport(input); + } finally { + console.log = original; + } + + expect(lines).toEqual(['', 'app\n└─ db postgres-database pdb_1']); + const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(parsed).toEqual(toDeploymentSummary(input) as never); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('writes no file when the env var is unset', () => { + delete process.env[DEPLOYMENT_RESULT_FILE_ENV]; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-report-')); + const original = console.log; + console.log = () => {}; + try { + deploymentReport(result('app', [])); + } finally { + console.log = original; + } + + expect(fs.readdirSync(dir)).toEqual([]); + fs.rmSync(dir, { recursive: true, force: true }); + }); }); diff --git a/packages/0-framework/3-tooling/cli/src/cli-error.ts b/packages/0-framework/3-tooling/cli/src/cli-error.ts index f3d90774..8a23d930 100644 --- a/packages/0-framework/3-tooling/cli/src/cli-error.ts +++ b/packages/0-framework/3-tooling/cli/src/cli-error.ts @@ -4,8 +4,8 @@ * core's LoadError/LowerError — uniformly: print the message, exit nonzero. */ export class CliError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = 'CliError'; } } diff --git a/packages/0-framework/3-tooling/cli/src/deployment-summary.ts b/packages/0-framework/3-tooling/cli/src/deployment-summary.ts new file mode 100644 index 00000000..37c02ef2 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/deployment-summary.ts @@ -0,0 +1,101 @@ +/** + * The deploy result's cross-process protocol, whole in one place: the + * serializable shape, the env var that names the carrier file, the writer the + * report hook calls from inside the alchemy child, and the reader the deploy + * operation runs after the child exits. `DeploymentResult` itself cannot + * cross the boundary — its `DeployedNode` entries hold live graph-node + * references (ADR-0033) — so the writer projects it down to what CAN. + * + * The summary is best-effort by contract: the writer never fails the child + * over it, and the reader maps absent or malformed to `undefined`. + */ +import * as fs from 'node:fs'; +import type { DeployedEntity, DeploymentResult } from '@internal/core/deploy'; +import { blindCast } from '@internal/foundation/casts'; + +/** Env var the deploy operation sets on the alchemy child: when present, + * the report hook also writes the JSON DeploymentSummary there. */ +export const DEPLOYMENT_RESULT_FILE_ENV = 'PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE'; + +/** The serializable projection of DeploymentResult — what CAN cross the process + * boundary. Writer (report hook) and reader (deploy operation) share this shape. */ +export interface DeployedNodeSummary { + readonly address: string; + readonly entities: readonly DeployedEntity[]; +} + +export interface DeploymentSummary { + readonly app: string; + readonly nodes: readonly DeployedNodeSummary[]; +} + +/** Pure projection: keeps app + each node's address/entities, drops the in-process `node`. */ +export function toDeploymentSummary(result: DeploymentResult): DeploymentSummary { + return { + app: result.app, + nodes: result.nodes.map((node) => ({ address: node.address, entities: node.entities })), + }; +} + +/** + * Writer half, called by the report hook inside the alchemy child: when the + * env var names a file, write the summary there. Best-effort — a write + * failure must not fail a deploy that already converged, so it is swallowed. + */ +export function writeDeploymentSummaryFile(result: DeploymentResult): void { + const file = process.env[DEPLOYMENT_RESULT_FILE_ENV]; + if (file === undefined || file.length === 0) return; + try { + fs.writeFileSync(file, JSON.stringify(toDeploymentSummary(result))); + } catch { + // The console rendering already happened; the summary is a convenience. + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Reader half, run by the deploy operation after the child exits. Absent or + * malformed → undefined — the summary is best-effort, never a deploy failure. + */ +export function readDeploymentSummary(resultFilePath: string): DeploymentSummary | undefined { + let raw: string; + try { + raw = fs.readFileSync(resultFilePath, 'utf8'); + } catch { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (!isRecord(parsed) || typeof parsed['app'] !== 'string' || !Array.isArray(parsed['nodes'])) { + return undefined; + } + for (const node of parsed['nodes']) { + if ( + !isRecord(node) || + typeof node['address'] !== 'string' || + !Array.isArray(node['entities']) + ) { + return undefined; + } + for (const entity of node['entities']) { + if ( + !isRecord(entity) || + typeof entity['kind'] !== 'string' || + typeof entity['id'] !== 'string' + ) { + return undefined; + } + } + } + return blindCast< + DeploymentSummary, + 'the field-by-field checks above validate the runtime shape (string app, nodes with string addresses and kind/id-carrying entities); optional entity fields (url, details) are presentation-only strings the writer serialized from the same type' + >(parsed); +} diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index e8432aa7..3fffb2c4 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -1,21 +1,14 @@ /** - * Local-dev spec § 6 `run-dev.ts`: `prisma-composer dev ` — steps 1–6 - * of `run()` reused via pipeline.ts, then the dev-only pipeline: capability - * check, containers, `--fresh` teardown, preflight, emulators, converge - * against a generated dev stack file, attach (front door + merged logs), - * watch loop until interrupted. + * Local-dev spec § 6 `run-dev.ts`: `prisma-composer dev ` — the CLI + * adapter over the programmatic `dev()` operation + * (../operations/execute-dev.ts): parse-shaped args in, events rendered to the + * console, signal handling and exit codes owned here. The pipeline itself + * (capability check, containers, `--fresh` teardown, preflight, emulators, + * converge, attach, watch loop) lives in the operation. */ -import * as path from 'node:path'; -import type { RunAssembler } from '@internal/assemble'; -import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; -import { containerEnv } from '@internal/core/config'; -import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/core/local-target'; -import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; import { CliError } from '../cli-error.ts'; -import { type PipelineDeps, runPipeline } from '../pipeline.ts'; -import { type RunAlchemyInput, runAlchemy } from '../run-alchemy.ts'; -import { DEV_STACK_RELATIVE_PATH, writeDevStackFile } from './generate-dev-stack.ts'; -import { startWatch, watchTargetsFrom } from './watch.ts'; +import { devWithDeps } from '../operations/dev.ts'; +import type { OperationDeps } from '../operations/shared.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `dev` command. */ export interface DevArgs { @@ -24,18 +17,8 @@ export interface DevArgs { readonly fresh: boolean; } -/** Injectable seams — the same shapes `run()`'s `RunDeps` offers deploy/destroy. */ -export interface DevRunDeps { - readonly runAssembler?: RunAssembler; - readonly alchemy?: (input: RunAlchemyInput) => number; - readonly config?: PrismaAppConfig; -} - -function toCliError(error: unknown): CliError { - return error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); -} +/** Injectable seams — the operations' own OperationDeps, under this adapter's historical name. */ +export type DevRunDeps = OperationDeps; /** `[dev] ready:` then one line per endpoint, ordered by address depth (fewest dots first) then lexicographic. Exported for tests. */ export function renderFrontDoor( @@ -56,237 +39,99 @@ function printFrontDoor( for (const line of renderFrontDoor(endpoints)) console.log(line); } -const EMULATOR_RETRY_ATTEMPTS = 5; -const EMULATOR_RETRY_DELAY_MS = 500; - -/** An emulator admin call right after a converge that just PUT dozens of resources through the same daemon can hit a transient refused/reset connection — a brief loopback hiccup under load, not a real failure. Retried before giving up. Applies to every attach admin call the dev session makes (`startServices`, `endpoints`). */ -async function withEmulatorRetry(call: () => Promise): Promise { - let lastError: unknown; - for (let attempt = 1; attempt <= EMULATOR_RETRY_ATTEMPTS; attempt += 1) { - try { - return await call(); - } catch (error) { - lastError = error; - if (attempt < EMULATOR_RETRY_ATTEMPTS) { - await new Promise((resolve) => setTimeout(resolve, EMULATOR_RETRY_DELAY_MS)); - } - } - } - throw lastError instanceof Error ? lastError : new Error(String(lastError)); -} - -async function mergedEndpoints( - attachments: readonly LocalTargetAttachment[], -): Promise { - const lists = await Promise.all(attachments.map((a) => withEmulatorRetry(() => a.endpoints()))); - return lists.flat(); -} - /** Runs the full dev pipeline; returns the process exit code. */ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { - if (process.platform === 'win32') { - throw new CliError('local dev is not supported on Windows yet.'); - } - - const cwd = process.cwd(); - const devDir = path.join(cwd, DEV_DIR); - - // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, - // Load, registry coverage, name resolution, assemble. - const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; - const { configPath, config, entryModule, graph, name, assembled } = await runPipeline( - args.entry, - args.name, - cwd, - pipelineDeps, + // Shipped output order: front door → `[dev] logs:` hint → unwatchable lines. + // The operation emits 'unwatchable' before it returns the session, so those + // lines are held back until the hint has printed. + let hintPrinted = false; + const pendingUnwatchable: string[] = []; + const result = await devWithDeps( + { + entry: args.entry, + name: args.name, + fresh: args.fresh, + onEvent: (event) => { + switch (event.kind) { + case 'ready': + printFrontDoor(event.endpoints); + break; + case 'unwatchable': { + const line = `[dev] ${event.address} has no watchable inputs`; + if (hintPrinted) console.log(line); + else pendingUnwatchable.push(line); + break; + } + case 'converge-failed': + console.error('[dev] converge failed — the running app is untouched; still watching.'); + console.error(`\nGenerated stack file: ${event.stackFilePath}`); + console.error( + `Run \`${event.reproduceCommand}\` from ${event.cwd} to reproduce this directly.`, + ); + break; + case 'rebuild-failed': + console.error(`[dev] rebuild failed: ${event.message}`); + break; + case 'watch-error': + console.error(`[dev] watch error: ${event.message}`); + break; + case 'stop-error': + console.error(`[dev] a service refused to stop: ${event.message}`); + break; + case 'stopping': + console.log( + "[dev] stopping — the app's services are stopping; emulators and data stay up.", + ); + break; + case 'stopped': + console.log('[dev] stopped.'); + break; + } + }, + }, + deps, ); - // 2. Dev-capability check — resolve every non-build-only extension's lazy - // `localTarget` thunk ONCE (ADR-0041's lazy reference); its pinned error - // names any extension without local-target support, and build-only - // extensions are exempt inside it. Every subsequent hook call runs off - // this resolved map. - let resolved: ReadonlyMap; - try { - resolved = await resolveLocalTargets(config); - } catch (error) { - throw toCliError(error); - } - - // 3. Containers — purely local, resolved before anything else can fail. - const containers = new Map(); - for (const [id, dev] of resolved) { - try { - containers.set(id, await dev.container.ensure({ appName: name, stage: undefined })); - } catch (error) { - throw toCliError(error); - } - } - - // 4. `--fresh`: teardown every participant's dev instance, then continue cold. - if (args.fresh) { - for (const [id, dev] of resolved) { - if (dev.teardown === undefined) continue; - try { - await dev.teardown({ container: containers.get(id), stage: undefined }); - } catch (error) { - throw toCliError(error); - } - } - } - - // 5. Preflight — always (dev has no deploy/destroy split). - for (const [id, dev] of resolved) { - if (dev.preflight === undefined) continue; - try { - await dev.preflight({ graph, container: containers.get(id), stage: undefined }); - } catch (error) { - throw toCliError(error); - } - } - - // 6. Emulators — ensure the daemons this topology's node kinds need. - for (const [id, dev] of resolved) { - if (dev.emulators === undefined) continue; - try { - await dev.emulators({ graph, container: containers.get(id), devDir }); - } catch (error) { - throw toCliError(error); + if (result.outcome === 'failed') { + const failure = result.failure; + if (failure.kind === 'execution' && failure.diagnostics !== undefined) { + const { exitCode, stackFilePath, reproduceCommand, cwd } = failure.diagnostics; + console.error(`\nGenerated stack file: ${stackFilePath}`); + console.error(`Run \`${reproduceCommand}\` from ${cwd} to reproduce this directly.`); + return exitCode ?? 1; } + throw failure.cause instanceof Error ? failure.cause : new CliError(failure.message); } - const converge = (): number => { - const stackPath = writeDevStackFile({ - entryPath: entryModule.path, - cwd, - configPath, - name, - assembled, - }); - const status = (deps.alchemy ?? runAlchemy)({ - command: 'deploy', - stackFileRelativePath: DEV_STACK_RELATIVE_PATH, - cwd, - stage: 'dev', - containerEnv: containerEnv(containers), - }); - if (status !== 0) { - console.error(`\nGenerated stack file: ${stackPath}`); - console.error( - `Run \`alchemy deploy ${DEV_STACK_RELATIVE_PATH} --yes --stage dev\` from ${cwd} ` + - 'to reproduce this directly.', - ); - } - return status; - }; - - // 7. Write the dev stack file and converge. - const firstStatus = converge(); - if (firstStatus !== 0) return firstStatus; - - // 8. Attach: start every stopped service (session resume — a no-op converge - // cannot restart what a previous session's Ctrl-C stopped), then print the - // front door and pump merged logs. - const attachments: LocalTargetAttachment[] = []; - for (const [id, dev] of resolved) { - attachments.push(await dev.attach({ container: containers.get(id), devDir })); - } - // On a partial failure, put the already-started attachments back to - // stopped — a session that never began should leave the machine exactly as - // the previous Ctrl-C did, not half-running. - const started: LocalTargetAttachment[] = []; - for (const attachment of attachments) { - try { - await withEmulatorRetry(() => attachment.startServices()); - started.push(attachment); - } catch (error) { - await Promise.all(started.map((a) => a.stopServices().catch(() => undefined))); - throw toCliError(error); - } - } - printFrontDoor(await mergedEndpoints(attachments)); + const session = result.session; // Logs are a separate command, not this view: `dev` supervises many service // processes and streaming them all inline drowns the front door and the // rebuild notices. `prisma-composer log` tails them on demand. console.log(`[dev] logs: prisma-composer log ${args.entry}`); + hintPrinted = true; + for (const line of pendingUnwatchable.splice(0)) console.log(line); - // 9. Watch loop until SIGINT/SIGTERM: rebuild → re-assemble → re-converge; - // a converge failure keeps the running app and keeps watching. - const { targets, unwatchable } = watchTargetsFrom(assembled.bundles); - for (const address of unwatchable) { - console.log(`[dev] ${address} has no watchable inputs`); - } - - const watch = startWatch(targets, () => { - // The whole rebuild is inside one try/catch: this runs fire-and-forget, - // so anything escaping it would be an unhandled rejection killing the - // process — the exact opposite of "a converge failure keeps the running - // app and keeps watching". - void (async () => { - try { - const rePipeline = await runPipeline(args.entry, args.name, cwd, pipelineDeps); - writeDevStackFile({ - entryPath: rePipeline.entryModule.path, - cwd, - configPath: rePipeline.configPath, - name: rePipeline.name, - assembled: rePipeline.assembled, - }); - const status = (deps.alchemy ?? runAlchemy)({ - command: 'deploy', - stackFileRelativePath: DEV_STACK_RELATIVE_PATH, - cwd, - stage: 'dev', - containerEnv: containerEnv(containers), - }); - if (status !== 0) { - console.error('[dev] converge failed — the running app is untouched; still watching.'); - return; - } - printFrontDoor(await mergedEndpoints(attachments)); - } catch (error) { - console.error( - `[dev] rebuild failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - })(); - }); - // A rebuild finishing before the OS-level watches attach would otherwise - // be missed entirely — wait until watching is real before handing over. - await watch.ready; - - await new Promise((resolve) => { - let stopping = false; - - const finish = (): void => { - if (stopping) return; - stopping = true; - console.log("[dev] stopping — the app's services are stopping; emulators and data stay up."); - watch.stop(); - void (async () => { - for (const attachment of attachments) { - await attachment.stopServices().catch(() => undefined); - } - console.log('[dev] stopped.'); - resolve(); - })(); - }; - - // alchemy's own library code (imported transitively while loading the - // app's config/providers) registers its own process-level SIGINT/SIGTERM - // listeners for ITS OWN in-process resource bookkeeping — irrelevant - // here, since the actual converge runs in a separate spawned `alchemy` - // child process (run-alchemy.ts), never in this one. Left in place, - // whichever of its listeners runs first can call process.exit() - // synchronously and tear this process down before the watch loop's own - // async cleanup (stopping the app's services) ever gets a turn. This is - // this process's OWN signal handling from here on: strip whatever else - // is registered and become the only listener. - process.removeAllListeners('SIGINT'); - process.removeAllListeners('SIGTERM'); - process.on('SIGINT', finish); - process.on('SIGTERM', finish); - }); + const finish = (): void => { + void session.stop(); + }; + // alchemy's own library code (imported transitively while loading the + // app's config/providers) registers its own process-level SIGINT/SIGTERM + // listeners for ITS OWN in-process resource bookkeeping — irrelevant + // here, since the actual converge runs in a separate spawned `alchemy` + // child process (run-alchemy.ts), never in this one. Left in place, + // whichever of its listeners runs first can call process.exit() + // synchronously and tear this process down before the watch loop's own + // async cleanup (stopping the app's services) ever gets a turn. This is + // this process's OWN signal handling from here on: strip whatever else + // is registered and become the only listener. + process.removeAllListeners('SIGINT'); + process.removeAllListeners('SIGTERM'); + process.on('SIGINT', finish); + process.on('SIGTERM', finish); + + await session.closed; + process.off('SIGINT', finish); + process.off('SIGTERM', finish); return 0; } diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index b97e9b0a..696a71bc 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -66,7 +66,11 @@ export interface WatchHandle { * nonexistent path is treated as a file target, so it starts reporting the * moment something creates it. */ -export function startWatch(targets: readonly WatchTarget[], onChange: () => void): WatchHandle { +export function startWatch( + targets: readonly WatchTarget[], + onChange: () => void, + onError?: (error: unknown) => void, +): WatchHandle { let timer: ReturnType | undefined; const trigger = (): void => { @@ -100,9 +104,10 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void // An 'error' emitted with no listener throws and would take the whole dev // session down — a watch error (EMFILE, a vanished directory) is worth a - // line, not the process. + // report to the caller, not the process. Rendering is the caller's: + // this module never touches the console (the dev OPERATION reaches it). const reportError = (error: unknown): void => { - console.error(`[dev] watch error: ${error instanceof Error ? error.message : String(error)}`); + onError?.(error); }; const watchers: FSWatcher[] = []; diff --git a/packages/0-framework/3-tooling/cli/src/exports/__tests__/control-import.test.ts b/packages/0-framework/3-tooling/cli/src/exports/__tests__/control-import.test.ts new file mode 100644 index 00000000..7d244d9b --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/exports/__tests__/control-import.test.ts @@ -0,0 +1,56 @@ +/** + * Pins the control entry's import-light guarantee structurally: a fresh bun + * process imports src/exports/control.ts with every heavy module poisoned + * (executors, pipeline, alchemy runner, stack generators, watch). If the + * entry's static graph ever reaches one of them, the import throws and this + * test fails — the guarantee stops resting on doc comments. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +describe('the ./control entry', () => { + test('statically imports none of the heavy pipeline modules', () => { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-control-import-')), + ); + try { + const controlPath = fileURLToPath(new URL('../control.ts', import.meta.url)); + const breakerPath = path.join(dir, 'poison-heavy-modules.ts'); + fs.writeFileSync( + breakerPath, + 'Bun.plugin({\n' + + " name: 'poison-heavy-modules',\n" + + ' setup(build) {\n' + + ' build.onLoad(\n' + + ' {\n' + + ' filter:\n' + + ' /(execute-deploy-destroy|execute-dev|execute-log|pipeline|run-alchemy|generate-stack|generate-dev-stack|watch|run-dev|run-log|main)\\.ts$/,\n' + + ' },\n' + + ' (args) => {\n' + + " throw new Error(`heavy module in the control entry's static graph: ${args.path}`);\n" + + ' },\n' + + ' );\n' + + ' },\n' + + '});\n', + ); + const probePath = path.join(dir, 'probe.ts'); + fs.writeFileSync(probePath, `import ${JSON.stringify(controlPath)};\n`); + + const probe = spawnSync(process.execPath, ['--preload', breakerPath, probePath], { + cwd: dir, + encoding: 'utf-8', + }); + + expect(probe.error).toBeUndefined(); + expect(probe.stdout).toBe(''); + expect(probe.stderr).toBe(''); + expect(probe.status).toBe(0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts new file mode 100644 index 00000000..5292a1d6 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -0,0 +1,29 @@ +/** + * Public surface (the `./control` subpath): the programmatic + * deploy/destroy/dev/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 + * `prisma-composer.config.ts`): this subpath is for hosts driving the deploy + * pipeline in-process. + */ + +export type { DeployedNodeSummary, DeploymentSummary } from '../deployment-summary.ts'; +export type { DeployInput, DeployResult } from '../operations/deploy.ts'; +export { deploy } from '../operations/deploy.ts'; +export type { + DestroyEvent, + DestroyInput, + DestroyResult, + DestroyTarget, +} from '../operations/destroy.ts'; +export { destroy } from '../operations/destroy.ts'; +export type { DevEvent, DevInput, DevSession, DevStartResult } from '../operations/dev.ts'; +export { dev } from '../operations/dev.ts'; +export type { LogEvent, LogInput, LogLine, LogResult } from '../operations/log.ts'; +export { log } from '../operations/log.ts'; +export type { + ExecutionDiagnostics, + OperationFailure, + ServiceEndpoint, +} from '../operations/shared.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/log/run-log.ts b/packages/0-framework/3-tooling/cli/src/log/run-log.ts index bf3d1c0a..ed69bd1f 100644 --- a/packages/0-framework/3-tooling/cli/src/log/run-log.ts +++ b/packages/0-framework/3-tooling/cli/src/log/run-log.ts @@ -1,17 +1,14 @@ /** - * `prisma-composer log [address]` — tail the merged logs of an - * already-running local app. It resolves the app the way `dev` does (config → - * localTarget → container → attach) but calls only the attachment's `logs()` - * view: it neither builds, provisions, starts, nor stops anything. `dev` no - * longer streams logs inline (that drowned the front door once it supervises - * more than one service); this is where logs live. + * `prisma-composer log [address]` — the CLI adapter over the + * programmatic `log()` operation (../operations/execute-log.ts): it owns the + * SIGINT/SIGTERM → abort wiring, the empty-services notice, and the + * `[service] line` rendering. Resolution, attach, and the merged stream live + * in the operation. `dev` no longer streams logs inline (that drowned the + * front door once it supervises more than one service); this is where logs + * live. */ -import * as path from 'node:path'; -import type { PrismaAppConfig } from '@internal/core/config'; -import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/core/local-target'; -import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; import { CliError } from '../cli-error.ts'; -import { type AppIdentity, resolveAppIdentity } from '../pipeline.ts'; +import { type LogDeps, logWithDeps } from '../operations/log.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `log` command. */ export interface LogArgs { @@ -23,89 +20,53 @@ export interface LogArgs { readonly tail: number; } -export interface LogRunDeps { - /** Substituted for the c12 evaluation of the discovered config file (discovery still runs). */ - readonly config?: PrismaAppConfig | undefined; - /** Overrides the identity resolution (config + name) — lets tests skip a real entry module. */ - readonly identity?: AppIdentity | undefined; -} - -function toCliError(error: unknown): CliError { - return error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); -} +/** Injectable seams — the log operation's own LogDeps, under this adapter's historical name. */ +export type LogRunDeps = LogDeps; /** Runs the log tail until interrupted; returns the process exit code. */ export async function runLog(args: LogArgs, deps: LogRunDeps = {}): Promise { - if (process.platform === 'win32') { - throw new CliError('local dev is not supported on Windows yet.'); - } - - const cwd = process.cwd(); - const devDir = path.join(cwd, DEV_DIR); - - const { config, name } = - deps.identity ?? - (await resolveAppIdentity(args.entry, args.name, cwd, { config: deps.config })); - - let resolved: ReadonlyMap; - try { - resolved = await resolveLocalTargets(config); - } catch (error) { - throw toCliError(error); - } - - const attachments: LocalTargetAttachment[] = []; - for (const target of resolved.values()) { - try { - const container = await target.container.ensure({ appName: name, stage: undefined }); - attachments.push(await target.attach({ container, devDir })); - } catch (error) { - throw toCliError(error); - } - } - - const services = (await Promise.all(attachments.map((a) => a.endpoints()))).flat(); - if (services.length === 0) { - console.error( - `[log] no running services for "${name}" — start it first with \`prisma-composer dev ${args.entry}\`.`, - ); - return 0; - } - if (args.address !== undefined && !services.some((s) => s.address === args.address)) { - throw new CliError( - `no service "${args.address}" in "${name}" — running services: ${services - .map((s) => s.address) - .join(', ')}.`, - ); - } - const controller = new AbortController(); const finish = (): void => controller.abort(); process.on('SIGINT', finish); process.on('SIGTERM', finish); try { - await Promise.all( - attachments.map(async (attachment) => { - try { - for await (const { service, line } of attachment.logs(controller.signal, { - tail: args.tail, - })) { - if (controller.signal.aborted) return; - if (args.address !== undefined && service !== args.address) continue; - console.log(`[${service}] ${line}`); - } - } catch (error) { - if (!controller.signal.aborted) { + const result = await logWithDeps( + { + entry: args.entry, + name: args.name, + address: args.address, + tail: args.tail, + signal: controller.signal, + onEvent: (event) => { + if (event.kind === 'stream-failed') { + console.error(`[log] stream failed: ${event.message}`); + } else if (event.kind === 'lines-dropped') { console.error( - `[log] stream failed: ${error instanceof Error ? error.message : String(error)}`, + `[log] falling behind — dropped the ${String(event.count)} oldest lines.`, ); } - } - }), + }, + }, + { config: deps.config, identity: deps.identity }, ); + + if (result.outcome === 'failed') { + throw result.failure.cause instanceof Error + ? result.failure.cause + : new CliError(result.failure.message); + } + + if (result.services.length === 0) { + console.error( + `[log] no running services for "${result.appName}" — start it first with \`prisma-composer dev ${args.entry}\`.`, + ); + return 0; + } + + for await (const { service, line } of result.lines) { + console.log(`[${service}] ${line}`); + } } finally { process.off('SIGINT', finish); process.off('SIGTERM', finish); diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index 5d9e56f6..ba6282eb 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -3,19 +3,13 @@ * prisma-next/packages/1-framework/3-tooling/cli/src/migration-cli.ts) + * orchestration of deploy-cli.md § The pipeline. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { RunAssembler } from '@internal/assemble'; -import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; -import { containerEnv } from '@internal/core/config'; import { Cli, Command, Option, UsageError } from 'clipanion'; import { CliError } from './cli-error.ts'; import { runDev } from './dev/run-dev.ts'; -import { GENERATED_STACK_RELATIVE_PATH, writeStackFile } from './generate-stack.ts'; import { runLog } from './log/run-log.ts'; -import { type PipelineDeps, runPipeline } from './pipeline.ts'; -import { type RunAlchemyInput, runAlchemy } from './run-alchemy.ts'; -import { validateStageName } from './validate-stage.ts'; +import { deployWithDeps } from './operations/deploy.ts'; +import { type DestroyTarget, destroyWithDeps } from './operations/destroy.ts'; +import type { OperationDeps, OperationFailure } from './operations/shared.ts'; const BINARY_NAME = 'prisma-composer'; @@ -213,49 +207,27 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { throw new UsageError(cli.usage(null, { detailed: true })); } -/** Injectable seams so tests can drive run() without a real wrapper build, config evaluation, or alchemy process. */ -export interface RunDeps { - /** Substituted into assembleServices — see @internal/assemble's RunAssembler. */ - readonly runAssembler?: RunAssembler; - readonly alchemy?: (input: RunAlchemyInput) => number; - /** Substituted for the c12 evaluation of the discovered config file (discovery itself still runs — the generated stack file needs the real path). Container lifecycle is stubbed via each extension's own `container` descriptor on this config. */ - readonly config?: PrismaAppConfig; -} +/** Injectable seams so tests can drive run() without a real wrapper build, config evaluation, or alchemy process — the operations' own OperationDeps, under the CLI's historical name. */ +export type RunDeps = OperationDeps; -/** Destroy must name its target explicitly — no silent default to production (spec §10). */ -function effectiveStage(args: ParsedArgs): string | undefined { - if (args.command === 'deploy') { - if (args.production) { - throw new CliError( - '--production is only valid with `destroy`; `deploy` targets production by default (omit --stage).', - ); +/** + * Renders a deploy/destroy operation failure the way run() always has: an + * alchemy exit becomes the two console.error hint lines and the child's own + * status; everything else rethrows the original error class (CliError, + * LoadError, …) so cli.ts formats it unchanged. + */ +function renderDeployDestroyFailure(failure: OperationFailure): number { + if (failure.kind === 'execution' && failure.diagnostics !== undefined) { + const { exitCode, stackFilePath, reproduceCommand, cwd } = failure.diagnostics; + console.error(`\nGenerated stack file: ${stackFilePath}`); + if (exitCode !== undefined) { + // --stage is part of the repro: without it, alchemy falls back to its + // machine-dependent dev_$USER default and reads DIFFERENT deploy state. + console.error(`Run \`${reproduceCommand}\` from ${cwd} to reproduce this directly.`); + return exitCode; } - return args.stage; - } - if (args.stage !== undefined && args.production) { - throw new CliError('Pass either --stage or --production to `destroy`, not both.'); - } - if (args.stage === undefined && !args.production) { - throw new CliError( - '`destroy` requires an explicit target: --stage to tear down a branch ' + - 'environment, or --production to tear down the production environment.', - ); - } - return args.production ? undefined : args.stage; -} - -const ALCHEMY_STATE_DIR = '.alchemy'; - -/** Warns (doesn't fail) when destroy finds no local deploy state under cwd — likely wrong directory or nothing deployed yet. */ -function warnIfNoLocalDeployState(cwd: string): void { - const stateDir = path.join(cwd, ALCHEMY_STATE_DIR); - const hasState = fs.existsSync(stateDir) && fs.readdirSync(stateDir).length > 0; - if (!hasState) { - console.warn( - `\nNo prior deploy state under ${cwd} — if you deployed from a different directory, run ` + - 'destroy from there; otherwise this is a no-op.', - ); } + throw failure.cause instanceof Error ? failure.cause : new CliError(failure.message); } /** Runs the full pipeline; returns the process exit code. */ @@ -287,165 +259,51 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< ); } - const stage = effectiveStage(args); - if (stage !== undefined) validateStageName(stage); - const cwd = process.cwd(); - - // 0. destroy-only guardrail — first, ahead of every other step, so it - // surfaces even when the rest of the pipeline goes on to fail for an - // unrelated reason (missing config, missing built output — both common - // companions of "nothing was ever deployed from here"). - if (args.command === 'destroy') { - warnIfNoLocalDeployState(cwd); - } - - // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, - // Load, registry coverage, name resolution, assemble. - const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; - const onAssembleError = - args.command === 'destroy' - ? (error: Error): CliError => - new CliError( - `${error.message}\n\ndestroy evaluates the same stack program as deploy, which packages ` + - 'the built artifacts — so the app must be built first. Run the build, then retry the destroy.', - ) - : undefined; - const { configPath, config, entryModule, graph, name, assembled } = await runPipeline( - args.entry, - args.name, - cwd, - pipelineDeps, - onAssembleError, - ); - - // 7. Resolve each extension's own container (e.g. Prisma Cloud's Project + - // named-stage Branch) via its own descriptor — deploy ensures (creates if - // absent), destroy locates only — after assembly succeeds, so a deploy - // that cannot assemble never creates anything on any platform. - const containers = new Map(); - for (const extension of config.extensions) { - if (extension.container === undefined) continue; - try { - if (args.command === 'deploy') { - containers.set(extension.id, await extension.container.ensure({ appName: name, stage })); - } else { - const instance = await extension.container.locate({ appName: name, stage }); - if (instance === undefined) { - throw new CliError( - `Nothing deployed for ${name}${stage !== undefined ? `/${stage}` : ''} — deploy it first.`, - ); - } - containers.set(extension.id, instance); - } - } catch (error) { - throw error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); + // Flag semantics stay the CLI's: the operations take discriminated inputs, + // so the string-flag combinations are validated here, with the same errors + // run() has always thrown (spec §10 — destroy must name its target). + if (args.command === 'deploy') { + if (args.production) { + throw new CliError( + '--production is only valid with `destroy`; `deploy` targets production by default (omit --stage).', + ); } + const result = await deployWithDeps( + { entry: args.entry, name: args.name, stage: args.stage }, + deps, + ); + if (result.outcome === 'deployed') return 0; + return renderDeployDestroyFailure(result.failure); } - // 7.3 The Alchemy stage is never left to Alchemy's own default (`dev_$USER` - // — machine-dependent, the TML-3157 incident): the state-owning extension's - // container (same selection as core's resolveStateLayer) pins it, else an - // explicit --stage must. - const alchemyStage = containers.get(config.state.extension)?.alchemyStage ?? stage; - if (alchemyStage === undefined) { - // Reachable only for deploy without --stage, and destroy --production - // (destroy --stage always has a user stage) — so the remedy can be - // command-specific without a third branch. + if (args.stage !== undefined && args.production) { + throw new CliError('Pass either --stage or --production to `destroy`, not both.'); + } + if (args.stage === undefined && !args.production) { throw new CliError( - 'The configured deploy target supplied no deploy scope (its container defines no ' + - 'alchemyStage), so Alchemy has no stage to run under. ' + - (args.command === 'deploy' - ? 'Pass --stage to choose the deploy scope explicitly.' - : 'destroy --production needs a target whose container supplies the production ' + - 'deploy scope.'), + '`destroy` requires an explicit target: --stage to tear down a branch ' + + 'environment, or --production to tear down the production environment.', ); } - - // 7.5 Preflight (deploy only): each extension verifies its platform - // prerequisites — e.g. that every secret env var in the provision manifest - // exists for the resolved stage (ADR-0029) — BEFORE any stack file is written - // or Alchemy runs, so a missing secret fails fast with nothing side-effected. - if (args.command === 'deploy') { - for (const extension of config.extensions) { - if (extension.preflight === undefined) continue; - try { - await extension.preflight({ graph, container: containers.get(extension.id), stage }); - } catch (error) { - throw error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); - } - } - } - - // 8. Generate .prisma-composer/alchemy.run.ts (tool state lives where you run the tool). - const stackPath = writeStackFile({ - entryPath: entryModule.path, - cwd, - configPath, - name, - assembled, - }); - - // 9. Shell out to alchemy against the generated file. - try { - const status = (deps.alchemy ?? runAlchemy)({ - command: args.command, - stackFileRelativePath: GENERATED_STACK_RELATIVE_PATH, - cwd, - stage: alchemyStage, - containerEnv: containerEnv(containers), - }); - if (status !== 0) { - console.error(`\nGenerated stack file: ${stackPath}`); - // --stage is part of the repro: without it, alchemy falls back to its - // machine-dependent dev_$USER default and reads DIFFERENT deploy state. - console.error( - `Run \`alchemy ${args.command} ${GENERATED_STACK_RELATIVE_PATH} --yes ` + - `--stage ${alchemyStage}\` from ${cwd} to reproduce this directly.`, - ); - return status; - } - // 9.5 Teardown (destroy only): each extension removes infrastructure it - // owns outside the stack — the destroy above may still have been reading - // it, and the containers below may refuse to go while it exists. What that - // infrastructure is, and whether losing it should fail the command, is the - // extension's business, not this module's. - if (args.command === 'destroy') { - for (const extension of config.extensions) { - if (extension.teardown === undefined) continue; - try { - await extension.teardown({ container: containers.get(extension.id), stage }); - } catch (error) { - throw error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); - } - } - - // 9.75 Container removal (destroy only, after every teardown): the CLI's - // two-loop order — all teardowns, then all removes — is what structurally - // preserves ADR-0034's guarantee that a stage's state database is deleted - // before its Branch (a Branch with an attached database refuses deletion). - for (const extension of config.extensions) { - if (extension.container === undefined) continue; - const instance = containers.get(extension.id); - if (instance === undefined) continue; - try { - await extension.container.remove(instance); - } catch (error) { - throw error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); + const target: DestroyTarget = + args.stage !== undefined ? { kind: 'stage', stage: args.stage } : { kind: 'production' }; + + const result = await destroyWithDeps( + { + entry: args.entry, + name: args.name, + target, + onEvent: (event) => { + if (event.kind === 'no-local-deploy-state') { + console.warn( + `\nNo prior deploy state under ${event.cwd} — if you deployed from a different directory, run ` + + 'destroy from there; otherwise this is a no-op.', + ); } - } - } - - return status; - } catch (error) { - console.error(`\nGenerated stack file: ${stackPath}`); - throw error; - } + }, + }, + deps, + ); + if (result.outcome === 'destroyed') return 0; + return renderDeployDestroyFailure(result.failure); } diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts new file mode 100644 index 00000000..25fc937f --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -0,0 +1,1273 @@ +/** + * Drives the programmatic operations (deploy/destroy/log) end to end with the + * same fakes run.test.ts uses at the RunDeps seams — no argv, no console, no + * exit codes. Every operation call runs inside `silently()`, which fails the + * test if the operation itself writes to the console: rendering belongs to + * the host. + */ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ServiceNode } from '@internal/core'; +import type { + ContainerDescriptor, + ContainerInstance, + ExtensionDescriptor, + LocateContainerInput, + PrismaAppConfig, +} from '@internal/core/config'; +import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/core/local-target'; +import * as Layer from 'effect/Layer'; +import { CliError } from '../../cli-error.ts'; +import { DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary } from '../../deployment-summary.ts'; +import type { AppIdentity } from '../../pipeline.ts'; +import type { RunAlchemyInput } from '../../run-alchemy.ts'; +import { deployWithDeps } from '../deploy.ts'; +import { destroyWithDeps } from '../destroy.ts'; +import { devWithDeps } from '../dev.ts'; +import { LOG_QUEUE_LIMIT } from '../execute-log.ts'; +import { type LogLine, logWithDeps } from '../log.ts'; + +const tmpDirs: string[] = []; + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir !== undefined) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Runs an operation and asserts it wrote NOTHING to the console — structured results only. */ +async function silently(run: () => Promise): Promise { + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + let calls = 0; + try { + return await run(); + } finally { + calls = logSpy.mock.calls.length + errorSpy.mock.calls.length + warnSpy.mock.calls.length; + logSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + expect(calls).toBe(0); + } +} + +const unused = () => { + throw new Error('descriptor body must not run inside an operation — only coverage is checked'); +}; + +interface ContainerCall { + readonly op: 'ensure' | 'locate' | 'remove'; + readonly input: LocateContainerInput; +} + +function makeFakeContainer(input: LocateContainerInput, alchemyStage?: string): ContainerInstance { + return { + input, + ...(alchemyStage !== undefined ? { alchemyStage } : {}), + serialize: () => JSON.stringify({ input }), + }; +} + +function fakeContainerDescriptor( + opts: { + readonly calls?: ContainerCall[]; + readonly notFound?: boolean; + readonly onRemove?: () => void; + readonly alchemyStage?: string; + } = {}, +): ContainerDescriptor { + const calls = opts.calls ?? []; + return { + ensure: async (input) => { + calls.push({ op: 'ensure', input }); + return makeFakeContainer(input, opts.alchemyStage); + }, + locate: async (input) => { + calls.push({ op: 'locate', input }); + if (opts.notFound === true) return undefined; + return makeFakeContainer(input, opts.alchemyStage); + }, + remove: async (instance) => { + calls.push({ op: 'remove', input: instance.input }); + opts.onRemove?.(); + }, + deserialize: (serialized) => { + const parsed = JSON.parse(serialized) as { input: LocateContainerInput }; + return makeFakeContainer(parsed.input); + }, + }; +} + +function fakeConfig( + hooks: Partial> = {}, + containerOpts: Parameters[0] = {}, +): PrismaAppConfig { + return { + extensions: [ + { + id: 'fixture-extension', + nodes: { + 'fixture/compute': { + kind: 'service', + provision: unused, + serialize: unused, + package: unused, + deploy: unused, + }, + }, + container: fakeContainerDescriptor(containerOpts), + ...(hooks.teardown !== undefined ? { teardown: hooks.teardown } : {}), + ...(hooks.preflight !== undefined ? { preflight: hooks.preflight } : {}), + }, + { id: 'fixture-build', nodes: { node: { kind: 'build', assemble: unused } } }, + ], + state: { extension: 'fixture-extension', create: unused }, + }; +} + +const coreIndex = path.resolve( + import.meta.dir, + '..', + '..', + '..', + '..', + '..', + '1-core', + 'core', + 'src', + 'exports', + 'index.ts', +); + +function makeAppDir( + name = 'fixture-app', + opts: { config?: boolean } = {}, +): { dir: string; entryPath: string } { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-cli-ops-'))); + tmpDirs.push(dir); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app' })); + if (opts.config !== false) { + fs.writeFileSync( + path.join(dir, 'prisma-composer.config.ts'), + '// fixture config — discovery target only; tests inject deps.config instead of evaluating this\nexport default {};\n', + ); + } + const entryPath = path.join(dir, 'service.ts'); + fs.writeFileSync( + entryPath, + [ + `import { module, service } from ${JSON.stringify(coreIndex)};`, + '', + `export default module(${JSON.stringify(name)}, {}, ({ provision }) => {`, + ' provision(', + ' service({', + " name: 'app',", + " extension: 'fixture-extension',", + " type: 'fixture/compute',", + ' inputs: {},', + ' params: {},', + " build: { extension: 'fixture-build', type: 'node', module: import.meta.url, entry: 'dist/server.js' },", + ' }),', + " { id: 'app' },", + ' );', + ' return {};', + '});', + '', + ].join('\n'), + ); + return { dir, entryPath }; +} + +const fakeAssembler = async (node: ServiceNode) => ({ + dir: path.join(path.dirname(fileURLToPath(node.build.module)), 'dist', 'bundle'), + entry: 'server.js', +}); + +const summaryFixture: DeploymentSummary = { + app: 'hello-ops', + nodes: [{ address: 'app', entities: [{ kind: 'compute-service', id: 'cps_1' }] }], +}; + +describe('deploy()', () => { + test('a successful deploy passes the result-file env var to alchemy and returns the summary the child wrote', async () => { + const app = makeAppDir('hello-ops'); + const calls: RunAlchemyInput[] = []; + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: (input) => { + calls.push(input); + const file = input.env?.[DEPLOYMENT_RESULT_FILE_ENV]; + if (typeof file === 'string') fs.writeFileSync(file, JSON.stringify(summaryFixture)); + return 0; + }, + }, + ), + ); + + expect(calls).toHaveLength(1); + const resultFile = calls[0]?.env?.[DEPLOYMENT_RESULT_FILE_ENV]; + expect(resultFile).toStartWith(path.join(app.dir, '.prisma-composer', 'deployment-result-')); + expect(resultFile).toEndWith('.json'); + expect(result).toEqual({ outcome: 'deployed', summary: summaryFixture }); + // Read once, then removed — nothing left for a later run to misread. + expect(fs.existsSync(resultFile ?? '')).toBe(false); + }); + + test('a deploy whose child wrote no result file still succeeds, with an undefined summary', async () => { + const app = makeAppDir('hello-ops'); + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 0 }, + ), + ); + + expect(result).toEqual({ outcome: 'deployed', summary: undefined }); + }); + + test('a malformed result file is treated as absent, never a deploy failure', async () => { + const app = makeAppDir('hello-ops'); + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: (input) => { + const file = input.env?.[DEPLOYMENT_RESULT_FILE_ENV]; + if (typeof file === 'string') fs.writeFileSync(file, '{"app": 42, "nodes": "nope"'); + return 0; + }, + }, + ), + ); + + expect(result).toEqual({ outcome: 'deployed', summary: undefined }); + }); + + test("each run's result file is its own — a stale file from another run is never read, and two runs never share a path", async () => { + const app = makeAppDir('hello-ops'); + fs.mkdirSync(path.join(app.dir, '.prisma-composer'), { recursive: true }); + fs.writeFileSync( + path.join(app.dir, '.prisma-composer', 'deployment-result-99999-stale.json'), + JSON.stringify(summaryFixture), + ); + const resultFiles: (string | undefined)[] = []; + const deps = { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: (input: RunAlchemyInput) => { + resultFiles.push(input.env?.[DEPLOYMENT_RESULT_FILE_ENV]); + return 0; + }, + }; + + const first = await silently(() => + deployWithDeps({ entry: app.entryPath, stage: 'ci-7', cwd: app.dir }, deps), + ); + const second = await silently(() => + deployWithDeps({ entry: app.entryPath, stage: 'ci-7', cwd: app.dir }, deps), + ); + + expect(first).toEqual({ outcome: 'deployed', summary: undefined }); + expect(second).toEqual({ outcome: 'deployed', summary: undefined }); + expect(resultFiles).toHaveLength(2); + expect(resultFiles[0]).not.toBe(resultFiles[1]); + }); + + test('a failed deploy removes the result file the child already wrote', async () => { + const app = makeAppDir('hello-ops'); + let resultFile: string | undefined; + + const result = await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-7', cwd: app.dir }, + { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: (input) => { + resultFile = input.env?.[DEPLOYMENT_RESULT_FILE_ENV]; + if (typeof resultFile === 'string') { + fs.writeFileSync(resultFile, JSON.stringify(summaryFixture)); + } + return 1; + }, + }, + ), + ); + + expect(result.outcome).toBe('failed'); + expect(resultFile).toBeDefined(); + expect(fs.existsSync(resultFile ?? '')).toBe(false); + }); + + test('the summary round-trips through a real child process writing via the report writer', async () => { + const app = makeAppDir('hello-ops'); + const writerPath = fileURLToPath(new URL('../../deployment-summary.ts', import.meta.url)); + const childPath = path.join(app.dir, 'report-child.ts'); + fs.writeFileSync( + childPath, + `import { writeDeploymentSummaryFile } from ${JSON.stringify(writerPath)};\n` + + 'const result = {\n' + + " app: 'hello-ops',\n" + + " nodes: [{ address: 'app', node: undefined, entities: [{ kind: 'compute-service', id: 'cps_1' }] }],\n" + + '} as never;\n' + + 'writeDeploymentSummaryFile(result);\n', + ); + + const result = await silently(() => + deployWithDeps( + { entry: app.entryPath, stage: 'ci-7', cwd: app.dir }, + { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: (input) => { + const child = spawnSync(process.execPath, [childPath], { + cwd: app.dir, + env: input.env, + encoding: 'utf-8', + }); + expect(child.stderr).toBe(''); + return child.status ?? 1; + }, + }, + ), + ); + + expect(result).toEqual({ outcome: 'deployed', summary: summaryFixture }); + }, 15_000); + + test('an invalid stage ref is an invalid-input failure, before any container call', async () => { + const app = makeAppDir(); + const containerCalls: ContainerCall[] = []; + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + stage: 'bad..ref', + cwd: app.dir, + }, + { + config: fakeConfig({}, { calls: containerCalls }), + runAssembler: fakeAssembler, + alchemy: () => 0, + }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure).toMatchObject({ kind: 'invalid-input' }); + expect(result.failure.message).toContain('Invalid --stage'); + expect(result.failure.cause).toBeInstanceOf(CliError); + expect(containerCalls).toEqual([]); + }); + + test('a missing prisma-composer.config.ts is a pipeline failure naming the filename', async () => { + const app = makeAppDir('no-config', { config: false }); + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + }, + { runAssembler: fakeAssembler, alchemy: () => 0 }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('pipeline'); + expect(result.failure.message).toContain('prisma-composer.config.ts'); + expect(result.failure.cause).toBeInstanceOf(CliError); + }); + + test('an extension-preflight throw is a pipeline failure — alchemy never runs, no stack file is written', async () => { + const app = makeAppDir('hello-preflight-fail'); + let alchemyRan = false; + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { + config: fakeConfig({ + preflight: async () => { + throw new Error('SECRET_X is not provisioned'); + }, + }), + runAssembler: fakeAssembler, + alchemy: () => { + alchemyRan = true; + return 0; + }, + }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('pipeline'); + expect(result.failure.message).toContain('SECRET_X is not provisioned'); + expect(alchemyRan).toBe(false); + expect(fs.existsSync(path.join(app.dir, '.prisma-composer', 'alchemy.run.ts'))).toBe(false); + }); + + test('an alchemy exit 42 is an execution failure carrying the exit code and both hint fields', async () => { + const app = makeAppDir(); + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 42 }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure).toEqual({ + kind: 'execution', + message: 'alchemy deploy exited with status 42.', + diagnostics: { + exitCode: 42, + stackFilePath: path.join(app.dir, '.prisma-composer', 'alchemy.run.ts'), + reproduceCommand: `alchemy deploy ${path.join('.prisma-composer', 'alchemy.run.ts')} --yes --stage ci-7`, + cwd: app.dir, + }, + }); + }); + + test('.prisma-composer existing as a FILE is a pipeline failure, not a rejection', async () => { + const app = makeAppDir('hello-ops'); + fs.writeFileSync(path.join(app.dir, '.prisma-composer'), 'not a directory'); + let alchemyRan = false; + + const result = await silently(() => + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: () => { + alchemyRan = true; + return 0; + }, + }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('pipeline'); + expect(alchemyRan).toBe(false); + }); + + test('a broken effect tree is a pipeline failure naming the mismatch — the executor cannot load, the host stays alive', () => { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-cli-ops-effect-')), + ); + tmpDirs.push(dir); + const writePackage = (segments: readonly string[], manifest: Record) => { + const pkgDir = path.join(dir, ...segments); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ main: 'index.js', ...manifest }), + ); + fs.writeFileSync(path.join(pkgDir, 'index.js'), 'module.exports = {};\n'); + }; + writePackage(['node_modules', 'alchemy'], { name: 'alchemy', version: '2.0.0-beta.59' }); + writePackage(['node_modules', 'effect'], { name: 'effect', version: '4.0.0-beta.102' }); + writePackage(['node_modules', '@prisma', 'composer'], { + name: '@prisma/composer', + version: '0.0.0', + dependencies: { effect: '4.0.0-beta.93' }, + }); + + // In a broken tree the executor's own import of alchemy throws. The repo's + // tree is healthy, so a fresh bun process reproduces that throw with a + // plugin that fails the executor's load; deploy() must diagnose it against + // `cwd`'s tree and return a structured failure — silent stdio, exit 0. + const operationsPath = fileURLToPath(new URL('../deploy.ts', import.meta.url)); + const breakerPath = path.join(dir, 'break-executor.ts'); + fs.writeFileSync( + breakerPath, + 'Bun.plugin({\n' + + " name: 'break-executor',\n" + + ' setup(build) {\n' + + ' build.onLoad({ filter: /execute-deploy-destroy\\.ts$/ }, () => {\n' + + " throw new Error('Schedule.either is not a function');\n" + + ' });\n' + + ' },\n' + + '});\n', + ); + const probePath = path.join(dir, 'probe.ts'); + const resultPath = path.join(dir, 'result.json'); + fs.writeFileSync( + probePath, + `import { deploy } from ${JSON.stringify(operationsPath)};\n` + + `const result = await deploy({ entry: 'service.ts', cwd: ${JSON.stringify(dir)} });\n` + + 'await Bun.write(\n' + + ` ${JSON.stringify(resultPath)},\n` + + ' JSON.stringify(result, (_key, value) =>\n' + + ' value instanceof Error ? { name: value.name, message: value.message } : value,\n' + + ' ),\n' + + ');\n', + ); + + const probe = spawnSync(process.execPath, ['--preload', breakerPath, probePath], { + cwd: dir, + encoding: 'utf-8', + }); + expect(probe.error).toBeUndefined(); + expect(probe.stdout).toBe(''); + expect(probe.stderr).toBe(''); + expect(probe.status).toBe(0); + + const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8')) as { + outcome: string; + failure: { kind: string; message: string; cause: { name: string; message: string } }; + }; + expect(result.outcome).toBe('failed'); + expect(result.failure.kind).toBe('pipeline'); + expect(result.failure.message).toContain('alchemy resolves effect@4.0.0-beta.102'); + expect(result.failure.cause).toEqual({ + name: 'Error', + message: 'Schedule.either is not a function', + }); + }); +}); + +describe('destroy()', () => { + test('a stage target locates the container with that stage; production locates with stage undefined', async () => { + for (const [target, expectedStage] of [ + [{ kind: 'stage', stage: 'staging' }, 'staging'], + [{ kind: 'production' }, undefined], + ] as const) { + const app = makeAppDir(); + fs.mkdirSync(path.join(app.dir, '.alchemy'), { recursive: true }); + fs.writeFileSync(path.join(app.dir, '.alchemy', 'state.json'), '{}'); + const containerCalls: ContainerCall[] = []; + + const result = await silently(() => + destroyWithDeps( + { + entry: app.entryPath, + target, + cwd: app.dir, + }, + { + config: fakeConfig({}, { calls: containerCalls, alchemyStage: 'br_x' }), + runAssembler: fakeAssembler, + alchemy: () => 0, + }, + ), + ); + + expect(result).toEqual({ outcome: 'destroyed' }); + expect(containerCalls[0]).toEqual({ + op: 'locate', + input: { appName: 'fixture-app', stage: expectedStage }, + }); + } + }); + + test('locate returning undefined is a pipeline failure naming the app and stage', async () => { + const app = makeAppDir(); + fs.mkdirSync(path.join(app.dir, '.alchemy'), { recursive: true }); + fs.writeFileSync(path.join(app.dir, '.alchemy', 'state.json'), '{}'); + + const result = await silently(() => + destroyWithDeps( + { + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + }, + { + config: fakeConfig({}, { notFound: true }), + runAssembler: fakeAssembler, + alchemy: () => 0, + }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('pipeline'); + expect(result.failure.message).toBe( + 'Nothing deployed for fixture-app/staging — deploy it first.', + ); + }); + + test('a successful destroy runs alchemy, then every teardown, then every container removal', async () => { + const app = makeAppDir(); + fs.mkdirSync(path.join(app.dir, '.alchemy'), { recursive: true }); + fs.writeFileSync(path.join(app.dir, '.alchemy', 'state.json'), '{}'); + const order: string[] = []; + + const result = await silently(() => + destroyWithDeps( + { + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + }, + { + config: fakeConfig( + { teardown: async () => void order.push('teardown') }, + { onRemove: () => void order.push('remove') }, + ), + runAssembler: fakeAssembler, + alchemy: () => { + order.push('alchemy'); + return 0; + }, + }, + ), + ); + + expect(result).toEqual({ outcome: 'destroyed' }); + expect(order).toEqual(['alchemy', 'teardown', 'remove']); + }); + + test('the no-local-deploy-state event fires before the pipeline runs', async () => { + const app = makeAppDir(); + const order: string[] = []; + + const result = await silently(() => + destroyWithDeps( + { + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + onEvent: (event) => void order.push(event.kind), + }, + { + config: fakeConfig(), + runAssembler: async (node) => { + order.push('assemble'); + return fakeAssembler(node); + }, + alchemy: () => 0, + }, + ), + ); + + expect(result).toEqual({ outcome: 'destroyed' }); + expect(order).toEqual(['no-local-deploy-state', 'assemble']); + }); + + test('no event fires when .alchemy holds state', async () => { + const app = makeAppDir(); + fs.mkdirSync(path.join(app.dir, '.alchemy'), { recursive: true }); + fs.writeFileSync(path.join(app.dir, '.alchemy', 'state.json'), '{}'); + const events: string[] = []; + + await silently(() => + destroyWithDeps( + { + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + onEvent: (event) => void events.push(event.kind), + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 0 }, + ), + ); + + expect(events).toEqual([]); + }); +}); + +interface Endpoint { + readonly address: string; + readonly url: string; +} + +function localContainer(): ContainerInstance { + return { input: { appName: 'app', stage: undefined }, serialize: () => 'x' }; +} + +function fakeAttachment( + endpoints: readonly Endpoint[], + logs: LocalTargetAttachment['logs'], +): LocalTargetAttachment { + return { + startServices: () => Promise.resolve(), + stopServices: () => Promise.resolve(), + endpoints: () => Promise.resolve(endpoints), + logs, + }; +} + +function linesAttachment( + endpoints: readonly Endpoint[], + lines: readonly LogLine[], +): LocalTargetAttachment { + return fakeAttachment(endpoints, async function* () { + for (const l of lines) yield l; + }); +} + +function configWith(attachments: readonly LocalTargetAttachment[]): PrismaAppConfig { + return { + extensions: attachments.map((attachment, index) => { + const descriptor: LocalTargetDescriptor = { + providers: () => Layer.empty, + container: { + ensure: () => Promise.resolve(localContainer()), + locate: () => Promise.resolve(undefined), + remove: () => Promise.resolve(), + deserialize: () => localContainer(), + }, + attach: () => Promise.resolve(attachment), + }; + return { + id: `x${String(index)}`, + nodes: { + svc: { + kind: 'service', + provision: unused, + serialize: unused, + package: unused, + deploy: unused, + }, + }, + localTarget: () => Promise.resolve(descriptor), + }; + }), + state: { extension: 'x0', create: unused }, + }; +} + +function identityFor(attachments: readonly LocalTargetAttachment[]): AppIdentity { + return { configPath: 'c', config: configWith(attachments), name: 'app' }; +} + +async function collect(lines: AsyncIterable): Promise { + const out: LogLine[] = []; + for await (const line of lines) out.push(line); + return out; +} + +/** A config whose one extension both deploys the fixture nodes and offers a local target — what dev() needs end to end. */ +function devConfigWith(attachment: LocalTargetAttachment): PrismaAppConfig { + const descriptor: LocalTargetDescriptor = { + providers: () => Layer.empty, + container: { + ensure: () => Promise.resolve(localContainer()), + locate: () => Promise.resolve(undefined), + remove: () => Promise.resolve(), + deserialize: () => localContainer(), + }, + attach: () => Promise.resolve(attachment), + }; + return { + extensions: [ + { + id: 'fixture-extension', + nodes: { + 'fixture/compute': { + kind: 'service', + provision: unused, + serialize: unused, + package: unused, + deploy: unused, + }, + }, + localTarget: () => Promise.resolve(descriptor), + }, + { id: 'fixture-build', nodes: { node: { kind: 'build', assemble: unused } } }, + ], + state: { extension: 'fixture-extension', create: unused }, + }; +} + +describe('dev()', () => { + test('a throw after services start (endpoint merge) is a pipeline failure, and the started services are stopped again', async () => { + const app = makeAppDir('hello-dev'); + let stops = 0; + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopServices: () => { + stops += 1; + return Promise.resolve(); + }, + endpoints: () => Promise.reject(new Error('emulator admin refused the connection')), + logs: async function* () {}, + }; + + const result = await silently(() => + devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + }, + { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('pipeline'); + expect(result.failure.message).toBe('emulator admin refused the connection'); + expect(stops).toBe(1); + }, 15_000); + + test('stop() surfaces a service that refuses to stop as a stop-error event, and still finishes', async () => { + const app = makeAppDir('hello-dev'); + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopServices: () => Promise.reject(new Error('service pid 123 will not die')), + endpoints: () => Promise.resolve([]), + logs: async function* () {}, + }; + const events: string[] = []; + + const result = await silently(async () => { + const start = await devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + onEvent: (event) => void events.push(event.kind), + }, + { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, + ); + if (start.outcome !== 'started') throw new Error('expected a started session'); + await start.session.stop(); + await start.session.closed; + return start; + }); + + expect(result.outcome).toBe('started'); + expect(events).toEqual(['ready', 'unwatchable', 'stopping', 'stop-error', 'stopped']); + }, 15_000); + + test('the DevSession contract: closed settles only via stop(), stop() is idempotent, and no process signal handler is ever registered', async () => { + const app = makeAppDir('hello-dev'); + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopServices: () => Promise.resolve(), + endpoints: () => Promise.resolve([{ address: 'app', url: 'http://localhost:3000' }]), + logs: async function* () {}, + }; + const sigintBefore = process.listenerCount('SIGINT'); + const sigtermBefore = process.listenerCount('SIGTERM'); + const events: string[] = []; + + await silently(async () => { + const start = await devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + onEvent: (event) => void events.push(event.kind), + }, + { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, + ); + if (start.outcome !== 'started') throw new Error('expected a started session'); + expect(start.session.endpoints).toEqual([{ address: 'app', url: 'http://localhost:3000' }]); + + let settled = false; + void start.session.closed.then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(settled).toBe(false); + + const firstStop = start.session.stop(); + const secondStop = start.session.stop(); + await firstStop; + await secondStop; + await start.session.closed; + expect(settled).toBe(true); + }); + + expect(events.filter((kind) => kind === 'stopping')).toHaveLength(1); + expect(events.filter((kind) => kind === 'stopped')).toHaveLength(1); + expect(process.listenerCount('SIGINT')).toBe(sigintBefore); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBefore); + }, 15_000); + + test('a host onEvent that throws cannot prevent closed from settling', async () => { + const app = makeAppDir('hello-dev'); + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopServices: () => Promise.resolve(), + endpoints: () => Promise.resolve([]), + logs: async function* () {}, + }; + + const result = await silently(async () => { + const start = await devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + onEvent: () => { + throw new Error('host renderer blew up'); + }, + }, + { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, + ); + if (start.outcome !== 'started') throw new Error('expected a started session'); + await start.session.stop(); + await start.session.closed; + return start; + }); + + expect(result.outcome).toBe('started'); + }, 15_000); + + test('.prisma-composer existing as a FILE is a pipeline failure, not a rejection', async () => { + const app = makeAppDir('hello-dev'); + fs.writeFileSync(path.join(app.dir, '.prisma-composer'), 'not a directory'); + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopServices: () => Promise.resolve(), + endpoints: () => Promise.resolve([]), + logs: async function* () {}, + }; + let alchemyRan = false; + + const result = await silently(() => + devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + }, + { + config: devConfigWith(attachment), + runAssembler: fakeAssembler, + alchemy: () => { + alchemyRan = true; + return 0; + }, + }, + ), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('pipeline'); + expect(alchemyRan).toBe(false); + }, 15_000); +}); + +describe('log()', () => { + test('merges every attachment into one stream and reports the running services', async () => { + const attachments = [ + linesAttachment([{ address: 'a', url: 'http://a' }], [{ service: 'a', line: 'from-a' }]), + linesAttachment([{ address: 'b', url: 'http://b' }], [{ service: 'b', line: 'from-b' }]), + ]; + + const result = await silently(() => + logWithDeps({ entry: 'service.ts' }, { identity: identityFor(attachments) }), + ); + + expect(result.outcome).toBe('attached'); + if (result.outcome !== 'attached') throw new Error('unreachable'); + expect(result.appName).toBe('app'); + expect([...result.services].sort((x, y) => x.address.localeCompare(y.address))).toEqual([ + { address: 'a', url: 'http://a' }, + { address: 'b', url: 'http://b' }, + ]); + const lines = await collect(result.lines); + expect(lines).toContainEqual({ service: 'a', line: 'from-a' }); + expect(lines).toContainEqual({ service: 'b', line: 'from-b' }); + }); + + test('an address filter keeps only that service', async () => { + const attachments = [ + linesAttachment( + [ + { address: 'a', url: 'http://a' }, + { address: 'b', url: 'http://b' }, + ], + [ + { service: 'a', line: 'from-a' }, + { service: 'b', line: 'from-b' }, + ], + ), + ]; + + const result = await silently(() => + logWithDeps({ entry: 'service.ts', address: 'a' }, { identity: identityFor(attachments) }), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + expect(await collect(result.lines)).toEqual([{ service: 'a', line: 'from-a' }]); + }); + + test('an unknown address is an invalid-input failure naming the running services', async () => { + const attachments = [linesAttachment([{ address: 'a', url: 'http://a' }], [])]; + + const result = await silently(() => + logWithDeps({ entry: 'service.ts', address: 'nope' }, { identity: identityFor(attachments) }), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('invalid-input'); + expect(result.failure.message).toBe('no service "nope" in "app" — running services: a.'); + }); + + test('zero running services is a valid attached result with an already-finished stream', async () => { + const attachments = [linesAttachment([], [])]; + + const result = await silently(() => + logWithDeps({ entry: 'service.ts' }, { identity: identityFor(attachments) }), + ); + + expect(result.outcome).toBe('attached'); + if (result.outcome !== 'attached') throw new Error('unreachable'); + expect(result.appName).toBe('app'); + expect(result.services).toEqual([]); + expect(await collect(result.lines)).toEqual([]); + }); + + test('aborting the signal ends the merged iterable while a source is still live', async () => { + const live = fakeAttachment([{ address: 'a', url: 'http://a' }], async function* (signal) { + yield { service: 'a', line: 'one' }; + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }); + const controller = new AbortController(); + + const result = await silently(() => + logWithDeps( + { + entry: 'service.ts', + signal: controller.signal, + }, + { identity: identityFor([live]) }, + ), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + const seen: string[] = []; + for await (const { line } of result.lines) { + seen.push(line); + controller.abort(); + } + expect(seen).toEqual(['one']); + }); + + test('a transient endpoints() refusal right after a converge is retried, not a failure', async () => { + let attempts = 0; + const flaky: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopServices: () => Promise.resolve(), + endpoints: () => { + attempts += 1; + if (attempts === 1) return Promise.reject(new Error('ECONNREFUSED')); + return Promise.resolve([{ address: 'a', url: 'http://a' }]); + }, + logs: async function* () {}, + }; + + const result = await silently(() => + logWithDeps({ entry: 'service.ts' }, { identity: identityFor([flaky]) }), + ); + + expect(result.outcome).toBe('attached'); + if (result.outcome !== 'attached') throw new Error('unreachable'); + expect(result.services).toEqual([{ address: 'a', url: 'http://a' }]); + expect(attempts).toBe(2); + }, 10_000); + + test('breaking out of the merged stream returns promptly even when a source ignores the signal', async () => { + const stubborn = fakeAttachment([{ address: 'a', url: 'http://a' }], async function* () { + yield { service: 'a', line: 'one' }; + await new Promise(() => undefined); + }); + + const result = await silently(() => + logWithDeps({ entry: 'service.ts' }, { identity: identityFor([stubborn]) }), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + const seen: string[] = []; + for await (const { line } of result.lines) { + seen.push(line); + break; + } + expect(seen).toEqual(['one']); + }, 5_000); + + test('lines.return() ends the stream promptly without aborting first', async () => { + const stubborn = fakeAttachment([{ address: 'a', url: 'http://a' }], async function* () { + yield { service: 'a', line: 'one' }; + await new Promise(() => undefined); + }); + + const result = await silently(() => + logWithDeps({ entry: 'service.ts' }, { identity: identityFor([stubborn]) }), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + const iterator = result.lines[Symbol.asyncIterator](); + expect((await iterator.next()).value).toEqual({ service: 'a', line: 'one' }); + expect(await iterator.return?.(undefined)).toEqual({ done: true, value: undefined }); + }, 5_000); + + test('a consumer that falls behind gets a bounded queue: oldest lines drop, a lines-dropped event says how many', async () => { + const TOTAL = LOG_QUEUE_LIMIT * 2; + const flood = fakeAttachment([{ address: 'a', url: 'http://a' }], async function* () { + for (let i = 0; i < TOTAL; i += 1) yield { service: 'a', line: String(i) }; + }); + const droppedCounts: number[] = []; + + const result = await silently(() => + logWithDeps( + { + entry: 'service.ts', + onEvent: (event) => { + if (event.kind === 'lines-dropped') droppedCounts.push(event.count); + }, + }, + { identity: identityFor([flood]) }, + ), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + const seen: LogLine[] = []; + for await (const line of result.lines) { + seen.push(line); + if (seen.length === 1) { + // Stall once so the pump floods the queue past its bound. + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + const droppedTotal = droppedCounts.reduce((sum, count) => sum + count, 0); + expect(droppedTotal).toBeGreaterThan(0); + expect(seen.length + droppedTotal).toBe(TOTAL); + }, 15_000); + + test('no event is delivered after the merged iterable has ended', async () => { + let failLate: (() => void) | undefined; + const lateFailer = fakeAttachment([{ address: 'a', url: 'http://a' }], async function* () { + yield { service: 'a', line: 'one' }; + await new Promise((_resolve, reject) => { + failLate = () => reject(new Error('daemon went away late')); + }); + }); + const events: string[] = []; + + const result = await silently(() => + logWithDeps( + { + entry: 'service.ts', + onEvent: (event) => void events.push(event.kind), + }, + { identity: identityFor([lateFailer]) }, + ), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + for await (const line of result.lines) { + void line; + break; + } + expect(failLate).toBeDefined(); + failLate?.(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(events).toEqual([]); + }, 5_000); + + test('a host onEvent that throws does not end the stream or reject a pump', async () => { + const failing = fakeAttachment([{ address: 'a', url: 'http://a' }], async function* () { + yield { service: 'a', line: 'before-crash' }; + throw new Error('daemon went away'); + }); + const healthy = linesAttachment( + [{ address: 'b', url: 'http://b' }], + [{ service: 'b', line: 'still-here' }], + ); + + const result = await silently(() => + logWithDeps( + { + entry: 'service.ts', + onEvent: () => { + throw new Error('host renderer blew up'); + }, + }, + { identity: identityFor([failing, healthy]) }, + ), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + const lines = await collect(result.lines); + expect(lines).toContainEqual({ service: 'a', line: 'before-crash' }); + expect(lines).toContainEqual({ service: 'b', line: 'still-here' }); + }); + + test("one stream's failure raises a stream-failed event and leaves the other streams running", async () => { + const failing = fakeAttachment([{ address: 'a', url: 'http://a' }], async function* () { + yield { service: 'a', line: 'before-crash' }; + throw new Error('daemon went away'); + }); + const healthy = linesAttachment( + [{ address: 'b', url: 'http://b' }], + [{ service: 'b', line: 'still-here' }], + ); + const events: string[] = []; + + const result = await silently(() => + logWithDeps( + { + entry: 'service.ts', + onEvent: (event) => { + if (event.kind === 'stream-failed') events.push(event.message); + }, + }, + { identity: identityFor([failing, healthy]) }, + ), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + const lines = await collect(result.lines); + expect(lines).toContainEqual({ service: 'a', line: 'before-crash' }); + expect(lines).toContainEqual({ service: 'b', line: 'still-here' }); + expect(events).toEqual(['daemon went away']); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts new file mode 100644 index 00000000..f9f426ff --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts @@ -0,0 +1,51 @@ +/** + * The programmatic `deploy` operation (`@prisma/composer/control`): typed + * input, structured result, no argv, no console, no process.exit. The + * prisma-composer CLI (main.ts) is a thin renderer over it. The executor + * loads lazily, so importing this module executes nothing; an executor that + * fails to load comes back as a structured `pipeline` failure, never a throw + * out of the host. + */ +import type { DeploymentSummary } from '../deployment-summary.ts'; +import { executorLoadFailure, type OperationDeps, type OperationFailure } from './shared.ts'; + +export interface DeployInput { + /** Path to the entry module, resolved against `cwd` — same contract as `prisma-composer deploy `. */ + readonly entry: string; + /** Override the root node's name (the `--name` flag's slot). */ + readonly name?: string | undefined; + /** Target stage. ABSENT = production — bare deploy targets production (main.ts effectiveStage). */ + readonly stage?: string | undefined; + /** Defaults to process.cwd(); the directory `.prisma-composer/` and `.alchemy` state live under. */ + readonly cwd?: string | undefined; +} + +export type DeployResult = + | { + readonly outcome: 'deployed'; + /** Parsed from the alchemy child's result file. Undefined when the child + * did not write one (injected fake alchemy, or a report-less apply). */ + readonly summary: DeploymentSummary | undefined; + } + | { readonly outcome: 'failed'; readonly failure: OperationFailure }; + +export async function deploy(input: DeployInput): Promise { + return deployWithDeps(input, {}); +} + +/** In-package variant threading the injection seam (the CLI's RunDeps, unit + * tests). Deliberately NOT re-exported through `./control` — the seam mirrors + * internal types and is not part of the published surface. */ +export async function deployWithDeps( + input: DeployInput, + deps: OperationDeps, +): Promise { + const cwd = input.cwd ?? process.cwd(); + let executor: typeof import('./execute-deploy-destroy.ts'); + try { + executor = await import('./execute-deploy-destroy.ts'); + } catch (error) { + return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; + } + return executor.executeDeploy(input, deps, cwd); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/destroy.ts new file mode 100644 index 00000000..d8fe2818 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/destroy.ts @@ -0,0 +1,52 @@ +/** + * The programmatic `destroy` operation (`@prisma/composer/control`): typed + * input, structured result, no argv, no console, no process.exit. The + * prisma-composer CLI (main.ts) is a thin renderer over it. The executor + * loads lazily, so importing this module executes nothing; an executor that + * fails to load comes back as a structured `pipeline` failure, never a throw + * out of the host. + */ +import { executorLoadFailure, type OperationDeps, type OperationFailure } from './shared.ts'; + +/** Destroy must name its target explicitly — no silent default to production. Encoded, not re-derived from flags. */ +export type DestroyTarget = + | { readonly kind: 'production' } + | { readonly kind: 'stage'; readonly stage: string }; + +export type DestroyEvent = + /** Emitted before the pipeline when `/.alchemy` is missing/empty. */ + { readonly kind: 'no-local-deploy-state'; readonly cwd: string }; + +export interface DestroyInput { + readonly entry: string; + readonly name?: string | undefined; + readonly target: DestroyTarget; + readonly cwd?: string | undefined; + /** Mid-operation notifications, in real time. Rendering is the host's. */ + readonly onEvent?: ((event: DestroyEvent) => void) | undefined; +} + +export type DestroyResult = + | { readonly outcome: 'destroyed' } + | { readonly outcome: 'failed'; readonly failure: OperationFailure }; + +export async function destroy(input: DestroyInput): Promise { + return destroyWithDeps(input, {}); +} + +/** In-package variant threading the injection seam (the CLI's RunDeps, unit + * tests). Deliberately NOT re-exported through `./control` — the seam mirrors + * internal types and is not part of the published surface. */ +export async function destroyWithDeps( + input: DestroyInput, + deps: OperationDeps, +): Promise { + const cwd = input.cwd ?? process.cwd(); + let executor: typeof import('./execute-deploy-destroy.ts'); + try { + executor = await import('./execute-deploy-destroy.ts'); + } catch (error) { + return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; + } + return executor.executeDestroy(input, deps, cwd); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/dev.ts b/packages/0-framework/3-tooling/cli/src/operations/dev.ts new file mode 100644 index 00000000..5ba0f8d6 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/dev.ts @@ -0,0 +1,77 @@ +/** + * The programmatic `dev` operation (`@prisma/composer/control`): typed input, + * events out through `onEvent`, lifetime owned by the returned DevSession — + * no argv, no console, no process.exit, and NEVER any process signal + * handling (the host owns signals; the CLI adapter dev/run-dev.ts shows the + * pattern). The executor loads lazily, so importing this module executes + * nothing; an executor that fails to load comes back as a structured + * `pipeline` failure, never a throw out of the host. + */ +import { + executorLoadFailure, + type OperationDeps, + type OperationFailure, + type ServiceEndpoint, +} from './shared.ts'; + +export type DevEvent = + /** Initial front door + after each successful re-converge. */ + | { readonly kind: 'ready'; readonly endpoints: readonly ServiceEndpoint[] } + | { readonly kind: 'unwatchable'; readonly address: string } + | { readonly kind: 'rebuild-failed'; readonly message: string } + /** The file watcher itself errored (EMFILE, a vanished directory); the session keeps running. */ + | { readonly kind: 'watch-error'; readonly message: string } + /** The app keeps running, still watching. */ + | { + readonly kind: 'converge-failed'; + readonly stackFilePath: string; + readonly reproduceCommand: string; + readonly cwd: string; + } + | { readonly kind: 'stopping' } + /** One service refused to stop during stop(); teardown continues and `stopped` still follows. */ + | { readonly kind: 'stop-error'; readonly message: string } + | { readonly kind: 'stopped' }; + +export interface DevInput { + readonly entry: string; + readonly name?: string | undefined; + readonly fresh?: boolean | undefined; + readonly cwd?: string | undefined; + readonly onEvent?: ((event: DevEvent) => void) | undefined; +} + +/** A running dev session. The operation NEVER touches process signal handlers — + * the host owns signals (and must evict alchemy's import-time SIGINT/SIGTERM + * listeners before installing its own; see run-dev.ts). */ +export interface DevSession { + /** The initial front door, already merged across attachments. */ + readonly endpoints: readonly ServiceEndpoint[]; + /** Stop the watch loop and the app's services (emulators and data stay up). + * Idempotent; emits 'stopping'/'stopped'; resolves `closed`. */ + stop(): Promise; + /** Settles when the session has fully stopped (via stop()). */ + readonly closed: Promise; +} + +export type DevStartResult = + | { readonly outcome: 'started'; readonly session: DevSession } + | { readonly outcome: 'failed'; readonly failure: OperationFailure }; + +export async function dev(input: DevInput): Promise { + return devWithDeps(input, {}); +} + +/** In-package variant threading the injection seam (the CLI's RunDeps, unit + * tests). Deliberately NOT re-exported through `./control` — the seam mirrors + * internal types and is not part of the published surface. */ +export async function devWithDeps(input: DevInput, deps: OperationDeps): Promise { + const cwd = input.cwd ?? process.cwd(); + let executor: typeof import('./execute-dev.ts'); + try { + executor = await import('./execute-dev.ts'); + } catch (error) { + return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; + } + return executor.executeDev(input, deps, cwd); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/emulator-retry.ts b/packages/0-framework/3-tooling/cli/src/operations/emulator-retry.ts new file mode 100644 index 00000000..ab0b0e77 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/emulator-retry.ts @@ -0,0 +1,25 @@ +/** + * An emulator admin call right after a converge that just PUT dozens of + * resources through the same daemon can hit a transient refused/reset + * connection — a brief loopback hiccup under load, not a real failure. + * Retried before giving up. Shared by the dev and log executors, which talk + * to the same daemons (`startServices`, `endpoints`, `attach`). + */ + +const EMULATOR_RETRY_ATTEMPTS = 5; +const EMULATOR_RETRY_DELAY_MS = 500; + +export async function withEmulatorRetry(call: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= EMULATOR_RETRY_ATTEMPTS; attempt += 1) { + try { + return await call(); + } catch (error) { + lastError = error; + if (attempt < EMULATOR_RETRY_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, EMULATOR_RETRY_DELAY_MS)); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts new file mode 100644 index 00000000..b63e8fd1 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -0,0 +1,330 @@ +/** + * The deploy/destroy executor — main.ts's pipeline orchestration with argv, + * console, and exit codes removed: typed inputs in, structured results out. + * Reached only by lazy import from deploy.ts/destroy.ts — this module's + * static graph transitively loads alchemy's provider tree, so the control + * entry must never import it statically. + */ +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { ContainerInstance } from '@internal/core/config'; +import { containerEnv } from '@internal/core/config'; +import { CliError } from '../cli-error.ts'; +import { + DEPLOYMENT_RESULT_FILE_ENV, + type DeploymentSummary, + readDeploymentSummary, +} from '../deployment-summary.ts'; +import { GENERATED_STACK_RELATIVE_PATH, writeStackFile } from '../generate-stack.ts'; +import { type PipelineDeps, type PipelineResult, runPipeline } from '../pipeline.ts'; +import { runAlchemy } from '../run-alchemy.ts'; +import { validateStageName } from '../validate-stage.ts'; +import type { DeployInput, DeployResult } from './deploy.ts'; +import type { DestroyEvent, DestroyInput, DestroyResult } from './destroy.ts'; +import type { ExtensionId, OperationDeps, OperationFailure } from './shared.ts'; + +const ALCHEMY_STATE_DIR = '.alchemy'; + +/** Destroy guardrail (moved from main.ts): true when `/.alchemy` is missing or empty — likely wrong directory or nothing deployed yet. */ +function hasNoLocalDeployState(cwd: string): boolean { + const stateDir = path.join(cwd, ALCHEMY_STATE_DIR); + return !(fs.existsSync(stateDir) && fs.readdirSync(stateDir).length > 0); +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +interface StackPipelineOptions { + readonly entry: string; + readonly name: string | undefined; + readonly stage: string | undefined; + readonly cwd: string; + readonly onEvent: ((event: DestroyEvent) => void) | undefined; + readonly deps: OperationDeps; +} + +export async function executeDeploy( + input: DeployInput, + deps: OperationDeps, + cwd: string, +): Promise { + const outcome = await runStackPipeline('deploy', { + entry: input.entry, + name: input.name, + stage: input.stage, + cwd, + onEvent: undefined, + deps, + }); + if (outcome.kind === 'failed') return { outcome: 'failed', failure: outcome.failure }; + return { outcome: 'deployed', summary: outcome.summary }; +} + +export async function executeDestroy( + input: DestroyInput, + deps: OperationDeps, + cwd: string, +): Promise { + const outcome = await runStackPipeline('destroy', { + entry: input.entry, + name: input.name, + stage: input.target.kind === 'stage' ? input.target.stage : undefined, + cwd, + onEvent: input.onEvent, + deps, + }); + if (outcome.kind === 'failed') return { outcome: 'failed', failure: outcome.failure }; + return { outcome: 'destroyed' }; +} + +type StackPipelineOutcome = + | { readonly kind: 'succeeded'; readonly summary: DeploymentSummary | undefined } + | { readonly kind: 'failed'; readonly failure: OperationFailure }; + +/** The pipeline both actions share: validate, resolve containers, preflight, + * write the stack file, run alchemy against it, then the destroy-only + * teardown/removal suffix. `summary` is only ever populated for deploy. */ +async function runStackPipeline( + action: 'deploy' | 'destroy', + opts: StackPipelineOptions, +): Promise { + const { entry, name, stage, cwd, onEvent, deps } = opts; + + if (stage !== undefined) { + try { + validateStageName(stage); + } catch (error) { + if (error instanceof CliError) { + return { + kind: 'failed', + failure: { kind: 'invalid-input', message: error.message, cause: error }, + }; + } + throw error; + } + } + + // Destroy-only guardrail — first, ahead of every other step, so it + // surfaces even when the rest of the pipeline goes on to fail for an + // unrelated reason (missing config, missing built output — both common + // companions of "nothing was ever deployed from here"). + if (action === 'destroy' && hasNoLocalDeployState(cwd)) { + onEvent?.({ kind: 'no-local-deploy-state', cwd }); + } + + let pipeline: PipelineResult; + let containers: Map; + let alchemyStage: string; + + try { + // The shared prefix (pipeline.ts): config discovery/load, entry load, + // Load, registry coverage, name resolution, assemble. + const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; + const onAssembleError = + action === 'destroy' + ? (error: Error): CliError => + new CliError( + `${error.message}\n\ndestroy evaluates the same stack program as deploy, which packages ` + + 'the built artifacts — so the app must be built first. Run the build, then retry the destroy.', + ) + : undefined; + pipeline = await runPipeline(entry, name, cwd, pipelineDeps, onAssembleError); + const { config, graph, name: resolvedName } = pipeline; + + // Resolve each extension's own container (e.g. Prisma Cloud's Project + + // named-stage Branch) via its own descriptor — deploy ensures (creates if + // absent), destroy locates only — after assembly succeeds, so a deploy + // that cannot assemble never creates anything on any platform. + containers = new Map(); + for (const extension of config.extensions) { + if (extension.container === undefined) continue; + try { + if (action === 'deploy') { + containers.set( + extension.id, + await extension.container.ensure({ appName: resolvedName, stage }), + ); + } else { + const instance = await extension.container.locate({ appName: resolvedName, stage }); + if (instance === undefined) { + throw new CliError( + `Nothing deployed for ${resolvedName}${stage !== undefined ? `/${stage}` : ''} — deploy it first.`, + ); + } + containers.set(extension.id, instance); + } + } catch (error) { + throw error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); + } + } + + // The Alchemy stage is never left to Alchemy's own default (`dev_$USER` + // — machine-dependent, the TML-3157 incident): the state-owning extension's + // container (same selection as core's resolveStateLayer) pins it, else an + // explicit --stage must. + const pinnedStage = containers.get(config.state.extension)?.alchemyStage ?? stage; + if (pinnedStage === undefined) { + // Reachable only for deploy without --stage, and destroy --production + // (destroy --stage always has a user stage) — so the remedy can be + // command-specific without a third branch. + throw new CliError( + 'The configured deploy target supplied no deploy scope (its container defines no ' + + 'alchemyStage), so Alchemy has no stage to run under. ' + + (action === 'deploy' + ? 'Pass --stage to choose the deploy scope explicitly.' + : 'destroy --production needs a target whose container supplies the production ' + + 'deploy scope.'), + ); + } + alchemyStage = pinnedStage; + + // Preflight (deploy only): each extension verifies its platform + // prerequisites — e.g. that every secret env var in the provision manifest + // exists for the resolved stage (ADR-0029) — BEFORE any stack file is written + // or Alchemy runs, so a missing secret fails fast with nothing side-effected. + if (action === 'deploy') { + for (const extension of config.extensions) { + if (extension.preflight === undefined) continue; + try { + await extension.preflight({ graph, container: containers.get(extension.id), stage }); + } catch (error) { + throw error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); + } + } + } + } catch (error) { + return { + kind: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } + + // Generate .prisma-composer/alchemy.run.ts (tool state lives where you run + // the tool). Inside the try: a stray `.prisma-composer` FILE, a read-only or + // full disk, or a permissions problem must come back as a failure result — + // "failures are values" covers stack generation too, not just the pipeline. + // + // The result file's name is unique per run, so a summary is only ever read + // from THIS child's report hook: concurrent runs sharing a cwd (two stages + // deployed from one checkout) cannot read or delete each other's file. + let stackPath: string; + const resultFilePath = path.join( + cwd, + '.prisma-composer', + `deployment-result-${String(process.pid)}-${randomUUID()}.json`, + ); + // Every return below funnels through the finally: the child can write the + // result file via the report hook before a later step fails, so failure + // paths must remove it too — otherwise each failed run leaves resource + // ids/URLs on disk and the files accumulate. + try { + try { + stackPath = writeStackFile({ + entryPath: pipeline.entryModule.path, + cwd, + configPath: pipeline.configPath, + name: pipeline.name, + assembled: pipeline.assembled, + }); + } catch (error) { + return { + kind: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } + + const reproduceCommand = `alchemy ${action} ${GENERATED_STACK_RELATIVE_PATH} --yes --stage ${alchemyStage}`; + + // Shell out to alchemy against the generated file. + let status: number; + try { + status = (deps.alchemy ?? runAlchemy)({ + command: action, + stackFileRelativePath: GENERATED_STACK_RELATIVE_PATH, + cwd, + stage: alchemyStage, + containerEnv: containerEnv(containers), + env: { ...process.env, [DEPLOYMENT_RESULT_FILE_ENV]: resultFilePath }, + }); + } catch (error) { + return { + kind: 'failed', + failure: { + kind: 'execution', + message: failureMessage(error), + cause: error, + diagnostics: { exitCode: undefined, stackFilePath: stackPath, reproduceCommand, cwd }, + }, + }; + } + if (status !== 0) { + return { + kind: 'failed', + failure: { + kind: 'execution', + message: `alchemy ${action} exited with status ${status}.`, + diagnostics: { exitCode: status, stackFilePath: stackPath, reproduceCommand, cwd }, + }, + }; + } + + try { + // Teardown (destroy only): each extension removes infrastructure it + // owns outside the stack — the destroy above may still have been reading + // it, and the containers below may refuse to go while it exists. What that + // infrastructure is, and whether losing it should fail the command, is the + // extension's business, not this module's. + if (action === 'destroy') { + for (const extension of pipeline.config.extensions) { + if (extension.teardown === undefined) continue; + try { + await extension.teardown({ container: containers.get(extension.id), stage }); + } catch (error) { + throw error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); + } + } + + // Container removal (destroy only, after every teardown): the CLI's + // two-loop order — all teardowns, then all removes — is what structurally + // preserves ADR-0034's guarantee that a stage's state database is deleted + // before its Branch (a Branch with an attached database refuses deletion). + for (const extension of pipeline.config.extensions) { + if (extension.container === undefined) continue; + const instance = containers.get(extension.id); + if (instance === undefined) continue; + try { + await extension.container.remove(instance); + } catch (error) { + throw error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); + } + } + } + } catch (error) { + return { + kind: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } + + if (action === 'deploy') { + return { kind: 'succeeded', summary: readDeploymentSummary(resultFilePath) }; + } + return { kind: 'succeeded', summary: undefined }; + } finally { + try { + fs.rmSync(resultFilePath, { force: true }); + } catch { + // Best-effort cleanup — never masks the result it wraps. + } + } +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts new file mode 100644 index 00000000..baa316d8 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -0,0 +1,300 @@ +/** + * The dev executor — run-dev.ts's pipeline (local-dev spec § 6) with console + * and signal handling removed: events out through `onEvent`, lifetime owned by + * the returned DevSession. The operation NEVER touches process signal + * handlers — the host does (see run-dev.ts). Reached only by lazy import + * from dev.ts — this module's static graph transitively loads alchemy's + * provider tree, so the control entry must never import it statically. + */ +import * as path from 'node:path'; +import type { ContainerInstance } from '@internal/core/config'; +import { containerEnv } from '@internal/core/config'; +import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/core/local-target'; +import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; +import { CliError } from '../cli-error.ts'; +import { DEV_STACK_RELATIVE_PATH, writeDevStackFile } from '../dev/generate-dev-stack.ts'; +import { startWatch, type WatchHandle, watchTargetsFrom } from '../dev/watch.ts'; +import { type PipelineDeps, runPipeline } from '../pipeline.ts'; +import { runAlchemy } from '../run-alchemy.ts'; +import type { DevEvent, DevInput, DevSession, DevStartResult } from './dev.ts'; +import { withEmulatorRetry } from './emulator-retry.ts'; +import type { ExtensionId, OperationDeps, ServiceEndpoint } from './shared.ts'; + +function toCliError(error: unknown): CliError { + return error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error), { cause: error }); +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function mergedEndpoints( + attachments: readonly LocalTargetAttachment[], +): Promise { + const lists = await Promise.all(attachments.map((a) => withEmulatorRetry(() => a.endpoints()))); + return lists.flat(); +} + +/** Runs the full dev pipeline; resolves to a running session or a structured failure. */ +export async function executeDev( + input: DevInput, + deps: OperationDeps, + cwd: string, +): Promise { + if (process.platform === 'win32') { + return { + outcome: 'failed', + failure: { + kind: 'unsupported-platform', + message: 'local dev is not supported on Windows yet.', + }, + }; + } + + const { onEvent } = input; + const devDir = path.join(cwd, DEV_DIR); + + let pipeline: Awaited>; + let resolved: ReadonlyMap; + const containers = new Map(); + + try { + // The shared prefix (pipeline.ts): config discovery/load, entry load, + // Load, registry coverage, name resolution, assemble. + const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; + pipeline = await runPipeline(input.entry, input.name, cwd, pipelineDeps); + const { config, graph, name } = pipeline; + + // Dev-capability check — resolve every non-build-only extension's lazy + // `localTarget` thunk ONCE (ADR-0041's lazy reference); its pinned error + // names any extension without local-target support, and build-only + // extensions are exempt inside it. Every subsequent hook call runs off + // this resolved map. + try { + resolved = await resolveLocalTargets(config); + } catch (error) { + throw toCliError(error); + } + + // Containers — purely local, resolved before anything else can fail. + for (const [id, dev] of resolved) { + try { + containers.set(id, await dev.container.ensure({ appName: name, stage: undefined })); + } catch (error) { + throw toCliError(error); + } + } + + // `--fresh`: teardown every participant's dev instance, then continue cold. + if (input.fresh === true) { + for (const [id, dev] of resolved) { + if (dev.teardown === undefined) continue; + try { + await dev.teardown({ container: containers.get(id), stage: undefined }); + } catch (error) { + throw toCliError(error); + } + } + } + + // Preflight — always (dev has no deploy/destroy split). + for (const [id, dev] of resolved) { + if (dev.preflight === undefined) continue; + try { + await dev.preflight({ graph, container: containers.get(id), stage: undefined }); + } catch (error) { + throw toCliError(error); + } + } + + // Emulators — ensure the daemons this topology's node kinds need. + for (const [id, dev] of resolved) { + if (dev.emulators === undefined) continue; + try { + await dev.emulators({ graph, container: containers.get(id), devDir }); + } catch (error) { + throw toCliError(error); + } + } + } catch (error) { + return { + outcome: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } + + const reproduceCommand = `alchemy deploy ${DEV_STACK_RELATIVE_PATH} --yes --stage dev`; + + const converge = (): { status: number; stackPath: string } => { + const stackPath = writeDevStackFile({ + entryPath: pipeline.entryModule.path, + cwd, + configPath: pipeline.configPath, + name: pipeline.name, + assembled: pipeline.assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + return { status, stackPath }; + }; + + // Write the dev stack file and converge. Inside the try: a stray + // `.prisma-composer` FILE, a full disk, or a spawn that throws must come + // back as a failure result, not a rejection out of dev(). + let first: { status: number; stackPath: string }; + try { + first = converge(); + } catch (error) { + return { + outcome: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } + if (first.status !== 0) { + return { + outcome: 'failed', + failure: { + kind: 'execution', + message: `alchemy deploy exited with status ${first.status}.`, + diagnostics: { + exitCode: first.status, + stackFilePath: first.stackPath, + reproduceCommand, + cwd, + }, + }, + }; + } + + // A host onEvent that throws is the host's bug, but it must not kill the + // session's own control flow — above all it must never prevent `closed` + // from settling, and a throw inside the fire-and-forget watch callback + // would be an unhandled rejection killing the process. + const emit = (event: DevEvent): void => { + try { + onEvent?.(event); + } catch { + // Swallowed: rendering is the host's; its failures are not the session's. + } + }; + + // Attach: start every stopped service (session resume — a no-op converge + // cannot restart what a previous session's Ctrl-C stopped), then report the + // front door. On ANY failure before the session is handed over — a partial + // startServices, the endpoint merge, watch setup — put the machine back the + // way a previous Ctrl-C left it: stop the watcher (if it started) and every + // service that started, THEN return the failure. A session that never began + // must not leave anything half-running. + const attachments: LocalTargetAttachment[] = []; + const started: LocalTargetAttachment[] = []; + let watch: WatchHandle | undefined; + try { + for (const [id, dev] of resolved) { + attachments.push(await dev.attach({ container: containers.get(id), devDir })); + } + for (const attachment of attachments) { + try { + await withEmulatorRetry(() => attachment.startServices()); + started.push(attachment); + } catch (error) { + throw toCliError(error); + } + } + const endpoints = await mergedEndpoints(attachments); + emit({ kind: 'ready', endpoints }); + + // Watch loop until the session is stopped: rebuild → re-assemble → + // re-converge; a converge failure keeps the running app and keeps watching. + const { targets, unwatchable } = watchTargetsFrom(pipeline.assembled.bundles); + for (const address of unwatchable) { + emit({ kind: 'unwatchable', address }); + } + + const watchDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; + watch = startWatch( + targets, + () => { + // The whole rebuild is inside one try/catch: this runs fire-and-forget, + // so anything escaping it would be an unhandled rejection killing the + // process — the exact opposite of "a converge failure keeps the running + // app and keeps watching". + void (async () => { + try { + const rePipeline = await runPipeline(input.entry, input.name, cwd, watchDeps); + const stackPath = writeDevStackFile({ + entryPath: rePipeline.entryModule.path, + cwd, + configPath: rePipeline.configPath, + name: rePipeline.name, + assembled: rePipeline.assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + if (status !== 0) { + emit({ kind: 'converge-failed', stackFilePath: stackPath, reproduceCommand, cwd }); + return; + } + emit({ kind: 'ready', endpoints: await mergedEndpoints(attachments) }); + } catch (error) { + emit({ kind: 'rebuild-failed', message: failureMessage(error) }); + } + })(); + }, + (error) => emit({ kind: 'watch-error', message: failureMessage(error) }), + ); + // A rebuild finishing before the OS-level watches attach would otherwise + // be missed entirely — wait until watching is real before handing over. + const startedWatch = watch; + await watch.ready; + + let stopping = false; + let resolveClosed: () => void = () => undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + + const stop = (): Promise => { + if (!stopping) { + stopping = true; + emit({ kind: 'stopping' }); + startedWatch.stop(); + void (async () => { + // A service that refuses to stop is surfaced, not swallowed — + // teardown continues, `stopped` still fires, `closed` still settles. + for (const attachment of attachments) { + try { + await attachment.stopServices(); + } catch (error) { + emit({ kind: 'stop-error', message: failureMessage(error) }); + } + } + emit({ kind: 'stopped' }); + resolveClosed(); + })(); + } + return closed; + }; + + const session: DevSession = { endpoints, stop, closed }; + return { outcome: 'started', session }; + } catch (error) { + watch?.stop(); + await Promise.all(started.map((a) => a.stopServices().catch(() => undefined))); + return { + outcome: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts new file mode 100644 index 00000000..501c32e7 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts @@ -0,0 +1,209 @@ +/** + * The log executor — run-log.ts's attach-and-tail (config → localTarget → + * container → attach → logs) with console and signal handling removed: the + * merged stream comes back as an AsyncIterable, ended by the caller's + * AbortSignal. Reached only by lazy import from log.ts — this module's + * static graph transitively loads alchemy's provider tree, so the control + * entry must never import it statically. + */ +import * as path from 'node:path'; +import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/core/local-target'; +import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; +import { CliError } from '../cli-error.ts'; +import { resolveAppIdentity } from '../pipeline.ts'; +import { withEmulatorRetry } from './emulator-retry.ts'; +import type { LogDeps, LogEvent, LogInput, LogLine, LogResult } from './log.ts'; + +function toCliError(error: unknown): CliError { + return error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error), { cause: error }); +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** The merge queue's bound: past this, the oldest line is dropped and the + * consumer is told via a `lines-dropped` event — a log viewer tolerates loss + * better than the host tolerates unbounded memory growth. Exported so tests + * can size their floods relative to the bound. */ +export const LOG_QUEUE_LIMIT = 10_000; + +/** + * Merges every attachment's log stream into one iterable: one pump per + * attachment pushing into a shared bounded queue. A pump's throw becomes a + * `stream-failed` event and ends that pump only; the merged iterable ends + * when `input.signal` aborts, when every pump ends, or when the consumer + * stops iterating (an early `break` aborts an internal controller so the + * pumps tear down — the generator never waits on a source that will not + * end). No event is delivered after the iterable has ended. Address + * filtering and `tail` apply exactly as the CLI always has. + */ +async function* mergeLogStreams( + attachments: readonly LocalTargetAttachment[], + input: LogInput, +): AsyncGenerator { + // Internal controller linked to the caller's signal: the caller's abort + // propagates in, and the generator's own end (early break/return) aborts it + // too, so the pumps always have a signal that CAN fire. + const controller = new AbortController(); + const { signal } = controller; + const abortInternal = (): void => controller.abort(); + if (input.signal?.aborted === true) controller.abort(); + input.signal?.addEventListener('abort', abortInternal, { once: true }); + + const queue: LogLine[] = []; + let dropped = 0; + let done = false; + let active = attachments.length; + let wake: (() => void) | undefined; + const notify = (): void => { + wake?.(); + wake = undefined; + }; + signal.addEventListener('abort', notify, { once: true }); + + const emit = (event: LogEvent): void => { + if (done) return; + try { + input.onEvent?.(event); + } catch { + // A throwing host callback must not reject a fire-and-forget pump or + // tear down the merged iterable — events are advisory. + } + }; + + // The pumps are fire-and-forget by design: they never reject (fully + // caught), and the generator's finally aborts rather than awaits them. + const pump = async (attachment: LocalTargetAttachment): Promise => { + try { + for await (const { service, line } of attachment.logs(signal, { + tail: input.tail ?? 0, + })) { + if (signal.aborted) return; + if (input.address !== undefined && service !== input.address) continue; + if (queue.length >= LOG_QUEUE_LIMIT) { + queue.shift(); + dropped += 1; + } + queue.push({ service, line }); + notify(); + } + } catch (error) { + if (!signal.aborted) { + emit({ kind: 'stream-failed', message: failureMessage(error) }); + } + } finally { + active -= 1; + notify(); + } + }; + for (const attachment of attachments) void pump(attachment); + + try { + while (true) { + let next = queue.shift(); + while (next !== undefined) { + if (dropped > 0) { + const count = dropped; + dropped = 0; + emit({ kind: 'lines-dropped', count }); + } + yield next; + next = queue.shift(); + } + if (signal.aborted || active === 0) break; + await new Promise((resolve) => { + wake = resolve; + }); + } + } finally { + // End-of-life, whichever way it came (abort, sources ended, consumer + // broke out): mark the stream done so no event lands in torn-down host + // state, abort the pumps, and DON'T await them — a source that ignores + // the signal must not wedge the consumer's `break`. + done = true; + controller.abort(); + signal.removeEventListener('abort', notify); + input.signal?.removeEventListener('abort', abortInternal); + } +} + +/** Resolves the running app and attaches to its log streams; the caller consumes `lines`. */ +export async function executeLog(input: LogInput, deps: LogDeps, cwd: string): Promise { + if (process.platform === 'win32') { + return { + outcome: 'failed', + failure: { + kind: 'unsupported-platform', + message: 'local dev is not supported on Windows yet.', + }, + }; + } + + const devDir = path.join(cwd, DEV_DIR); + + let name: string; + const attachments: LocalTargetAttachment[] = []; + let services: readonly { readonly address: string; readonly url: string }[]; + + try { + const identity = + deps.identity ?? + (await resolveAppIdentity(input.entry, input.name, cwd, { config: deps.config })); + name = identity.name; + + let resolved: ReadonlyMap; + try { + resolved = await resolveLocalTargets(identity.config); + } catch (error) { + throw toCliError(error); + } + + for (const target of resolved.values()) { + try { + const container = await target.container.ensure({ appName: name, stage: undefined }); + attachments.push(await withEmulatorRetry(() => target.attach({ container, devDir }))); + } catch (error) { + throw toCliError(error); + } + } + + services = ( + await Promise.all(attachments.map((a) => withEmulatorRetry(() => a.endpoints()))) + ).flat(); + } catch (error) { + return { + outcome: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } + + if (services.length === 0) { + return { + outcome: 'attached', + appName: name, + services: [], + lines: mergeLogStreams([], input), + }; + } + if (input.address !== undefined && !services.some((s) => s.address === input.address)) { + return { + outcome: 'failed', + failure: { + kind: 'invalid-input', + message: `no service "${input.address}" in "${name}" — running services: ${services + .map((s) => s.address) + .join(', ')}.`, + }, + }; + } + + return { + outcome: 'attached', + appName: name, + services, + lines: mergeLogStreams(attachments, input), + }; +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/log.ts b/packages/0-framework/3-tooling/cli/src/operations/log.ts new file mode 100644 index 00000000..9d26f282 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/log.ts @@ -0,0 +1,77 @@ +/** + * The programmatic `log` operation (`@prisma/composer/control`): typed input, + * the merged stream back as an AsyncIterable ended by the caller's + * AbortSignal — no argv, no console, no process.exit. The executor loads + * lazily, so importing this module executes nothing; an executor that fails + * to load comes back as a structured `pipeline` failure, never a throw out + * of the host. + */ +import type { PrismaAppConfig } from '@internal/core/config'; +import type { AppIdentity } from '../pipeline.ts'; +import { executorLoadFailure, type OperationFailure, type ServiceEndpoint } from './shared.ts'; + +export interface LogLine { + readonly service: string; + readonly line: string; +} + +export type LogEvent = + /** One attachment's stream died; the others continue. */ + | { readonly kind: 'stream-failed'; readonly message: string } + /** The consumer fell behind and the bounded merge queue overflowed: + * `count` oldest lines were dropped since the last delivered line. */ + | { readonly kind: 'lines-dropped'; readonly count: number }; + +/** The log operation's in-package injection seam (the CLI's LogRunDeps, unit + * tests) — threaded through logWithDeps, never part of the published surface. */ +export interface LogDeps { + /** Substituted for the c12 evaluation of the discovered config file (discovery still runs). */ + readonly config?: PrismaAppConfig | undefined; + /** Overrides the identity resolution (config + name) — lets tests skip a real entry module. */ + readonly identity?: AppIdentity | undefined; +} + +export interface LogInput { + readonly entry: string; + readonly name?: string | undefined; + /** Restrict to one service's dotted address; validated against running services. */ + readonly address?: string | undefined; + /** Trailing history lines before live output. Defaults to 0 (live only) — + * the attachment contract's default; the CLI's user-facing default of 20 stays in main.ts. */ + readonly tail?: number | undefined; + readonly cwd?: string | undefined; + /** Ends the stream when aborted. The host owns SIGINT/SIGTERM → abort. */ + readonly signal?: AbortSignal | undefined; + readonly onEvent?: ((event: LogEvent) => void) | undefined; +} + +export type LogResult = + | { + readonly outcome: 'attached'; + /** For the adapter's empty-services notice. */ + readonly appName: string; + /** Every running service. EMPTY means nothing is running — a valid, non-failure state; + * `lines` is then an already-finished iterable. */ + readonly services: readonly ServiceEndpoint[]; + /** Merged, address-filtered stream; ends on signal abort or when every source ends. */ + readonly lines: AsyncIterable; + } + | { readonly outcome: 'failed'; readonly failure: OperationFailure }; + +export async function log(input: LogInput): Promise { + return logWithDeps(input, {}); +} + +/** In-package variant threading the injection seam (the CLI's LogRunDeps, + * unit tests). Deliberately NOT re-exported through `./control` — the seam + * mirrors internal types and is not part of the published surface. */ +export async function logWithDeps(input: LogInput, deps: LogDeps): Promise { + const cwd = input.cwd ?? process.cwd(); + let executor: typeof import('./execute-log.ts'); + try { + executor = await import('./execute-log.ts'); + } catch (error) { + return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; + } + return executor.executeLog(input, deps, cwd); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts new file mode 100644 index 00000000..fcd4f5bb --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -0,0 +1,85 @@ +/** + * What every operation module shares: the injectable deps seam, the failure + * union, and the executor-load diagnosis. Import-light — the per-operation + * modules (deploy/destroy/dev/log) stay cheap to import because this is all + * they pull in statically. + */ +import type { RunAssembler } from '@internal/assemble'; +import type { PrismaAppConfig } from '@internal/core/config'; +import { checkEffectResolution } from '../check-effect-resolution.ts'; +import { CliError } from '../cli-error.ts'; +import type { RunAlchemyInput } from '../run-alchemy.ts'; + +/** The `id` of an ExtensionDescriptor — what keys the executors' per-extension maps. */ +export type ExtensionId = string; + +/** A running service's dotted address plus its local URL — what `dev` reports + * as the front door and `log` reports as the tailable services. */ +export interface ServiceEndpoint { + readonly address: string; + readonly url: string; +} + +/** + * The operations' in-package injection seam — lets the CLI's own tests drive + * them without a real wrapper build, config evaluation, or alchemy process. + * Threaded through the *WithDeps variants, never part of the published + * surface: the fields mirror internal types. + */ +export interface OperationDeps { + readonly runAssembler?: RunAssembler | undefined; + readonly alchemy?: ((input: RunAlchemyInput) => number) | undefined; + readonly config?: PrismaAppConfig | undefined; +} + +/** + * Where a failed execution left its artifacts — details of the CURRENT + * execution mechanism (a spawned deploy-engine child driving a generated + * stack file), for hosts that want to print a reproduce hint. The mechanism + * is not part of the surface's contract, so these fields may change or + * disappear if the mechanism does; branch on `message`/`cause` for anything + * durable. + */ +export interface ExecutionDiagnostics { + /** The child's exit status; undefined means the spawn itself threw. */ + readonly exitCode: number | undefined; + readonly stackFilePath: string; + readonly reproduceCommand: string; + readonly cwd: string; +} + +/** Why an operation did not complete. `message` is the same fix-naming text the + * CLI prints today; `cause` is the original thrown error. */ +export type OperationFailure = + /** A typed input was rejected (invalid --stage ref name, unknown log address). */ + | { readonly kind: 'invalid-input'; readonly message: string; readonly cause?: unknown } + /** The host platform cannot run this operation (dev/log on win32). */ + | { readonly kind: 'unsupported-platform'; readonly message: string; readonly cause?: unknown } + /** Any failure between loading the execution stack and the alchemy spawn: + * a dependency tree the executor cannot load in, missing config, bad entry + * export, LoadError, coverage miss, assemble, container, extension preflight. + * (Finer-grained diagnostics are the next slice.) */ + | { readonly kind: 'pipeline'; readonly message: string; readonly cause?: unknown } + /** The deploy engine ran and failed. */ + | { + readonly kind: 'execution'; + readonly message: string; + readonly cause?: unknown; + readonly diagnostics?: ExecutionDiagnostics | undefined; + }; + +/** Diagnoses a failed executor import: when the app's tree resolves a + * mismatched `effect` (the known way that import breaks), the failure carries + * the fix-naming message from checkEffectResolution; otherwise the original + * error's own message. */ +export function executorLoadFailure(error: unknown, cwd: string): OperationFailure { + try { + checkEffectResolution(cwd); + } catch (diagnostic) { + if (diagnostic instanceof CliError) { + return { kind: 'pipeline', message: diagnostic.message, cause: error }; + } + } + const message = error instanceof Error ? error.message : String(error); + return { kind: 'pipeline', message, cause: error }; +} diff --git a/packages/0-framework/3-tooling/cli/src/render-deployment.ts b/packages/0-framework/3-tooling/cli/src/render-deployment.ts index 11439480..9e8b81dd 100644 --- a/packages/0-framework/3-tooling/cli/src/render-deployment.ts +++ b/packages/0-framework/3-tooling/cli/src/render-deployment.ts @@ -8,6 +8,7 @@ * for presence but never for truth. */ import type { DeployedEntity, DeployedNode, DeploymentResult } from '@internal/core/deploy'; +import { writeDeploymentSummaryFile } from './deployment-summary.ts'; /** Gap between the deepest tree label and the entity column. */ const LABEL_GAP = 3; @@ -116,9 +117,11 @@ export function renderDeployment(result: DeploymentResult): string { /** * The report hook the generated stack file wires into `LowerOptions`. Prints a - * leading blank line so the summary separates from alchemy's own apply output. + * leading blank line so the summary separates from alchemy's own apply output, + * then hands the result to the cross-process writer (deployment-summary.ts). */ export function deploymentReport(result: DeploymentResult): void { console.log(''); console.log(renderDeployment(result)); + writeDeploymentSummaryFile(result); } diff --git a/packages/0-framework/3-tooling/cli/tsdown.config.ts b/packages/0-framework/3-tooling/cli/tsdown.config.ts index 73c855f0..bda11c22 100644 --- a/packages/0-framework/3-tooling/cli/tsdown.config.ts +++ b/packages/0-framework/3-tooling/cli/tsdown.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ index: 'src/exports/index.ts', bin: 'src/bin.ts', report: 'src/exports/render-deployment.ts', + control: 'src/exports/control.ts', }, exports: typeof baseConfig.exports === 'object' diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 203dd430..580715ee 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -9,6 +9,7 @@ "exports": { ".": "./dist/index.mjs", "./config": "./dist/config.mjs", + "./control": "./dist/control.mjs", "./deploy": "./dist/deploy.mjs", "./local-target": "./dist/local-target.mjs", "./report": "./dist/report.mjs", diff --git a/packages/9-public/composer/src/exports/control.ts b/packages/9-public/composer/src/exports/control.ts new file mode 100644 index 00000000..750dd3fe --- /dev/null +++ b/packages/9-public/composer/src/exports/control.ts @@ -0,0 +1 @@ +export * from '@internal/cli/control'; diff --git a/packages/9-public/composer/tsdown.config.ts b/packages/9-public/composer/tsdown.config.ts index 070d9f5f..3f2a8c17 100644 --- a/packages/9-public/composer/tsdown.config.ts +++ b/packages/9-public/composer/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig([ entry: { index: 'src/exports/index.ts', config: 'src/exports/config.ts', + control: 'src/exports/control.ts', deploy: 'src/exports/deploy.ts', 'local-target': 'src/exports/local-target.ts', report: 'src/exports/report.ts', diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 6967fed7..95d67c02 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -667,6 +667,40 @@ the silent `undefined`. Only reachable if you authored the connection or the extension on one side — every shipped block supplies what it declares. +### Driving deploys from code + +`@prisma/composer/control` exposes the CLI's operations in-process: typed +`deploy`, `destroy`, `dev`, and `log` returning structured results — no argv, +no console output, no exit codes. The CLI itself is a renderer over them. + +```ts +import { deploy } from '@prisma/composer/control'; +const result = await deploy({ entry: 'module.ts', stage: 'pr-42' }); +// result: { outcome: 'deployed', summary? } | { outcome: 'failed', failure } +``` + +- Failures come back as `{ outcome: 'failed', failure }` with + `failure.kind` ∈ `invalid-input` | `unsupported-platform` | `pipeline` | `execution` + and the same fix-naming `message` the CLI prints. An `execution` failure's + optional `diagnostics` (exit code, reproduce command) describes the current + execution mechanism — branch on `message`/`cause` for anything durable. + The effect version conflict is a `pipeline` failure carrying the same + diagnostic, and importing the module executes nothing until an operation + runs. +- `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }` + — explicit, never defaulted. +- `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on + a successful deploy is normal. +- The deploy engine's live output still streams to the host process's stdio — + the current mechanism; the operations don't capture it. +- `dev` resolves to `{ outcome: 'started', session }` or a failure; the + session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and + the host owns signal handling. `log` resolves to + `{ outcome: 'attached', appName, services, lines }` or a failure, where + `lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by + the consumer stopping early); zero running services is a valid result, not + an error. + ## Production pitfalls - **Scale-to-zero closes idle database connections.** A persistent client diff --git a/test/integration/test/control.deploy.test.ts b/test/integration/test/control.deploy.test.ts new file mode 100644 index 00000000..54136332 --- /dev/null +++ b/test/integration/test/control.deploy.test.ts @@ -0,0 +1,32 @@ +/** + * The slice's done-condition (TML-3174): a consumer OUTSIDE the CLI drives + * deploy end to end through `@prisma/composer/control` — real config + * discovery, real `/control` extension resolution, real assemble — without + * touching argv, console capture, or exit codes. The fixture app has no built + * output, so the pipeline fails structurally at the same terminal point the + * binary test (cli.extension-config.test.ts) pins on stderr. + */ +import { describe, expect, test } from 'bun:test'; +import * as path from 'node:path'; +import { deploy } from '@prisma/composer/control'; + +const integrationDir = path.resolve(import.meta.dir, '..'); +const fixtureEntry = path.join( + integrationDir, + 'test', + 'fixtures', + 'extension-config', + 'service.ts', +); + +describe('@prisma/composer/control — programmatic deploy over the real extension config', () => { + test('resolves both /control entries for real and fails structurally at the missing built entry, not at resolution', async () => { + const result = await deploy({ entry: fixtureEntry, cwd: integrationDir }); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('pipeline'); + expect(result.failure.message).toContain('no built entry at'); + expect(result.failure.message).toContain('run your build first'); + }, 30_000); +}); diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index 81ccd68d..ac95201a 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -40,6 +40,7 @@ "@internal/cli/report": [ "./packages/0-framework/3-tooling/cli/src/exports/render-deployment.ts" ], + "@internal/cli/control": ["./packages/0-framework/3-tooling/cli/src/exports/control.ts"], "@internal/cli": ["./packages/0-framework/3-tooling/cli/src/exports/index.ts"], "@internal/lowering/postgres": [ "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts" @@ -153,6 +154,7 @@ "./packages/9-public/composer-prisma-cloud/src/exports/index.ts" ], "@prisma/composer/report": ["./packages/9-public/composer/src/exports/report.ts"], + "@prisma/composer/control": ["./packages/9-public/composer/src/exports/control.ts"], "@prisma/composer": ["./packages/9-public/composer/src/exports/index.ts"] } }