From accfba128bbaf2fe5ea9eec471d32cd60b30d7a2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 17:53:42 +0200 Subject: [PATCH 01/27] feat(cli): project a deployment result into a serializable summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploymentReport now also writes a JSON DeploymentSummary — the serializable projection of DeploymentResult (address + entities, no in-process node) — to the file named by PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE when that env var is set. The printed report is unchanged, and nothing is written without the env var, so the generated stack file stays byte-identical. This is the writer half of the cross-process result contract the programmatic deploy operation reads back (TML-3174 design §3.4). Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/render-deployment.test.ts | 91 ++++++++++++++++++- .../3-tooling/cli/src/render-deployment.ts | 29 ++++++ 2 files changed, 118 insertions(+), 2 deletions(-) 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..248b9306 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,7 +1,15 @@ -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 { deploymentReport, renderDeployment } from '../render-deployment.ts'; +import { + DEPLOYMENT_RESULT_FILE_ENV, + deploymentReport, + renderDeployment, + toDeploymentSummary, +} from '../render-deployment.ts'; /** * The renderer reads only `address` and `entities` — `node` is along for the @@ -165,8 +173,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 +232,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/render-deployment.ts b/packages/0-framework/3-tooling/cli/src/render-deployment.ts index 11439480..7c4a405b 100644 --- a/packages/0-framework/3-tooling/cli/src/render-deployment.ts +++ b/packages/0-framework/3-tooling/cli/src/render-deployment.ts @@ -7,8 +7,33 @@ * resolved; nothing here is scraped from a node's outputs, which are checked * for presence but never for truth. */ +import * as fs from 'node:fs'; import type { DeployedEntity, DeployedNode, DeploymentResult } from '@internal/core/deploy'; +/** Env var the deploy operation sets on the alchemy child: when present, + * deploymentReport 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 })), + }; +} + /** Gap between the deepest tree label and the entity column. */ const LABEL_GAP = 3; @@ -121,4 +146,8 @@ export function renderDeployment(result: DeploymentResult): string { export function deploymentReport(result: DeploymentResult): void { console.log(''); console.log(renderDeployment(result)); + const file = process.env[DEPLOYMENT_RESULT_FILE_ENV]; + if (file !== undefined && file.length > 0) { + fs.writeFileSync(file, JSON.stringify(toDeploymentSummary(result))); + } } From 97bdc1bb11519872ce31934a803aa3c270f454cd Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 17:58:50 +0200 Subject: [PATCH 02/27] refactor(cli): extract deploy/destroy into typed operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy() and destroy() are now programmatic operations (operations/): typed inputs, structured results, no argv, no console, no process.exit. The pipeline orchestration (main.ts steps 0-9.75) moved verbatim into execute-deploy-destroy.ts, reached only by dynamic import after a structured effect-resolution preflight (TML-3158) so the operations entry stays import-safe in a broken effect tree. main.ts run() becomes a thin renderer: flag combinations validated with the same CliError texts, the destroy no-state warning rendered from the operation event, alchemy-failure hints and passthrough exit codes unchanged. run.test.ts passes unmodified — the extraction proof. A successful deploy now also reads back the DeploymentSummary the alchemy child writes via PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE (absent or malformed file = undefined summary, never a failure). Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/dev/run-dev.ts | 6 +- .../0-framework/3-tooling/cli/src/main.ts | 261 ++++---------- .../src/operations/execute-deploy-destroy.ts | 339 ++++++++++++++++++ .../cli/src/operations/operations.ts | 49 +++ .../3-tooling/cli/src/operations/results.ts | 179 +++++++++ 5 files changed, 630 insertions(+), 204 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/operations.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/results.ts 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..8ed5e4f7 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 @@ -26,9 +26,9 @@ export interface DevArgs { /** 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; + readonly runAssembler?: RunAssembler | undefined; + readonly alchemy?: ((input: RunAlchemyInput) => number) | undefined; + readonly config?: PrismaAppConfig | undefined; } function toCliError(error: unknown): CliError { diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index 5d9e56f6..d963dce7 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -3,19 +3,12 @@ * 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 { deploy, destroy } from './operations/operations.ts'; +import type { DestroyTarget, OperationDeps, OperationFailure } from './operations/results.ts'; const BINARY_NAME = 'prisma-composer'; @@ -213,49 +206,29 @@ 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') { + console.error(`\nGenerated stack file: ${failure.stackFilePath}`); + if (failure.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 \`${failure.reproduceCommand}\` from ${failure.cwd} to reproduce this directly.`, ); + return failure.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; } + throw failure.cause instanceof Error ? failure.cause : new CliError(failure.message); } /** Runs the full pipeline; returns the process exit code. */ @@ -287,165 +260,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 deploy({ + 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)); + const target: DestroyTarget = + args.stage !== undefined ? { kind: 'stage', stage: args.stage } : { kind: 'production' }; + + const result = await destroy({ + 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.', + ); } - } - } - - // 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, + }, + deps, }); - - // 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)); - } - } - } - - return status; - } catch (error) { - console.error(`\nGenerated stack file: ${stackPath}`); - throw error; - } + if (result.outcome === 'destroyed') return 0; + return renderDeployDestroyFailure(result.failure); } 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..d78becd9 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -0,0 +1,339 @@ +/** + * The deploy/destroy executor — main.ts's pipeline orchestration (steps 0–9.75) + * with argv, console, and exit codes removed: typed inputs in, structured + * results out. Reached only by dynamic import from operations.ts, after the + * effect-resolution preflight — this module's static graph transitively loads + * alchemy's provider tree. + */ +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 { blindCast } from '@internal/foundation/casts'; +import { CliError } from '../cli-error.ts'; +import { GENERATED_STACK_RELATIVE_PATH, writeStackFile } from '../generate-stack.ts'; +import { type PipelineDeps, type PipelineResult, runPipeline } from '../pipeline.ts'; +import { DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary } from '../render-deployment.ts'; +import { runAlchemy } from '../run-alchemy.ts'; +import { validateStageName } from '../validate-stage.ts'; +import type { + DeployInput, + DeployResult, + DestroyEvent, + DestroyInput, + DestroyResult, + OperationDeps, + OperationFailure, +} from './results.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); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Reads the alchemy child's result file (written by deploymentReport when + * DEPLOYMENT_RESULT_FILE_ENV is set). 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); +} + +interface ExecuteOptions { + readonly entry: string; + readonly name: string | undefined; + readonly stage: string | undefined; + readonly cwd: string; + readonly onEvent: ((event: DestroyEvent) => void) | undefined; + readonly deps: OperationDeps | undefined; +} + +export async function executeDeploy(input: DeployInput, cwd: string): Promise { + const outcome = await executeDeployOrDestroy('deploy', { + entry: input.entry, + name: input.name, + stage: input.stage, + cwd, + onEvent: undefined, + deps: input.deps, + }); + if (outcome.failure !== undefined) return { outcome: 'failed', failure: outcome.failure }; + return { outcome: 'deployed', summary: outcome.summary }; +} + +export async function executeDestroy(input: DestroyInput, cwd: string): Promise { + const outcome = await executeDeployOrDestroy('destroy', { + entry: input.entry, + name: input.name, + stage: input.target.kind === 'stage' ? input.target.stage : undefined, + cwd, + onEvent: input.onEvent, + deps: input.deps, + }); + if (outcome.failure !== undefined) return { outcome: 'failed', failure: outcome.failure }; + return { outcome: 'destroyed' }; +} + +interface ExecuteOutcome { + readonly failure?: OperationFailure | undefined; + readonly summary?: DeploymentSummary | undefined; +} + +async function executeDeployOrDestroy( + action: 'deploy' | 'destroy', + opts: ExecuteOptions, +): Promise { + const { entry, name, stage, cwd, onEvent, deps } = opts; + + if (stage !== undefined) { + try { + validateStageName(stage); + } catch (error) { + if (error instanceof CliError) { + return { failure: { kind: 'invalid-input', message: error.message, cause: error } }; + } + throw error; + } + } + + // 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 (action === 'destroy' && hasNoLocalDeployState(cwd)) { + onEvent?.({ kind: 'no-local-deploy-state', cwd }); + } + + let pipeline: PipelineResult; + let containers: Map; + let alchemyStage: string; + + try { + // 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 = + 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; + + // 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. + 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)); + } + } + + // 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 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; + + // 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 (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 { failure: { kind: 'pipeline', message: failureMessage(error), cause: error } }; + } + + // 8. Generate .prisma-composer/alchemy.run.ts (tool state lives where you run the tool). + const stackPath = writeStackFile({ + entryPath: pipeline.entryModule.path, + cwd, + configPath: pipeline.configPath, + name: pipeline.name, + assembled: pipeline.assembled, + }); + + const reproduceCommand = `alchemy ${action} ${GENERATED_STACK_RELATIVE_PATH} --yes --stage ${alchemyStage}`; + + // Stale-result guard: remove any previous run's result file so a summary is + // only ever read from THIS child's report hook. + const resultFilePath = path.join(cwd, '.prisma-composer', 'deployment-result.json'); + fs.rmSync(resultFilePath, { force: true }); + + // 9. 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 { + failure: { + kind: 'execution', + message: failureMessage(error), + exitCode: undefined, + stackFilePath: stackPath, + reproduceCommand, + cwd, + cause: error, + }, + }; + } + if (status !== 0) { + return { + failure: { + kind: 'execution', + message: `alchemy ${action} exited with status ${status}.`, + exitCode: status, + stackFilePath: stackPath, + reproduceCommand, + cwd, + }, + }; + } + + try { + // 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 (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)); + } + } + + // 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 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 { failure: { kind: 'pipeline', message: failureMessage(error), cause: error } }; + } + + if (action === 'deploy') { + return { summary: readDeploymentSummary(resultFilePath) }; + } + return {}; +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/operations.ts b/packages/0-framework/3-tooling/cli/src/operations/operations.ts new file mode 100644 index 00000000..0a651499 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/operations.ts @@ -0,0 +1,49 @@ +/** + * The programmatic control surface over the deploy pipeline — @internal/assemble's + * second consumer (deploy-cli.md § Contracts). Typed inputs, structured results, + * no argv, no console, no process.exit. The prisma-composer CLI (main.ts) is a + * thin renderer over these operations. + * + * Crash safety (TML-3158, mirrors bin.ts): this module's STATIC graph must stay + * free of the alchemy-touching tree — a mismatched `effect` crashes that tree at + * import time. Each operation runs checkEffectResolution() first and only then + * dynamically imports its executor. + */ +import { checkEffectResolution } from '../check-effect-resolution.ts'; +import { CliError } from '../cli-error.ts'; +import type { + DeployInput, + DeployResult, + DestroyInput, + DestroyResult, + OperationFailure, +} from './results.ts'; + +/** Structured form of bin.ts's preflight: a mismatched tree is a result, not a crash. */ +function runEffectPreflight(cwd: string): OperationFailure | undefined { + try { + checkEffectResolution(cwd); + return undefined; + } catch (error) { + if (error instanceof CliError) { + return { kind: 'effect-resolution', message: error.message, cause: error }; + } + throw error; // a bug in the check itself, not a user-tree condition + } +} + +export async function deploy(input: DeployInput): Promise { + const cwd = input.cwd ?? process.cwd(); + const preflight = runEffectPreflight(cwd); + if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; + const { executeDeploy } = await import('./execute-deploy-destroy.ts'); + return executeDeploy(input, cwd); +} + +export async function destroy(input: DestroyInput): Promise { + const cwd = input.cwd ?? process.cwd(); + const preflight = runEffectPreflight(cwd); + if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; + const { executeDestroy } = await import('./execute-deploy-destroy.ts'); + return executeDestroy(input, cwd); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/results.ts b/packages/0-framework/3-tooling/cli/src/operations/results.ts new file mode 100644 index 00000000..2eb59705 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/results.ts @@ -0,0 +1,179 @@ +/** + * Typed inputs and structured results for the programmatic operations + * (`@prisma/composer/control`). Zero runtime imports from the heavy + * alchemy-touching tree — everything here is `import type`, erased in the + * build, so this module is import-safe in a broken effect tree (TML-3158). + */ +import type { RunAssembler } from '@internal/assemble'; +import type { PrismaAppConfig } from '@internal/core/config'; +import type { AppIdentity } from '../pipeline.ts'; +import type { DeploymentSummary } from '../render-deployment.ts'; +import type { RunAlchemyInput } from '../run-alchemy.ts'; + +/** The injectable seams every operation shares — identical to main.ts's RunDeps + * (which becomes a re-export alias of this type). */ +export interface OperationDeps { + readonly runAssembler?: RunAssembler | undefined; + readonly alchemy?: ((input: RunAlchemyInput) => number) | undefined; + readonly config?: PrismaAppConfig | undefined; +} + +/** 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 = + /** TML-3158: alchemy would resolve a mismatched `effect`; nothing was imported, nothing ran. */ + | { readonly kind: 'effect-resolution'; readonly message: string; readonly cause?: unknown } + /** 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'; readonly message: string; readonly cause?: unknown } + /** Any failure between config discovery and the alchemy spawn: 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 alchemy child ran and failed. `exitCode` undefined means the spawn itself threw. */ + | { + readonly kind: 'execution'; + readonly message: string; + readonly exitCode: number | undefined; + readonly stackFilePath: string; + readonly reproduceCommand: string; + readonly cwd: string; + readonly cause?: unknown; + }; + +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; + readonly deps?: OperationDeps | 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 }; + +/** 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; + readonly deps?: OperationDeps | undefined; +} + +export type DestroyResult = + | { readonly outcome: 'destroyed' } + | { readonly outcome: 'failed'; readonly failure: OperationFailure }; + +// ---- dev ---- + +export interface DevEndpoint { + readonly address: string; + readonly url: string; +} + +export type DevEvent = + /** Initial front door + after each successful re-converge. */ + | { readonly kind: 'ready'; readonly endpoints: readonly DevEndpoint[] } + | { readonly kind: 'unwatchable'; readonly address: string } + | { readonly kind: 'rebuild-failed'; 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' } + | { 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; + readonly deps?: OperationDeps | 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 DevEndpoint[]; + /** 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 }; + +// ---- log ---- + +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 }; + +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; + readonly deps?: + | { + readonly config?: PrismaAppConfig | undefined; + readonly identity?: AppIdentity | undefined; + } + | 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 DevEndpoint[]; + /** Merged, address-filtered stream; ends on signal abort or when every source ends. */ + readonly lines: AsyncIterable; + } + | { readonly outcome: 'failed'; readonly failure: OperationFailure }; From e082e7f21795221d9ca1ea9f49835d4d94629af2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 18:02:25 +0200 Subject: [PATCH 03/27] refactor(cli): extract dev and log into typed operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev() returns a DevSession (endpoints, stop(), closed) and reports lifecycle through onEvent — the operation never touches process signal handlers; the CLI adapter keeps the removeAllListeners + single-listener signal ownership and renders each event with the exact console lines it always printed. log() returns the running services plus a merged, address-filtered AsyncIterable of lines, ended by the caller-owned AbortSignal; per-stream failures surface as stream-failed events without ending the other streams. run-dev.ts and run-log.ts become thin parse-shaped adapters over the operations; their suites pass unchanged. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/dev/run-dev.ts | 304 ++++-------------- .../3-tooling/cli/src/log/run-log.ts | 115 +++---- .../cli/src/operations/execute-dev.ts | 273 ++++++++++++++++ .../cli/src/operations/execute-log.ts | 156 +++++++++ .../cli/src/operations/operations.ts | 20 ++ 5 files changed, 553 insertions(+), 315 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/execute-log.ts 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 8ed5e4f7..1cbf8461 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,16 @@ /** - * 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 type { PrismaAppConfig } from '@internal/core/config'; 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 { dev } from '../operations/operations.ts'; +import type { RunAlchemyInput } from '../run-alchemy.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `dev` command. */ export interface DevArgs { @@ -31,12 +26,6 @@ export interface DevRunDeps { readonly config?: PrismaAppConfig | undefined; } -function toCliError(error: unknown): CliError { - return error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); -} - /** `[dev] ready:` then one line per endpoint, ordered by address depth (fewest dots first) then lexicographic. Exported for tests. */ export function renderFrontDoor( endpoints: readonly { readonly address: string; readonly url: string }[], @@ -56,237 +45,76 @@ 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, - ); - - // 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); + const result = await dev({ + entry: args.entry, + name: args.name, + fresh: args.fresh, + onEvent: (event) => { + switch (event.kind) { + case 'ready': + printFrontDoor(event.endpoints); + break; + case 'unwatchable': + console.log(`[dev] ${event.address} has no watchable inputs`); + break; + case 'converge-failed': + console.error('[dev] converge failed — the running app is untouched; still watching.'); + break; + case 'rebuild-failed': + console.error(`[dev] rebuild failed: ${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; } - } - } - - // 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); - } - } + }, + deps, + }); - 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}`); + if (result.outcome === 'failed') { + const failure = result.failure; + if (failure.kind === 'execution') { + console.error(`\nGenerated stack file: ${failure.stackFilePath}`); console.error( - `Run \`alchemy deploy ${DEV_STACK_RELATIVE_PATH} --yes --stage dev\` from ${cwd} ` + - 'to reproduce this directly.', + `Run \`${failure.reproduceCommand}\` from ${failure.cwd} to reproduce this directly.`, ); + return failure.exitCode ?? 1; } - 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); - } + throw failure.cause instanceof Error ? failure.cause : new CliError(failure.message); } - 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}`); - // 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; return 0; } 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..258644c1 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,16 @@ /** - * `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 { log } from '../operations/operations.ts'; +import type { AppIdentity } from '../pipeline.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `log` command. */ export interface LogArgs { @@ -30,82 +29,44 @@ export interface LogRunDeps { readonly identity?: AppIdentity | undefined; } -function toCliError(error: unknown): CliError { - return error instanceof CliError - ? error - : new CliError(error instanceof Error ? error.message : String(error)); -} - /** 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) { - console.error( - `[log] stream failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } + const result = await log({ + 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}`); } - }), - ); + }, + deps: { 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/operations/execute-dev.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts new file mode 100644 index 00000000..e5b6ba64 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -0,0 +1,273 @@ +/** + * 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 dynamic import + * from operations.ts, after the effect-resolution preflight — this module's + * static graph transitively loads alchemy's provider tree. + */ +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, watchTargetsFrom } from '../dev/watch.ts'; +import { type PipelineDeps, runPipeline } from '../pipeline.ts'; +import { runAlchemy } from '../run-alchemy.ts'; +import type { DevEndpoint, DevInput, DevSession, DevStartResult } from './results.ts'; + +function toCliError(error: unknown): CliError { + return error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +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; resolves to a running session or a structured failure. */ +export async function executeDev(input: DevInput, cwd: string): Promise { + if (process.platform === 'win32') { + return { + outcome: 'failed', + failure: { kind: 'unsupported', message: 'local dev is not supported on Windows yet.' }, + }; + } + + const { onEvent, deps } = input; + const devDir = path.join(cwd, DEV_DIR); + + let pipeline: Awaited>; + let resolved: ReadonlyMap; + const containers = new Map(); + + try { + // 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 }; + pipeline = await runPipeline(input.entry, input.name, cwd, pipelineDeps); + const { config, graph, name } = pipeline; + + // 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. + try { + resolved = await resolveLocalTargets(config); + } catch (error) { + throw toCliError(error); + } + + // 3. 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); + } + } + + // 4. `--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); + } + } + } + + // 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); + } + } + } 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 }; + }; + + // 7. Write the dev stack file and converge. + const first = converge(); + if (first.status !== 0) { + return { + outcome: 'failed', + failure: { + kind: 'execution', + message: `alchemy deploy exited with status ${first.status}.`, + exitCode: first.status, + stackFilePath: first.stackPath, + reproduceCommand, + cwd, + }, + }; + } + + // 8. 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. + const attachments: LocalTargetAttachment[] = []; + try { + 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); + } + } + } catch (error) { + return { + outcome: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } + + const endpoints = await mergedEndpoints(attachments); + onEvent?.({ kind: 'ready', endpoints }); + + // 9. 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) { + onEvent?.({ kind: 'unwatchable', address }); + } + + const pipelineDeps: PipelineDeps = { runAssembler: deps?.runAssembler, config: deps?.config }; + 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(input.entry, input.name, cwd, pipelineDeps); + 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) { + onEvent?.({ kind: 'converge-failed', stackFilePath: stackPath, reproduceCommand, cwd }); + return; + } + onEvent?.({ kind: 'ready', endpoints: await mergedEndpoints(attachments) }); + } catch (error) { + onEvent?.({ kind: 'rebuild-failed', 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. + await watch.ready; + + let stopping = false; + let resolveClosed: () => void = () => undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + + const stop = (): Promise => { + if (!stopping) { + stopping = true; + onEvent?.({ kind: 'stopping' }); + watch.stop(); + void (async () => { + for (const attachment of attachments) { + await attachment.stopServices().catch(() => undefined); + } + onEvent?.({ kind: 'stopped' }); + resolveClosed(); + })(); + } + return closed; + }; + + const session: DevSession = { endpoints, stop, closed }; + return { outcome: 'started', session }; +} 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..9fd2036d --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts @@ -0,0 +1,156 @@ +/** + * 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 dynamic import from operations.ts, after the + * effect-resolution preflight — this module's static graph transitively loads + * alchemy's provider tree. + */ +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 type { LogInput, LogLine, LogResult } from './results.ts'; + +function toCliError(error: unknown): CliError { + return error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Merges every attachment's log stream into one iterable: one pump per + * attachment pushing into a shared queue. A pump's throw becomes a + * `stream-failed` event and ends that pump only; the merged iterable ends when + * `input.signal` aborts or every pump ends. Address filtering and `tail` apply + * exactly as the CLI always has. + */ +async function* mergeLogStreams( + attachments: readonly LocalTargetAttachment[], + input: LogInput, +): AsyncGenerator { + const signal = input.signal ?? new AbortController().signal; + const queue: LogLine[] = []; + let active = attachments.length; + let wake: (() => void) | undefined; + const notify = (): void => { + wake?.(); + wake = undefined; + }; + signal.addEventListener('abort', notify, { once: true }); + + const pumps = attachments.map(async (attachment) => { + 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; + queue.push({ service, line }); + notify(); + } + } catch (error) { + if (!signal.aborted) { + input.onEvent?.({ kind: 'stream-failed', message: failureMessage(error) }); + } + } finally { + active -= 1; + notify(); + } + }); + + try { + while (true) { + let next = queue.shift(); + while (next !== undefined) { + yield next; + next = queue.shift(); + } + if (signal.aborted || active === 0) break; + await new Promise((resolve) => { + wake = resolve; + }); + } + } finally { + signal.removeEventListener('abort', notify); + await Promise.all(pumps); + } +} + +/** Resolves the running app and attaches to its log streams; the caller consumes `lines`. */ +export async function executeLog(input: LogInput, cwd: string): Promise { + if (process.platform === 'win32') { + return { + outcome: 'failed', + failure: { kind: 'unsupported', 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 = + input.deps?.identity ?? + (await resolveAppIdentity(input.entry, input.name, cwd, { config: input.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 target.attach({ container, devDir })); + } catch (error) { + throw toCliError(error); + } + } + + services = (await Promise.all(attachments.map((a) => 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/operations.ts b/packages/0-framework/3-tooling/cli/src/operations/operations.ts index 0a651499..ffc136a2 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/operations.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/operations.ts @@ -16,6 +16,10 @@ import type { DeployResult, DestroyInput, DestroyResult, + DevInput, + DevStartResult, + LogInput, + LogResult, OperationFailure, } from './results.ts'; @@ -47,3 +51,19 @@ export async function destroy(input: DestroyInput): Promise { const { executeDestroy } = await import('./execute-deploy-destroy.ts'); return executeDestroy(input, cwd); } + +export async function dev(input: DevInput): Promise { + const cwd = input.cwd ?? process.cwd(); + const preflight = runEffectPreflight(cwd); + if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; + const { executeDev } = await import('./execute-dev.ts'); + return executeDev(input, cwd); +} + +export async function log(input: LogInput): Promise { + const cwd = input.cwd ?? process.cwd(); + const preflight = runEffectPreflight(cwd); + if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; + const { executeLog } = await import('./execute-log.ts'); + return executeLog(input, cwd); +} From e138a24ce081670491173de2cdcdbf6baac1ecc0 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 18:04:00 +0200 Subject: [PATCH 04/27] feat(composer): publish the operations as @prisma/composer/control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `./control` subpath on @internal/cli (generated exports map committed) and @prisma/composer (hand-maintained map, per the published-package exception): the deploy/destroy/dev/log operations, their input/result types, and the DEPLOYMENT_RESULT_FILE_ENV cross-process contract. Depcruise aliases and the architecture per-file entry for the 9-public shim keep every edge visible to the cruiser; lint:deps is green. Named `/control` after the plane the CLI sources already occupy — the shim doc-comment distinguishes it from an extension's ADR-0017 `/control` entry. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- architecture.config.json | 6 ++++ .../0-framework/3-tooling/cli/package.json | 1 + .../3-tooling/cli/src/exports/control.ts | 31 +++++++++++++++++++ .../3-tooling/cli/tsdown.config.ts | 1 + packages/9-public/composer/package.json | 1 + .../9-public/composer/src/exports/control.ts | 1 + packages/9-public/composer/tsdown.config.ts | 1 + tsconfig.depcruise.json | 2 ++ 8 files changed, 44 insertions(+) create mode 100644 packages/0-framework/3-tooling/cli/src/exports/control.ts create mode 100644 packages/9-public/composer/src/exports/control.ts 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/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/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts new file mode 100644 index 00000000..b610cfc1 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -0,0 +1,31 @@ +/** + * Public surface (the `./control` subpath): the programmatic + * deploy/destroy/dev/log operations. Implementation lives in ../operations/. + * Import-safe in a broken effect tree — the heavy pipeline loads only behind + * each operation's own preflight. 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 { deploy, destroy, dev, log } from '../operations/operations.ts'; +export type { + DeployInput, + DeployResult, + DestroyEvent, + DestroyInput, + DestroyResult, + DestroyTarget, + DevEndpoint, + DevEvent, + DevInput, + DevSession, + DevStartResult, + LogEvent, + LogInput, + LogLine, + LogResult, + OperationDeps, + OperationFailure, +} from '../operations/results.ts'; +export type { DeployedNodeSummary, DeploymentSummary } from '../render-deployment.ts'; +export { DEPLOYMENT_RESULT_FILE_ENV } from '../render-deployment.ts'; 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/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"] } } From 8bf201a1ca83f89ff83ae3ed1a9a21d6af19e38a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 18:09:05 +0200 Subject: [PATCH 05/27] test(cli): pin the programmatic operations, in-repo and against the published tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operations.test.ts drives deploy/destroy/log with the run.test.ts fakes: the result-file round trip (env var passed, stale file removed pre-spawn, malformed file = undefined summary), structured invalid-input/pipeline/execution failures with the exact CLI messages, destroy target discrimination and teardown-before-remove order, the pre-pipeline no-local-deploy-state event, the merged/ filtered/abortable log stream with stream-failed events, and a seeded effect-mismatch tree returning an effect-resolution result. Every operation call runs inside silently(), which fails on any console output. control.deploy.test.ts is the slice done-condition: a consumer outside the CLI imports @prisma/composer/control and runs deploy over the real integration fixture, reaching the same missing-built-entry terminal point the binary test pins — as a structured result. check-npm-effect-resolution.mjs now also probes the adversarial package-manager tree in-process: importing the control surface must not crash, and deploy() must return the effect-resolution failure with exit 0. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/__tests__/operations.test.ts | 759 ++++++++++++++++++ scripts/check-npm-effect-resolution.mjs | 47 +- test/integration/test/control.deploy.test.ts | 32 + 3 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts create mode 100644 test/integration/test/control.deploy.test.ts 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..4fa1cc5a --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -0,0 +1,759 @@ +/** + * 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 * 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 type { AppIdentity } from '../../pipeline.ts'; +import { DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary } from '../../render-deployment.ts'; +import type { RunAlchemyInput } from '../../run-alchemy.ts'; +import { deploy, destroy, log } from '../operations.ts'; +import type { LogLine } from '../results.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; resultFilePath: 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, + resultFilePath: path.join(dir, '.prisma-composer', 'deployment-result.json'), + }; +} + +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(() => + deploy({ + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + deps: { + 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); + expect(calls[0]?.env?.[DEPLOYMENT_RESULT_FILE_ENV]).toBe(app.resultFilePath); + expect(result).toEqual({ outcome: 'deployed', summary: summaryFixture }); + }); + + 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(() => + deploy({ + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + deps: { 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(() => + deploy({ + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + deps: { + 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("a previous run's stale result file is removed before alchemy spawns", async () => { + const app = makeAppDir('hello-ops'); + fs.mkdirSync(path.dirname(app.resultFilePath), { recursive: true }); + fs.writeFileSync(app.resultFilePath, JSON.stringify(summaryFixture)); + let existedAtSpawn: boolean | undefined; + + const result = await silently(() => + deploy({ + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + deps: { + config: fakeConfig(), + runAssembler: fakeAssembler, + alchemy: () => { + existedAtSpawn = fs.existsSync(app.resultFilePath); + return 0; + }, + }, + }), + ); + + expect(existedAtSpawn).toBe(false); + expect(result).toEqual({ outcome: 'deployed', summary: undefined }); + }); + + 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(() => + deploy({ + entry: app.entryPath, + stage: 'bad..ref', + cwd: app.dir, + deps: { + 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(() => + deploy({ + entry: app.entryPath, + cwd: app.dir, + deps: { 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(() => + deploy({ + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + deps: { + 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(() => + deploy({ + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + deps: { 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.', + 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('a broken effect tree is an effect-resolution failure — nothing heavier is imported, nothing runs', async () => { + 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' }, + }); + let assemblerRan = false; + + const result = await silently(() => + deploy({ + entry: 'service.ts', + cwd: dir, + deps: { + runAssembler: async (node) => { + assemblerRan = true; + return fakeAssembler(node); + }, + }, + }), + ); + + expect(result.outcome).toBe('failed'); + if (result.outcome !== 'failed') throw new Error('unreachable'); + expect(result.failure.kind).toBe('effect-resolution'); + expect(result.failure.message).toContain('alchemy resolves effect@4.0.0-beta.102'); + expect(result.failure.cause).toBeInstanceOf(CliError); + expect(assemblerRan).toBe(false); + }); +}); + +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(() => + destroy({ + entry: app.entryPath, + target, + cwd: app.dir, + deps: { + 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(() => + destroy({ + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + deps: { + 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(() => + destroy({ + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + deps: { + 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(() => + destroy({ + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + onEvent: (event) => void order.push(event.kind), + deps: { + 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(() => + destroy({ + entry: app.entryPath, + target: { kind: 'stage', stage: 'staging' }, + cwd: app.dir, + onEvent: (event) => void events.push(event.kind), + deps: { 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; +} + +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(() => + log({ entry: 'service.ts', deps: { 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(() => + log({ entry: 'service.ts', address: 'a', deps: { 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(() => + log({ entry: 'service.ts', address: 'nope', deps: { 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(() => + log({ entry: 'service.ts', deps: { 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(() => + log({ + entry: 'service.ts', + signal: controller.signal, + deps: { 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("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(() => + log({ + entry: 'service.ts', + onEvent: (event) => void events.push(event.message), + deps: { 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/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index 711a1f6f..00eb921f 100644 --- a/scripts/check-npm-effect-resolution.mjs +++ b/scripts/check-npm-effect-resolution.mjs @@ -277,8 +277,53 @@ async function checkAdversarialShape(tarballs) { ); } + // The programmatic surface must catch the same broken tree structurally: + // importing `@prisma/composer/control` stays crash-free (its static graph + // keeps the alchemy tree behind each operation's own preflight), and + // deploy() reports the mismatch as a `{ kind: 'effect-resolution' }` + // failure result — exit 0, no throw. + const controlProbe = spawnSync( + process.execPath, + [ + '-e', + `import('@prisma/composer/control') + .then(({ deploy }) => deploy({ entry: 'service.ts' })) + .then((result) => { + if (result.outcome !== 'failed' || result.failure.kind !== 'effect-resolution') { + console.error('unexpected result: ' + JSON.stringify(result)); + process.exit(1); + } + process.stdout.write(result.failure.message); + });`, + ], + { cwd: appDir, encoding: 'utf-8' }, + ); + if (controlProbe.error) { + fail(`[${label}] failed to spawn node for the control-surface probe: ${controlProbe.error}`); + } + const controlOutput = `${controlProbe.stdout}${controlProbe.stderr}`; + if (controlProbe.status !== 0) { + fail( + `[${label}] the programmatic deploy() did not return a structured effect-resolution ` + + `failure in a broken tree (exit ${controlProbe.status}):\n${controlOutput}`, + ); + } + if (!controlOutput.includes(CLI_CHECK_MARKER)) { + fail( + `[${label}] deploy()'s effect-resolution failure is missing the check's message ` + + `(expected "${CLI_CHECK_MARKER}"):\n${controlOutput}`, + ); + } + if (/is not a function/.test(controlOutput)) { + fail( + `[${label}] importing @prisma/composer/control crashed inside alchemy's tree instead of ` + + `reporting the structured failure:\n${controlOutput}`, + ); + } + process.stderr.write( - `[${label}] OK — broken tree caught at start-up with the actionable error, deploy and --help alike\n`, + `[${label}] OK — broken tree caught at start-up with the actionable error, deploy, --help, ` + + 'and the programmatic control surface alike\n', ); } 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); +}); From cb5f73d76d49ef1da3d37633fe3848d84de594ff Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 18:12:12 +0200 Subject: [PATCH 06/27] docs(composer): document the programmatic control API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0043 records the ./control surface and the PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE cross-process contract (plus the index entry). The deploying guide gains a "Driving deploys from code" section, mirrored tersely into skills/prisma-composer/SKILL.md (user-facing-surface-changes: both in the same PR), and deploy-cli.md § Contracts now names @internal/assemble's second consumer as shipped rather than future. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/deploy-cli.md | 8 +++- ...path-is-the-programmatic-deploy-surface.md | 41 ++++++++++++++++ docs/design/90-decisions/README.md | 1 + docs/guides/deploying.md | 48 +++++++++++++++++++ skills/prisma-composer/SKILL.md | 29 +++++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md 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..761dfb31 --- /dev/null +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -0,0 +1,41 @@ +# ADR-0043: `@prisma/composer/control` is the programmatic deploy surface + +## Decision + +The deploy pipeline is drivable in-process through a published subpath, **`@prisma/composer/control`**: four typed operations — `deploy`, `destroy`, `dev`, `log` — with structured inputs and results, no argv, no console output, no `process.exit`. The `prisma-composer` CLI (`main.ts`, `run-dev.ts`, `run-log.ts`) is a thin renderer over these operations, so the two surfaces cannot drift. The implementation lives in `@internal/cli`'s `src/operations/`, re-exported per ADR-0035 through `src/exports/control.ts` on `@internal/cli` and on `@prisma/composer` — no new workspace package, because the operations are an extraction of the CLI's own orchestration and only `packages/9-public/` publishes (ADR-0027/ADR-0028). + +Two contracts anchor the surface: + +1. **The `./control` entry's static import graph stays free of the alchemy-touching tree.** A mismatched `effect` in the consumer's tree crashes that tree *at import time* (TML-3158), so each operation first runs `checkEffectResolution(cwd)` — reported as a `{ kind: 'effect-resolution' }` failure result, not a crash — and only then dynamically imports its executor. Importing the subpath is safe even in a broken tree; `scripts/check-npm-effect-resolution.mjs`'s adversarial shape pins this against a real package-manager install. + +2. **`DEPLOYMENT_RESULT_FILE_ENV` (`PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE`) carries the deploy result across the process boundary.** The `DeploymentResult` only materializes inside the spawned alchemy child (the generated stack file's `report:` hook, ADR-0007/ADR-0033), and `DeployedNode` holds the graph node itself, so it cannot cross processes. When the env var names a file, `deploymentReport` also writes a JSON **`DeploymentSummary`** there — the serializable projection (app + per-node `address`/`entities`). The deploy operation sets the variable on the child, removes any stale file before spawning, and reads the file back after exit 0. The summary is best-effort: an absent or malformed file yields `summary: undefined` on a still-successful deploy, never a failure. + +The name `control` is deliberately the plane the CLI sources already occupy in `architecture.config.json`, matching the existing `/control` subpaths (`@prisma/composer/node/control`, `/nextjs/control`, `@prisma/composer-prisma-cloud/control`). It is a different consumer class from an *extension's* `/control` entry (ADR-0017: control-plane descriptors importable only from `prisma-composer.config.ts`); the shim's doc comment records the distinction. + +## Reasoning + +- `@internal/assemble` was extracted with "the future programmatic deploy API" named as its second consumer (deploy-cli.md § Contracts); this ADR is that consumer landing. A host embedding Composer (the unified `prisma` CLI) needs results it can branch on, not stdout to scrape. +- The CLI consuming the operations is the proof of faithfulness: the extraction moved `main.ts`'s orchestration verbatim, and the CLI's behavior-pinning suite (`run.test.ts`) passes unmodified against the re-pointed commands. +- A file named by an env var is the narrowest cross-process channel that survives `stdio: 'inherit'` (kept — the alchemy child's own output still streams to the host's terminal; capturing it is out of scope here). The file lives under the already-tool-owned `.prisma-composer/` directory (ADR-0004). + +## Consequences + +- Structured failures are coarse for now: one `pipeline` kind spans everything between config discovery and the alchemy spawn. Finer-grained diagnostics are a follow-up slice; `invalid-input`, `unsupported`, `effect-resolution`, and `execution` (alchemy ran and failed, with exit code and reproduce command) are already distinct. +- `dev` returns a session handle (`endpoints`, `stop()`, `closed`) and **never touches process signal handlers** — signal ownership (including evicting alchemy's import-time listeners) stays with the host, as the CLI adapter demonstrates. +- `log` returns the running services plus an `AsyncIterable` of lines ended by a caller-owned `AbortSignal`; per-stream failures surface as events without ending the other streams. +- Writers of the report hook and readers of the result file share one shape (`DeploymentSummary` in `render-deployment.ts`); changing it is a cross-process protocol change and must stay backward-tolerant (the reader treats anything unrecognizable as absent). + +## Alternatives considered + +- **A new workspace package for the operations.** Rejected: everything the operations need already lives in `@internal/cli`, and ADR-0027/ADR-0028 make `packages/9-public/` the only publishable location — a new internal package would add a boundary with nothing on the other side. +- **Naming the subpath `./operations` or `./pilot`.** Rejected in favor of the plane name the sources already carry; the collision with extension `/control` entries is a documentation problem, not a naming-precision one. +- **Parsing the summary from the child's stdout.** Rejected: stdout is `inherit` (the user watches alchemy work), and scraping it would couple the protocol to presentation. +- **Failing the deploy when the result file is missing after exit 0.** Rejected: whether alchemy re-runs the report hook on a no-op converge is not guaranteed, and a summary is a convenience — a deploy that converged 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 env var 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..0f51ec12 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 free of the alchemy-touching tree (TML-3158 — each operation runs the effect preflight, reported as a `{ kind: 'effect-resolution' }` result, before dynamically importing its executor), 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..3f7ac708 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -296,6 +296,54 @@ 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 `effect-resolution` (the + [effect version conflict](#when-a-deploy-stops-on-an-effect-version-conflict), + caught before anything heavy loads — importing the module is safe even in a + broken tree), `invalid-input`, `unsupported`, `pipeline` (anything between + config discovery and the deploy engine), or `execution` (the engine ran and + failed — carrying its exit code and an exact reproduce command). +- **`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.** The + operations return structured results but don't capture the live deploy + output; run them where that output belongs, or with stdio redirected. +- **`dev` returns a session, not an exit code** — `{ endpoints, stop(), + closed }`, with progress (`ready`, `converge-failed`, …) delivered through + `onEvent`. The operation never installs signal handlers; wiring Ctrl-C to + `session.stop()` is yours. +- **`log` returns the running services and an `AsyncIterable` of lines**, + ended by an `AbortSignal` you own. Zero running services is a valid result + (empty `services`, finished stream), not an error. + ## The full picture [`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md) diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 6967fed7..b5d1b7ee 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -667,6 +667,35 @@ 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` ∈ `effect-resolution` | `invalid-input` | `unsupported` | + `pipeline` | `execution` and the same fix-naming `message` the CLI prints. + The effect version conflict is a structured result here, and importing the + module is safe even in a broken dependency tree. +- `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 operations don't capture it. +- `dev` resolves to a session `{ endpoints, stop(), closed }` with progress + via `onEvent`; the host owns signal handling. `log` resolves to + `{ appName, services, lines }` where `lines` is an `AsyncIterable` ended by + a caller-owned `AbortSignal`; zero running services is a valid result, not + an error. + ## Production pitfalls - **Scale-to-zero closes idle database connections.** A persistent client From 72530f84ed9f8d754db80124ee7bbfc0d4ce049a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 18:33:47 +0200 Subject: [PATCH 07/27] fix(cli): return a structured failure when dev() throws after attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint merge and watch setup ran outside any try/catch, so a throw there (e.g. withEmulatorRetry exhausting its attempts) rejected the dev() promise instead of producing { outcome: "failed", kind: "pipeline" } as design § 3.5 requires. The attach try block now extends through the endpoint merge, watch setup, and session construction; the startServices rollback is unchanged. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/__tests__/operations.test.ts | 56 ++++++- .../cli/src/operations/execute-dev.ts | 143 +++++++++--------- 2 files changed, 126 insertions(+), 73 deletions(-) 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 index 4fa1cc5a..075bce66 100644 --- 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 @@ -24,7 +24,7 @@ import { CliError } from '../../cli-error.ts'; import type { AppIdentity } from '../../pipeline.ts'; import { DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary } from '../../render-deployment.ts'; import type { RunAlchemyInput } from '../../run-alchemy.ts'; -import { deploy, destroy, log } from '../operations.ts'; +import { deploy, destroy, dev, log } from '../operations.ts'; import type { LogLine } from '../results.ts'; const tmpDirs: string[] = []; @@ -633,6 +633,60 @@ async function collect(lines: AsyncIterable): Promise { return out; } +describe('dev()', () => { + test('a throw after services start (endpoint merge) is a pipeline failure, not a rejection', async () => { + const app = makeAppDir('hello-dev'); + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopServices: () => Promise.resolve(), + endpoints: () => Promise.reject(new Error('emulator admin refused the connection')), + logs: async function* () {}, + }; + const descriptor: LocalTargetDescriptor = { + providers: () => Layer.empty, + container: { + ensure: () => Promise.resolve(localContainer()), + locate: () => Promise.resolve(undefined), + remove: () => Promise.resolve(), + deserialize: () => localContainer(), + }, + attach: () => Promise.resolve(attachment), + }; + const config: PrismaAppConfig = { + 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 }, + }; + + const result = await silently(() => + dev({ + entry: app.entryPath, + cwd: app.dir, + deps: { config, 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'); + }, 15_000); +}); + describe('log()', () => { test('merges every attachment into one stream and reports the running services', async () => { const attachments = [ 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 index e5b6ba64..9c33c4cb 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -192,82 +192,81 @@ export async function executeDev(input: DevInput, cwd: string): Promise { - // 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, pipelineDeps); - 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) { - onEvent?.({ kind: 'converge-failed', stackFilePath: stackPath, reproduceCommand, cwd }); - return; - } - onEvent?.({ kind: 'ready', endpoints: await mergedEndpoints(attachments) }); - } catch (error) { - onEvent?.({ kind: 'rebuild-failed', 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. - await watch.ready; + const endpoints = await mergedEndpoints(attachments); + onEvent?.({ kind: 'ready', endpoints }); - let stopping = false; - let resolveClosed: () => void = () => undefined; - const closed = new Promise((resolve) => { - resolveClosed = resolve; - }); + // 9. 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) { + onEvent?.({ kind: 'unwatchable', address }); + } - const stop = (): Promise => { - if (!stopping) { - stopping = true; - onEvent?.({ kind: 'stopping' }); - watch.stop(); + const watchDeps: PipelineDeps = { runAssembler: deps?.runAssembler, config: deps?.config }; + 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 () => { - for (const attachment of attachments) { - await attachment.stopServices().catch(() => undefined); + 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) { + onEvent?.({ kind: 'converge-failed', stackFilePath: stackPath, reproduceCommand, cwd }); + return; + } + onEvent?.({ kind: 'ready', endpoints: await mergedEndpoints(attachments) }); + } catch (error) { + onEvent?.({ kind: 'rebuild-failed', message: failureMessage(error) }); } - onEvent?.({ kind: 'stopped' }); - resolveClosed(); })(); - } - return closed; - }; + }); + // 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; - const session: DevSession = { endpoints, stop, closed }; - return { outcome: 'started', session }; + let stopping = false; + let resolveClosed: () => void = () => undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + + const stop = (): Promise => { + if (!stopping) { + stopping = true; + onEvent?.({ kind: 'stopping' }); + watch.stop(); + void (async () => { + for (const attachment of attachments) { + await attachment.stopServices().catch(() => undefined); + } + onEvent?.({ kind: 'stopped' }); + resolveClosed(); + })(); + } + return closed; + }; + + const session: DevSession = { endpoints, stop, closed }; + return { outcome: 'started', session }; + } catch (error) { + return { + outcome: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } } From f8243e7028badb09153f57264b3e73c743315c66 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 18:34:03 +0200 Subject: [PATCH 08/27] fix(cli): restore the shipped dev output order for unwatchable notices The CLI adapter printed unwatchable notices before the `[dev] logs:` hint; the shipped order was front door, hint, then unwatchable lines. Notices received before the session is returned are now buffered and flushed right after the hint; later ones print immediately. Every string is unchanged. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../0-framework/3-tooling/cli/src/dev/run-dev.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 1cbf8461..0c6fde6f 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 @@ -47,6 +47,11 @@ function printFrontDoor( /** Runs the full dev pipeline; returns the process exit code. */ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { + // 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 dev({ entry: args.entry, name: args.name, @@ -56,9 +61,12 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { void session.stop(); From 602523941f987defecead996d4fbdf3e96eb3559 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 22:43:15 +0200 Subject: [PATCH 09/27] docs(adr): rewrite ADR-0043 for fresh-eyes readers Ground the decision in a usage example, build the narrative up from motivation through import safety and the process-boundary contract, and strip refactor-history framing and ticket references. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- ...path-is-the-programmatic-deploy-surface.md | 83 +++++++++++++++---- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md index 761dfb31..879f76ab 100644 --- a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -2,39 +2,86 @@ ## Decision -The deploy pipeline is drivable in-process through a published subpath, **`@prisma/composer/control`**: four typed operations — `deploy`, `destroy`, `dev`, `log` — with structured inputs and results, no argv, no console output, no `process.exit`. The `prisma-composer` CLI (`main.ts`, `run-dev.ts`, `run-log.ts`) is a thin renderer over these operations, so the two surfaces cannot drift. The implementation lives in `@internal/cli`'s `src/operations/`, re-exported per ADR-0035 through `src/exports/control.ts` on `@internal/cli` and on `@prisma/composer` — no new workspace package, because the operations are an extraction of the CLI's own orchestration and only `packages/9-public/` publishes (ADR-0027/ADR-0028). +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, never print to the console, and never call `process.exit`. The `prisma-composer` CLI is a thin renderer over these same operations, so the command-line surface and the programmatic surface cannot drift apart. -Two contracts anchor the surface: +A host — another CLI embedding Composer, a CI tool, a test — uses it like this: -1. **The `./control` entry's static import graph stays free of the alchemy-touching tree.** A mismatched `effect` in the consumer's tree crashes that tree *at import time* (TML-3158), so each operation first runs `checkEffectResolution(cwd)` — reported as a `{ kind: 'effect-resolution' }` failure result, not a crash — and only then dynamically imports its executor. Importing the subpath is safe even in a broken tree; `scripts/check-npm-effect-resolution.mjs`'s adversarial shape pins this against a real package-manager install. +```ts +import { deploy } from '@prisma/composer/control'; -2. **`DEPLOYMENT_RESULT_FILE_ENV` (`PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE`) carries the deploy result across the process boundary.** The `DeploymentResult` only materializes inside the spawned alchemy child (the generated stack file's `report:` hook, ADR-0007/ADR-0033), and `DeployedNode` holds the graph node itself, so it cannot cross processes. When the env var names a file, `deploymentReport` also writes a JSON **`DeploymentSummary`** there — the serializable projection (app + per-node `address`/`entities`). The deploy operation sets the variable on the child, removes any stale file before spawning, and reads the file back after exit 0. The summary is best-effort: an absent or malformed file yields `summary: undefined` on a still-successful deploy, never a failure. +const result = await deploy({ entry: 'module.ts', stage: 'feat-auth' }); -The name `control` is deliberately the plane the CLI sources already occupy in `architecture.config.json`, matching the existing `/control` subpaths (`@prisma/composer/node/control`, `/nextjs/control`, `@prisma/composer-prisma-cloud/control`). It is a different consumer class from an *extension's* `/control` entry (ADR-0017: control-plane descriptors importable only from `prisma-composer.config.ts`); the shim's doc comment records the distinction. +if (result.outcome === 'deployed') { + for (const node of result.summary?.nodes ?? []) { + console.log(node.address, node.entities); + } +} else { + switch (result.failure.kind) { + case 'effect-resolution': // the app's dependency tree can't load alchemy safely + case 'invalid-input': // e.g. a stage name git would reject + case 'pipeline': // config discovery through assembly failed + case 'execution': // alchemy ran and exited nonzero + report(result.failure.message); + } +} +``` -## Reasoning +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, where it exists, machine-usable context (the alchemy exit code, the generated stack-file path, the exact command to reproduce the run). -- `@internal/assemble` was extracted with "the future programmatic deploy API" named as its second consumer (deploy-cli.md § Contracts); this ADR is that consumer landing. A host embedding Composer (the unified `prisma` CLI) needs results it can branch on, not stdout to scrape. -- The CLI consuming the operations is the proof of faithfulness: the extraction moved `main.ts`'s orchestration verbatim, and the CLI's behavior-pinning suite (`run.test.ts`) passes unmodified against the re-pointed commands. -- A file named by an env var is the narrowest cross-process channel that survives `stdio: 'inherit'` (kept — the alchemy child's own output still streams to the host's terminal; capturing it is out of scope here). The file lives under the already-tool-owned `.prisma-composer/` directory (ADR-0004). +## 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 is always safe + +Composer executes deploys through [alchemy](https://alchemy.run), whose module tree depends on `effect`. When the app's `node_modules` resolves a mismatched `effect` version, alchemy's modules **throw at import time** — before any function is called. A naive API module that statically imported the pipeline would therefore crash the host process the moment the host imported it, even if the host never called an operation. + +The `./control` entry defends against this structurally: + +1. Its **static import graph contains no alchemy-reachable module** — only types, the resolution checker, and the result definitions. Importing the subpath executes nothing dangerous, even inside a broken tree. An adversarial fixture in `scripts/check-npm-effect-resolution.mjs` pins this against a real package-manager install: importing `@prisma/composer/control` from a tree with a seeded `effect` mismatch must succeed. +2. Each operation first runs `checkEffectResolution(cwd)` against the **target app's** directory (not the host's own), and reports a mismatch as a `{ kind: 'effect-resolution' }` failure result. Only after the check passes does it dynamically `import()` the executor that reaches the pipeline and alchemy. + +So a broken app tree yields a structured failure from a live, functioning host — 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: + +- `render-deployment.ts` defines **`DeploymentSummary`** — the serializable projection of a result: the app name and, per node, its `address` and deployed `entities`. +- When the environment variable **`PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE`** names a file, the report hook writes the summary there as JSON, in addition to its normal console rendering. +- The deploy operation sets that variable on the child, removes any stale file before spawning, and reads the file back after a zero exit. + +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 -- Structured failures are coarse for now: one `pipeline` kind spans everything between config discovery and the alchemy spawn. Finer-grained diagnostics are a follow-up slice; `invalid-input`, `unsupported`, `effect-resolution`, and `execution` (alchemy ran and failed, with exit code and reproduce command) are already distinct. -- `dev` returns a session handle (`endpoints`, `stop()`, `closed`) and **never touches process signal handlers** — signal ownership (including evicting alchemy's import-time listeners) stays with the host, as the CLI adapter demonstrates. -- `log` returns the running services plus an `AsyncIterable` of lines ended by a caller-owned `AbortSignal`; per-stream failures surface as events without ending the other streams. -- Writers of the report hook and readers of the result file share one shape (`DeploymentSummary` in `render-deployment.ts`); changing it is a cross-process protocol change and must stay backward-tolerant (the reader treats anything unrecognizable as absent). +- **The failure taxonomy is deliberately coarse at the pipeline stage.** One `pipeline` kind spans everything from config discovery through assembly and container preparation; `effect-resolution`, `invalid-input`, `unsupported`, 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** — `stdio: 'inherit'` is part of the surface's contract. A host that must capture or redirect execution output needs a new option on the operations, not a workaround. ## Alternatives considered -- **A new workspace package for the operations.** Rejected: everything the operations need already lives in `@internal/cli`, and ADR-0027/ADR-0028 make `packages/9-public/` the only publishable location — a new internal package would add a boundary with nothing on the other side. -- **Naming the subpath `./operations` or `./pilot`.** Rejected in favor of the plane name the sources already carry; the collision with extension `/control` entries is a documentation problem, not a naming-precision one. -- **Parsing the summary from the child's stdout.** Rejected: stdout is `inherit` (the user watches alchemy work), and scraping it would couple the protocol to presentation. -- **Failing the deploy when the result file is missing after exit 0.** Rejected: whether alchemy re-runs the report hook on a no-op converge is not guaranteed, and a summary is a convenience — a deploy that converged must not be reported as failed for lacking one. +- **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 env var contract crosses into. +- [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. From 79c8be505909a1225359697ff4d59073c88ded93 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 22:58:58 +0200 Subject: [PATCH 10/27] refactor(cli): shrink the control API effect defenses to a lazy-load catch path The per-operation checkEffectResolution preflight and the dedicated effect-resolution failure kind were oversized for what is now a transient upstream condition. Each operation instead wraps the lazy import of its executor in a try/catch: on a load failure it diagnoses the target tree with checkEffectResolution and returns a pipeline failure carrying the fix-naming message (or the original error message when the tree is healthy), with the import error as cause. The entry stays import-light as a general no-import-side-effects property; bin.ts keeps its own start-up check. Reverts the control-surface probe added to scripts/check-npm-effect-resolution.mjs back to main. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/exports/control.ts | 4 +- .../operations/__tests__/operations.test.ts | 66 +++++++++++++----- .../src/operations/execute-deploy-destroy.ts | 6 +- .../cli/src/operations/execute-dev.ts | 7 +- .../cli/src/operations/execute-log.ts | 6 +- .../cli/src/operations/operations.ts | 69 +++++++++++-------- .../3-tooling/cli/src/operations/results.ts | 12 ++-- scripts/check-npm-effect-resolution.mjs | 47 +------------ 8 files changed, 109 insertions(+), 108 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts index b610cfc1..57fa71c3 100644 --- a/packages/0-framework/3-tooling/cli/src/exports/control.ts +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -1,8 +1,8 @@ /** * Public surface (the `./control` subpath): the programmatic * deploy/destroy/dev/log operations. Implementation lives in ../operations/. - * Import-safe in a broken effect tree — the heavy pipeline loads only behind - * each operation's own preflight. Distinct from an EXTENSION's `/control` + * 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. 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 index 075bce66..9f62c2ea 100644 --- 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 @@ -6,6 +6,7 @@ * 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'; @@ -385,7 +386,7 @@ describe('deploy()', () => { }); }); - test('a broken effect tree is an effect-resolution failure — nothing heavier is imported, nothing runs', async () => { + 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-')), ); @@ -406,27 +407,58 @@ describe('deploy()', () => { version: '0.0.0', dependencies: { effect: '4.0.0-beta.93' }, }); - let assemblerRan = false; - const result = await silently(() => - deploy({ - entry: 'service.ts', - cwd: dir, - deps: { - runAssembler: async (node) => { - assemblerRan = true; - return fakeAssembler(node); - }, - }, - }), + // 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('../operations.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'); - if (result.outcome !== 'failed') throw new Error('unreachable'); - expect(result.failure.kind).toBe('effect-resolution'); + expect(result.failure.kind).toBe('pipeline'); expect(result.failure.message).toContain('alchemy resolves effect@4.0.0-beta.102'); - expect(result.failure.cause).toBeInstanceOf(CliError); - expect(assemblerRan).toBe(false); + expect(result.failure.cause).toEqual({ + name: 'Error', + message: 'Schedule.either is not a function', + }); }); }); 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 index d78becd9..ed06885a 100644 --- 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 @@ -1,9 +1,9 @@ /** * The deploy/destroy executor — main.ts's pipeline orchestration (steps 0–9.75) * with argv, console, and exit codes removed: typed inputs in, structured - * results out. Reached only by dynamic import from operations.ts, after the - * effect-resolution preflight — this module's static graph transitively loads - * alchemy's provider tree. + * results out. Reached only by lazy import from operations.ts — this module's + * static graph transitively loads alchemy's provider tree, so the control + * entry must never import it statically. */ import * as fs from 'node:fs'; import * as path from 'node:path'; 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 index 9c33c4cb..d3ee652d 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -2,9 +2,10 @@ * 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 dynamic import - * from operations.ts, after the effect-resolution preflight — this module's - * static graph transitively loads alchemy's provider tree. + * handlers — the host does (see run-dev.ts). Reached only by lazy import + * from operations.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'; 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 index 9fd2036d..1231f8fc 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts @@ -2,9 +2,9 @@ * 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 dynamic import from operations.ts, after the - * effect-resolution preflight — this module's static graph transitively loads - * alchemy's provider tree. + * AbortSignal. Reached only by lazy import from operations.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'; diff --git a/packages/0-framework/3-tooling/cli/src/operations/operations.ts b/packages/0-framework/3-tooling/cli/src/operations/operations.ts index ffc136a2..d226922d 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/operations.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/operations.ts @@ -4,10 +4,10 @@ * no argv, no console, no process.exit. The prisma-composer CLI (main.ts) is a * thin renderer over these operations. * - * Crash safety (TML-3158, mirrors bin.ts): this module's STATIC graph must stay - * free of the alchemy-touching tree — a mismatched `effect` crashes that tree at - * import time. Each operation runs checkEffectResolution() first and only then - * dynamically imports its executor. + * The entry stays import-light: executors load lazily, so importing this + * module is cheap and executes nothing until an operation runs. An executor + * that fails to load comes back as a structured `pipeline` failure, never a + * throw out of the host. */ import { checkEffectResolution } from '../check-effect-resolution.ts'; import { CliError } from '../cli-error.ts'; @@ -23,47 +23,62 @@ import type { OperationFailure, } from './results.ts'; -/** Structured form of bin.ts's preflight: a mismatched tree is a result, not a crash. */ -function runEffectPreflight(cwd: string): OperationFailure | 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. */ +function executorLoadFailure(error: unknown, cwd: string): OperationFailure { try { checkEffectResolution(cwd); - return undefined; - } catch (error) { - if (error instanceof CliError) { - return { kind: 'effect-resolution', message: error.message, cause: error }; + } catch (diagnostic) { + if (diagnostic instanceof CliError) { + return { kind: 'pipeline', message: diagnostic.message, cause: error }; } - throw error; // a bug in the check itself, not a user-tree condition } + const message = error instanceof Error ? error.message : String(error); + return { kind: 'pipeline', message, cause: error }; } export async function deploy(input: DeployInput): Promise { const cwd = input.cwd ?? process.cwd(); - const preflight = runEffectPreflight(cwd); - if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; - const { executeDeploy } = await import('./execute-deploy-destroy.ts'); - return executeDeploy(input, 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, cwd); } export async function destroy(input: DestroyInput): Promise { const cwd = input.cwd ?? process.cwd(); - const preflight = runEffectPreflight(cwd); - if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; - const { executeDestroy } = await import('./execute-deploy-destroy.ts'); - return executeDestroy(input, 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, cwd); } export async function dev(input: DevInput): Promise { const cwd = input.cwd ?? process.cwd(); - const preflight = runEffectPreflight(cwd); - if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; - const { executeDev } = await import('./execute-dev.ts'); - return executeDev(input, 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, cwd); } export async function log(input: LogInput): Promise { const cwd = input.cwd ?? process.cwd(); - const preflight = runEffectPreflight(cwd); - if (preflight !== undefined) return { outcome: 'failed', failure: preflight }; - const { executeLog } = await import('./execute-log.ts'); - return executeLog(input, 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, cwd); } diff --git a/packages/0-framework/3-tooling/cli/src/operations/results.ts b/packages/0-framework/3-tooling/cli/src/operations/results.ts index 2eb59705..d2de0df0 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/results.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/results.ts @@ -1,8 +1,7 @@ /** * Typed inputs and structured results for the programmatic operations - * (`@prisma/composer/control`). Zero runtime imports from the heavy - * alchemy-touching tree — everything here is `import type`, erased in the - * build, so this module is import-safe in a broken effect tree (TML-3158). + * (`@prisma/composer/control`). Everything here is `import type`, erased in + * the build — importing this module loads no runtime code. */ import type { RunAssembler } from '@internal/assemble'; import type { PrismaAppConfig } from '@internal/core/config'; @@ -21,14 +20,13 @@ export interface OperationDeps { /** 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 = - /** TML-3158: alchemy would resolve a mismatched `effect`; nothing was imported, nothing ran. */ - | { readonly kind: 'effect-resolution'; readonly message: string; readonly cause?: unknown } /** 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'; readonly message: string; readonly cause?: unknown } - /** Any failure between config discovery and the alchemy spawn: missing config, - * bad entry export, LoadError, coverage miss, assemble, container, extension preflight. + /** 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 alchemy child ran and failed. `exitCode` undefined means the spawn itself threw. */ diff --git a/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index 00eb921f..711a1f6f 100644 --- a/scripts/check-npm-effect-resolution.mjs +++ b/scripts/check-npm-effect-resolution.mjs @@ -277,53 +277,8 @@ async function checkAdversarialShape(tarballs) { ); } - // The programmatic surface must catch the same broken tree structurally: - // importing `@prisma/composer/control` stays crash-free (its static graph - // keeps the alchemy tree behind each operation's own preflight), and - // deploy() reports the mismatch as a `{ kind: 'effect-resolution' }` - // failure result — exit 0, no throw. - const controlProbe = spawnSync( - process.execPath, - [ - '-e', - `import('@prisma/composer/control') - .then(({ deploy }) => deploy({ entry: 'service.ts' })) - .then((result) => { - if (result.outcome !== 'failed' || result.failure.kind !== 'effect-resolution') { - console.error('unexpected result: ' + JSON.stringify(result)); - process.exit(1); - } - process.stdout.write(result.failure.message); - });`, - ], - { cwd: appDir, encoding: 'utf-8' }, - ); - if (controlProbe.error) { - fail(`[${label}] failed to spawn node for the control-surface probe: ${controlProbe.error}`); - } - const controlOutput = `${controlProbe.stdout}${controlProbe.stderr}`; - if (controlProbe.status !== 0) { - fail( - `[${label}] the programmatic deploy() did not return a structured effect-resolution ` + - `failure in a broken tree (exit ${controlProbe.status}):\n${controlOutput}`, - ); - } - if (!controlOutput.includes(CLI_CHECK_MARKER)) { - fail( - `[${label}] deploy()'s effect-resolution failure is missing the check's message ` + - `(expected "${CLI_CHECK_MARKER}"):\n${controlOutput}`, - ); - } - if (/is not a function/.test(controlOutput)) { - fail( - `[${label}] importing @prisma/composer/control crashed inside alchemy's tree instead of ` + - `reporting the structured failure:\n${controlOutput}`, - ); - } - process.stderr.write( - `[${label}] OK — broken tree caught at start-up with the actionable error, deploy, --help, ` + - 'and the programmatic control surface alike\n', + `[${label}] OK — broken tree caught at start-up with the actionable error, deploy and --help alike\n`, ); } From 8a1261a87c747a733029daaf36998a659b47b7c2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 22:59:12 +0200 Subject: [PATCH 11/27] docs(composer): demote import-safety of the control entry to a general property ADR-0043 no longer frames import-safety as an effect-specific contract with its own failure kind: the entry is import-light with lazily loaded executors, and a tree that cannot load the deploy stack surfaces as a structured pipeline failure with a diagnostic message. Update the ADR index line, the deploying guide, and the composer skill to the reduced failure taxonomy. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- ...bpath-is-the-programmatic-deploy-surface.md | 18 ++++++------------ docs/design/90-decisions/README.md | 2 +- docs/guides/deploying.md | 12 ++++++------ skills/prisma-composer/SKILL.md | 8 ++++---- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md index 879f76ab..9a084c56 100644 --- a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -17,10 +17,9 @@ if (result.outcome === 'deployed') { } } else { switch (result.failure.kind) { - case 'effect-resolution': // the app's dependency tree can't load alchemy safely - case 'invalid-input': // e.g. a stage name git would reject - case 'pipeline': // config discovery through assembly failed - case 'execution': // alchemy ran and exited nonzero + 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); } } @@ -36,14 +35,9 @@ Because the CLI's commands are renderers over the same operations, there is exac ## Importing the subpath is always safe -Composer executes deploys through [alchemy](https://alchemy.run), whose module tree depends on `effect`. When the app's `node_modules` resolves a mismatched `effect` version, alchemy's modules **throw at import time** — before any function is called. A naive API module that statically imported the pipeline would therefore crash the host process the moment the host imported it, even if the host never called an operation. +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 `./control` entry defends against this structurally: - -1. Its **static import graph contains no alchemy-reachable module** — only types, the resolution checker, and the result definitions. Importing the subpath executes nothing dangerous, even inside a broken tree. An adversarial fixture in `scripts/check-npm-effect-resolution.mjs` pins this against a real package-manager install: importing `@prisma/composer/control` from a tree with a seeded `effect` mismatch must succeed. -2. Each operation first runs `checkEffectResolution(cwd)` against the **target app's** directory (not the host's own), and reports a mismatch as a `{ kind: 'effect-resolution' }` failure result. Only after the check passes does it dynamically `import()` the executor that reaches the pipeline and alchemy. - -So a broken app tree yields a structured failure from a live, functioning host — never an import-time crash. +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 @@ -67,7 +61,7 @@ The subpath is named `control` because that is the architecture plane these sour ## Consequences -- **The failure taxonomy is deliberately coarse at the pipeline stage.** One `pipeline` kind spans everything from config discovery through assembly and container preparation; `effect-resolution`, `invalid-input`, `unsupported`, and `execution` are distinct. Callers needing to distinguish pipeline sub-failures must parse messages until a finer taxonomy exists. +- **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`, 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** — `stdio: 'inherit'` is part of the surface's contract. A host that must capture or redirect execution output needs a new option on the operations, not a workaround. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 0f51ec12..96cd729d 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -64,4 +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 free of the alchemy-touching tree (TML-3158 — each operation runs the effect preflight, reported as a `{ kind: 'effect-resolution' }` result, before dynamically importing its executor), 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. +- [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 3f7ac708..22369f63 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -324,12 +324,12 @@ What to know before embedding it: 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 `effect-resolution` (the - [effect version conflict](#when-a-deploy-stops-on-an-effect-version-conflict), - caught before anything heavy loads — importing the module is safe even in a - broken tree), `invalid-input`, `unsupported`, `pipeline` (anything between - config discovery and the deploy engine), or `execution` (the engine ran and - failed — carrying its exit code and an exact reproduce command). + `failure.kind` is one of `invalid-input`, `unsupported`, `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 — carrying its exit code and an exact reproduce + command). 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`. diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index b5d1b7ee..a1607e3b 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -680,10 +680,10 @@ const result = await deploy({ entry: 'module.ts', stage: 'pr-42' }); ``` - Failures come back as `{ outcome: 'failed', failure }` with - `failure.kind` ∈ `effect-resolution` | `invalid-input` | `unsupported` | - `pipeline` | `execution` and the same fix-naming `message` the CLI prints. - The effect version conflict is a structured result here, and importing the - module is safe even in a broken dependency tree. + `failure.kind` ∈ `invalid-input` | `unsupported` | `pipeline` | `execution` + and the same fix-naming `message` the CLI prints. 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 From 0e6a112a89f20dd82a9449219cb173a1b7812750 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:18:18 +0200 Subject: [PATCH 12/27] refactor(cli): group the control surface by operation, one module each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operations/results.ts and operations/operations.ts grouped every input and result type by category, with ascii-art-free-but-monolithic files. Each operation now owns one module — operations/{deploy,destroy,dev,log}.ts — holding its input types, result types, and the operation function, each lazily importing its executor so the control entry stays import-light. OperationFailure/OperationDeps and the executor-load diagnosis live in operations/shared.ts. Also from the round-2 review: executeDeployOrDestroy is renamed runStackPipeline and its internal return is a proper discriminated union (succeeded/failed) instead of field-presence encoding; the executors drop the step-number comments inherited from the deleted main.ts sequence; the per-extension container maps name their key (ExtensionId); deps seams are marked @internal test seams; the log deps shape is named once (LogDeps). Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/dev/run-dev.ts | 2 +- .../3-tooling/cli/src/exports/control.ts | 21 ++- .../3-tooling/cli/src/log/run-log.ts | 2 +- .../0-framework/3-tooling/cli/src/main.ts | 5 +- .../operations/__tests__/operations.test.ts | 8 +- .../3-tooling/cli/src/operations/deploy.ts | 42 +++++ .../3-tooling/cli/src/operations/destroy.ts | 43 +++++ .../3-tooling/cli/src/operations/dev.ts | 67 +++++++ .../src/operations/execute-deploy-destroy.ts | 87 +++++---- .../cli/src/operations/execute-dev.ts | 28 +-- .../cli/src/operations/execute-log.ts | 4 +- .../3-tooling/cli/src/operations/log.ts | 70 +++++++ .../cli/src/operations/operations.ts | 84 --------- .../3-tooling/cli/src/operations/results.ts | 177 ------------------ .../3-tooling/cli/src/operations/shared.ts | 64 +++++++ 15 files changed, 370 insertions(+), 334 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/operations/deploy.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/destroy.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/dev.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/log.ts delete mode 100644 packages/0-framework/3-tooling/cli/src/operations/operations.ts delete mode 100644 packages/0-framework/3-tooling/cli/src/operations/results.ts create mode 100644 packages/0-framework/3-tooling/cli/src/operations/shared.ts 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 0c6fde6f..91cb6b49 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 @@ -9,7 +9,7 @@ import type { RunAssembler } from '@internal/assemble'; import type { PrismaAppConfig } from '@internal/core/config'; import { CliError } from '../cli-error.ts'; -import { dev } from '../operations/operations.ts'; +import { dev } from '../operations/dev.ts'; import type { RunAlchemyInput } from '../run-alchemy.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `dev` command. */ diff --git a/packages/0-framework/3-tooling/cli/src/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts index 57fa71c3..766a0229 100644 --- a/packages/0-framework/3-tooling/cli/src/exports/control.ts +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -7,25 +7,26 @@ * `prisma-composer.config.ts`): this subpath is for hosts driving the deploy * pipeline in-process. */ -export { deploy, destroy, dev, log } from '../operations/operations.ts'; + +export type { DeployInput, DeployResult } from '../operations/deploy.ts'; +export { deploy } from '../operations/deploy.ts'; export type { - DeployInput, - DeployResult, DestroyEvent, DestroyInput, DestroyResult, DestroyTarget, +} from '../operations/destroy.ts'; +export { destroy } from '../operations/destroy.ts'; +export type { DevEndpoint, DevEvent, DevInput, DevSession, DevStartResult, - LogEvent, - LogInput, - LogLine, - LogResult, - OperationDeps, - OperationFailure, -} from '../operations/results.ts'; +} from '../operations/dev.ts'; +export { dev } from '../operations/dev.ts'; +export type { LogDeps, LogEvent, LogInput, LogLine, LogResult } from '../operations/log.ts'; +export { log } from '../operations/log.ts'; +export type { OperationDeps, OperationFailure } from '../operations/shared.ts'; export type { DeployedNodeSummary, DeploymentSummary } from '../render-deployment.ts'; export { DEPLOYMENT_RESULT_FILE_ENV } from '../render-deployment.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 258644c1..c3a0628d 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 @@ -9,7 +9,7 @@ */ import type { PrismaAppConfig } from '@internal/core/config'; import { CliError } from '../cli-error.ts'; -import { log } from '../operations/operations.ts'; +import { log } from '../operations/log.ts'; import type { AppIdentity } from '../pipeline.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `log` command. */ diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index d963dce7..8dec08d3 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -7,8 +7,9 @@ import { Cli, Command, Option, UsageError } from 'clipanion'; import { CliError } from './cli-error.ts'; import { runDev } from './dev/run-dev.ts'; import { runLog } from './log/run-log.ts'; -import { deploy, destroy } from './operations/operations.ts'; -import type { DestroyTarget, OperationDeps, OperationFailure } from './operations/results.ts'; +import { deploy } from './operations/deploy.ts'; +import { type DestroyTarget, destroy } from './operations/destroy.ts'; +import type { OperationDeps, OperationFailure } from './operations/shared.ts'; const BINARY_NAME = 'prisma-composer'; 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 index 9f62c2ea..f459b0d1 100644 --- 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 @@ -25,8 +25,10 @@ import { CliError } from '../../cli-error.ts'; import type { AppIdentity } from '../../pipeline.ts'; import { DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary } from '../../render-deployment.ts'; import type { RunAlchemyInput } from '../../run-alchemy.ts'; -import { deploy, destroy, dev, log } from '../operations.ts'; -import type { LogLine } from '../results.ts'; +import { deploy } from '../deploy.ts'; +import { destroy } from '../destroy.ts'; +import { dev } from '../dev.ts'; +import { type LogLine, log } from '../log.ts'; const tmpDirs: string[] = []; @@ -412,7 +414,7 @@ describe('deploy()', () => { // 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('../operations.ts', import.meta.url)); + const operationsPath = fileURLToPath(new URL('../deploy.ts', import.meta.url)); const breakerPath = path.join(dir, 'break-executor.ts'); fs.writeFileSync( breakerPath, 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..011f4852 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts @@ -0,0 +1,42 @@ +/** + * 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 '../render-deployment.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; + readonly deps?: OperationDeps | 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 { + 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, 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..fe9eb257 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/destroy.ts @@ -0,0 +1,43 @@ +/** + * 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; + readonly deps?: OperationDeps | undefined; +} + +export type DestroyResult = + | { readonly outcome: 'destroyed' } + | { readonly outcome: 'failed'; readonly failure: OperationFailure }; + +export async function destroy(input: DestroyInput): 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, 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..f62d12b6 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/dev.ts @@ -0,0 +1,67 @@ +/** + * 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 } from './shared.ts'; + +export interface DevEndpoint { + readonly address: string; + readonly url: string; +} + +export type DevEvent = + /** Initial front door + after each successful re-converge. */ + | { readonly kind: 'ready'; readonly endpoints: readonly DevEndpoint[] } + | { readonly kind: 'unwatchable'; readonly address: string } + | { readonly kind: 'rebuild-failed'; 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' } + | { 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; + readonly deps?: OperationDeps | 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 DevEndpoint[]; + /** 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 { + 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, cwd); +} 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 index ed06885a..53625f6c 100644 --- 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 @@ -1,7 +1,7 @@ /** - * The deploy/destroy executor — main.ts's pipeline orchestration (steps 0–9.75) - * with argv, console, and exit codes removed: typed inputs in, structured - * results out. Reached only by lazy import from operations.ts — this module's + * 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. */ @@ -16,15 +16,9 @@ import { type PipelineDeps, type PipelineResult, runPipeline } from '../pipeline import { DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary } from '../render-deployment.ts'; import { runAlchemy } from '../run-alchemy.ts'; import { validateStageName } from '../validate-stage.ts'; -import type { - DeployInput, - DeployResult, - DestroyEvent, - DestroyInput, - DestroyResult, - OperationDeps, - OperationFailure, -} from './results.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'; @@ -87,7 +81,7 @@ export function readDeploymentSummary(resultFilePath: string): DeploymentSummary >(parsed); } -interface ExecuteOptions { +interface StackPipelineOptions { readonly entry: string; readonly name: string | undefined; readonly stage: string | undefined; @@ -97,7 +91,7 @@ interface ExecuteOptions { } export async function executeDeploy(input: DeployInput, cwd: string): Promise { - const outcome = await executeDeployOrDestroy('deploy', { + const outcome = await runStackPipeline('deploy', { entry: input.entry, name: input.name, stage: input.stage, @@ -105,12 +99,12 @@ export async function executeDeploy(input: DeployInput, cwd: string): Promise { - const outcome = await executeDeployOrDestroy('destroy', { + const outcome = await runStackPipeline('destroy', { entry: input.entry, name: input.name, stage: input.target.kind === 'stage' ? input.target.stage : undefined, @@ -118,19 +112,21 @@ export async function executeDestroy(input: DestroyInput, cwd: string): Promise< onEvent: input.onEvent, deps: input.deps, }); - if (outcome.failure !== undefined) return { outcome: 'failed', failure: outcome.failure }; + if (outcome.kind === 'failed') return { outcome: 'failed', failure: outcome.failure }; return { outcome: 'destroyed' }; } -interface ExecuteOutcome { - readonly failure?: OperationFailure | undefined; - readonly summary?: DeploymentSummary | undefined; -} +type StackPipelineOutcome = + | { readonly kind: 'succeeded'; readonly summary: DeploymentSummary | undefined } + | { readonly kind: 'failed'; readonly failure: OperationFailure }; -async function executeDeployOrDestroy( +/** 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: ExecuteOptions, -): Promise { + opts: StackPipelineOptions, +): Promise { const { entry, name, stage, cwd, onEvent, deps } = opts; if (stage !== undefined) { @@ -138,13 +134,16 @@ async function executeDeployOrDestroy( validateStageName(stage); } catch (error) { if (error instanceof CliError) { - return { failure: { kind: 'invalid-input', message: error.message, cause: error } }; + return { + kind: 'failed', + failure: { kind: 'invalid-input', message: error.message, cause: error }, + }; } throw error; } } - // 0. destroy-only guardrail — first, ahead of every other step, so it + // 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"). @@ -153,11 +152,11 @@ async function executeDeployOrDestroy( } let pipeline: PipelineResult; - let containers: Map; + let containers: Map; let alchemyStage: string; try { - // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, + // 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 = @@ -171,11 +170,11 @@ async function executeDeployOrDestroy( pipeline = await runPipeline(entry, name, cwd, pipelineDeps, onAssembleError); const { config, graph, name: resolvedName } = pipeline; - // 7. Resolve each extension's own container (e.g. Prisma Cloud's Project + + // 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(); + containers = new Map(); for (const extension of config.extensions) { if (extension.container === undefined) continue; try { @@ -200,7 +199,7 @@ async function executeDeployOrDestroy( } } - // 7.3 The Alchemy stage is never left to Alchemy's own default (`dev_$USER` + // 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. @@ -220,7 +219,7 @@ async function executeDeployOrDestroy( } alchemyStage = pinnedStage; - // 7.5 Preflight (deploy only): each extension verifies its platform + // 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. @@ -237,10 +236,13 @@ async function executeDeployOrDestroy( } } } catch (error) { - return { failure: { kind: 'pipeline', message: failureMessage(error), cause: error } }; + return { + kind: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; } - // 8. Generate .prisma-composer/alchemy.run.ts (tool state lives where you run the tool). + // Generate .prisma-composer/alchemy.run.ts (tool state lives where you run the tool). const stackPath = writeStackFile({ entryPath: pipeline.entryModule.path, cwd, @@ -256,7 +258,7 @@ async function executeDeployOrDestroy( const resultFilePath = path.join(cwd, '.prisma-composer', 'deployment-result.json'); fs.rmSync(resultFilePath, { force: true }); - // 9. Shell out to alchemy against the generated file. + // Shell out to alchemy against the generated file. let status: number; try { status = (deps?.alchemy ?? runAlchemy)({ @@ -269,6 +271,7 @@ async function executeDeployOrDestroy( }); } catch (error) { return { + kind: 'failed', failure: { kind: 'execution', message: failureMessage(error), @@ -282,6 +285,7 @@ async function executeDeployOrDestroy( } if (status !== 0) { return { + kind: 'failed', failure: { kind: 'execution', message: `alchemy ${action} exited with status ${status}.`, @@ -294,7 +298,7 @@ async function executeDeployOrDestroy( } try { - // 9.5 Teardown (destroy only): each extension removes infrastructure it + // 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 @@ -311,7 +315,7 @@ async function executeDeployOrDestroy( } } - // 9.75 Container removal (destroy only, after every teardown): the CLI's + // 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). @@ -329,11 +333,14 @@ async function executeDeployOrDestroy( } } } catch (error) { - return { failure: { kind: 'pipeline', message: failureMessage(error), cause: error } }; + return { + kind: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; } if (action === 'deploy') { - return { summary: readDeploymentSummary(resultFilePath) }; + return { kind: 'succeeded', summary: readDeploymentSummary(resultFilePath) }; } - return {}; + return { kind: 'succeeded', summary: undefined }; } 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 index d3ee652d..6c34ff88 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -3,9 +3,8 @@ * 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 operations.ts — this module's static graph transitively loads - * alchemy's provider tree, so the control entry must never import it - * statically. + * 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'; @@ -17,7 +16,8 @@ import { DEV_STACK_RELATIVE_PATH, writeDevStackFile } from '../dev/generate-dev- import { startWatch, watchTargetsFrom } from '../dev/watch.ts'; import { type PipelineDeps, runPipeline } from '../pipeline.ts'; import { runAlchemy } from '../run-alchemy.ts'; -import type { DevEndpoint, DevInput, DevSession, DevStartResult } from './results.ts'; +import type { DevEndpoint, DevInput, DevSession, DevStartResult } from './dev.ts'; +import type { ExtensionId } from './shared.ts'; function toCliError(error: unknown): CliError { return error instanceof CliError @@ -69,16 +69,16 @@ export async function executeDev(input: DevInput, cwd: string): Promise>; let resolved: ReadonlyMap; - const containers = new Map(); + const containers = new Map(); try { - // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, + // 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; - // 2. Dev-capability check — resolve every non-build-only extension's lazy + // 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 @@ -89,7 +89,7 @@ export async function executeDev(input: DevInput, cwd: string): Promise void) | undefined; + readonly deps?: LogDeps | 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 DevEndpoint[]; + /** 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 { + 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, cwd); +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/operations.ts b/packages/0-framework/3-tooling/cli/src/operations/operations.ts deleted file mode 100644 index d226922d..00000000 --- a/packages/0-framework/3-tooling/cli/src/operations/operations.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * The programmatic control surface over the deploy pipeline — @internal/assemble's - * second consumer (deploy-cli.md § Contracts). Typed inputs, structured results, - * no argv, no console, no process.exit. The prisma-composer CLI (main.ts) is a - * thin renderer over these operations. - * - * The entry stays import-light: executors load lazily, so importing this - * module is cheap and executes nothing until an operation runs. An executor - * that fails to load comes back as a structured `pipeline` failure, never a - * throw out of the host. - */ -import { checkEffectResolution } from '../check-effect-resolution.ts'; -import { CliError } from '../cli-error.ts'; -import type { - DeployInput, - DeployResult, - DestroyInput, - DestroyResult, - DevInput, - DevStartResult, - LogInput, - LogResult, - OperationFailure, -} from './results.ts'; - -/** 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. */ -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 }; -} - -export async function deploy(input: DeployInput): 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, cwd); -} - -export async function destroy(input: DestroyInput): 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, cwd); -} - -export async function dev(input: DevInput): 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, cwd); -} - -export async function log(input: LogInput): 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, cwd); -} diff --git a/packages/0-framework/3-tooling/cli/src/operations/results.ts b/packages/0-framework/3-tooling/cli/src/operations/results.ts deleted file mode 100644 index d2de0df0..00000000 --- a/packages/0-framework/3-tooling/cli/src/operations/results.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Typed inputs and structured results for the programmatic operations - * (`@prisma/composer/control`). Everything here is `import type`, erased in - * the build — importing this module loads no runtime code. - */ -import type { RunAssembler } from '@internal/assemble'; -import type { PrismaAppConfig } from '@internal/core/config'; -import type { AppIdentity } from '../pipeline.ts'; -import type { DeploymentSummary } from '../render-deployment.ts'; -import type { RunAlchemyInput } from '../run-alchemy.ts'; - -/** The injectable seams every operation shares — identical to main.ts's RunDeps - * (which becomes a re-export alias of this type). */ -export interface OperationDeps { - readonly runAssembler?: RunAssembler | undefined; - readonly alchemy?: ((input: RunAlchemyInput) => number) | undefined; - readonly config?: PrismaAppConfig | undefined; -} - -/** 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'; 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 alchemy child ran and failed. `exitCode` undefined means the spawn itself threw. */ - | { - readonly kind: 'execution'; - readonly message: string; - readonly exitCode: number | undefined; - readonly stackFilePath: string; - readonly reproduceCommand: string; - readonly cwd: string; - readonly cause?: unknown; - }; - -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; - readonly deps?: OperationDeps | 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 }; - -/** 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; - readonly deps?: OperationDeps | undefined; -} - -export type DestroyResult = - | { readonly outcome: 'destroyed' } - | { readonly outcome: 'failed'; readonly failure: OperationFailure }; - -// ---- dev ---- - -export interface DevEndpoint { - readonly address: string; - readonly url: string; -} - -export type DevEvent = - /** Initial front door + after each successful re-converge. */ - | { readonly kind: 'ready'; readonly endpoints: readonly DevEndpoint[] } - | { readonly kind: 'unwatchable'; readonly address: string } - | { readonly kind: 'rebuild-failed'; 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' } - | { 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; - readonly deps?: OperationDeps | 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 DevEndpoint[]; - /** 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 }; - -// ---- log ---- - -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 }; - -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; - readonly deps?: - | { - readonly config?: PrismaAppConfig | undefined; - readonly identity?: AppIdentity | undefined; - } - | 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 DevEndpoint[]; - /** Merged, address-filtered stream; ends on signal abort or when every source ends. */ - readonly lines: AsyncIterable; - } - | { readonly outcome: 'failed'; readonly failure: OperationFailure }; 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..bbbb481a --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -0,0 +1,64 @@ +/** + * 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; + +/** + * @internal Test seam — lets the CLI's own tests drive the operations without + * a real wrapper build, config evaluation, or alchemy process. No stability + * guarantee: the fields mirror internal types and can change in any release. + */ +export interface OperationDeps { + readonly runAssembler?: RunAssembler | undefined; + readonly alchemy?: ((input: RunAlchemyInput) => number) | undefined; + readonly config?: PrismaAppConfig | undefined; +} + +/** 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'; 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 alchemy child ran and failed. `exitCode` undefined means the spawn itself threw. */ + | { + readonly kind: 'execution'; + readonly message: string; + readonly exitCode: number | undefined; + readonly stackFilePath: string; + readonly reproduceCommand: string; + readonly cwd: string; + readonly cause?: unknown; + }; + +/** 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 }; +} From ce53ad03c15660feebcc47712e8b92ddfaddd93f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:19:50 +0200 Subject: [PATCH 13/27] refactor(cli): speak the caller's language in the control types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'unsupported' said too little — it means exactly one thing, the host platform is Windows — so the failure kind is now 'unsupported-platform', renamed before any external consumer can switch on the old literal. - DevEndpoint meant "a running service's address + URL", nothing dev-specific, and log's services were typed with it. It is now ServiceEndpoint, defined once in operations/shared.ts. - DevRunDeps re-declared OperationDeps structurally and LogRunDeps re-declared the log deps shape; both are now aliases, so the shapes cannot drift. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- ...subpath-is-the-programmatic-deploy-surface.md | 2 +- docs/guides/deploying.md | 2 +- .../0-framework/3-tooling/cli/src/dev/run-dev.ts | 12 +++--------- .../3-tooling/cli/src/exports/control.ts | 10 ++-------- .../0-framework/3-tooling/cli/src/log/run-log.ts | 12 +++--------- .../3-tooling/cli/src/operations/dev.ts | 16 ++++++++-------- .../3-tooling/cli/src/operations/execute-dev.ts | 11 +++++++---- .../3-tooling/cli/src/operations/execute-log.ts | 5 ++++- .../3-tooling/cli/src/operations/log.ts | 5 ++--- .../3-tooling/cli/src/operations/shared.ts | 9 ++++++++- skills/prisma-composer/SKILL.md | 2 +- 11 files changed, 40 insertions(+), 46 deletions(-) diff --git a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md index 9a084c56..11d4e10d 100644 --- a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -61,7 +61,7 @@ The subpath is named `control` because that is the architecture plane these sour ## 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`, and `execution` are distinct. Callers needing to distinguish pipeline sub-failures must parse messages until a finer taxonomy exists. +- **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** — `stdio: 'inherit'` is part of the surface's contract. A host that must capture or redirect execution output needs a new option on the operations, not a workaround. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 22369f63..e9382d32 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -324,7 +324,7 @@ What to know before embedding it: 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`, `pipeline` + `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` 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 91cb6b49..1b680dc2 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 @@ -6,11 +6,9 @@ * (capability check, containers, `--fresh` teardown, preflight, emulators, * converge, attach, watch loop) lives in the operation. */ -import type { RunAssembler } from '@internal/assemble'; -import type { PrismaAppConfig } from '@internal/core/config'; import { CliError } from '../cli-error.ts'; import { dev } from '../operations/dev.ts'; -import type { RunAlchemyInput } from '../run-alchemy.ts'; +import type { OperationDeps } from '../operations/shared.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `dev` command. */ export interface DevArgs { @@ -19,12 +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 | undefined; - readonly alchemy?: ((input: RunAlchemyInput) => number) | undefined; - readonly config?: PrismaAppConfig | undefined; -} +/** 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( diff --git a/packages/0-framework/3-tooling/cli/src/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts index 766a0229..0067dffd 100644 --- a/packages/0-framework/3-tooling/cli/src/exports/control.ts +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -17,16 +17,10 @@ export type { DestroyTarget, } from '../operations/destroy.ts'; export { destroy } from '../operations/destroy.ts'; -export type { - DevEndpoint, - DevEvent, - DevInput, - DevSession, - DevStartResult, -} from '../operations/dev.ts'; +export type { DevEvent, DevInput, DevSession, DevStartResult } from '../operations/dev.ts'; export { dev } from '../operations/dev.ts'; export type { LogDeps, LogEvent, LogInput, LogLine, LogResult } from '../operations/log.ts'; export { log } from '../operations/log.ts'; -export type { OperationDeps, OperationFailure } from '../operations/shared.ts'; +export type { OperationDeps, OperationFailure, ServiceEndpoint } from '../operations/shared.ts'; export type { DeployedNodeSummary, DeploymentSummary } from '../render-deployment.ts'; export { DEPLOYMENT_RESULT_FILE_ENV } from '../render-deployment.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 c3a0628d..29667084 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 @@ -7,10 +7,8 @@ * front door once it supervises more than one service); this is where logs * live. */ -import type { PrismaAppConfig } from '@internal/core/config'; import { CliError } from '../cli-error.ts'; -import { log } from '../operations/log.ts'; -import type { AppIdentity } from '../pipeline.ts'; +import { type LogDeps, log } from '../operations/log.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `log` command. */ export interface LogArgs { @@ -22,12 +20,8 @@ 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; -} +/** 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 { diff --git a/packages/0-framework/3-tooling/cli/src/operations/dev.ts b/packages/0-framework/3-tooling/cli/src/operations/dev.ts index f62d12b6..ea9cec1f 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/dev.ts @@ -7,16 +7,16 @@ * 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'; - -export interface DevEndpoint { - readonly address: string; - readonly url: string; -} +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 DevEndpoint[] } + | { readonly kind: 'ready'; readonly endpoints: readonly ServiceEndpoint[] } | { readonly kind: 'unwatchable'; readonly address: string } | { readonly kind: 'rebuild-failed'; readonly message: string } /** The app keeps running, still watching. */ @@ -43,7 +43,7 @@ export interface DevInput { * listeners before installing its own; see run-dev.ts). */ export interface DevSession { /** The initial front door, already merged across attachments. */ - readonly endpoints: readonly DevEndpoint[]; + 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; 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 index 6c34ff88..2191510d 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -16,8 +16,8 @@ import { DEV_STACK_RELATIVE_PATH, writeDevStackFile } from '../dev/generate-dev- import { startWatch, watchTargetsFrom } from '../dev/watch.ts'; import { type PipelineDeps, runPipeline } from '../pipeline.ts'; import { runAlchemy } from '../run-alchemy.ts'; -import type { DevEndpoint, DevInput, DevSession, DevStartResult } from './dev.ts'; -import type { ExtensionId } from './shared.ts'; +import type { DevInput, DevSession, DevStartResult } from './dev.ts'; +import type { ExtensionId, ServiceEndpoint } from './shared.ts'; function toCliError(error: unknown): CliError { return error instanceof CliError @@ -50,7 +50,7 @@ async function withEmulatorRetry(call: () => Promise): Promise { async function mergedEndpoints( attachments: readonly LocalTargetAttachment[], -): Promise { +): Promise { const lists = await Promise.all(attachments.map((a) => withEmulatorRetry(() => a.endpoints()))); return lists.flat(); } @@ -60,7 +60,10 @@ export async function executeDev(input: DevInput, cwd: string): Promise; } diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts index bbbb481a..447bcf74 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -13,6 +13,13 @@ 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; +} + /** * @internal Test seam — lets the CLI's own tests drive the operations without * a real wrapper build, config evaluation, or alchemy process. No stability @@ -30,7 +37,7 @@ 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'; readonly message: string; readonly cause?: unknown } + | { 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. diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index a1607e3b..ddf10bcc 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -680,7 +680,7 @@ const result = await deploy({ entry: 'module.ts', stage: 'pr-42' }); ``` - Failures come back as `{ outcome: 'failed', failure }` with - `failure.kind` ∈ `invalid-input` | `unsupported` | `pipeline` | `execution` + `failure.kind` ∈ `invalid-input` | `unsupported-platform` | `pipeline` | `execution` and the same fix-naming `message` the CLI prints. The effect version conflict is a `pipeline` failure carrying the same diagnostic, and importing the module executes nothing until an operation runs. From 628428dfd2be3516d1ea833ab6f49a6bea59f6af Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:22:49 +0200 Subject: [PATCH 14/27] refactor(cli): keep the execution mechanism out of the published contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported execution failure required exitCode/stackFilePath/ reproduceCommand/cwd — all facts about the CURRENT mechanism (a spawned alchemy child driving a generated stack file), frozen into the surface's types. They now live in an optional 'diagnostics' object documented as mechanism-detail with no stability promise; message/cause are the durable fields. The CLI adapters read diagnostics and print exactly what they always printed, and their rethrow is guarded: a non-Error cause becomes a CliError from the failure message instead of a raw throw. The deployment-summary protocol also gets one named home: deployment-summary.ts holds the shape, the env var, the writer (now best-effort — a write failure cannot fail a converged deploy), and the reader validation. render-deployment.ts is presentation-only again; its report hook calls the writer. The env var's value export is gone from ./control — no host has a use for it, both halves reach it by direct import. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/render-deployment.test.ts | 8 +- .../3-tooling/cli/src/deployment-summary.ts | 101 ++++++++++++++++++ .../3-tooling/cli/src/dev/run-dev.ts | 11 +- .../3-tooling/cli/src/exports/control.ts | 10 +- .../0-framework/3-tooling/cli/src/main.ts | 14 ++- .../operations/__tests__/operations.test.ts | 12 ++- .../3-tooling/cli/src/operations/deploy.ts | 2 +- .../src/operations/execute-deploy-destroy.ts | 66 ++---------- .../cli/src/operations/execute-dev.ts | 10 +- .../3-tooling/cli/src/operations/shared.ts | 23 +++- .../3-tooling/cli/src/render-deployment.ts | 34 +----- 11 files changed, 164 insertions(+), 127 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/deployment-summary.ts 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 248b9306..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 @@ -4,12 +4,8 @@ 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, - deploymentReport, - renderDeployment, - toDeploymentSummary, -} from '../render-deployment.ts'; +import { DEPLOYMENT_RESULT_FILE_ENV, toDeploymentSummary } from '../deployment-summary.ts'; +import { deploymentReport, renderDeployment } from '../render-deployment.ts'; /** * The renderer reads only `address` and `entities` — `node` is along for the 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 1b680dc2..b6aeadc5 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 @@ -82,12 +82,11 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { expect(result.failure).toEqual({ kind: 'execution', message: 'alchemy deploy exited with status 42.', - 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, + 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, + }, }); }); diff --git a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts index 011f4852..da2723e8 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts @@ -6,7 +6,7 @@ * fails to load comes back as a structured `pipeline` failure, never a throw * out of the host. */ -import type { DeploymentSummary } from '../render-deployment.ts'; +import type { DeploymentSummary } from '../deployment-summary.ts'; import { executorLoadFailure, type OperationDeps, type OperationFailure } from './shared.ts'; export interface DeployInput { 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 index 53625f6c..fcf51c6b 100644 --- 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 @@ -9,11 +9,14 @@ 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 { blindCast } from '@internal/foundation/casts'; 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 { DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary } from '../render-deployment.ts'; import { runAlchemy } from '../run-alchemy.ts'; import { validateStageName } from '../validate-stage.ts'; import type { DeployInput, DeployResult } from './deploy.ts'; @@ -32,55 +35,6 @@ function failureMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -/** - * Reads the alchemy child's result file (written by deploymentReport when - * DEPLOYMENT_RESULT_FILE_ENV is set). 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); -} - interface StackPipelineOptions { readonly entry: string; readonly name: string | undefined; @@ -275,11 +229,8 @@ async function runStackPipeline( failure: { kind: 'execution', message: failureMessage(error), - exitCode: undefined, - stackFilePath: stackPath, - reproduceCommand, - cwd, cause: error, + diagnostics: { exitCode: undefined, stackFilePath: stackPath, reproduceCommand, cwd }, }, }; } @@ -289,10 +240,7 @@ async function runStackPipeline( failure: { kind: 'execution', message: `alchemy ${action} exited with status ${status}.`, - exitCode: status, - stackFilePath: stackPath, - reproduceCommand, - cwd, + diagnostics: { exitCode: status, stackFilePath: stackPath, reproduceCommand, cwd }, }, }; } 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 index 2191510d..a1152b24 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -167,10 +167,12 @@ export async function executeDev(input: DevInput, cwd: string): Promise ({ address: node.address, entities: node.entities })), - }; -} +import { writeDeploymentSummaryFile } from './deployment-summary.ts'; /** Gap between the deepest tree label and the entity column. */ const LABEL_GAP = 3; @@ -141,13 +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)); - const file = process.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (file !== undefined && file.length > 0) { - fs.writeFileSync(file, JSON.stringify(toDeploymentSummary(result))); - } + writeDeploymentSummaryFile(result); } From f9a2244c363fd828cdd396a2915e41b64560b680 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:24:21 +0200 Subject: [PATCH 15/27] fix(cli): stack generation failures are results, not rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three code paths sat outside every try: writeStackFile and the stale-result rmSync in the deploy/destroy executor, and the first converge (writeDevStackFile + spawn) in the dev executor. A stray .prisma-composer FILE, a read-only or full disk, or a permissions problem made deploy()/dev() reject — breaking the surface headline contract that failures come back as values. All three now map to pipeline failures, pinned by tests that reproduce the review probe (.prisma-composer as a file must yield a failure result and never reach alchemy). Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/__tests__/operations.test.ts | 123 +++++++++++++----- .../src/operations/execute-deploy-destroy.ts | 37 ++++-- .../cli/src/operations/execute-dev.ts | 14 +- 3 files changed, 129 insertions(+), 45 deletions(-) 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 index 71754bfa..88074f99 100644 --- 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 @@ -390,6 +390,33 @@ describe('deploy()', () => { }); }); + 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(() => + deploy({ + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + deps: { + 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-')), @@ -669,6 +696,39 @@ async function collect(lines: AsyncIterable): Promise { 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, not a rejection', async () => { const app = makeAppDir('hello-dev'); @@ -678,41 +738,12 @@ describe('dev()', () => { endpoints: () => Promise.reject(new Error('emulator admin refused the connection')), logs: async function* () {}, }; - const descriptor: LocalTargetDescriptor = { - providers: () => Layer.empty, - container: { - ensure: () => Promise.resolve(localContainer()), - locate: () => Promise.resolve(undefined), - remove: () => Promise.resolve(), - deserialize: () => localContainer(), - }, - attach: () => Promise.resolve(attachment), - }; - const config: PrismaAppConfig = { - 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 }, - }; const result = await silently(() => dev({ entry: app.entryPath, cwd: app.dir, - deps: { config, runAssembler: fakeAssembler, alchemy: () => 0 }, + deps: { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, }), ); @@ -721,6 +752,38 @@ describe('dev()', () => { expect(result.failure.kind).toBe('pipeline'); expect(result.failure.message).toBe('emulator admin refused the connection'); }, 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(() => + dev({ + entry: app.entryPath, + cwd: app.dir, + deps: { + 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()', () => { 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 index fcf51c6b..4dd66c24 100644 --- 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 @@ -196,21 +196,32 @@ async function runStackPipeline( }; } - // Generate .prisma-composer/alchemy.run.ts (tool state lives where you run the tool). - const stackPath = writeStackFile({ - entryPath: pipeline.entryModule.path, - cwd, - configPath: pipeline.configPath, - name: pipeline.name, - assembled: pipeline.assembled, - }); + // 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. + let stackPath: string; + const resultFilePath = path.join(cwd, '.prisma-composer', 'deployment-result.json'); + try { + stackPath = writeStackFile({ + entryPath: pipeline.entryModule.path, + cwd, + configPath: pipeline.configPath, + name: pipeline.name, + assembled: pipeline.assembled, + }); - const reproduceCommand = `alchemy ${action} ${GENERATED_STACK_RELATIVE_PATH} --yes --stage ${alchemyStage}`; + // Stale-result guard: remove any previous run's result file so a summary is + // only ever read from THIS child's report hook. + fs.rmSync(resultFilePath, { force: true }); + } catch (error) { + return { + kind: 'failed', + failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, + }; + } - // Stale-result guard: remove any previous run's result file so a summary is - // only ever read from THIS child's report hook. - const resultFilePath = path.join(cwd, '.prisma-composer', 'deployment-result.json'); - fs.rmSync(resultFilePath, { force: true }); + const reproduceCommand = `alchemy ${action} ${GENERATED_STACK_RELATIVE_PATH} --yes --stage ${alchemyStage}`; // Shell out to alchemy against the generated file. let status: number; 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 index a1152b24..e7dd031c 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -159,8 +159,18 @@ export async function executeDev(input: DevInput, cwd: string): Promise Date: Thu, 6 Aug 2026 23:28:11 +0200 Subject: [PATCH 16/27] fix(cli): make the merged log stream safe to stop, bounded, and quiet after end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects in mergeLogStreams, all host-facing: - A consumer that stopped iterating without aborting hung forever: the generator's finally awaited pumps whose sources never end. The merge now runs on an internal AbortController linked to the caller's signal; the finally aborts it and does NOT await the pumps, so break/lines.return() terminate promptly even against a source that ignores the signal. - The queue was unbounded. It is now capped at 10k lines with a drop-oldest policy; the consumer learns how many lines it lost through a new lines-dropped LogEvent member, coalesced per delivery. The CLI adapter ignores it (its console drain never fell behind before either). - Events could fire into torn-down host state after the iterable ended; a done flag plus the abort now silence them. - log skipped the emulator retry dev has, so a transient loopback refusal right after a converge — precisely when the CLI's own hint says to run log — became a hard failure. attach() and endpoints() now retry through the shared withEmulatorRetry, moved to operations/emulator-retry.ts. CliError also accepts ErrorOptions so the executors' toCliError wrappers preserve the original error as cause instead of flattening it to a string. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/cli-error.ts | 4 +- .../operations/__tests__/operations.test.ts | 121 +++++++++++++++++- .../cli/src/operations/emulator-retry.ts | 25 ++++ .../cli/src/operations/execute-dev.ts | 22 +--- .../cli/src/operations/execute-log.ts | 69 ++++++++-- .../3-tooling/cli/src/operations/log.ts | 5 +- 6 files changed, 209 insertions(+), 37 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/operations/emulator-retry.ts 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/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 88074f99..8ac7875d 100644 --- 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 @@ -884,6 +884,123 @@ describe('log()', () => { 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(() => + log({ entry: 'service.ts', deps: { 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(() => + log({ entry: 'service.ts', deps: { 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(() => + log({ entry: 'service.ts', deps: { 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 = 10_150; + 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(() => + log({ + entry: 'service.ts', + onEvent: (event) => { + if (event.kind === 'lines-dropped') droppedCounts.push(event.count); + }, + deps: { 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(() => + log({ + entry: 'service.ts', + onEvent: (event) => void events.push(event.kind), + deps: { identity: identityFor([lateFailer]) }, + }), + ); + + if (result.outcome !== 'attached') throw new Error('expected attached'); + for await (const line of result.lines) { + void line; + break; + } + failLate?.(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(events).toEqual([]); + }, 5_000); + 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' }; @@ -898,7 +1015,9 @@ describe('log()', () => { const result = await silently(() => log({ entry: 'service.ts', - onEvent: (event) => void events.push(event.message), + onEvent: (event) => { + if (event.kind === 'stream-failed') events.push(event.message); + }, deps: { identity: identityFor([failing, healthy]) }, }), ); 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-dev.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts index e7dd031c..1ebfc7f6 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -17,37 +17,19 @@ import { startWatch, watchTargetsFrom } from '../dev/watch.ts'; import { type PipelineDeps, runPipeline } from '../pipeline.ts'; import { runAlchemy } from '../run-alchemy.ts'; import type { DevInput, DevSession, DevStartResult } from './dev.ts'; +import { withEmulatorRetry } from './emulator-retry.ts'; import type { ExtensionId, ServiceEndpoint } from './shared.ts'; function toCliError(error: unknown): CliError { return error instanceof CliError ? error - : new CliError(error instanceof Error ? error.message : String(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); } -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 { 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 index 58d3aab9..36ee3996 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts @@ -11,31 +11,50 @@ import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/cor import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; import { CliError } from '../cli-error.ts'; import { resolveAppIdentity } from '../pipeline.ts'; -import type { LogInput, LogLine, LogResult } from './log.ts'; +import { withEmulatorRetry } from './emulator-retry.ts'; +import type { 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)); + : 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. */ +const LOG_QUEUE_LIMIT = 10_000; + /** * Merges every attachment's log stream into one iterable: one pump per - * attachment pushing into a shared queue. A pump's throw becomes a - * `stream-failed` event and ends that pump only; the merged iterable ends when - * `input.signal` aborts or every pump ends. Address filtering and `tail` apply - * exactly as the CLI always has. + * 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 { - const signal = input.signal ?? new AbortController().signal; + // 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 => { @@ -44,30 +63,46 @@ async function* mergeLogStreams( }; signal.addEventListener('abort', notify, { once: true }); - const pumps = attachments.map(async (attachment) => { + const emit = (event: LogEvent): void => { + if (!done) input.onEvent?.(event); + }; + + // 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) { - input.onEvent?.({ kind: 'stream-failed', message: failureMessage(error) }); + 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(); } @@ -77,8 +112,14 @@ async function* mergeLogStreams( }); } } 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); - await Promise.all(pumps); + input.signal?.removeEventListener('abort', abortInternal); } } @@ -116,13 +157,15 @@ export async function executeLog(input: LogInput, cwd: string): Promise target.attach({ container, devDir }))); } catch (error) { throw toCliError(error); } } - services = (await Promise.all(attachments.map((a) => a.endpoints()))).flat(); + services = ( + await Promise.all(attachments.map((a) => withEmulatorRetry(() => a.endpoints()))) + ).flat(); } catch (error) { return { outcome: 'failed', diff --git a/packages/0-framework/3-tooling/cli/src/operations/log.ts b/packages/0-framework/3-tooling/cli/src/operations/log.ts index 7165bd2f..54f8335a 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/log.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/log.ts @@ -17,7 +17,10 @@ export interface LogLine { export type LogEvent = /** One attachment's stream died; the others continue. */ - { readonly kind: 'stream-failed'; readonly message: string }; + | { 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 }; /** * @internal Test seam — lets the CLI's own tests drive `log` without a real From ea56d3c10998fb25e03d4cbb948f9c1994e5bb7d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:31:22 +0200 Subject: [PATCH 17/27] fix(cli): harden the dev session's event and teardown paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every event emission in the dev executor goes through a guard: a host onEvent that throws is the host's bug, and it can no longer prevent closed from settling or become an unhandled rejection out of the fire-and-forget watch callback. - A failure between attach and session hand-over (partial startServices, endpoint merge, watch setup) now rolls everything back — watcher stopped, every started service stopped — before the failure result returns. - Watch errors no longer print from inside the operation: startWatch takes an onError callback, the executor maps it to a new watch-error DevEvent, and the CLI adapter prints today's exact '[dev] watch error:' line. That was the last console write reachable from an operation. - stop() no longer swallows teardown errors: a service that refuses to stop becomes a stop-error DevEvent; teardown continues, stopped still fires, closed still settles. The CLI adapter ignores it, matching the shipped behavior where these were silently dropped. - The CLI adapter also renders the converge-failed event's hint fields (stack file path + reproduce command) and removes its SIGINT/SIGTERM listeners once the session closes. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/dev/run-dev.ts | 9 ++ .../3-tooling/cli/src/dev/watch.ts | 11 +- .../operations/__tests__/operations.test.ts | 63 +++++++++- .../3-tooling/cli/src/operations/dev.ts | 4 + .../cli/src/operations/execute-dev.ts | 116 +++++++++++------- 5 files changed, 153 insertions(+), 50 deletions(-) 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 b6aeadc5..4ddd4153 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 @@ -63,10 +63,17 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise 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/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 8ac7875d..cbd88ec0 100644 --- 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 @@ -730,11 +730,15 @@ function devConfigWith(attachment: LocalTargetAttachment): PrismaAppConfig { } describe('dev()', () => { - test('a throw after services start (endpoint merge) is a pipeline failure, not a rejection', async () => { + 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: () => Promise.resolve(), + stopServices: () => { + stops += 1; + return Promise.resolve(); + }, endpoints: () => Promise.reject(new Error('emulator admin refused the connection')), logs: async function* () {}, }; @@ -751,6 +755,61 @@ describe('dev()', () => { 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 dev({ + entry: app.entryPath, + cwd: app.dir, + onEvent: (event) => void events.push(event.kind), + deps: { 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('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 dev({ + entry: app.entryPath, + cwd: app.dir, + onEvent: () => { + throw new Error('host renderer blew up'); + }, + deps: { 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 () => { diff --git a/packages/0-framework/3-tooling/cli/src/operations/dev.ts b/packages/0-framework/3-tooling/cli/src/operations/dev.ts index ea9cec1f..ef0517b7 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/dev.ts @@ -19,6 +19,8 @@ export type DevEvent = | { 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'; @@ -27,6 +29,8 @@ export type DevEvent = 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 { 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 index 1ebfc7f6..8aeb1160 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -13,10 +13,10 @@ import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/cor 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, watchTargetsFrom } from '../dev/watch.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 { DevInput, DevSession, DevStartResult } from './dev.ts'; +import type { DevEvent, DevInput, DevSession, DevStartResult } from './dev.ts'; import { withEmulatorRetry } from './emulator-retry.ts'; import type { ExtensionId, ServiceEndpoint } from './shared.ts'; @@ -169,72 +169,90 @@ export async function executeDev(input: DevInput, cwd: string): Promise { + 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. + // 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 })); } - // 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); } } const endpoints = await mergedEndpoints(attachments); - onEvent?.({ kind: 'ready', endpoints }); + 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) { - onEvent?.({ kind: 'unwatchable', address }); + emit({ kind: 'unwatchable', address }); } const watchDeps: PipelineDeps = { runAssembler: deps?.runAssembler, config: deps?.config }; - 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(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) { - onEvent?.({ kind: 'converge-failed', stackFilePath: stackPath, reproduceCommand, cwd }); - return; + 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) }); } - onEvent?.({ kind: 'ready', endpoints: await mergedEndpoints(attachments) }); - } catch (error) { - onEvent?.({ 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; @@ -246,13 +264,19 @@ export async function executeDev(input: DevInput, cwd: string): Promise => { if (!stopping) { stopping = true; - onEvent?.({ kind: 'stopping' }); - watch.stop(); + 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) { - await attachment.stopServices().catch(() => undefined); + try { + await attachment.stopServices(); + } catch (error) { + emit({ kind: 'stop-error', message: failureMessage(error) }); + } } - onEvent?.({ kind: 'stopped' }); + emit({ kind: 'stopped' }); resolveClosed(); })(); } @@ -262,6 +286,8 @@ export async function executeDev(input: DevInput, cwd: string): Promise a.stopServices().catch(() => undefined))); return { outcome: 'failed', failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, From 7458963929ccc1faa9922ca9d7d421062e9fc870 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:37:13 +0200 Subject: [PATCH 18/27] refactor(cli): strip the injection seam from the published operation inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per operator ruling: either it's public API or it isn't — we don't ship production code just for tests. DeployInput/DestroyInput/DevInput/LogInput no longer carry deps, and OperationDeps/LogDeps are gone from the ./control shims. Each per-operation module now pairs the clean public function with an in-package *WithDeps variant (deployWithDeps, ...) that takes the seam as a separate parameter; the executors take (input, deps, cwd). The CLI adapters and unit tests thread RunDeps through the WithDeps variants — run.test.ts unchanged — and the published integration consumer (test/integration control.deploy.test.ts) keeps driving the real pipeline with no seam at all. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/dev/run-dev.ts | 78 +++-- .../3-tooling/cli/src/exports/control.ts | 3 +- .../3-tooling/cli/src/log/run-log.ts | 28 +- .../0-framework/3-tooling/cli/src/main.ts | 38 +- .../operations/__tests__/operations.test.ts | 326 ++++++++++-------- .../3-tooling/cli/src/operations/deploy.ts | 13 +- .../3-tooling/cli/src/operations/destroy.ts | 13 +- .../3-tooling/cli/src/operations/dev.ts | 10 +- .../src/operations/execute-deploy-destroy.ts | 22 +- .../cli/src/operations/execute-dev.ts | 18 +- .../cli/src/operations/execute-log.ts | 8 +- .../3-tooling/cli/src/operations/log.ts | 17 +- .../3-tooling/cli/src/operations/shared.ts | 7 +- 13 files changed, 335 insertions(+), 246 deletions(-) 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 4ddd4153..aaef9a78 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 @@ -7,7 +7,7 @@ * converge, attach, watch loop) lives in the operation. */ import { CliError } from '../cli-error.ts'; -import { dev } from '../operations/dev.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. */ @@ -46,46 +46,48 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { - 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; + 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 'stopping': + console.log( + "[dev] stopping — the app's services are stopping; emulators and data stay up.", + ); + break; + case 'stopped': + console.log('[dev] stopped.'); + 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 '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, - }); + ); if (result.outcome === 'failed') { const failure = result.failure; diff --git a/packages/0-framework/3-tooling/cli/src/exports/control.ts b/packages/0-framework/3-tooling/cli/src/exports/control.ts index 7095ff9f..5292a1d6 100644 --- a/packages/0-framework/3-tooling/cli/src/exports/control.ts +++ b/packages/0-framework/3-tooling/cli/src/exports/control.ts @@ -20,11 +20,10 @@ export type { export { destroy } from '../operations/destroy.ts'; export type { DevEvent, DevInput, DevSession, DevStartResult } from '../operations/dev.ts'; export { dev } from '../operations/dev.ts'; -export type { LogDeps, LogEvent, LogInput, LogLine, LogResult } from '../operations/log.ts'; +export type { LogEvent, LogInput, LogLine, LogResult } from '../operations/log.ts'; export { log } from '../operations/log.ts'; export type { ExecutionDiagnostics, - OperationDeps, 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 29667084..7ef7faac 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 @@ -8,7 +8,7 @@ * live. */ import { CliError } from '../cli-error.ts'; -import { type LogDeps, log } from '../operations/log.ts'; +import { type LogDeps, logWithDeps } from '../operations/log.ts'; /** The subset of `ParsedArgs` `run()` hands off for the `log` command. */ export interface LogArgs { @@ -31,19 +31,21 @@ export async function runLog(args: LogArgs, deps: LogRunDeps = {}): Promise { - if (event.kind === 'stream-failed') { - console.error(`[log] stream failed: ${event.message}`); - } + 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}`); + } + }, }, - deps: { config: deps.config, identity: deps.identity }, - }); + { config: deps.config, identity: deps.identity }, + ); if (result.outcome === 'failed') { throw result.failure.cause instanceof Error diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index 0c7e42a8..ba6282eb 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -7,8 +7,8 @@ import { Cli, Command, Option, UsageError } from 'clipanion'; import { CliError } from './cli-error.ts'; import { runDev } from './dev/run-dev.ts'; import { runLog } from './log/run-log.ts'; -import { deploy } from './operations/deploy.ts'; -import { type DestroyTarget, destroy } from './operations/destroy.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'; @@ -268,12 +268,10 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< '--production is only valid with `destroy`; `deploy` targets production by default (omit --stage).', ); } - const result = await deploy({ - entry: args.entry, - name: args.name, - stage: args.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); } @@ -290,20 +288,22 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< const target: DestroyTarget = args.stage !== undefined ? { kind: 'stage', stage: args.stage } : { kind: 'production' }; - const result = await destroy({ - 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.', - ); - } + 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.', + ); + } + }, }, 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 index cbd88ec0..090af984 100644 --- 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 @@ -25,10 +25,10 @@ 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 { deploy } from '../deploy.ts'; -import { destroy } from '../destroy.ts'; -import { dev } from '../dev.ts'; -import { type LogLine, log } from '../log.ts'; +import { deployWithDeps } from '../deploy.ts'; +import { destroyWithDeps } from '../destroy.ts'; +import { devWithDeps } from '../dev.ts'; +import { type LogLine, logWithDeps } from '../log.ts'; const tmpDirs: string[] = []; @@ -203,11 +203,13 @@ describe('deploy()', () => { const calls: RunAlchemyInput[] = []; const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'ci-7', - cwd: app.dir, - deps: { + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: (input) => { @@ -217,7 +219,7 @@ describe('deploy()', () => { return 0; }, }, - }), + ), ); expect(calls).toHaveLength(1); @@ -229,12 +231,14 @@ describe('deploy()', () => { const app = makeAppDir('hello-ops'); const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'ci-7', - cwd: app.dir, - deps: { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 0 }, - }), + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 0 }, + ), ); expect(result).toEqual({ outcome: 'deployed', summary: undefined }); @@ -244,11 +248,13 @@ describe('deploy()', () => { const app = makeAppDir('hello-ops'); const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'ci-7', - cwd: app.dir, - deps: { + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: (input) => { @@ -257,7 +263,7 @@ describe('deploy()', () => { return 0; }, }, - }), + ), ); expect(result).toEqual({ outcome: 'deployed', summary: undefined }); @@ -270,11 +276,13 @@ describe('deploy()', () => { let existedAtSpawn: boolean | undefined; const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'ci-7', - cwd: app.dir, - deps: { + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => { @@ -282,7 +290,7 @@ describe('deploy()', () => { return 0; }, }, - }), + ), ); expect(existedAtSpawn).toBe(false); @@ -294,16 +302,18 @@ describe('deploy()', () => { const containerCalls: ContainerCall[] = []; const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'bad..ref', - cwd: app.dir, - deps: { + deployWithDeps( + { + entry: app.entryPath, + stage: 'bad..ref', + cwd: app.dir, + }, + { config: fakeConfig({}, { calls: containerCalls }), runAssembler: fakeAssembler, alchemy: () => 0, }, - }), + ), ); expect(result.outcome).toBe('failed'); @@ -318,11 +328,13 @@ describe('deploy()', () => { const app = makeAppDir('no-config', { config: false }); const result = await silently(() => - deploy({ - entry: app.entryPath, - cwd: app.dir, - deps: { runAssembler: fakeAssembler, alchemy: () => 0 }, - }), + deployWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + }, + { runAssembler: fakeAssembler, alchemy: () => 0 }, + ), ); expect(result.outcome).toBe('failed'); @@ -337,11 +349,13 @@ describe('deploy()', () => { let alchemyRan = false; const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'ci-7', - cwd: app.dir, - deps: { + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig({ preflight: async () => { throw new Error('SECRET_X is not provisioned'); @@ -353,7 +367,7 @@ describe('deploy()', () => { return 0; }, }, - }), + ), ); expect(result.outcome).toBe('failed'); @@ -368,12 +382,14 @@ describe('deploy()', () => { const app = makeAppDir(); const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'ci-7', - cwd: app.dir, - deps: { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 42 }, - }), + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 42 }, + ), ); expect(result.outcome).toBe('failed'); @@ -396,11 +412,13 @@ describe('deploy()', () => { let alchemyRan = false; const result = await silently(() => - deploy({ - entry: app.entryPath, - stage: 'ci-7', - cwd: app.dir, - deps: { + deployWithDeps( + { + entry: app.entryPath, + stage: 'ci-7', + cwd: app.dir, + }, + { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => { @@ -408,7 +426,7 @@ describe('deploy()', () => { return 0; }, }, - }), + ), ); expect(result.outcome).toBe('failed'); @@ -505,16 +523,18 @@ describe('destroy()', () => { const containerCalls: ContainerCall[] = []; const result = await silently(() => - destroy({ - entry: app.entryPath, - target, - cwd: app.dir, - deps: { + destroyWithDeps( + { + entry: app.entryPath, + target, + cwd: app.dir, + }, + { config: fakeConfig({}, { calls: containerCalls, alchemyStage: 'br_x' }), runAssembler: fakeAssembler, alchemy: () => 0, }, - }), + ), ); expect(result).toEqual({ outcome: 'destroyed' }); @@ -531,16 +551,18 @@ describe('destroy()', () => { fs.writeFileSync(path.join(app.dir, '.alchemy', 'state.json'), '{}'); const result = await silently(() => - destroy({ - entry: app.entryPath, - target: { kind: 'stage', stage: 'staging' }, - cwd: app.dir, - deps: { + 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'); @@ -558,11 +580,13 @@ describe('destroy()', () => { const order: string[] = []; const result = await silently(() => - destroy({ - entry: app.entryPath, - target: { kind: 'stage', stage: 'staging' }, - cwd: app.dir, - deps: { + 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') }, @@ -573,7 +597,7 @@ describe('destroy()', () => { return 0; }, }, - }), + ), ); expect(result).toEqual({ outcome: 'destroyed' }); @@ -585,12 +609,14 @@ describe('destroy()', () => { const order: string[] = []; const result = await silently(() => - destroy({ - entry: app.entryPath, - target: { kind: 'stage', stage: 'staging' }, - cwd: app.dir, - onEvent: (event) => void order.push(event.kind), - deps: { + 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'); @@ -598,7 +624,7 @@ describe('destroy()', () => { }, alchemy: () => 0, }, - }), + ), ); expect(result).toEqual({ outcome: 'destroyed' }); @@ -612,13 +638,15 @@ describe('destroy()', () => { const events: string[] = []; await silently(() => - destroy({ - entry: app.entryPath, - target: { kind: 'stage', stage: 'staging' }, - cwd: app.dir, - onEvent: (event) => void events.push(event.kind), - deps: { config: fakeConfig(), runAssembler: fakeAssembler, alchemy: () => 0 }, - }), + 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([]); @@ -744,11 +772,13 @@ describe('dev()', () => { }; const result = await silently(() => - dev({ - entry: app.entryPath, - cwd: app.dir, - deps: { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, - }), + devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + }, + { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, + ), ); expect(result.outcome).toBe('failed'); @@ -769,12 +799,14 @@ describe('dev()', () => { const events: string[] = []; const result = await silently(async () => { - const start = await dev({ - entry: app.entryPath, - cwd: app.dir, - onEvent: (event) => void events.push(event.kind), - deps: { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, - }); + 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; @@ -795,14 +827,16 @@ describe('dev()', () => { }; const result = await silently(async () => { - const start = await dev({ - entry: app.entryPath, - cwd: app.dir, - onEvent: () => { - throw new Error('host renderer blew up'); + const start = await devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + onEvent: () => { + throw new Error('host renderer blew up'); + }, }, - deps: { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => 0 }, - }); + { 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; @@ -824,10 +858,12 @@ describe('dev()', () => { let alchemyRan = false; const result = await silently(() => - dev({ - entry: app.entryPath, - cwd: app.dir, - deps: { + devWithDeps( + { + entry: app.entryPath, + cwd: app.dir, + }, + { config: devConfigWith(attachment), runAssembler: fakeAssembler, alchemy: () => { @@ -835,7 +871,7 @@ describe('dev()', () => { return 0; }, }, - }), + ), ); expect(result.outcome).toBe('failed'); @@ -853,7 +889,7 @@ describe('log()', () => { ]; const result = await silently(() => - log({ entry: 'service.ts', deps: { identity: identityFor(attachments) } }), + logWithDeps({ entry: 'service.ts' }, { identity: identityFor(attachments) }), ); expect(result.outcome).toBe('attached'); @@ -883,7 +919,7 @@ describe('log()', () => { ]; const result = await silently(() => - log({ entry: 'service.ts', address: 'a', deps: { identity: identityFor(attachments) } }), + logWithDeps({ entry: 'service.ts', address: 'a' }, { identity: identityFor(attachments) }), ); if (result.outcome !== 'attached') throw new Error('expected attached'); @@ -894,7 +930,7 @@ describe('log()', () => { const attachments = [linesAttachment([{ address: 'a', url: 'http://a' }], [])]; const result = await silently(() => - log({ entry: 'service.ts', address: 'nope', deps: { identity: identityFor(attachments) } }), + logWithDeps({ entry: 'service.ts', address: 'nope' }, { identity: identityFor(attachments) }), ); expect(result.outcome).toBe('failed'); @@ -907,7 +943,7 @@ describe('log()', () => { const attachments = [linesAttachment([], [])]; const result = await silently(() => - log({ entry: 'service.ts', deps: { identity: identityFor(attachments) } }), + logWithDeps({ entry: 'service.ts' }, { identity: identityFor(attachments) }), ); expect(result.outcome).toBe('attached'); @@ -927,11 +963,13 @@ describe('log()', () => { const controller = new AbortController(); const result = await silently(() => - log({ - entry: 'service.ts', - signal: controller.signal, - deps: { identity: identityFor([live]) }, - }), + logWithDeps( + { + entry: 'service.ts', + signal: controller.signal, + }, + { identity: identityFor([live]) }, + ), ); if (result.outcome !== 'attached') throw new Error('expected attached'); @@ -957,7 +995,7 @@ describe('log()', () => { }; const result = await silently(() => - log({ entry: 'service.ts', deps: { identity: identityFor([flaky]) } }), + logWithDeps({ entry: 'service.ts' }, { identity: identityFor([flaky]) }), ); expect(result.outcome).toBe('attached'); @@ -973,7 +1011,7 @@ describe('log()', () => { }); const result = await silently(() => - log({ entry: 'service.ts', deps: { identity: identityFor([stubborn]) } }), + logWithDeps({ entry: 'service.ts' }, { identity: identityFor([stubborn]) }), ); if (result.outcome !== 'attached') throw new Error('expected attached'); @@ -992,7 +1030,7 @@ describe('log()', () => { }); const result = await silently(() => - log({ entry: 'service.ts', deps: { identity: identityFor([stubborn]) } }), + logWithDeps({ entry: 'service.ts' }, { identity: identityFor([stubborn]) }), ); if (result.outcome !== 'attached') throw new Error('expected attached'); @@ -1009,13 +1047,15 @@ describe('log()', () => { const droppedCounts: number[] = []; const result = await silently(() => - log({ - entry: 'service.ts', - onEvent: (event) => { - if (event.kind === 'lines-dropped') droppedCounts.push(event.count); + logWithDeps( + { + entry: 'service.ts', + onEvent: (event) => { + if (event.kind === 'lines-dropped') droppedCounts.push(event.count); + }, }, - deps: { identity: identityFor([flood]) }, - }), + { identity: identityFor([flood]) }, + ), ); if (result.outcome !== 'attached') throw new Error('expected attached'); @@ -1043,11 +1083,13 @@ describe('log()', () => { const events: string[] = []; const result = await silently(() => - log({ - entry: 'service.ts', - onEvent: (event) => void events.push(event.kind), - deps: { identity: identityFor([lateFailer]) }, - }), + logWithDeps( + { + entry: 'service.ts', + onEvent: (event) => void events.push(event.kind), + }, + { identity: identityFor([lateFailer]) }, + ), ); if (result.outcome !== 'attached') throw new Error('expected attached'); @@ -1072,13 +1114,15 @@ describe('log()', () => { const events: string[] = []; const result = await silently(() => - log({ - entry: 'service.ts', - onEvent: (event) => { - if (event.kind === 'stream-failed') events.push(event.message); + logWithDeps( + { + entry: 'service.ts', + onEvent: (event) => { + if (event.kind === 'stream-failed') events.push(event.message); + }, }, - deps: { identity: identityFor([failing, healthy]) }, - }), + { identity: identityFor([failing, healthy]) }, + ), ); if (result.outcome !== 'attached') throw new Error('expected attached'); diff --git a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts index da2723e8..f9f426ff 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts @@ -18,7 +18,6 @@ export interface DeployInput { readonly stage?: string | undefined; /** Defaults to process.cwd(); the directory `.prisma-composer/` and `.alchemy` state live under. */ readonly cwd?: string | undefined; - readonly deps?: OperationDeps | undefined; } export type DeployResult = @@ -31,6 +30,16 @@ export type DeployResult = | { 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 { @@ -38,5 +47,5 @@ export async function deploy(input: DeployInput): Promise { } catch (error) { return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; } - return executor.executeDeploy(input, 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 index fe9eb257..d8fe2818 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/destroy.ts @@ -24,7 +24,6 @@ export interface DestroyInput { readonly cwd?: string | undefined; /** Mid-operation notifications, in real time. Rendering is the host's. */ readonly onEvent?: ((event: DestroyEvent) => void) | undefined; - readonly deps?: OperationDeps | undefined; } export type DestroyResult = @@ -32,6 +31,16 @@ export type DestroyResult = | { 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 { @@ -39,5 +48,5 @@ export async function destroy(input: DestroyInput): Promise { } catch (error) { return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; } - return executor.executeDestroy(input, 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 index ef0517b7..5ba0f8d6 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/dev.ts @@ -39,7 +39,6 @@ export interface DevInput { readonly fresh?: boolean | undefined; readonly cwd?: string | undefined; readonly onEvent?: ((event: DevEvent) => void) | undefined; - readonly deps?: OperationDeps | undefined; } /** A running dev session. The operation NEVER touches process signal handlers — @@ -60,6 +59,13 @@ export type DevStartResult = | { 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 { @@ -67,5 +73,5 @@ export async function dev(input: DevInput): Promise { } catch (error) { return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; } - return executor.executeDev(input, cwd); + return executor.executeDev(input, deps, cwd); } 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 index 4dd66c24..67bc798b 100644 --- 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 @@ -41,30 +41,38 @@ interface StackPipelineOptions { readonly stage: string | undefined; readonly cwd: string; readonly onEvent: ((event: DestroyEvent) => void) | undefined; - readonly deps: OperationDeps | undefined; + readonly deps: OperationDeps; } -export async function executeDeploy(input: DeployInput, cwd: string): Promise { +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: input.deps, + deps, }); if (outcome.kind === 'failed') return { outcome: 'failed', failure: outcome.failure }; return { outcome: 'deployed', summary: outcome.summary }; } -export async function executeDestroy(input: DestroyInput, cwd: string): Promise { +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: input.deps, + deps, }); if (outcome.kind === 'failed') return { outcome: 'failed', failure: outcome.failure }; return { outcome: 'destroyed' }; @@ -112,7 +120,7 @@ async function runStackPipeline( 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 pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; const onAssembleError = action === 'destroy' ? (error: Error): CliError => @@ -226,7 +234,7 @@ async function runStackPipeline( // Shell out to alchemy against the generated file. let status: number; try { - status = (deps?.alchemy ?? runAlchemy)({ + status = (deps.alchemy ?? runAlchemy)({ command: action, stackFileRelativePath: GENERATED_STACK_RELATIVE_PATH, cwd, 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 index 8aeb1160..baa316d8 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -18,7 +18,7 @@ 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, ServiceEndpoint } from './shared.ts'; +import type { ExtensionId, OperationDeps, ServiceEndpoint } from './shared.ts'; function toCliError(error: unknown): CliError { return error instanceof CliError @@ -38,7 +38,11 @@ async function mergedEndpoints( } /** Runs the full dev pipeline; resolves to a running session or a structured failure. */ -export async function executeDev(input: DevInput, cwd: string): Promise { +export async function executeDev( + input: DevInput, + deps: OperationDeps, + cwd: string, +): Promise { if (process.platform === 'win32') { return { outcome: 'failed', @@ -49,7 +53,7 @@ export async function executeDev(input: DevInput, cwd: string): Promise>; @@ -59,7 +63,7 @@ export async function executeDev(input: DevInput, cwd: string): Promise { @@ -231,7 +235,7 @@ export async function executeDev(input: DevInput, cwd: string): Promise { +export async function executeLog(input: LogInput, deps: LogDeps, cwd: string): Promise { if (process.platform === 'win32') { return { outcome: 'failed', @@ -143,8 +143,8 @@ export async function executeLog(input: LogInput, cwd: string): Promise; diff --git a/packages/0-framework/3-tooling/cli/src/operations/log.ts b/packages/0-framework/3-tooling/cli/src/operations/log.ts index 54f8335a..9d26f282 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/log.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/log.ts @@ -22,11 +22,10 @@ export type LogEvent = * `count` oldest lines were dropped since the last delivered line. */ | { readonly kind: 'lines-dropped'; readonly count: number }; -/** - * @internal Test seam — lets the CLI's own tests drive `log` without a real - * config evaluation or entry module. No stability guarantee. - */ +/** 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; @@ -44,7 +43,6 @@ export interface LogInput { /** Ends the stream when aborted. The host owns SIGINT/SIGTERM → abort. */ readonly signal?: AbortSignal | undefined; readonly onEvent?: ((event: LogEvent) => void) | undefined; - readonly deps?: LogDeps | undefined; } export type LogResult = @@ -61,6 +59,13 @@ export type LogResult = | { 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 { @@ -68,5 +73,5 @@ export async function log(input: LogInput): Promise { } catch (error) { return { outcome: 'failed', failure: executorLoadFailure(error, cwd) }; } - return executor.executeLog(input, 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 index 77081733..fcd4f5bb 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -21,9 +21,10 @@ export interface ServiceEndpoint { } /** - * @internal Test seam — lets the CLI's own tests drive the operations without - * a real wrapper build, config evaluation, or alchemy process. No stability - * guarantee: the fields mirror internal types and can change in any release. + * 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; From f7fec8c0dbe7d7235c2684309e718c414b800269 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:39:20 +0200 Subject: [PATCH 19/27] fix(cli): give each deploy run its own result file, and prove the round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The result file was a fixed path under cwd, so two deploys sharing a working directory (two stages from one checkout, a host mid-deploy while the CLI runs) silently corrupted each other: run B's stale-guard deleted run A's summary and both children raced one file. The path now carries pid + a UUID, the pre-spawn stale-guard is gone (nothing can be stale on a unique path), and the file is deleted right after the operation reads it. The summary protocol also gains its first covering test through a REAL child process: the injected alchemy spawns a bun child that calls writeDeploymentSummaryFile with the env var the operation set, and the operation reads back exactly what the writer wrote — previously every regression in the pair (env-var drift, report unwired) presented as a normal deploy with summary: undefined. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/__tests__/operations.test.ts | 82 ++++++++++++++----- .../src/operations/execute-deploy-destroy.ts | 23 ++++-- 2 files changed, 77 insertions(+), 28 deletions(-) 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 index 090af984..b8809ee8 100644 --- 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 @@ -147,7 +147,7 @@ const coreIndex = path.resolve( function makeAppDir( name = 'fixture-app', opts: { config?: boolean } = {}, -): { dir: string; entryPath: string; resultFilePath: string } { +): { 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' })); @@ -180,11 +180,7 @@ function makeAppDir( '', ].join('\n'), ); - return { - dir, - entryPath, - resultFilePath: path.join(dir, '.prisma-composer', 'deployment-result.json'), - }; + return { dir, entryPath }; } const fakeAssembler = async (node: ServiceNode) => ({ @@ -223,8 +219,12 @@ describe('deploy()', () => { ); expect(calls).toHaveLength(1); - expect(calls[0]?.env?.[DEPLOYMENT_RESULT_FILE_ENV]).toBe(app.resultFilePath); + 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 () => { @@ -269,33 +269,71 @@ describe('deploy()', () => { expect(result).toEqual({ outcome: 'deployed', summary: undefined }); }); - test("a previous run's stale result file is removed before alchemy spawns", async () => { + 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('the summary round-trips through a real child process writing via the report writer', async () => { const app = makeAppDir('hello-ops'); - fs.mkdirSync(path.dirname(app.resultFilePath), { recursive: true }); - fs.writeFileSync(app.resultFilePath, JSON.stringify(summaryFixture)); - let existedAtSpawn: boolean | undefined; + 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, - }, + { entry: app.entryPath, stage: 'ci-7', cwd: app.dir }, { config: fakeConfig(), runAssembler: fakeAssembler, - alchemy: () => { - existedAtSpawn = fs.existsSync(app.resultFilePath); - return 0; + 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(existedAtSpawn).toBe(false); - expect(result).toEqual({ outcome: 'deployed', summary: undefined }); - }); + 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(); 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 index 67bc798b..5a5fb90a 100644 --- 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 @@ -5,6 +5,7 @@ * 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'; @@ -208,8 +209,16 @@ async function runStackPipeline( // 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.json'); + const resultFilePath = path.join( + cwd, + '.prisma-composer', + `deployment-result-${String(process.pid)}-${randomUUID()}.json`, + ); try { stackPath = writeStackFile({ entryPath: pipeline.entryModule.path, @@ -218,10 +227,6 @@ async function runStackPipeline( name: pipeline.name, assembled: pipeline.assembled, }); - - // Stale-result guard: remove any previous run's result file so a summary is - // only ever read from THIS child's report hook. - fs.rmSync(resultFilePath, { force: true }); } catch (error) { return { kind: 'failed', @@ -307,7 +312,13 @@ async function runStackPipeline( } if (action === 'deploy') { - return { kind: 'succeeded', summary: readDeploymentSummary(resultFilePath) }; + const summary = readDeploymentSummary(resultFilePath); + try { + fs.rmSync(resultFilePath, { force: true }); + } catch { + // Best-effort cleanup — the summary is already in hand. + } + return { kind: 'succeeded', summary }; } return { kind: 'succeeded', summary: undefined }; } From d79813fee18dc01c6e0bfdea1d74da86f42332ac Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:40:27 +0200 Subject: [PATCH 20/27] test(cli): pin the DevSession contract and the control entry's static graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ADR-level guarantees had no test: - DevSession: closed settles only via stop(), stop() is idempotent (stopping/stopped fire once, both calls resolve), and a dev() run leaves process.listenerCount('SIGINT'/'SIGTERM') untouched — the operation never registers signal handlers, so the host can own signals. - Import-lightness: a fresh bun process imports src/exports/control.ts with every heavy module poisoned (executors, pipeline, run-alchemy, stack generators, watch, adapters); if the entry's static graph ever reaches one, the import throws. Replaces the reverted CI probe with an in-repo structural check. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../exports/__tests__/control-import.test.ts | 56 +++++++++++++++++++ .../operations/__tests__/operations.test.ts | 45 +++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 packages/0-framework/3-tooling/cli/src/exports/__tests__/control-import.test.ts 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/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index b8809ee8..87bec29a 100644 --- 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 @@ -855,6 +855,51 @@ describe('dev()', () => { 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 = { From 7234f36c0a7e33317aaa2e0516cf0557af7e8c4a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 6 Aug 2026 23:42:05 +0200 Subject: [PATCH 21/27] docs(composer): align ADR-0043, the deploying guide, and the SKILL with round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR heading softened to what the body defends ('Importing the subpath executes nothing'), and the body now cites the structural test pinning the entry's static graph. - The stdio consequence stops promising a mechanism: the spawned child and 'stdio: inherit' are how composer deploys today, not part of the contract — matching the demotion of the spawn-shaped failure fields into the optional diagnostics object, which all three documents now describe. - The ADR names the accepted structural cost: @internal/cli now contains a surface that is not a CLI. - The summary-protocol section points at deployment-summary.ts, the unique per-run result file, the best-effort writer, and the env var's removal from the public exports. - The guide and SKILL document dev/log outcome discriminants ({outcome:'started', session} / {outcome:'attached', ...}), the lines-dropped and watch-error events, and clean early termination of the log stream. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- ...path-is-the-programmatic-deploy-surface.md | 17 ++++++----- docs/guides/deploying.md | 29 ++++++++++++------- skills/prisma-composer/SKILL.md | 21 +++++++++----- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md index 11d4e10d..0b2972d3 100644 --- a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -2,7 +2,7 @@ ## 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, never print to the console, and never call `process.exit`. The `prisma-composer` CLI is a thin renderer over these same operations, so the command-line surface and the programmatic surface cannot drift apart. +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: @@ -25,7 +25,7 @@ if (result.outcome === 'deployed') { } ``` -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, where it exists, machine-usable context (the alchemy exit code, the generated stack-file path, the exact command to reproduce the run). +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 @@ -33,9 +33,9 @@ Failures are values, not exceptions: every way a deploy can go wrong comes back 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 is always safe +## 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 `./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. @@ -45,9 +45,9 @@ Deploy execution happens in a **spawned alchemy child process** driving a genera The operation therefore uses the narrowest channel that works: -- `render-deployment.ts` defines **`DeploymentSummary`** — the serializable projection of a result: the app name and, per node, its `address` and deployed `entities`. -- When the environment variable **`PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE`** names a file, the report hook writes the summary there as JSON, in addition to its normal console rendering. -- The deploy operation sets that variable on the child, removes any stale file before spawning, and reads the file back after a zero exit. +- `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). @@ -64,7 +64,8 @@ The subpath is named `control` because that is the architecture plane these sour - **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** — `stdio: 'inherit'` is part of the surface's contract. A host that must capture or redirect execution output needs a new option on the operations, not a workaround. +- **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 diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index e9382d32..bec25efc 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -328,21 +328,30 @@ What to know before embedding it: (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 — carrying its exit code and an exact reproduce - command). Importing the module executes nothing until you call an operation. + (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.** The - operations return structured results but don't capture the live deploy - output; run them where that output belongs, or with stdio redirected. -- **`dev` returns a session, not an exit code** — `{ endpoints, stop(), - closed }`, with progress (`ready`, `converge-failed`, …) delivered through +- **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` returns the running services and an `AsyncIterable` of lines**, - ended by an `AbortSignal` you own. Zero running services is a valid result - (empty `services`, finished stream), not an error. +- **`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 diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index ddf10bcc..95d67c02 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -681,19 +681,24 @@ const result = await deploy({ entry: 'module.ts', stage: 'pr-42' }); - 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. The effect version - conflict is a `pipeline` failure carrying the same diagnostic, and importing - the module executes nothing until an operation runs. + 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 operations don't capture it. -- `dev` resolves to a session `{ endpoints, stop(), closed }` with progress - via `onEvent`; the host owns signal handling. `log` resolves to - `{ appName, services, lines }` where `lines` is an `AsyncIterable` ended by - a caller-owned `AbortSignal`; zero running services is a valid result, not + 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 From a06e2e098151b374a5e78cf71a0ce1af188ccaa6 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 09:05:33 +0200 Subject: [PATCH 22/27] fix(cli): report a service that refuses to stop in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev operation emits a stop-error DevEvent when stopServices() throws, but run-dev.ts had no case for it, so the failure never reached the console — the user only saw "[dev] stopped." Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/0-framework/3-tooling/cli/src/dev/run-dev.ts | 3 +++ 1 file changed, 3 insertions(+) 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 aaef9a78..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 @@ -75,6 +75,9 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise Date: Fri, 7 Aug 2026 09:05:37 +0200 Subject: [PATCH 23/27] fix(cli): report dropped log lines in the log command The bounded merge queue emits lines-dropped when the consumer falls behind, but runLog ignored every event except stream-failed, so lines vanished without warning. Branches explicitly on the kind so a future event kind is not misrendered. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/0-framework/3-tooling/cli/src/log/run-log.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 7ef7faac..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 @@ -41,6 +41,10 @@ export async function runLog(args: LogArgs, deps: LogRunDeps = {}): Promise { if (event.kind === 'stream-failed') { console.error(`[log] stream failed: ${event.message}`); + } else if (event.kind === 'lines-dropped') { + console.error( + `[log] falling behind — dropped the ${String(event.count)} oldest lines.`, + ); } }, }, From 3f362efad2edf01aecbe10f752f95f5b09d3d231 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 09:06:26 +0200 Subject: [PATCH 24/27] test(cli): derive the flood test size from the exported queue bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TOTAL was 10_150 against a bound of 10_000 — a 1.5% margin that one consumed line plus scheduling variation could erase, flipping the droppedTotal > 0 assertion flaky. LOG_QUEUE_LIMIT is now exported and the flood is twice the bound. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/operations/__tests__/operations.test.ts | 3 ++- .../0-framework/3-tooling/cli/src/operations/execute-log.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 index 87bec29a..0c6a492d 100644 --- 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 @@ -28,6 +28,7 @@ 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[] = []; @@ -1123,7 +1124,7 @@ describe('log()', () => { }, 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 = 10_150; + 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) }; }); 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 index f3377fe6..dbe583cd 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts @@ -26,8 +26,9 @@ function failureMessage(error: unknown): string { /** 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. */ -const LOG_QUEUE_LIMIT = 10_000; + * 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 From 677e46e7a8f3f2f3fc862c05ecb1d0bebd536d16 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 09:06:43 +0200 Subject: [PATCH 25/27] test(cli): prove the late-failure path is armed before firing it The no-events-after-end test assigns failLate only when the generator resumes past its first yield; if the pump ever stopped requesting the second item, failLate?.() would no-op and the test would pass without exercising the late failure. Asserting failLate is defined first makes that regression loud. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/operations/__tests__/operations.test.ts | 1 + 1 file changed, 1 insertion(+) 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 index 0c6a492d..1b66b32c 100644 --- 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 @@ -1181,6 +1181,7 @@ describe('log()', () => { void line; break; } + expect(failLate).toBeDefined(); failLate?.(); await new Promise((resolve) => setTimeout(resolve, 20)); expect(events).toEqual([]); From 84789538ce81cbc07b55349f0d490ecfd3c6a94d Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 09:07:14 +0200 Subject: [PATCH 26/27] fix(cli): shield the log stream from a throwing host callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emit() called input.onEvent unguarded: a throw during a pump's stream-failed emission rejected the fire-and-forget pump promise (an unhandled rejection), and a throw for lines-dropped rejected the merged iterable. Host events are advisory — a broken renderer must not tear down the stream. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/__tests__/operations.test.ts | 28 +++++++++++++++++++ .../cli/src/operations/execute-log.ts | 8 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) 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 index 1b66b32c..0524649a 100644 --- 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 @@ -1187,6 +1187,34 @@ describe('log()', () => { 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' }; 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 index dbe583cd..501c32e7 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-log.ts @@ -65,7 +65,13 @@ async function* mergeLogStreams( signal.addEventListener('abort', notify, { once: true }); const emit = (event: LogEvent): void => { - if (!done) input.onEvent?.(event); + 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 From 39a66553b1d9f23a607a435ed75fa2e5af542e63 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 09:07:59 +0200 Subject: [PATCH 27/27] fix(cli): remove the per-run result file on failure paths too The child can write deployment-result--.json via the report hook before a later step fails; cleanup only ran on the successful deploy path, so every failed run left resource ids/URLs on disk and the files accumulated. The stack-file/alchemy/teardown suffix now funnels through one try/finally that removes the file best-effort. Refs: TML-3174 Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/__tests__/operations.test.ts | 26 +++ .../src/operations/execute-deploy-destroy.ts | 180 +++++++++--------- 2 files changed, 119 insertions(+), 87 deletions(-) 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 index 0524649a..25fc937f 100644 --- 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 @@ -300,6 +300,32 @@ describe('deploy()', () => { 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)); 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 index 5a5fb90a..b63e8fd1 100644 --- 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 @@ -219,106 +219,112 @@ async function runStackPipeline( '.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 { - 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 }, - }; - } + 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}`; + 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 }, - }, - }; - } + // 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)); + 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)); + // 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 }, + }; } - } catch (error) { - return { - kind: 'failed', - failure: { kind: 'pipeline', message: failureMessage(error), cause: error }, - }; - } - if (action === 'deploy') { - const summary = readDeploymentSummary(resultFilePath); + 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 — the summary is already in hand. + // Best-effort cleanup — never masks the result it wraps. } - return { kind: 'succeeded', summary }; } - return { kind: 'succeeded', summary: undefined }; }