From 6c95391e22eb50f380303a764bb4a4c01603268a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 22:55:12 +0200 Subject: [PATCH 1/5] fix(cli): refuse deploy/destroy/dev when alchemy resolves a mismatched effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TML-3158 reopened on 0.6.0: the exact pins are not sufficient in real trees. A consumer app that also depends on a floating @effect/* range (the operator hit @effect/platform-node-shared@^4.0.0-beta.93, which npm floats to beta.102) drags a newer effect peer to the root of node_modules, over our pins, with only an npm warning. alchemy then imports the newer copy and crashes mid-deploy (`TypeError: Schedule.both is not a function`). No dependency declaration can prevent this. Empirically verified in scratch trees on npm 11.6.2: an exact effect peerDependency on our packages does NOT make npm fail or keep the pinned version at root — transitive peer conflicts are downgraded to warnings and the newer copy is hoisted anyway. So the enforcement point moves to the CLI: - check-effect-resolution.ts resolves effect from alchemy's installed position (walk up to node_modules/alchemy, realpath, createRequire) and compares it to the version @prisma/composer's own installed package.json pins (single source of truth — never hardcoded). On mismatch it fails with a short error naming found vs required and the consumer fix: `"overrides": { "effect": "" }`. When either side cannot be determined (no alchemy, no composer, odd layout) the check skips instead of misfiring — later steps already report those states with their own errors. Verified not to misfire in this monorepo (hoisted pnpm layout resolves beta.93) or in healthy npm trees. - bin.ts runs the check BEFORE loading the rest of the CLI, for the alchemy-driving commands only (deploy/destroy/dev): the command modules transitively import alchemy's provider tree, which crashes at import time in exactly the broken trees the check exists to explain — so cli.ts is now imported dynamically after the check. --help and log keep working in a broken tree. Unit tests cover resolution, the healthy/unknown/mismatch rule, and the operator's exact broken shape (root effect@beta.102, composer's pin nested). Docs: the failure and its fix are documented in docs/guides/deploying.md and skills/prisma-composer/SKILL.md. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/guides/deploying.md | 27 +++ .../__tests__/check-effect-resolution.test.ts | 180 ++++++++++++++++++ packages/0-framework/3-tooling/cli/src/bin.ts | 24 ++- .../cli/src/check-effect-resolution.ts | 105 ++++++++++ skills/prisma-composer/SKILL.md | 6 + 5 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index eb5451fa..c19ea79a 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -182,6 +182,33 @@ You'll only meet this if you wrote the connection or the extension on one side of the wire — every block that ships with the framework supplies what it declares. +## When a deploy stops on an effect version conflict + +Before doing anything else, `deploy`, `destroy`, and `dev` verify that the +installed dependency tree gives alchemy (the deploy engine Composer drives) +the exact `effect` version `@prisma/composer` pins. When it doesn't, the +command stops immediately: + +``` +Error: Dependency conflict: alchemy resolves effect@, but +@prisma/composer requires effect@. Your package manager installed a +second effect that alchemy picks up; deploying with it would crash inside +alchemy. +``` + +This happens when another dependency in your app floats to a newer `effect` +and your package manager hoists that copy where alchemy resolves it — npm +allows this with only a warning, and without the check the deploy would crash +mid-run with a `TypeError` from inside alchemy. The fix is the one the error +prints: add to your app's `package.json`, then reinstall: + +```json +"overrides": { "effect": "" } +``` + +(npm calls this `overrides`; yarn calls it `resolutions`, pnpm +`pnpm.overrides`.) + ## Production behavior What deployed apps actually run into, and what to do about it: diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts new file mode 100644 index 00000000..88dc7f47 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts @@ -0,0 +1,180 @@ +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 { + checkEffectResolution, + effectMismatchError, + findAlchemyPackageDir, + requiredEffectVersion, + resolveEffectVersionFrom, +} from '../check-effect-resolution.ts'; + +const tmpDirs: string[] = []; + +function makeTmpDir(): string { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-cli-effect-')), + ); + tmpDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir !== undefined) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Lays down a resolvable package at `root/<...segments>` with the given manifest fields. */ +function writePackage( + root: string, + segments: readonly string[], + manifest: Record, +): string { + const dir = path.join(root, ...segments); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ main: 'index.js', ...manifest }), + ); + fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = {};\n'); + return dir; +} + +function writeHealthyTree(root: string, version = '4.0.0-beta.93'): void { + writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy', version: '2.0.0-beta.59' }); + writePackage(root, ['node_modules', 'effect'], { name: 'effect', version }); + writePackage(root, ['node_modules', '@prisma', 'composer'], { + name: '@prisma/composer', + version: '0.0.0', + dependencies: { effect: version }, + }); +} + +describe('findAlchemyPackageDir()', () => { + test('finds node_modules/alchemy in the starting directory', () => { + const root = makeTmpDir(); + const dir = writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy' }); + expect(findAlchemyPackageDir(root)).toBe(dir); + }); + + test('walks up through parents (hoisted layouts)', () => { + const root = makeTmpDir(); + const dir = writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy' }); + const nested = path.join(root, 'apps', 'my-app'); + fs.mkdirSync(nested, { recursive: true }); + expect(findAlchemyPackageDir(nested)).toBe(dir); + }); + + test('returns undefined when alchemy is not installed anywhere above', () => { + expect(findAlchemyPackageDir(makeTmpDir())).toBeUndefined(); + }); +}); + +describe('resolveEffectVersionFrom()', () => { + test("returns the version of the effect the package's position resolves", () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + const alchemyDir = path.join(root, 'node_modules', 'alchemy'); + expect(resolveEffectVersionFrom(alchemyDir)).toBe('4.0.0-beta.102'); + }); + + test('a copy nested inside the package wins over the root copy (Node resolution order)', () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + writePackage(root, ['node_modules', 'alchemy', 'node_modules', 'effect'], { + name: 'effect', + version: '4.0.0-beta.93', + }); + const alchemyDir = path.join(root, 'node_modules', 'alchemy'); + expect(resolveEffectVersionFrom(alchemyDir)).toBe('4.0.0-beta.93'); + }); + + test('returns undefined when effect is not resolvable from there', () => { + const root = makeTmpDir(); + const alchemyDir = writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy' }); + expect(resolveEffectVersionFrom(alchemyDir)).toBeUndefined(); + }); +}); + +describe('requiredEffectVersion()', () => { + test("reads the pin from @prisma/composer's installed package.json", () => { + const root = makeTmpDir(); + writeHealthyTree(root); + expect(requiredEffectVersion(root)).toBe('4.0.0-beta.93'); + }); + + test('returns undefined when @prisma/composer is not resolvable', () => { + expect(requiredEffectVersion(makeTmpDir())).toBeUndefined(); + }); +}); + +describe('effectMismatchError()', () => { + test('healthy: same version on both sides yields no error', () => { + expect(effectMismatchError('4.0.0-beta.93', '4.0.0-beta.93')).toBeUndefined(); + }); + + test('unknown on either side yields no error (the check must not misfire on unusual layouts)', () => { + expect(effectMismatchError(undefined, '4.0.0-beta.93')).toBeUndefined(); + expect(effectMismatchError('4.0.0-beta.102', undefined)).toBeUndefined(); + }); + + test('mismatch names found + required versions and the overrides fix, rendered from the pin', () => { + const message = effectMismatchError('4.0.0-beta.102', '4.0.0-beta.93'); + expect(message).toContain('alchemy resolves effect@4.0.0-beta.102'); + expect(message).toContain('requires effect@4.0.0-beta.93'); + expect(message).toContain('"overrides": { "effect": "4.0.0-beta.93" }'); + }); +}); + +describe('checkEffectResolution()', () => { + test('no-op on a healthy tree', () => { + const root = makeTmpDir(); + writeHealthyTree(root); + expect(() => checkEffectResolution(root)).not.toThrow(); + }); + + test("no-op when alchemy isn't installed (later steps report that with their own error)", () => { + expect(() => checkEffectResolution(makeTmpDir())).not.toThrow(); + }); + + test("throws the actionable CliError on the operator's broken shape: root effect@beta.102, composer's pin nested", () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + // The composer package pins beta.93 and got its own nested copy — exactly + // the tree npm builds when a floating @effect/* range hoists beta.102. + const composerDir = path.join(root, 'node_modules', '@prisma', 'composer'); + fs.writeFileSync( + path.join(composerDir, 'package.json'), + JSON.stringify({ + name: '@prisma/composer', + version: '0.0.0', + main: 'index.js', + dependencies: { effect: '4.0.0-beta.93' }, + }), + ); + expect(() => checkEffectResolution(root)).toThrow( + /alchemy resolves effect@4\.0\.0-beta\.102, but @prisma\/composer requires effect@4\.0\.0-beta\.93/, + ); + }); + + test('runs from a nested app directory below the install root', () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + const composerDir = path.join(root, 'node_modules', '@prisma', 'composer'); + fs.writeFileSync( + path.join(composerDir, 'package.json'), + JSON.stringify({ + name: '@prisma/composer', + version: '0.0.0', + main: 'index.js', + dependencies: { effect: '4.0.0-beta.93' }, + }), + ); + const nested = path.join(root, 'apps', 'my-app'); + fs.mkdirSync(nested, { recursive: true }); + expect(() => checkEffectResolution(nested)).toThrow(/Dependency conflict/); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/bin.ts b/packages/0-framework/3-tooling/cli/src/bin.ts index 80743b3f..d5a5efd8 100755 --- a/packages/0-framework/3-tooling/cli/src/bin.ts +++ b/packages/0-framework/3-tooling/cli/src/bin.ts @@ -1,4 +1,26 @@ #!/usr/bin/env node -import { cli } from './cli.ts'; +import { checkEffectResolution } from './check-effect-resolution.ts'; +import { CliError } from './cli-error.ts'; +// The effect preflight (TML-3158) must run BEFORE the rest of the CLI loads: +// the command modules transitively import alchemy's provider tree, which +// crashes at import time when the installed tree resolves a mismatched +// `effect` — exactly the break the check exists to explain. Hence the argv +// sniff here (only the alchemy-driving commands need a healthy tree; help and +// `log` must keep working in a broken one) and the dynamic import below, +// which keeps that import graph out of this module's static graph. +const command = process.argv[2]; +if (command === 'deploy' || command === 'destroy' || command === 'dev') { + try { + checkEffectResolution(process.cwd()); + } catch (error) { + if (error instanceof CliError) { + console.error(`Error: ${error.message}`); + process.exit(1); + } + throw error; + } +} + +const { cli } = await import('./cli.ts'); void cli(); diff --git a/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts b/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts new file mode 100644 index 00000000..061bdf42 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts @@ -0,0 +1,105 @@ +/** + * Deploy preflight (TML-3158): verify that alchemy resolves the exact `effect` + * version @prisma/composer pins. Package managers cannot be made to enforce + * this — alchemy's own peer range accepts newer effect betas, and npm resolves + * transitive peer conflicts with a warning, not a failure — so a floating + * `@effect/*` range anywhere in the consumer's tree can hoist a newer effect + * to the root, where alchemy picks it up and crashes mid-deploy + * (`TypeError: Schedule.either is not a function`). This check turns that + * silent break into a start-up error naming the fix. + */ +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { CliError } from './cli-error.ts'; + +/** Walks up from `startDir` looking for `node_modules/alchemy` (mirrors resolveAlchemyBin). Undefined when absent — the check skips rather than second-guessing later, clearer failures. */ +export function findAlchemyPackageDir(startDir: string): string | undefined { + let dir = startDir; + while (true) { + const candidate = path.join(dir, 'node_modules', 'alchemy'); + if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** + * The `effect` version Node gives `packageDir`'s code, resolved exactly as + * Node would: from the package's real path (so pnpm symlink layouts resolve + * from the package's own store position, like at runtime). Undefined when + * `effect` is not resolvable from there. + */ +export function resolveEffectVersionFrom(packageDir: string): string | undefined { + let entry: string; + try { + const requireFrom = createRequire(path.join(fs.realpathSync(packageDir), 'noop.js')); + entry = requireFrom.resolve('effect'); + } catch { + return undefined; + } + let dir = path.dirname(entry); + while (!fs.existsSync(path.join(dir, 'package.json'))) { + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } + const version: unknown = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'), + ).version; + return typeof version === 'string' ? version : undefined; +} + +/** + * The `effect` version @prisma/composer pins, read from its own installed + * package.json — the single source of truth; never hardcoded here. Undefined + * when @prisma/composer is not resolvable from `startDir` (e.g. the CLI is + * driven some other way), in which case the check skips. + */ +export function requiredEffectVersion(startDir: string): string | undefined { + let manifestPath: string; + try { + const requireFrom = createRequire(path.join(startDir, 'noop.js')); + manifestPath = requireFrom.resolve('@prisma/composer/package.json'); + } catch { + return undefined; + } + const manifest: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if (typeof manifest !== 'object' || manifest === null || !('dependencies' in manifest)) { + return undefined; + } + const dependencies = manifest.dependencies; + if (typeof dependencies !== 'object' || dependencies === null || !('effect' in dependencies)) { + return undefined; + } + const version = dependencies.effect; + return typeof version === 'string' ? version : undefined; +} + +/** Pure comparison + message rendering, separated so tests cover the rule without a filesystem. Returns the error message, or undefined when the tree is healthy or either side is unknown. */ +export function effectMismatchError( + found: string | undefined, + required: string | undefined, +): string | undefined { + if (found === undefined || required === undefined || found === required) return undefined; + return ( + `Dependency conflict: alchemy resolves effect@${found}, but @prisma/composer requires ` + + `effect@${required}. Your package manager installed a second effect that alchemy picks up; ` + + 'deploying with it would crash inside alchemy.\n\n' + + "Fix: add this to your app's package.json, then reinstall:\n\n" + + ` "overrides": { "effect": "${required}" }\n\n` + + '(npm uses "overrides"; yarn calls it "resolutions", pnpm "pnpm.overrides".)' + ); +} + +/** Runs the preflight from the app's directory; throws CliError on a mismatched tree, no-op otherwise. */ +export function checkEffectResolution(cwd: string): void { + const alchemyDir = findAlchemyPackageDir(cwd); + if (alchemyDir === undefined) return; + const message = effectMismatchError( + resolveEffectVersionFrom(alchemyDir), + requiredEffectVersion(cwd), + ); + if (message !== undefined) throw new CliError(message); +} diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 7322ba3b..166c7707 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -684,6 +684,12 @@ every shipped block supplies what it declares. locally where nothing is enforced. - **Cold starts reset service-to-service connections.** A call into a scaled-to-zero service can get `ECONNRESET`; retry it. +- **`deploy`/`destroy`/`dev` stop at start-up on an `effect` version + conflict** (`Dependency conflict: alchemy resolves effect@...`). Another + dependency floated a newer `effect` and the package manager hoisted it over + Composer's pin. Do what the error says: add + `"overrides": { "effect": "" }` to the app's `package.json` + (yarn: `resolutions`; pnpm: `pnpm.overrides`) and reinstall. - **The ingress buffers streaming responses.** An open SSE tail delivers nothing and times out at 60s — don't build on streamed HTTP responses. From 857c23eea3cd45de377bb11d18b3737902dd7d86 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 22:55:23 +0200 Subject: [PATCH 2/5] ci: adversarial npm shape proves the CLI catches a hoisted effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the standalone-install regression check with the tree that broke 0.6.0 in the field: the scratch app also depends on @effect/platform-node-shared@^4.0.0-beta.93, which npm floats to the newest beta and hoists its newer effect peer over our exact pins — install still exits 0 (verified; a peerDependency changes nothing). Acceptance for that shape: npm refuses the install outright, OR the install lands broken (alchemy resolves effect != the pin) and running the built prisma-composer bin there must exit non-zero with the start-up check's error ("alchemy resolves effect@..."), never the Schedule TypeError. The two healthy shapes additionally assert the inverse: the CLI check must NOT trip on a good tree. Signed-off-by: willbot Signed-off-by: Will Madden --- scripts/check-npm-effect-resolution.mjs | 115 +++++++++++++++++++++--- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index 0cc49b8e..f549fe88 100644 --- a/scripts/check-npm-effect-resolution.mjs +++ b/scripts/check-npm-effect-resolution.mjs @@ -14,13 +14,23 @@ // warns, so the break is invisible in-repo; this check installs the real // tarballs with real npm against the real registry. // +// A third, adversarial shape reproduces the tree that broke 0.6.0 in the +// field: the app ALSO depends on `@effect/platform-node-shared@^4.0.0-beta.93`, +// which npm floats to the newest beta, dragging its newer `effect` peer to the +// root — over our exact pins, with only a warning (empirically verified; a +// peerDependency does not prevent it either). Nothing we declare can stop +// that, so the acceptance there is the CLI's own start-up check: running the +// built `prisma-composer` in that broken tree must exit non-zero with our +// actionable error, not the Schedule TypeError. The healthy shapes assert the +// inverse: the check must NOT trip on a good tree. +// // Requires the two public packages to be built (`pnpm turbo build // --filter=@prisma/composer --filter=@prisma/composer-prisma-cloud`) and // network access to the npm registry. // // Usage: node scripts/check-npm-effect-resolution.mjs -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, @@ -86,15 +96,47 @@ function collectEffectVersions(node, found = new Map()) { return found; } -async function checkShape(label, tarballs) { +/** The stable marker of the CLI's own start-up check (check-effect-resolution.ts). */ +const CLI_CHECK_MARKER = 'alchemy resolves effect@'; + +function installApp(label, tarballs, extraDependencies = {}) { const appDir = join(work, label); mkdirSync(appDir, { recursive: true }); - writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: label, private: true })); + writeFileSync( + join(appDir, 'package.json'), + JSON.stringify({ name: label, private: true, dependencies: extraDependencies }), + ); process.stderr.write(`\n[${label}] npm install ${tarballs.length} tarball(s)...\n`); execFileSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], { cwd: appDir, stdio: ['ignore', 'ignore', 'inherit'], }); + return appDir; +} + +/** The version of the `effect` that Node resolves from alchemy's installed position, plus its entry path. */ +function effectSeenByAlchemy(label, appDir) { + const alchemyDir = join(appDir, 'node_modules', 'alchemy'); + if (!existsSync(alchemyDir)) fail(`[${label}] alchemy is not installed`); + const requireFromAlchemy = createRequire(join(alchemyDir, 'noop.js')); + const entry = requireFromAlchemy.resolve('effect'); + let pkgDir = dirname(entry); + while (!existsSync(join(pkgDir, 'package.json'))) pkgDir = dirname(pkgDir); + const version = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')).version; + return { version, entry }; +} + +/** Runs the built prisma-composer bin with `deploy app.ts` in the scratch app; returns { status, output }. */ +function runCli(label, appDir) { + const bin = join(appDir, 'node_modules', '.bin', 'prisma-composer'); + if (!existsSync(bin)) fail(`[${label}] the prisma-composer bin is not installed`); + const result = spawnSync(bin, ['deploy', 'app.ts'], { cwd: appDir, encoding: 'utf-8' }); + if (result.error) fail(`[${label}] failed to spawn the prisma-composer bin: ${result.error}`); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; +} + +async function checkShape(label, tarballs) { + const appDir = installApp(label, tarballs); const tree = JSON.parse( execFileSync('npm', ['ls', 'effect', '--all', '--json'], { cwd: appDir, encoding: 'utf-8' }), @@ -108,13 +150,7 @@ async function checkShape(label, tarballs) { ); } - const alchemyDir = join(appDir, 'node_modules', 'alchemy'); - if (!existsSync(alchemyDir)) fail(`[${label}] alchemy is not installed`); - const requireFromAlchemy = createRequire(join(alchemyDir, 'noop.js')); - const effectEntry = requireFromAlchemy.resolve('effect'); - let pkgDir = dirname(effectEntry); - while (!existsSync(join(pkgDir, 'package.json'))) pkgDir = dirname(pkgDir); - const resolvedVersion = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')).version; + const { version: resolvedVersion, entry: effectEntry } = effectSeenByAlchemy(label, appDir); process.stderr.write(`[${label}] alchemy resolves effect@${resolvedVersion} (${effectEntry})\n`); if (resolvedVersion !== pinnedEffect) { fail(`[${label}] alchemy resolves effect@${resolvedVersion}, expected ${pinnedEffect}`); @@ -124,9 +160,62 @@ async function checkShape(label, tarballs) { if (typeof Schedule.either !== 'function') { fail(`[${label}] Schedule.either is missing from the effect alchemy resolves`); } + + // The CLI's start-up check must NOT trip on this healthy tree — the deploy + // should get past it and fail on the app itself (no entry/config here). + const cli = runCli(label, appDir); + if (cli.output.includes(CLI_CHECK_MARKER)) { + fail(`[${label}] the CLI's effect check misfired on a healthy tree:\n${cli.output}`); + } + process.stderr.write(`[${label}] OK — single effect@${pinnedEffect}, Schedule.either present\n`); } +// The operator's failing chain: a direct dependency on +// `@effect/platform-node-shared@^4.0.0-beta.93` floats to the newest beta and +// hoists its newer `effect` peer over our pins (install still exits 0). In +// that tree the built CLI must refuse to deploy with our clear error. +async function checkAdversarialShape(tarballs) { + const label = 'adversarial-node-shared-float'; + let appDir; + try { + appDir = installApp(label, tarballs, { + '@effect/platform-node-shared': '^4.0.0-beta.93', + }); + } catch { + // Also acceptable: npm refuses the conflicted install outright. + process.stderr.write(`[${label}] OK — npm refused the conflicting install\n`); + return; + } + + const { version: resolvedVersion } = effectSeenByAlchemy(label, appDir); + process.stderr.write(`[${label}] alchemy resolves effect@${resolvedVersion}\n`); + if (resolvedVersion === pinnedEffect) { + process.stderr.write( + `[${label}] note: npm kept the pinned effect at alchemy's position — the shape is no ` + + 'longer adversarial under this npm version\n', + ); + return; + } + + const cli = runCli(label, appDir); + if (cli.status === 0) { + fail(`[${label}] the CLI exited 0 in a tree where alchemy resolves effect@${resolvedVersion}`); + } + if (!cli.output.includes(CLI_CHECK_MARKER)) { + fail( + `[${label}] the CLI failed without the effect check's error (expected "${CLI_CHECK_MARKER}"):\n` + + cli.output, + ); + } + if (/is not a function/.test(cli.output)) { + fail(`[${label}] the CLI crashed with a TypeError instead of the effect check:\n${cli.output}`); + } + process.stderr.write( + `[${label}] OK — broken tree detected at start-up with the actionable error\n`, + ); +} + work = mkdtempSync(join(tmpdir(), 'npm-effect-check-')); try { const tarballDir = join(work, 'tarballs'); @@ -136,8 +225,12 @@ try { await checkShape('composer-only', [composerTgz]); await checkShape('composer-and-prisma-cloud', [composerTgz, prismaCloudTgz]); + await checkAdversarialShape([composerTgz, prismaCloudTgz]); - process.stderr.write(`\nOK — npm dedupes to a single effect@${pinnedEffect} in both shapes.\n`); + process.stderr.write( + `\nOK — npm dedupes to a single effect@${pinnedEffect} in the healthy shapes, and the CLI ` + + 'catches the adversarial tree at start-up.\n', + ); } finally { rmSync(work, { recursive: true, force: true }); } From d0dec87fe9f8ba253f8b9f707e4d71e4b05cbb4b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 07:45:11 +0200 Subject: [PATCH 3/5] fix(cli): run the effect preflight for every command, not just deploying ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The argv sniff assumed only deploy/destroy/dev load alchemy. The adversarial npm shape disproves it: `prisma-composer --help` in a tree where alchemy resolves a mismatched effect dies with the raw `Schedule.both is not a function` from alchemy/lib/AWS/IAM/SAMLProvider.js, because the CLI module graph reaches the provider tree whatever the argv says. Run the check for every invocation, so no command can meet that TypeError. The npm shape now asserts it: `--help` in the broken tree must report the check, and the install-refused path is only accepted when npm actually failed on the dependency conflict, so a registry outage cannot turn the one end-to-end proof green. The healthy shapes assert the bin still reaches its usage output — proof it ran at all, which "the marker is absent" alone does not give. That assertion tests the banner rather than the exit code: a bare `--help` exits 1 on main too, since clipanion reports it as a missing command. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/guides/deploying.md | 2 +- packages/0-framework/3-tooling/cli/src/bin.ts | 26 +++--- .../cli/src/check-effect-resolution.ts | 4 + scripts/check-npm-effect-resolution.mjs | 88 ++++++++++++++----- skills/prisma-composer/SKILL.md | 2 +- 5 files changed, 85 insertions(+), 37 deletions(-) diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index c19ea79a..4b851d66 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -184,7 +184,7 @@ declares. ## When a deploy stops on an effect version conflict -Before doing anything else, `deploy`, `destroy`, and `dev` verify that the +Before doing anything else, every `prisma-composer` command verifies that the installed dependency tree gives alchemy (the deploy engine Composer drives) the exact `effect` version `@prisma/composer` pins. When it doesn't, the command stops immediately: diff --git a/packages/0-framework/3-tooling/cli/src/bin.ts b/packages/0-framework/3-tooling/cli/src/bin.ts index d5a5efd8..d18c7cc8 100755 --- a/packages/0-framework/3-tooling/cli/src/bin.ts +++ b/packages/0-framework/3-tooling/cli/src/bin.ts @@ -5,21 +5,19 @@ import { CliError } from './cli-error.ts'; // The effect preflight (TML-3158) must run BEFORE the rest of the CLI loads: // the command modules transitively import alchemy's provider tree, which // crashes at import time when the installed tree resolves a mismatched -// `effect` — exactly the break the check exists to explain. Hence the argv -// sniff here (only the alchemy-driving commands need a healthy tree; help and -// `log` must keep working in a broken one) and the dynamic import below, -// which keeps that import graph out of this module's static graph. -const command = process.argv[2]; -if (command === 'deploy' || command === 'destroy' || command === 'dev') { - try { - checkEffectResolution(process.cwd()); - } catch (error) { - if (error instanceof CliError) { - console.error(`Error: ${error.message}`); - process.exit(1); - } - throw error; +// `effect` — exactly the break the check exists to explain. The dynamic import +// below keeps that graph out of this module's static graph, so the check gets +// to run first. It guards every command, not just the deploying ones: the +// graph loads whatever the argv says, so `--help` crashes in a broken tree too +// (proved by the adversarial shape in scripts/check-npm-effect-resolution.mjs). +try { + checkEffectResolution(process.cwd()); +} catch (error) { + if (error instanceof CliError) { + console.error(`Error: ${error.message}`); + process.exit(1); } + throw error; } const { cli } = await import('./cli.ts'); diff --git a/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts b/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts index 061bdf42..94c62b44 100644 --- a/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts +++ b/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts @@ -56,6 +56,10 @@ export function resolveEffectVersionFrom(packageDir: string): string | undefined * package.json — the single source of truth; never hardcoded here. Undefined * when @prisma/composer is not resolvable from `startDir` (e.g. the CLI is * driven some other way), in which case the check skips. + * + * Assumes the pin is an exact version — effectMismatchError compares with + * `===`, which scripts/check-npm-effect-resolution.mjs enforces in CI; + * relaxing the pin to a range means revisiting that comparison. */ export function requiredEffectVersion(startDir: string): string | undefined { let manifestPath: string; diff --git a/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index f549fe88..8a7ddaf3 100644 --- a/scripts/check-npm-effect-resolution.mjs +++ b/scripts/check-npm-effect-resolution.mjs @@ -99,6 +99,7 @@ function collectEffectVersions(node, found = new Map()) { /** The stable marker of the CLI's own start-up check (check-effect-resolution.ts). */ const CLI_CHECK_MARKER = 'alchemy resolves effect@'; +/** Runs `npm install` for a scratch app; returns { appDir, status, output } instead of throwing so callers can judge HOW an install failed. */ function installApp(label, tarballs, extraDependencies = {}) { const appDir = join(work, label); mkdirSync(appDir, { recursive: true }); @@ -107,11 +108,13 @@ function installApp(label, tarballs, extraDependencies = {}) { JSON.stringify({ name: label, private: true, dependencies: extraDependencies }), ); process.stderr.write(`\n[${label}] npm install ${tarballs.length} tarball(s)...\n`); - execFileSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], { + const result = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], { cwd: appDir, - stdio: ['ignore', 'ignore', 'inherit'], + encoding: 'utf-8', }); - return appDir; + if (result.error) fail(`[${label}] failed to spawn npm: ${result.error}`); + process.stderr.write(result.stderr); + return { appDir, status: result.status, output: `${result.stdout}${result.stderr}` }; } /** The version of the `effect` that Node resolves from alchemy's installed position, plus its entry path. */ @@ -126,17 +129,38 @@ function effectSeenByAlchemy(label, appDir) { return { version, entry }; } -/** Runs the built prisma-composer bin with `deploy app.ts` in the scratch app; returns { status, output }. */ -function runCli(label, appDir) { +/** Runs the built prisma-composer bin in the scratch app; returns { status, output }. */ +function runCli(label, appDir, args) { const bin = join(appDir, 'node_modules', '.bin', 'prisma-composer'); if (!existsSync(bin)) fail(`[${label}] the prisma-composer bin is not installed`); - const result = spawnSync(bin, ['deploy', 'app.ts'], { cwd: appDir, encoding: 'utf-8' }); + const result = spawnSync(bin, args, { cwd: appDir, encoding: 'utf-8' }); if (result.error) fail(`[${label}] failed to spawn the prisma-composer bin: ${result.error}`); return { status: result.status, output: `${result.stdout}${result.stderr}` }; } +/** + * Asserts the bundled bin starts and reaches its own usage output. The proof is + * the usage banner, not the exit code: a bare `--help` exits 1 on main too, + * because clipanion reports it as a missing command. + */ +function assertCliStarts(label, appDir) { + const help = runCli(label, appDir, ['--help']); + if (!help.output.includes('prisma-composer ')) { + fail( + `[${label}] \`prisma-composer --help\` did not reach its usage output in a healthy tree ` + + `(exit ${help.status}):\n${help.output}`, + ); + } + if (/is not a function|Cannot find module/.test(help.output)) { + fail(`[${label}] the CLI crashed on its module graph in a healthy tree:\n${help.output}`); + } +} + async function checkShape(label, tarballs) { - const appDir = installApp(label, tarballs); + const { appDir, status: installStatus, output: installOutput } = installApp(label, tarballs); + if (installStatus !== 0) { + fail(`[${label}] npm install failed (exit ${installStatus}):\n${installOutput}`); + } const tree = JSON.parse( execFileSync('npm', ['ls', 'effect', '--all', '--json'], { cwd: appDir, encoding: 'utf-8' }), @@ -161,12 +185,20 @@ async function checkShape(label, tarballs) { fail(`[${label}] Schedule.either is missing from the effect alchemy resolves`); } - // The CLI's start-up check must NOT trip on this healthy tree — the deploy - // should get past it and fail on the app itself (no entry/config here). - const cli = runCli(label, appDir); + // Positive proof the CLI actually starts in this healthy tree — without it, + // "the failure marker is absent" would also hold for a CLI that never ran. + assertCliStarts(label, appDir); + + // The start-up check must NOT trip on this healthy tree — the deploy should + // get past it and fail on the app itself (no entry/config here), never on a + // broken module graph. + const cli = runCli(label, appDir, ['deploy', 'app.ts']); if (cli.output.includes(CLI_CHECK_MARKER)) { fail(`[${label}] the CLI's effect check misfired on a healthy tree:\n${cli.output}`); } + if (/is not a function|Cannot find module/.test(cli.output)) { + fail(`[${label}] the CLI crashed on its module graph in a healthy tree:\n${cli.output}`); + } process.stderr.write(`[${label}] OK — single effect@${pinnedEffect}, Schedule.either present\n`); } @@ -177,15 +209,18 @@ async function checkShape(label, tarballs) { // that tree the built CLI must refuse to deploy with our clear error. async function checkAdversarialShape(tarballs) { const label = 'adversarial-node-shared-float'; - let appDir; - try { - appDir = installApp(label, tarballs, { - '@effect/platform-node-shared': '^4.0.0-beta.93', - }); - } catch { - // Also acceptable: npm refuses the conflicted install outright. - process.stderr.write(`[${label}] OK — npm refused the conflicting install\n`); - return; + const { appDir, status, output } = installApp(label, tarballs, { + '@effect/platform-node-shared': '^4.0.0-beta.93', + }); + if (status !== 0) { + // Also acceptable: npm refuses the conflicted install outright — but ONLY + // when it actually failed on the dependency conflict. Any other failure + // (registry outage, a bug here) must fail the check, not silently pass it. + if (/ERESOLVE|Conflicting peer dependency|unable to resolve dependency tree/i.test(output)) { + process.stderr.write(`[${label}] OK — npm refused the conflicting install\n`); + return; + } + fail(`[${label}] npm install failed for a reason other than the conflict:\n${output}`); } const { version: resolvedVersion } = effectSeenByAlchemy(label, appDir); @@ -198,7 +233,7 @@ async function checkAdversarialShape(tarballs) { return; } - const cli = runCli(label, appDir); + const cli = runCli(label, appDir, ['deploy', 'app.ts']); if (cli.status === 0) { fail(`[${label}] the CLI exited 0 in a tree where alchemy resolves effect@${resolvedVersion}`); } @@ -211,8 +246,19 @@ async function checkAdversarialShape(tarballs) { if (/is not a function/.test(cli.output)) { fail(`[${label}] the CLI crashed with a TypeError instead of the effect check:\n${cli.output}`); } + + // Every command loads the graph that crashes, so every command must hit the + // check first — `--help` included, or the user meets the raw TypeError there. + const help = runCli(label, appDir, ['--help']); + if (!help.output.includes(CLI_CHECK_MARKER) || /is not a function/.test(help.output)) { + fail( + `[${label}] \`prisma-composer --help\` did not report the effect check in a broken tree ` + + `(exit ${help.status}):\n${help.output}`, + ); + } + process.stderr.write( - `[${label}] OK — broken tree detected at start-up with the actionable error\n`, + `[${label}] OK — broken tree caught at start-up with the actionable error, deploy and --help alike\n`, ); } diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 166c7707..6967fed7 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -684,7 +684,7 @@ every shipped block supplies what it declares. locally where nothing is enforced. - **Cold starts reset service-to-service connections.** A call into a scaled-to-zero service can get `ECONNRESET`; retry it. -- **`deploy`/`destroy`/`dev` stop at start-up on an `effect` version +- **Every `prisma-composer` command stops at start-up on an `effect` version conflict** (`Dependency conflict: alchemy resolves effect@...`). Another dependency floated a newer `effect` and the package manager hoisted it over Composer's pin. Do what the error says: add From 7d452a8dd432590bc55056a82bc12d2472aa18c2 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 11:36:17 +0200 Subject: [PATCH 4/5] test(cli): make the adversarial shape deterministic and assert the error type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups. The adversarial fixture named a floating range, so the day npm stopped producing a mismatch the shape would have passed while proving nothing. It now names the exact @effect/platform-node-shared release whose effect peer is newer than our pin, and treats "no mismatch" as a failure that says which constant to move — reachable only when the pin itself moves past that release, which is fixture maintenance rather than registry weather. `--help` in the broken tree must also exit non-zero, not merely print the error: a preflight that explains itself and exits 0 would let a script carry on. The unit tests assert CliError rather than message text alone, since bin.ts branches on that type to choose the exit status. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/guides/deploying.md | 2 +- .../__tests__/check-effect-resolution.test.ts | 3 ++ scripts/check-npm-effect-resolution.mjs | 31 ++++++++++++------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 4b851d66..8c22d1e4 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -189,7 +189,7 @@ installed dependency tree gives alchemy (the deploy engine Composer drives) the exact `effect` version `@prisma/composer` pins. When it doesn't, the command stops immediately: -``` +```text Error: Dependency conflict: alchemy resolves effect@, but @prisma/composer requires effect@. Your package manager installed a second effect that alchemy picks up; deploying with it would crash inside diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts index 88dc7f47..c00471a9 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts @@ -9,6 +9,7 @@ import { requiredEffectVersion, resolveEffectVersionFrom, } from '../check-effect-resolution.ts'; +import { CliError } from '../cli-error.ts'; const tmpDirs: string[] = []; @@ -155,6 +156,7 @@ describe('checkEffectResolution()', () => { dependencies: { effect: '4.0.0-beta.93' }, }), ); + expect(() => checkEffectResolution(root)).toThrow(CliError); expect(() => checkEffectResolution(root)).toThrow( /alchemy resolves effect@4\.0\.0-beta\.102, but @prisma\/composer requires effect@4\.0\.0-beta\.93/, ); @@ -175,6 +177,7 @@ describe('checkEffectResolution()', () => { ); const nested = path.join(root, 'apps', 'my-app'); fs.mkdirSync(nested, { recursive: true }); + expect(() => checkEffectResolution(nested)).toThrow(CliError); expect(() => checkEffectResolution(nested)).toThrow(/Dependency conflict/); }); }); diff --git a/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index 8a7ddaf3..0d5e9d3f 100644 --- a/scripts/check-npm-effect-resolution.mjs +++ b/scripts/check-npm-effect-resolution.mjs @@ -203,14 +203,18 @@ async function checkShape(label, tarballs) { process.stderr.write(`[${label}] OK — single effect@${pinnedEffect}, Schedule.either present\n`); } -// The operator's failing chain: a direct dependency on -// `@effect/platform-node-shared@^4.0.0-beta.93` floats to the newest beta and -// hoists its newer `effect` peer over our pins (install still exits 0). In -// that tree the built CLI must refuse to deploy with our clear error. +// The reported chain: an app dependency on `@effect/platform-node-shared` +// whose own `effect` peer is newer than our pin, so npm hoists that newer +// effect over us (the install still exits 0). In that tree the built CLI must +// refuse to run with our clear error. The app there reached this version by +// floating `^4.0.0-beta.93`; the fixture names it exactly so the shape stays +// adversarial no matter what the registry publishes next. +const ADVERSARIAL_NODE_SHARED = '4.0.0-beta.103'; + async function checkAdversarialShape(tarballs) { const label = 'adversarial-node-shared-float'; const { appDir, status, output } = installApp(label, tarballs, { - '@effect/platform-node-shared': '^4.0.0-beta.93', + '@effect/platform-node-shared': ADVERSARIAL_NODE_SHARED, }); if (status !== 0) { // Also acceptable: npm refuses the conflicted install outright — but ONLY @@ -226,11 +230,12 @@ async function checkAdversarialShape(tarballs) { const { version: resolvedVersion } = effectSeenByAlchemy(label, appDir); process.stderr.write(`[${label}] alchemy resolves effect@${resolvedVersion}\n`); if (resolvedVersion === pinnedEffect) { - process.stderr.write( - `[${label}] note: npm kept the pinned effect at alchemy's position — the shape is no ` + - 'longer adversarial under this npm version\n', + fail( + `[${label}] this shape no longer produces a mismatch, so it proves nothing: ` + + `@effect/platform-node-shared@${ADVERSARIAL_NODE_SHARED} now agrees with our ` + + `effect@${pinnedEffect}. Point ADVERSARIAL_NODE_SHARED at a release whose effect peer ` + + 'is newer than the pin.', ); - return; } const cli = runCli(label, appDir, ['deploy', 'app.ts']); @@ -250,9 +255,13 @@ async function checkAdversarialShape(tarballs) { // Every command loads the graph that crashes, so every command must hit the // check first — `--help` included, or the user meets the raw TypeError there. const help = runCli(label, appDir, ['--help']); - if (!help.output.includes(CLI_CHECK_MARKER) || /is not a function/.test(help.output)) { + if ( + help.status === 0 || + !help.output.includes(CLI_CHECK_MARKER) || + /is not a function/.test(help.output) + ) { fail( - `[${label}] \`prisma-composer --help\` did not report the effect check in a broken tree ` + + `[${label}] \`prisma-composer --help\` did not fail with the effect check in a broken tree ` + `(exit ${help.status}):\n${help.output}`, ); } From f55b02b3514eeefe7a91041ac564dc71e6d5336d Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 11:40:23 +0200 Subject: [PATCH 5/5] docs: show the yarn and pnpm spelling of the effect override Naming `resolutions` and `pnpm.overrides` without showing them left readers on those package managers to guess the nesting, which pnpm gets wrong in a way that fails silently. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/guides/deploying.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 8c22d1e4..546cd4cc 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -200,14 +200,26 @@ This happens when another dependency in your app floats to a newer `effect` and your package manager hoists that copy where alchemy resolves it — npm allows this with only a warning, and without the check the deploy would crash mid-run with a `TypeError` from inside alchemy. The fix is the one the error -prints: add to your app's `package.json`, then reinstall: +prints — pin the version your package manager should use everywhere, in your +app's `package.json`: ```json "overrides": { "effect": "" } ``` -(npm calls this `overrides`; yarn calls it `resolutions`, pnpm -`pnpm.overrides`.) +yarn spells it `resolutions`: + +```json +"resolutions": { "effect": "" } +``` + +and pnpm nests it under `pnpm`: + +```json +"pnpm": { "overrides": { "effect": "" } } +``` + +Reinstall afterwards — the setting only takes effect when the tree is rebuilt. ## Production behavior