From b51b74c4fa6008788fc3209747486cec8cced71b Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:27:43 -0800 Subject: [PATCH 1/4] fix(cli): report error when plugin module cannot be resolved Previously, when a plugin module couldn't be found (e.g., package not installed), the error was silently swallowed and the plugin was skipped. Now the CLI reports a clear error message distinguishing between missing modules and other loading failures. Closes #2393 Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/actions/generate.ts | 13 ++++--- packages/cli/test/generate.test.ts | 54 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index fe31688b8..5c78a7643 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -340,10 +340,15 @@ async function loadPluginModule(provider: string, basePath: string) { // last resort, try to import as esm directly try { - return (await import(moduleSpec)).default as CliPlugin; - } catch { - // plugin may not export a generator so we simply ignore the error here - return undefined; + const mod = await import(moduleSpec); + // plugin may not export a generator, return undefined in that case + return mod.default as CliPlugin | undefined; + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException)?.code; + if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') { + throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`); + } + throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`); } } diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index 646cbb680..fb405d609 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -199,6 +199,60 @@ model User { expect(fs.existsSync(path.join(workDir, 'zenstack/input.ts'))).toBe(false); }); + it('should report error for unresolvable plugin module', async () => { + const modelWithMissingPlugin = ` +plugin foo { + provider = '@zenstackhq/nonexistent-plugin' +} + +model User { + id String @id @default(cuid()) +} +`; + const { workDir } = await createProject(modelWithMissingPlugin); + expect(() => runCli('generate', workDir)).toThrow(/Cannot find plugin module/); + }); + + it('should succeed when plugin module exists but has no CLI generator', async () => { + const modelWithNoGeneratorPlugin = ` +plugin foo { + provider = './my-plugin.mjs' +} + +model User { + id String @id @default(cuid()) +} +`; + const { workDir } = await createProject(modelWithNoGeneratorPlugin); + // Create a plugin module that doesn't export a default CLI generator + fs.writeFileSync(path.join(workDir, 'zenstack/my-plugin.mjs'), 'export const name = "no-generator";'); + runCli('generate', workDir); + // Should succeed without error, generating the default typescript output + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true); + }); + + it('should succeed when plugin only provides a plugin.zmodel for custom attributes', async () => { + const modelWithZmodelOnlyPlugin = ` +plugin myPlugin { + provider = './my-plugin' +} + +model User { + id String @id @default(cuid()) + @@custom +} +`; + const { workDir } = await createProject(modelWithZmodelOnlyPlugin); + // Create a plugin directory with index.mjs (no default export) and a plugin.zmodel defining a custom attribute + const pluginDir = path.join(workDir, 'zenstack/my-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'index.mjs'), 'export const name = "my-plugin";'); + fs.writeFileSync(path.join(pluginDir, 'plugin.zmodel'), 'attribute @@custom()'); + runCli('generate', workDir); + // Should succeed without error, generating the default typescript output + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true); + }); + it('should prefer CLI options over @core/typescript plugin settings for generateModels and generateInput', async () => { const modelWithPlugin = ` plugin typescript { From f64236d8655978ce548624ec8135be3d705366ee Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:35:05 -0800 Subject: [PATCH 2/4] 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 --- packages/cli/src/actions/action-utils.ts | 87 +++++++++++++++++++++++- packages/cli/src/actions/check.ts | 17 ++++- packages/cli/src/actions/generate.ts | 87 ++---------------------- packages/cli/test/check.test.ts | 14 ++++ 4 files changed, 117 insertions(+), 88 deletions(-) diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index 7539c4ca0..9659ded31 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -1,13 +1,16 @@ +import { invariant } from '@zenstackhq/common-helpers'; import { type ZModelServices, loadDocument } from '@zenstackhq/language'; -import { type Model, isDataSource } from '@zenstackhq/language/ast'; -import { PrismaSchemaGenerator } from '@zenstackhq/sdk'; +import { type Model, type Plugin, isDataSource, type LiteralExpr } from '@zenstackhq/language/ast'; +import { type CliPlugin, PrismaSchemaGenerator } from '@zenstackhq/sdk'; import colors from 'colors'; +import { createJiti } from 'jiti'; import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; -import { CliError } from '../cli-error'; +import { pathToFileURL } from 'node:url'; import terminalLink from 'terminal-link'; import { z } from 'zod'; +import { CliError } from '../cli-error'; export function getSchemaFile(file?: string) { if (file) { @@ -219,6 +222,84 @@ export async function getZenStackPackages( return result.filter((p) => !!p); } +export function getPluginProvider(plugin: Plugin) { + const providerField = plugin.fields.find((f) => f.name === 'provider'); + invariant(providerField, `Plugin ${plugin.name} does not have a provider field`); + const provider = (providerField.value as LiteralExpr).value as string; + return provider; +} + +export async function loadPluginModule(provider: string, basePath: string) { + let moduleSpec = provider; + if (moduleSpec.startsWith('.')) { + // relative to schema's path + moduleSpec = path.resolve(basePath, moduleSpec); + } + + const importAsEsm = async (spec: string) => { + try { + const result = (await import(spec)).default as CliPlugin; + return result; + } catch (err) { + throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`); + } + }; + + const jiti = createJiti(pathToFileURL(basePath).toString()); + const importAsTs = async (spec: string) => { + try { + const result = (await jiti.import(spec, { default: true })) as CliPlugin; + return result; + } catch (err) { + throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`); + } + }; + + const esmSuffixes = ['.js', '.mjs']; + const tsSuffixes = ['.ts', '.mts']; + + if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) { + // try provider as ESM file + if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) { + return await importAsEsm(pathToFileURL(moduleSpec).toString()); + } + + // try provider as TS file + if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) { + return await importAsTs(moduleSpec); + } + } + + // try ESM index files in provider directory + for (const suffix of esmSuffixes) { + const indexPath = path.join(moduleSpec, `index${suffix}`); + if (fs.existsSync(indexPath)) { + return await importAsEsm(pathToFileURL(indexPath).toString()); + } + } + + // try TS index files in provider directory + for (const suffix of tsSuffixes) { + const indexPath = path.join(moduleSpec, `index${suffix}`); + if (fs.existsSync(indexPath)) { + return await importAsTs(indexPath); + } + } + + // last resort, try to import as esm directly + try { + const mod = await import(moduleSpec); + // plugin may not export a generator, return undefined in that case + return mod.default as CliPlugin | undefined; + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException)?.code; + if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') { + throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`); + } + throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`); + } +} + const FETCH_CLI_MAX_TIME = 1000; const CLI_CONFIG_ENDPOINT = 'https://zenstack.dev/config/cli-v3.json'; diff --git a/packages/cli/src/actions/check.ts b/packages/cli/src/actions/check.ts index 10f063149..a7f765f36 100644 --- a/packages/cli/src/actions/check.ts +++ b/packages/cli/src/actions/check.ts @@ -1,5 +1,7 @@ +import { isPlugin, type Model } from '@zenstackhq/language/ast'; import colors from 'colors'; -import { getSchemaFile, loadSchemaDocument } from './action-utils'; +import path from 'node:path'; +import { getPluginProvider, getSchemaFile, loadPluginModule, loadSchemaDocument } from './action-utils'; type Options = { schema?: string; @@ -12,7 +14,8 @@ export async function run(options: Options) { const schemaFile = getSchemaFile(options.schema); try { - await loadSchemaDocument(schemaFile); + const model = await loadSchemaDocument(schemaFile); + await checkPluginResolution(schemaFile, model); console.log(colors.green('✓ Schema validation completed successfully.')); } catch (error) { console.error(colors.red('✗ Schema validation failed.')); @@ -20,3 +23,13 @@ export async function run(options: Options) { throw error; } } + +async function checkPluginResolution(schemaFile: string, model: Model) { + const plugins = model.declarations.filter(isPlugin); + for (const plugin of plugins) { + const provider = getPluginProvider(plugin); + if (!provider.startsWith('@core/')) { + await loadPluginModule(provider, path.dirname(schemaFile)); + } + } +} diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 5c78a7643..ce499ef11 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -1,22 +1,21 @@ import { invariant, singleDebounce } from '@zenstackhq/common-helpers'; import { ZModelLanguageMetaData } from '@zenstackhq/language'; -import { isPlugin, LiteralExpr, Plugin, type AbstractDeclaration, type Model } from '@zenstackhq/language/ast'; +import { isPlugin, type AbstractDeclaration, type Model } from '@zenstackhq/language/ast'; import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils'; import { type CliPlugin } from '@zenstackhq/sdk'; import { watch } from 'chokidar'; import colors from 'colors'; -import { createJiti } from 'jiti'; -import fs from 'node:fs'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; import ora, { type Ora } from 'ora'; import semver from 'semver'; import { CliError } from '../cli-error'; import * as corePlugins from '../plugins'; import { getOutputPath, + getPluginProvider, getSchemaFile, getZenStackPackages, + loadPluginModule, loadSchemaDocument, startUsageTipsFetch, } from './action-utils'; @@ -258,14 +257,7 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string, } } -function getPluginProvider(plugin: Plugin) { - const providerField = plugin.fields.find((f) => f.name === 'provider'); - invariant(providerField, `Plugin ${plugin.name} does not have a provider field`); - const provider = (providerField.value as LiteralExpr).value as string; - return provider; -} - -function getPluginOptions(plugin: Plugin): Record { +function getPluginOptions(plugin: Parameters[0]): Record { const result: Record = {}; for (const field of plugin.fields) { if (field.name === 'provider') { @@ -281,77 +273,6 @@ function getPluginOptions(plugin: Plugin): Record { return result; } -async function loadPluginModule(provider: string, basePath: string) { - let moduleSpec = provider; - if (moduleSpec.startsWith('.')) { - // relative to schema's path - moduleSpec = path.resolve(basePath, moduleSpec); - } - - const importAsEsm = async (spec: string) => { - try { - const result = (await import(spec)).default as CliPlugin; - return result; - } catch (err) { - throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`); - } - }; - - const jiti = createJiti(pathToFileURL(basePath).toString()); - const importAsTs = async (spec: string) => { - try { - const result = (await jiti.import(spec, { default: true })) as CliPlugin; - return result; - } catch (err) { - throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`); - } - }; - - const esmSuffixes = ['.js', '.mjs']; - const tsSuffixes = ['.ts', '.mts']; - - if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) { - // try provider as ESM file - if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) { - return await importAsEsm(pathToFileURL(moduleSpec).toString()); - } - - // try provider as TS file - if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) { - return await importAsTs(moduleSpec); - } - } - - // try ESM index files in provider directory - for (const suffix of esmSuffixes) { - const indexPath = path.join(moduleSpec, `index${suffix}`); - if (fs.existsSync(indexPath)) { - return await importAsEsm(pathToFileURL(indexPath).toString()); - } - } - - // try TS index files in provider directory - for (const suffix of tsSuffixes) { - const indexPath = path.join(moduleSpec, `index${suffix}`); - if (fs.existsSync(indexPath)) { - return await importAsTs(indexPath); - } - } - - // last resort, try to import as esm directly - try { - const mod = await import(moduleSpec); - // plugin may not export a generator, return undefined in that case - return mod.default as CliPlugin | undefined; - } catch (err) { - const errorCode = (err as NodeJS.ErrnoException)?.code; - if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') { - throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`); - } - throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`); - } -} - async function checkForMismatchedPackages(projectPath: string) { const packages = await getZenStackPackages(projectPath); if (!packages.length) { diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts index 99d31ecda..efd0b0b25 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -81,6 +81,20 @@ describe('CLI validate command test', () => { expect(() => runCli('check', workDir)).not.toThrow(); }); + it('should report error for unresolvable plugin module', async () => { + const modelWithMissingPlugin = ` +plugin foo { + provider = '@zenstackhq/nonexistent-plugin' +} + +model User { + id String @id @default(cuid()) +} +`; + const { workDir } = await createProject(modelWithMissingPlugin); + expect(() => runCli('check', workDir)).toThrow(/Cannot find plugin module/); + }); + it('should validate schema with syntax errors', async () => { const modelWithSyntaxError = ` model User { From cfff4bd6f0adf68e41357e43039a87f560edb711 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:38:47 -0800 Subject: [PATCH 3/4] test(cli): add positive test for check command with resolvable plugin Co-Authored-By: Claude Opus 4.6 --- packages/cli/test/check.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts index efd0b0b25..becc9dfd7 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -81,6 +81,25 @@ describe('CLI validate command test', () => { expect(() => runCli('check', workDir)).not.toThrow(); }); + it('should succeed when plugin module is resolvable', async () => { + const modelWithPlugin = ` +plugin myPlugin { + provider = './my-plugin' +} + +model User { + id String @id @default(cuid()) + @@custom +} +`; + const { workDir } = await createProject(modelWithPlugin); + const pluginDir = path.join(workDir, 'zenstack/my-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'index.mjs'), 'export const name = "my-plugin";'); + fs.writeFileSync(path.join(pluginDir, 'plugin.zmodel'), 'attribute @@custom()'); + expect(() => runCli('check', workDir)).not.toThrow(); + }); + it('should report error for unresolvable plugin module', async () => { const modelWithMissingPlugin = ` plugin foo { From 650c76c2b2530f8ac41ef958fa2fc76089393694 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:23:01 -0800 Subject: [PATCH 4/4] test(cli): add test for .zmodel file as plugin provider Validates that generate succeeds when a plugin provider points to a .zmodel file that defines custom attributes (no code module needed). Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/actions/action-utils.ts | 5 +++++ packages/cli/test/generate.test.ts | 19 +++++++++++++++++++ tests/runtimes/bun/schemas/schema.zmodel | 2 +- .../edge-runtime/schemas/schema.zmodel | 2 +- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index 9659ded31..aed343193 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -230,6 +230,11 @@ export function getPluginProvider(plugin: Plugin) { } export async function loadPluginModule(provider: string, basePath: string) { + if (provider.toLowerCase().endsWith('.zmodel')) { + // provider is a zmodel file, no plugin code module to load + return undefined; + } + let moduleSpec = provider; if (moduleSpec.startsWith('.')) { // relative to schema's path diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index fb405d609..291f0e734 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -253,6 +253,25 @@ model User { expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true); }); + it('should succeed when plugin provider is a .zmodel file', async () => { + const modelWithZmodelProvider = ` +plugin myPlugin { + provider = './custom-attrs/plugin.zmodel' +} + +model User { + id String @id @default(cuid()) + @@custom +} +`; + const { workDir } = await createProject(modelWithZmodelProvider); + const pluginDir = path.join(workDir, 'zenstack/custom-attrs'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'plugin.zmodel'), 'attribute @@custom()'); + runCli('generate', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true); + }); + it('should prefer CLI options over @core/typescript plugin settings for generateModels and generateInput', async () => { const modelWithPlugin = ` plugin typescript { diff --git a/tests/runtimes/bun/schemas/schema.zmodel b/tests/runtimes/bun/schemas/schema.zmodel index ee491c084..f9abf4160 100644 --- a/tests/runtimes/bun/schemas/schema.zmodel +++ b/tests/runtimes/bun/schemas/schema.zmodel @@ -3,7 +3,7 @@ datasource db { } plugin policy { - provider = "../../../packages/plugins/policy" + provider = "../../../../packages/plugins/policy/plugin.zmodel" } model User { diff --git a/tests/runtimes/edge-runtime/schemas/schema.zmodel b/tests/runtimes/edge-runtime/schemas/schema.zmodel index 7872ce263..9842dc4ca 100644 --- a/tests/runtimes/edge-runtime/schemas/schema.zmodel +++ b/tests/runtimes/edge-runtime/schemas/schema.zmodel @@ -3,7 +3,7 @@ datasource db { } plugin policy { - provider = "../../../packages/plugins/policy" + provider = "../../../../packages/plugins/policy/plugin.zmodel" } model User {