diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index 7539c4ca0..aed343193 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,89 @@ 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) { + 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 + 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 fe31688b8..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,72 +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 { - return (await import(moduleSpec)).default as CliPlugin; - } catch { - // plugin may not export a generator so we simply ignore the error here - return undefined; - } -} - 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..becc9dfd7 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -81,6 +81,39 @@ 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 { + 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 { diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index 646cbb680..291f0e734 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -199,6 +199,79 @@ 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 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 {