Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"hono": "^4.12.31"
},
"devDependencies": {
"@prisma/cli-engine": "0.2.3",
"@prisma/cli-engine": "0.3.0",
"@types/bun": "^1.3.13",
"typescript": "^6.0.3"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/pn-widgets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"pg": "8.22.0"
},
"devDependencies": {
"@prisma/cli-engine": "0.2.3",
"@prisma/cli-engine": "0.3.0",
"@prisma/composer": "workspace:0.14.0",
"@prisma/composer-cli": "workspace:0.14.0",
"@prisma/management-api-sdk": "^1.47.0",
Expand Down
2 changes: 1 addition & 1 deletion examples/store/modules/catalog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@prisma/composer-prisma-cloud": "workspace:0.14.0"
},
"devDependencies": {
"@prisma/cli-engine": "0.2.3",
"@prisma/cli-engine": "0.3.0",
"@prisma/composer": "workspace:0.14.0",
"@prisma/composer-prisma-cloud": "workspace:0.14.0",
"@types/bun": "^1.3.13",
Expand Down
2 changes: 1 addition & 1 deletion examples/store/modules/orders/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@prisma/composer-prisma-cloud": "workspace:0.14.0"
},
"devDependencies": {
"@prisma/cli-engine": "0.2.3",
"@prisma/cli-engine": "0.3.0",
"@prisma/composer": "workspace:0.14.0",
"@prisma/composer-prisma-cloud": "workspace:0.14.0",
"@types/bun": "^1.3.13",
Expand Down
2 changes: 1 addition & 1 deletion packages/0-framework/3-tooling/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@internal/assemble": "workspace:0.14.0",
"@internal/core": "workspace:0.14.0",
"@internal/foundation": "workspace:0.14.0",
"@prisma/cli-engine": "0.2.3",
"@prisma/cli-engine": "0.3.0",
"c12": "^3.3.4",
"chokidar": "^4.0.3"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('findConfigPathForEntry() — the walk-up', () => {
});

describe('resolveConfigFile() — which file a load will use', () => {
test('a relative configPath resolves against the cwd the command runs in', () => {
test('the section-supplied absolute configPath is used as given, whatever the cwd', () => {
const dir = makeTree();
const appDir = path.join(dir, 'apps', 'shop');
fs.mkdirSync(appDir, { recursive: true });
Expand All @@ -138,8 +138,8 @@ describe('resolveConfigFile() — which file a load will use', () => {

const resolved = resolveConfigFile({
entryPath: path.join(dir, 'module.ts'),
configPath: `./apps/shop/${CONFIG_FILENAME}`,
cwd: dir,
configPath,
cwd: appDir,
});

expect(resolved.path).toBe(configPath);
Expand All @@ -164,7 +164,7 @@ describe('resolveConfigFile() — which file a load will use', () => {
try {
resolveConfigFile({
entryPath: path.join(dir, 'module.ts'),
configPath: './not-here.config.ts',
configPath: path.join(dir, 'not-here.config.ts'),
cwd: dir,
});
} catch (thrown: unknown) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { type ComposerSection, composerSection } from '../section.ts';

const VERSION = '0.6.0-test';
const NO_CONFIG = (): Promise<LoadedConfig> =>
Promise.resolve({ path: '/app/prisma.config.ts', sections: {}, diagnostics: [] });
Promise.resolve({ files: [{ path: '/app/prisma.config.ts', sections: {} }], diagnostics: [] });

function fakeHost(cwd: string): HostProcess & { out: string[]; err: string[] } {
const out: string[] = [];
Expand Down Expand Up @@ -193,13 +193,54 @@ describe('the composer section through the engine', () => {
expect(result.presented?.data).toEqual({});
});

test('a configPath reaches the handler as ctx.config', async () => {
test('a configPath reaches the handler as ctx.config, resolved against the declaring file', async () => {
// The harness seeds the config file at the run's cwd, `/`, so the
// section's relative path resolves against that directory — not against
// wherever this test process happens to run.
const result = await probeCli({
composer: { configPath: './x/prisma-composer.config.ts' },
}).run(['probe', '--json']);
expect(result.exitCode).toBe(0);
expect(result.presented?.data).toEqual({
configPath: './x/prisma-composer.config.ts',
configPath: path.join(path.sep, 'x', 'prisma-composer.config.ts'),
} satisfies ComposerSection);
});

/**
* The reason the section resolves paths at all. prisma.config.ts files form a
* chain — discovered from cwd up to the repo root and merged per key — so a
* `composer` section written once at the root reaches commands run in any
* subdirectory. The root file is the only one declaring `configPath`, and the
* run happens two directories below it, so both wrong answers are visible:
* resolving against cwd or against the nearest file on the chain would name
* `/repo/apps/shop/prisma-composer.config.ts`.
*/
test('a configPath declared at the repo root names the same file from a subdirectory', async () => {
const repo = path.join(path.sep, 'repo');
const appDir = path.join(repo, 'apps', 'shop');
const cli = createTestCli({
commandFamilies: [
defineCommandFamily({ configSection: composerSection, commands: { probe } }),
],
commands: { probe },
loadConfig: () =>
Promise.resolve({
files: [
{ path: path.join(appDir, 'prisma.config.ts'), sections: {} },
{
path: path.join(repo, 'prisma.config.ts'),
sections: { composer: { configPath: './prisma-composer.config.ts' } },
},
],
diagnostics: [],
}),
});

const result = await cli.run(['probe', '--json'], { cwd: appDir });

expect(result.exitCode).toBe(0);
expect(result.presented?.data).toEqual({
configPath: path.join(repo, 'prisma-composer.config.ts'),
} satisfies ComposerSection);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ describe('runComposerCli() — the real Runtime, on a command that needs config'
// The section's own field, not just the fact that dev ran: `configPath`
// travels in the operation's SECOND argument, so a handler that read the
// section but forgot to pass it on would still satisfy the call count.
expect(double.calls.deps.dev[0]?.configPath).toBe('custom');
// The value arrives resolved against the config file that declared it
// (which the loader realpaths), not against the run's cwd.
expect(double.calls.deps.dev[0]?.configPath).toBe(path.join(fs.realpathSync(dir), 'custom'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { HostProcess, LoadedConfig } from '@prisma/cli-engine';
import { createRuntime, detectPackageManager } from '../runtime.ts';

const noConfig = (): Promise<LoadedConfig> =>
Promise.resolve({ path: '/app/prisma.config.ts', sections: {}, diagnostics: [] });
Promise.resolve({ files: [{ path: '/app/prisma.config.ts', sections: {} }], diagnostics: [] });

interface FakeHost extends HostProcess {
readonly listeners: Map<string, Set<() => void>>;
Expand Down Expand Up @@ -237,8 +237,7 @@ describe('createRuntime()', () => {

test('the loader is exposed as loadConfig, and its result is passed through untouched', async () => {
const config: LoadedConfig = {
path: '/app/prisma.config.ts',
sections: { composer: { configPath: 'x.ts' } },
files: [{ path: '/app/prisma.config.ts', sections: { composer: { configPath: 'x.ts' } } }],
diagnostics: [],
};
const runtime = createRuntime(fakeHost(), () => Promise.resolve(config));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,65 @@
* user's typo as a bug in composer.
*/
import { describe, expect, test } from 'bun:test';
import * as path from 'node:path';
import type { SectionProvenance } from '@prisma/cli-engine';
import { composerSection } from '../section.ts';

const DECLARING_FILE = path.join(path.sep, 'repo', 'prisma.config.ts');

function provenance(file: string = DECLARING_FILE): SectionProvenance {
return { files: [file], keys: { configPath: file } };
}

describe('composerSection.validate()', () => {
test('absence is valid and yields an empty section', () => {
const result = composerSection.validate(undefined);
const result = composerSection.validate(undefined, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({});
expect(result.diagnostics).toEqual([]);
});

test('an empty section is valid — every field is optional', () => {
const result = composerSection.validate({});
const result = composerSection.validate({}, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({});
});

test('a configPath comes through', () => {
const result = composerSection.validate({ configPath: './app/prisma-composer.config.ts' });
/**
* The declaring file is `/repo/prisma.config.ts` and this test process runs
* somewhere else entirely, so a validator that resolved against the process
* cwd could not produce the expected path.
*/
test('a relative configPath resolves against the file that declared it', () => {
const result = composerSection.validate(
{ configPath: './app/prisma-composer.config.ts' },
provenance(),
);
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({
configPath: path.join(path.sep, 'repo', 'app', 'prisma-composer.config.ts'),
});
});

test('an absolute configPath passes through unchanged', () => {
const absolute = path.join(path.sep, 'elsewhere', 'prisma-composer.config.ts');
const result = composerSection.validate({ configPath: absolute }, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({ configPath: './app/prisma-composer.config.ts' });
expect(result.ok && result.value).toEqual({ configPath: absolute });
});

test('a provenance without the configPath key fails instead of throwing', () => {
const result = composerSection.validate(
{ configPath: './x.ts' },
{ files: [DECLARING_FILE], keys: {} },
);
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID');
});

test('a non-object section fails with a field diagnostic', () => {
for (const raw of ['nope', 42, [], null]) {
const result = composerSection.validate(raw);
const result = composerSection.validate(raw, provenance());
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID');
expect(result.diagnostics[0]?.severity).toBe('error');
Expand All @@ -39,23 +73,23 @@ describe('composerSection.validate()', () => {

test('a non-string or empty configPath fails', () => {
for (const configPath of [42, '', {}, true]) {
const result = composerSection.validate({ configPath });
const result = composerSection.validate({ configPath }, provenance());
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.summary).toContain('configPath');
}
});

test('an unrecognized field warns but does not fail — a newer config still runs', () => {
const result = composerSection.validate({ configPath: 'x.ts', stage: 'prod' });
const result = composerSection.validate({ configPath: 'x.ts', stage: 'prod' }, provenance());
expect(result.ok).toBe(true);
expect(result.ok && result.value).toEqual({ configPath: 'x.ts' });
expect(result.ok && result.value).toEqual({ configPath: path.join(path.sep, 'repo', 'x.ts') });
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]?.severity).toBe('warn');
expect(result.diagnostics[0]?.summary).toContain('stage');
});

test('every diagnostic carries the nextActions the engine renders', () => {
const result = composerSection.validate({ configPath: 42 });
const result = composerSection.validate({ configPath: 42 }, provenance());
expect(result.diagnostics[0]?.nextActions.length).toBeGreaterThan(0);
expect(result.diagnostics[0]?.nextActions[0]?.label).toBeTruthy();
});
Expand Down Expand Up @@ -86,7 +120,7 @@ describe('composerSection.validate()', () => {
),
];
for (const raw of hostile) {
expect(() => composerSection.validate(raw)).not.toThrow();
expect(() => composerSection.validate(raw, provenance())).not.toThrow();
}
});

Expand All @@ -100,6 +134,7 @@ describe('composerSection.validate()', () => {
},
},
),
provenance(),
);
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG.FIELD_INVALID');
Expand Down
40 changes: 35 additions & 5 deletions packages/0-framework/3-tooling/cli/src/family/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@
import {
type ConfigSection,
defineConfigSection,
resolveSectionPath,
type SectionProvenance,
type SectionValidation,
} from '@prisma/cli-engine';
import type { Diagnostic } from '@prisma/cli-engine/protocol';

export interface ComposerSection {
/**
* Path to `prisma-composer.config.ts`, absolute or relative to the process
* cwd. Absent — the common case — means composer searches upward from the
* command's entry argument, as it always has.
* Absolute path to `prisma-composer.config.ts`. In the config file it may
* be written relative; the validator resolves it against the file that
* declared it, so a root-declared path means the same file from every
* subdirectory. Absent — the common case — means composer searches upward
* from the command's entry argument, as it always has.
*/
readonly configPath?: string | undefined;
}
Expand All @@ -46,7 +50,7 @@ function diagnostic(spec: {
};
}

function validate(raw: unknown): SectionValidation<ComposerSection> {
function validate(raw: unknown, provenance: SectionProvenance): SectionValidation<ComposerSection> {
// Absence is normal and is the validator's to own: with no section,
// composer walks up from the entry exactly as it does today.
if (raw === undefined) return { ok: true, value: {}, diagnostics: [] };
Expand Down Expand Up @@ -103,6 +107,32 @@ function validate(raw: unknown): SectionValidation<ComposerSection> {
};
}

// A relative configPath means "relative to the file that declared it": a
// section written once at the repo root must name the same file from every
// subdirectory a command runs in. resolveSectionPath throws only when the
// key is missing from the provenance, which cannot happen for a key just
// read out of the section — but this validator must never throw, so even
// the impossible case becomes a diagnostic rather than an internal error.
let resolvedConfigPath: string | undefined;
if (configPath !== undefined) {
try {
resolvedConfigPath = resolveSectionPath(provenance, 'configPath', configPath);
} catch {
return {
ok: false,
diagnostics: [
diagnostic({
code: 'CONFIG.FIELD_INVALID',
severity: 'error',
summary:
'`composer.configPath` could not be resolved against the file that declared it.',
fix: 'Write `configPath` as an absolute path, or check the `composer` section of prisma.config.ts.',
}),
],
};
}
}

// An unrecognized field is a warning, not a failure: the section's fields
// grow by contract amendment, so a config written for a newer composer must
// still run on this one. A warning on an ok validation reaches stderr and
Expand All @@ -111,7 +141,7 @@ function validate(raw: unknown): SectionValidation<ComposerSection> {

return {
ok: true,
value: configPath === undefined ? {} : { configPath },
value: resolvedConfigPath === undefined ? {} : { configPath: resolvedConfigPath },
diagnostics: unknown.map((key) =>
diagnostic({
code: 'CONFIG.FIELD_UNKNOWN',
Expand Down
18 changes: 9 additions & 9 deletions packages/0-framework/3-tooling/cli/src/load-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,17 @@ export interface ConfigLoadRequest {
/** The command's entry argument. Anchors the walk when no explicit path is given. */
readonly entryPath: string;
/**
* The `composer` config section's `configPath`, absolute or relative to
* `cwd`. When present the section wins: the walk is skipped, and so is the
* same-file check — that check exists to catch a walk finding one file while
* c12 loaded another, and a path the user named directly has no walk to
* disagree with.
* The `composer` config section's `configPath`, absolute — the section
* validator resolved a relative path against the config file that declared
* it, so this code never resolves it against `cwd`. When present the
* section wins: the walk is skipped, and so is the same-file check — that
* check exists to catch a walk finding one file while c12 loaded another,
* and a path the user named directly has no walk to disagree with.
*/
readonly configPath?: string | undefined;
/**
* The directory the command runs in: it anchors a relative `configPath` and
* is where the effect-resolution check looks for the installed tree.
* Defaults to the entry's directory.
* The directory the command runs in: where the effect-resolution check
* looks for the installed tree. Defaults to the entry's directory.
*/
readonly cwd?: string | undefined;
}
Expand Down Expand Up @@ -304,7 +304,7 @@ function configSource(request: ConfigLoadRequest): ConfigSource {
: { ok: true, path: discovered, explicit: false };
}

const configPath = path.resolve(cwd, request.configPath);
const configPath = path.resolve(request.configPath);
if (!fs.existsSync(configPath)) {
return {
ok: false,
Expand Down
Loading
Loading