-
Notifications
You must be signed in to change notification settings - Fork 1
fix(cli): fail at start-up on a mismatched effect version instead of crashing inside alchemy #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6c95391
fix(cli): refuse deploy/destroy/dev when alchemy resolves a mismatche…
wmadden-electric 857c23e
ci: adversarial npm shape proves the CLI catches a hoisted effect
wmadden-electric d0dec87
fix(cli): run the effect preflight for every command, not just deploy…
wmadden-electric 7d452a8
test(cli): make the adversarial shape deterministic and assert the er…
wmadden-electric f55b02b
docs: show the yarn and pnpm spelling of the effect override
wmadden-electric File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| 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'; | ||
| import { CliError } from '../cli-error.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, unknown>, | ||
| ): 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(CliError); | ||
| 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(CliError); | ||
| expect(() => checkEffectResolution(nested)).toThrow(/Dependency conflict/); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,24 @@ | ||
| #!/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. 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'); | ||
| void cli(); |
109 changes: 109 additions & 0 deletions
109
packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| /** | ||
| * 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. | ||
| * | ||
| * 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; | ||
| 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); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.