Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
87 changes: 84 additions & 3 deletions packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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;
Comment thread
ymc9 marked this conversation as resolved.
} 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.`);
}
Comment thread
ymc9 marked this conversation as resolved.
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';

Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/actions/check.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,11 +14,22 @@ 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.'));
// Re-throw to maintain CLI exit code behavior
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));
}
}
}
82 changes: 4 additions & 78 deletions packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown> {
function getPluginOptions(plugin: Parameters<typeof getPluginProvider>[0]): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const field of plugin.fields) {
if (field.name === 'provider') {
Expand All @@ -281,72 +273,6 @@ function getPluginOptions(plugin: Plugin): Record<string, unknown> {
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) {
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/test/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/test/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading