Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,14 @@ export async function loadPluginModule(provider: string, basePath: string) {
}
}

// try jiti import for bare package specifiers (handles workspace packages)
try {
const result = (await jiti.import(moduleSpec, { default: true })) as CliPlugin;
return result;
} catch {
// fall through to last resort
}

// last resort, try to import as esm directly
try {
const mod = await import(moduleSpec);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/actions/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ async function checkPluginResolution(schemaFile: string, model: Model) {
for (const plugin of plugins) {
const provider = getPluginProvider(plugin);
if (!provider.startsWith('@core/')) {
await loadPluginModule(provider, path.dirname(schemaFile));
const pluginSourcePath =
plugin.$cstNode?.parent?.element.$document?.uri?.fsPath ?? schemaFile;
await loadPluginModule(provider, path.dirname(pluginSourcePath));
}
}
}
8 changes: 6 additions & 2 deletions packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string,
throw new CliError(`Unknown core plugin: ${provider}`);
}
} else {
cliPlugin = await loadPluginModule(provider, path.dirname(schemaFile));
// resolve relative plugin paths against the schema file where the plugin is declared,
// not the entry schema file
const pluginSourcePath =
plugin.$cstNode?.parent?.element.$document?.uri?.fsPath ?? schemaFile;
cliPlugin = await loadPluginModule(provider, path.dirname(pluginSourcePath));
}

if (cliPlugin) {
Expand Down Expand Up @@ -252,7 +256,7 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string,
spinner?.succeed();
} catch (err) {
spinner?.fail();
console.error(err);
throw err;
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'dotenv/config';
import { ZModelLanguageMetaData } from '@zenstackhq/language';
import colors from 'colors';
import { Command, CommanderError, Option } from 'commander';
import 'dotenv/config';
import * as actions from './actions';
import { CliError } from './cli-error';
import { telemetry } from './telemetry';
Expand Down
81 changes: 80 additions & 1 deletion packages/cli/test/generate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { formatDocument } from '@zenstackhq/language';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createProject, runCli } from './utils';
import { createProject, getDefaultPrelude, runCli } from './utils';

const model = `
model User {
Expand Down Expand Up @@ -272,6 +273,84 @@ model User {
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
});

it('should load plugin from a bare package specifier via jiti', async () => {
const modelWithBarePlugin = `
plugin foo {
provider = 'my-test-plugin'
}

model User {
id String @id @default(cuid())
}
`;
const { workDir } = await createProject(modelWithBarePlugin);
// Create a fake node_modules package with a TS entry point
// This can only be resolved by jiti, not by native import() or fs.existsSync checks
const pkgDir = path.join(workDir, 'node_modules/my-test-plugin');
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, 'package.json'),
JSON.stringify({ name: 'my-test-plugin', main: './index.ts' }),
);
fs.writeFileSync(
path.join(pkgDir, 'index.ts'),
`
const plugin = {
name: 'test-bare-plugin',
statusText: 'Testing bare plugin',
async generate() {},
};
export default plugin;
`,
);
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
});

it('should resolve plugin paths relative to the schema file where the plugin is declared', async () => {
// Entry schema imports a sub-schema that declares a plugin with a relative path.
// The plugin path should resolve relative to the sub-schema, not the entry schema.
const { workDir } = await createProject(
`import './core/core'

${getDefaultPrelude()}

model User {
id String @id @default(cuid())
}
`,
{ customPrelude: true },
);

// Create core/ subdirectory with its own schema and plugin
const coreDir = path.join(workDir, 'zenstack/core');
fs.mkdirSync(coreDir, { recursive: true });

const coreSchema = await formatDocument(`
plugin foo {
provider = './my-core-plugin.ts'
}
`);
fs.writeFileSync(path.join(coreDir, 'core.zmodel'), coreSchema);

// Plugin lives next to the core schema, NOT next to the entry schema
fs.writeFileSync(
path.join(coreDir, 'my-core-plugin.ts'),
`
const plugin = {
name: 'core-plugin',
statusText: 'Testing core plugin',
async generate() {},
};
export default plugin;
`,
);

// This would fail if the plugin path was resolved relative to the entry schema
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 {
Expand Down
Loading