Skip to content

Commit f64236d

Browse files
ymc9claude
andcommitted
fix(cli): add plugin resolution check to zen check command
Extract `loadPluginModule` and `getPluginProvider` to shared action-utils so both `generate` and `check` commands validate plugin resolution. The `zen check` command now reports an error when a plugin module cannot be found, matching the behavior of `zen generate`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b51b74c commit f64236d

4 files changed

Lines changed: 117 additions & 88 deletions

File tree

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

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

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 & 83 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,77 +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-
const mod = await import(moduleSpec);
344-
// plugin may not export a generator, return undefined in that case
345-
return mod.default as CliPlugin | undefined;
346-
} catch (err) {
347-
const errorCode = (err as NodeJS.ErrnoException)?.code;
348-
if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') {
349-
throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`);
350-
}
351-
throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`);
352-
}
353-
}
354-
355276
async function checkForMismatchedPackages(projectPath: string) {
356277
const packages = await getZenStackPackages(projectPath);
357278
if (!packages.length) {

packages/cli/test/check.test.ts

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

84+
it('should report error for unresolvable plugin module', async () => {
85+
const modelWithMissingPlugin = `
86+
plugin foo {
87+
provider = '@zenstackhq/nonexistent-plugin'
88+
}
89+
90+
model User {
91+
id String @id @default(cuid())
92+
}
93+
`;
94+
const { workDir } = await createProject(modelWithMissingPlugin);
95+
expect(() => runCli('check', workDir)).toThrow(/Cannot find plugin module/);
96+
});
97+
8498
it('should validate schema with syntax errors', async () => {
8599
const modelWithSyntaxError = `
86100
model User {

0 commit comments

Comments
 (0)