Skip to content

Commit 3c181f6

Browse files
ymc9claude
andauthored
fix(cli): report error when plugin module cannot be resolved (#2447)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5bff025 commit 3c181f6

File tree

7 files changed

+216
-85
lines changed

7 files changed

+216
-85
lines changed

packages/cli/src/actions/action-utils.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import { invariant } from '@zenstackhq/common-helpers';
12
import { type ZModelServices, loadDocument } from '@zenstackhq/language';
2-
import { type Model, isDataSource } from '@zenstackhq/language/ast';
3-
import { PrismaSchemaGenerator } from '@zenstackhq/sdk';
3+
import { type Model, type Plugin, isDataSource, type LiteralExpr } from '@zenstackhq/language/ast';
4+
import { type CliPlugin, PrismaSchemaGenerator } from '@zenstackhq/sdk';
45
import colors from 'colors';
6+
import { createJiti } from 'jiti';
57
import fs from 'node:fs';
68
import { createRequire } from 'node:module';
79
import path from 'node:path';
8-
import { CliError } from '../cli-error';
10+
import { pathToFileURL } from 'node:url';
911
import terminalLink from 'terminal-link';
1012
import { z } from 'zod';
13+
import { CliError } from '../cli-error';
1114

1215
export function getSchemaFile(file?: string) {
1316
if (file) {
@@ -219,6 +222,89 @@ export async function getZenStackPackages(
219222
return result.filter((p) => !!p);
220223
}
221224

225+
export function getPluginProvider(plugin: Plugin) {
226+
const providerField = plugin.fields.find((f) => f.name === 'provider');
227+
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
228+
const provider = (providerField.value as LiteralExpr).value as string;
229+
return provider;
230+
}
231+
232+
export async function loadPluginModule(provider: string, basePath: string) {
233+
if (provider.toLowerCase().endsWith('.zmodel')) {
234+
// provider is a zmodel file, no plugin code module to load
235+
return undefined;
236+
}
237+
238+
let moduleSpec = provider;
239+
if (moduleSpec.startsWith('.')) {
240+
// relative to schema's path
241+
moduleSpec = path.resolve(basePath, moduleSpec);
242+
}
243+
244+
const importAsEsm = async (spec: string) => {
245+
try {
246+
const result = (await import(spec)).default as CliPlugin;
247+
return result;
248+
} catch (err) {
249+
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
250+
}
251+
};
252+
253+
const jiti = createJiti(pathToFileURL(basePath).toString());
254+
const importAsTs = async (spec: string) => {
255+
try {
256+
const result = (await jiti.import(spec, { default: true })) as CliPlugin;
257+
return result;
258+
} catch (err) {
259+
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
260+
}
261+
};
262+
263+
const esmSuffixes = ['.js', '.mjs'];
264+
const tsSuffixes = ['.ts', '.mts'];
265+
266+
if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) {
267+
// try provider as ESM file
268+
if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
269+
return await importAsEsm(pathToFileURL(moduleSpec).toString());
270+
}
271+
272+
// try provider as TS file
273+
if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
274+
return await importAsTs(moduleSpec);
275+
}
276+
}
277+
278+
// try ESM index files in provider directory
279+
for (const suffix of esmSuffixes) {
280+
const indexPath = path.join(moduleSpec, `index${suffix}`);
281+
if (fs.existsSync(indexPath)) {
282+
return await importAsEsm(pathToFileURL(indexPath).toString());
283+
}
284+
}
285+
286+
// try TS index files in provider directory
287+
for (const suffix of tsSuffixes) {
288+
const indexPath = path.join(moduleSpec, `index${suffix}`);
289+
if (fs.existsSync(indexPath)) {
290+
return await importAsTs(indexPath);
291+
}
292+
}
293+
294+
// last resort, try to import as esm directly
295+
try {
296+
const mod = await import(moduleSpec);
297+
// plugin may not export a generator, return undefined in that case
298+
return mod.default as CliPlugin | undefined;
299+
} catch (err) {
300+
const errorCode = (err as NodeJS.ErrnoException)?.code;
301+
if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') {
302+
throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`);
303+
}
304+
throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`);
305+
}
306+
}
307+
222308
const FETCH_CLI_MAX_TIME = 1000;
223309
const CLI_CONFIG_ENDPOINT = 'https://zenstack.dev/config/cli-v3.json';
224310

packages/cli/src/actions/check.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { isPlugin, type Model } from '@zenstackhq/language/ast';
12
import colors from 'colors';
2-
import { getSchemaFile, loadSchemaDocument } from './action-utils';
3+
import path from 'node:path';
4+
import { getPluginProvider, getSchemaFile, loadPluginModule, loadSchemaDocument } from './action-utils';
35

46
type Options = {
57
schema?: string;
@@ -12,11 +14,22 @@ export async function run(options: Options) {
1214
const schemaFile = getSchemaFile(options.schema);
1315

1416
try {
15-
await loadSchemaDocument(schemaFile);
17+
const model = await loadSchemaDocument(schemaFile);
18+
await checkPluginResolution(schemaFile, model);
1619
console.log(colors.green('✓ Schema validation completed successfully.'));
1720
} catch (error) {
1821
console.error(colors.red('✗ Schema validation failed.'));
1922
// Re-throw to maintain CLI exit code behavior
2023
throw error;
2124
}
2225
}
26+
27+
async function checkPluginResolution(schemaFile: string, model: Model) {
28+
const plugins = model.declarations.filter(isPlugin);
29+
for (const plugin of plugins) {
30+
const provider = getPluginProvider(plugin);
31+
if (!provider.startsWith('@core/')) {
32+
await loadPluginModule(provider, path.dirname(schemaFile));
33+
}
34+
}
35+
}

packages/cli/src/actions/generate.ts

Lines changed: 4 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { invariant, singleDebounce } from '@zenstackhq/common-helpers';
22
import { ZModelLanguageMetaData } from '@zenstackhq/language';
3-
import { isPlugin, LiteralExpr, Plugin, type AbstractDeclaration, type Model } from '@zenstackhq/language/ast';
3+
import { isPlugin, type AbstractDeclaration, type Model } from '@zenstackhq/language/ast';
44
import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils';
55
import { type CliPlugin } from '@zenstackhq/sdk';
66
import { watch } from 'chokidar';
77
import colors from 'colors';
8-
import { createJiti } from 'jiti';
9-
import fs from 'node:fs';
108
import path from 'node:path';
11-
import { pathToFileURL } from 'node:url';
129
import ora, { type Ora } from 'ora';
1310
import semver from 'semver';
1411
import { CliError } from '../cli-error';
1512
import * as corePlugins from '../plugins';
1613
import {
1714
getOutputPath,
15+
getPluginProvider,
1816
getSchemaFile,
1917
getZenStackPackages,
18+
loadPluginModule,
2019
loadSchemaDocument,
2120
startUsageTipsFetch,
2221
} from './action-utils';
@@ -258,14 +257,7 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string,
258257
}
259258
}
260259

261-
function getPluginProvider(plugin: Plugin) {
262-
const providerField = plugin.fields.find((f) => f.name === 'provider');
263-
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
264-
const provider = (providerField.value as LiteralExpr).value as string;
265-
return provider;
266-
}
267-
268-
function getPluginOptions(plugin: Plugin): Record<string, unknown> {
260+
function getPluginOptions(plugin: Parameters<typeof getPluginProvider>[0]): Record<string, unknown> {
269261
const result: Record<string, unknown> = {};
270262
for (const field of plugin.fields) {
271263
if (field.name === 'provider') {
@@ -281,72 +273,6 @@ function getPluginOptions(plugin: Plugin): Record<string, unknown> {
281273
return result;
282274
}
283275

284-
async function loadPluginModule(provider: string, basePath: string) {
285-
let moduleSpec = provider;
286-
if (moduleSpec.startsWith('.')) {
287-
// relative to schema's path
288-
moduleSpec = path.resolve(basePath, moduleSpec);
289-
}
290-
291-
const importAsEsm = async (spec: string) => {
292-
try {
293-
const result = (await import(spec)).default as CliPlugin;
294-
return result;
295-
} catch (err) {
296-
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
297-
}
298-
};
299-
300-
const jiti = createJiti(pathToFileURL(basePath).toString());
301-
const importAsTs = async (spec: string) => {
302-
try {
303-
const result = (await jiti.import(spec, { default: true })) as CliPlugin;
304-
return result;
305-
} catch (err) {
306-
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
307-
}
308-
};
309-
310-
const esmSuffixes = ['.js', '.mjs'];
311-
const tsSuffixes = ['.ts', '.mts'];
312-
313-
if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) {
314-
// try provider as ESM file
315-
if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
316-
return await importAsEsm(pathToFileURL(moduleSpec).toString());
317-
}
318-
319-
// try provider as TS file
320-
if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
321-
return await importAsTs(moduleSpec);
322-
}
323-
}
324-
325-
// try ESM index files in provider directory
326-
for (const suffix of esmSuffixes) {
327-
const indexPath = path.join(moduleSpec, `index${suffix}`);
328-
if (fs.existsSync(indexPath)) {
329-
return await importAsEsm(pathToFileURL(indexPath).toString());
330-
}
331-
}
332-
333-
// try TS index files in provider directory
334-
for (const suffix of tsSuffixes) {
335-
const indexPath = path.join(moduleSpec, `index${suffix}`);
336-
if (fs.existsSync(indexPath)) {
337-
return await importAsTs(indexPath);
338-
}
339-
}
340-
341-
// last resort, try to import as esm directly
342-
try {
343-
return (await import(moduleSpec)).default as CliPlugin;
344-
} catch {
345-
// plugin may not export a generator so we simply ignore the error here
346-
return undefined;
347-
}
348-
}
349-
350276
async function checkForMismatchedPackages(projectPath: string) {
351277
const packages = await getZenStackPackages(projectPath);
352278
if (!packages.length) {

packages/cli/test/check.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,39 @@ describe('CLI validate command test', () => {
8181
expect(() => runCli('check', workDir)).not.toThrow();
8282
});
8383

84+
it('should succeed when plugin module is resolvable', async () => {
85+
const modelWithPlugin = `
86+
plugin myPlugin {
87+
provider = './my-plugin'
88+
}
89+
90+
model User {
91+
id String @id @default(cuid())
92+
@@custom
93+
}
94+
`;
95+
const { workDir } = await createProject(modelWithPlugin);
96+
const pluginDir = path.join(workDir, 'zenstack/my-plugin');
97+
fs.mkdirSync(pluginDir, { recursive: true });
98+
fs.writeFileSync(path.join(pluginDir, 'index.mjs'), 'export const name = "my-plugin";');
99+
fs.writeFileSync(path.join(pluginDir, 'plugin.zmodel'), 'attribute @@custom()');
100+
expect(() => runCli('check', workDir)).not.toThrow();
101+
});
102+
103+
it('should report error for unresolvable plugin module', async () => {
104+
const modelWithMissingPlugin = `
105+
plugin foo {
106+
provider = '@zenstackhq/nonexistent-plugin'
107+
}
108+
109+
model User {
110+
id String @id @default(cuid())
111+
}
112+
`;
113+
const { workDir } = await createProject(modelWithMissingPlugin);
114+
expect(() => runCli('check', workDir)).toThrow(/Cannot find plugin module/);
115+
});
116+
84117
it('should validate schema with syntax errors', async () => {
85118
const modelWithSyntaxError = `
86119
model User {

packages/cli/test/generate.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,79 @@ model User {
199199
expect(fs.existsSync(path.join(workDir, 'zenstack/input.ts'))).toBe(false);
200200
});
201201

202+
it('should report error for unresolvable plugin module', async () => {
203+
const modelWithMissingPlugin = `
204+
plugin foo {
205+
provider = '@zenstackhq/nonexistent-plugin'
206+
}
207+
208+
model User {
209+
id String @id @default(cuid())
210+
}
211+
`;
212+
const { workDir } = await createProject(modelWithMissingPlugin);
213+
expect(() => runCli('generate', workDir)).toThrow(/Cannot find plugin module/);
214+
});
215+
216+
it('should succeed when plugin module exists but has no CLI generator', async () => {
217+
const modelWithNoGeneratorPlugin = `
218+
plugin foo {
219+
provider = './my-plugin.mjs'
220+
}
221+
222+
model User {
223+
id String @id @default(cuid())
224+
}
225+
`;
226+
const { workDir } = await createProject(modelWithNoGeneratorPlugin);
227+
// Create a plugin module that doesn't export a default CLI generator
228+
fs.writeFileSync(path.join(workDir, 'zenstack/my-plugin.mjs'), 'export const name = "no-generator";');
229+
runCli('generate', workDir);
230+
// Should succeed without error, generating the default typescript output
231+
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
232+
});
233+
234+
it('should succeed when plugin only provides a plugin.zmodel for custom attributes', async () => {
235+
const modelWithZmodelOnlyPlugin = `
236+
plugin myPlugin {
237+
provider = './my-plugin'
238+
}
239+
240+
model User {
241+
id String @id @default(cuid())
242+
@@custom
243+
}
244+
`;
245+
const { workDir } = await createProject(modelWithZmodelOnlyPlugin);
246+
// Create a plugin directory with index.mjs (no default export) and a plugin.zmodel defining a custom attribute
247+
const pluginDir = path.join(workDir, 'zenstack/my-plugin');
248+
fs.mkdirSync(pluginDir, { recursive: true });
249+
fs.writeFileSync(path.join(pluginDir, 'index.mjs'), 'export const name = "my-plugin";');
250+
fs.writeFileSync(path.join(pluginDir, 'plugin.zmodel'), 'attribute @@custom()');
251+
runCli('generate', workDir);
252+
// Should succeed without error, generating the default typescript output
253+
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
254+
});
255+
256+
it('should succeed when plugin provider is a .zmodel file', async () => {
257+
const modelWithZmodelProvider = `
258+
plugin myPlugin {
259+
provider = './custom-attrs/plugin.zmodel'
260+
}
261+
262+
model User {
263+
id String @id @default(cuid())
264+
@@custom
265+
}
266+
`;
267+
const { workDir } = await createProject(modelWithZmodelProvider);
268+
const pluginDir = path.join(workDir, 'zenstack/custom-attrs');
269+
fs.mkdirSync(pluginDir, { recursive: true });
270+
fs.writeFileSync(path.join(pluginDir, 'plugin.zmodel'), 'attribute @@custom()');
271+
runCli('generate', workDir);
272+
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
273+
});
274+
202275
it('should prefer CLI options over @core/typescript plugin settings for generateModels and generateInput', async () => {
203276
const modelWithPlugin = `
204277
plugin typescript {

tests/runtimes/bun/schemas/schema.zmodel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ datasource db {
33
}
44

55
plugin policy {
6-
provider = "../../../packages/plugins/policy"
6+
provider = "../../../../packages/plugins/policy/plugin.zmodel"
77
}
88

99
model User {

tests/runtimes/edge-runtime/schemas/schema.zmodel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ datasource db {
33
}
44

55
plugin policy {
6-
provider = "../../../packages/plugins/policy"
6+
provider = "../../../../packages/plugins/policy/plugin.zmodel"
77
}
88

99
model User {

0 commit comments

Comments
 (0)