Skip to content
Open
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
68 changes: 68 additions & 0 deletions packages/cli/src/actions/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ type ResolveOptions = CommonOptions & {
rolledBack?: string;
};

type DiffOptions = CommonOptions & {
fromEmpty?: boolean;
toEmpty?: boolean;
fromSchemaDatamodel?: boolean;
toSchemaDatamodel?: boolean;
fromMigrationsDirectory?: string;
toMigrationsDirectory?: string;
fromUrl?: string;
toUrl?: string;
shadowDatabaseUrl?: string;
script?: boolean;
exitCode?: boolean;
extraArgs?: string[];
};

/**
* CLI action for migration-related commands
*/
Expand Down Expand Up @@ -62,6 +77,10 @@ export async function run(command: string, options: CommonOptions) {
case 'resolve':
await runResolve(prismaSchemaFile, options as ResolveOptions);
break;

case 'diff':
runDiff(prismaSchemaFile, options as DiffOptions);
break;
}
} finally {
if (fs.existsSync(prismaSchemaFile)) {
Expand Down Expand Up @@ -140,6 +159,55 @@ function runResolve(prismaSchemaFile: string, options: ResolveOptions) {
}
}

function runDiff(prismaSchemaFile: string, options: DiffOptions) {
try {
const parts = ['migrate diff'];

if (options.fromEmpty) {
parts.push('--from-empty');
}
if (options.toEmpty) {
parts.push('--to-empty');
}
if (options.fromSchemaDatamodel) {
parts.push(`--from-schema-datamodel "${prismaSchemaFile}"`);
}
if (options.toSchemaDatamodel) {
parts.push(`--to-schema-datamodel "${prismaSchemaFile}"`);
}
if (options.fromMigrationsDirectory) {
parts.push(`--from-migrations-directory "${options.fromMigrationsDirectory}"`);
}
if (options.toMigrationsDirectory) {
parts.push(`--to-migrations-directory "${options.toMigrationsDirectory}"`);
}
if (options.fromUrl) {
parts.push(`--from-url "${options.fromUrl}"`);
}
if (options.toUrl) {
parts.push(`--to-url "${options.toUrl}"`);
}
if (options.shadowDatabaseUrl) {
parts.push(`--shadow-database-url "${options.shadowDatabaseUrl}"`);
}
if (options.script) {
parts.push('--script');
}
if (options.exitCode) {
parts.push('--exit-code');
}

// pass through any extra args
if (options.extraArgs?.length) {
parts.push(...options.extraArgs);
}

execPrisma(parts.join(' '));
Comment on lines +162 to +205
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "migrate.ts" | grep -E "(packages/cli|src)" | head -5

Repository: zenstackhq/zenstack

Length of output: 100


🏁 Script executed:

find . -type f -name "exec-utils.ts" | head -5

Repository: zenstackhq/zenstack

Length of output: 101


🏁 Script executed:

# Read the migrate.ts file to see the actual code
git ls-files "packages/cli/src/actions/migrate.ts" && \
cat -n "packages/cli/src/actions/migrate.ts" | sed -n '150,220p'

Repository: zenstackhq/zenstack

Length of output: 2688


🏁 Script executed:

# Read the exec-utils.ts file to understand how execPrisma works
git ls-files "packages/cli/src/utils/exec-utils.ts" && \
wc -l "packages/cli/src/utils/exec-utils.ts"

Repository: zenstackhq/zenstack

Length of output: 139


🏁 Script executed:

cat -n "packages/cli/src/utils/exec-utils.ts"

Repository: zenstackhq/zenstack

Length of output: 2292


Escape all arguments before shell execution.

Line 205 passes parts.join(' ') directly to execPrisma, which executes it as a shell command via execSync (exec-utils.ts:60). Values like options.fromUrl, options.toUrl, options.shadowDatabaseUrl, and options.extraArgs are not escaped, so any containing spaces, $, backticks, or quotes will be interpreted by the shell before Prisma sees them. Use proper shell escaping or switch to argv-based execution.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/actions/migrate.ts` around lines 162 - 205, runDiff
constructs a shell command by joining parts and calls execPrisma with a single
string, which allows unescaped values (options.fromUrl, options.toUrl,
options.shadowDatabaseUrl, options.extraArgs) to be interpreted by the shell;
change execPrisma usage so arguments are passed safely (either by making
execPrisma accept an argv array and call child_process.spawn/execFile without a
shell, or by properly shell-escaping each part before joining) and update
runDiff to pass the array of parts (or escaped parts) instead of parts.join('
'); reference runDiff and execPrisma (and the exec-utils helper that currently
uses execSync) when making the change.

} catch (err) {
handleSubProcessError(err);
}
}

function handleSubProcessError(err: unknown) {
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
process.exit(err.status);
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,35 @@ function createProgram() {
.description('Resolve issues with database migrations in deployment databases')
.action((options) => migrateAction('resolve', options));

migrateCommand
.command('diff')
.addOption(schemaOption)
.addOption(noVersionCheckOption)
.addOption(new Option('--from-empty', 'assume the "from" state is an empty schema'))
.addOption(new Option('--to-empty', 'assume the "to" state is an empty schema'))
.addOption(
new Option(
'--from-schema-datamodel',
'use the ZModel schema as the "from" source (auto-generates Prisma schema)',
),
)
.addOption(
new Option(
'--to-schema-datamodel',
'use the ZModel schema as the "to" source (auto-generates Prisma schema)',
),
)
.addOption(new Option('--from-migrations-directory <path>', 'path to the "from" migrations directory'))
.addOption(new Option('--to-migrations-directory <path>', 'path to the "to" migrations directory'))
.addOption(new Option('--from-url <url>', 'database URL as the "from" source'))
.addOption(new Option('--to-url <url>', 'database URL as the "to" source'))
.addOption(new Option('--shadow-database-url <url>', 'shadow database URL for migrations'))
.addOption(new Option('--script', 'output a SQL script instead of human-readable diff'))
.addOption(new Option('--exit-code', 'exit with non-zero code if diff is not empty'))
.allowExcessArguments(true)
.description('Compare database schemas from two sources and output the differences')
.action((options, command) => migrateAction('diff', { ...options, extraArgs: command.args }));

const dbCommand = program.command('db').description('Manage your database schema during development');

dbCommand
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
5 changes: 5 additions & 0 deletions packages/cli/test/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,9 @@ describe('CLI migrate commands test', () => {
const { workDir } = await createProject(model, { provider: 'sqlite' });
expect(() => runCli('migrate resolve', workDir)).toThrow();
});

it('supports migrate diff with --from-empty and --to-schema-datamodel', async () => {
const { workDir } = await createProject(model, { provider: 'sqlite' });
runCli('migrate diff --from-empty --to-schema-datamodel --script', workDir);
});
});
Loading