Skip to content

Commit 98668e8

Browse files
authored
feat: cli plugin support (#181)
* feat: cli plugin support * update * update * more fixes
1 parent fb6c03e commit 98668e8

File tree

30 files changed

+319
-106
lines changed

30 files changed

+319
-106
lines changed

.vscode/launch.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"request": "launch",
1111
"skipFiles": ["<node_internals>/**"],
1212
"type": "node",
13-
"args": ["generate", "--schema", "${workspaceFolder}/samples/blog/zenstack/schema.zmodel"]
13+
"args": ["generate"],
14+
"cwd": "${workspaceFolder}/samples/blog/zenstack"
1415
},
1516
{
1617
"name": "Debug with TSX",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"license": "MIT",
2222
"devDependencies": {
2323
"@eslint/js": "^9.29.0",
24-
"@types/node": "^20.17.24",
24+
"@types/node": "catalog:",
2525
"eslint": "~9.29.0",
2626
"glob": "^11.0.2",
2727
"prettier": "^3.5.3",
Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { invariant } from '@zenstackhq/common-helpers';
2-
import { isPlugin, LiteralExpr, type Model } from '@zenstackhq/language/ast';
3-
import { PrismaSchemaGenerator, TsSchemaGenerator, type CliGenerator } from '@zenstackhq/sdk';
2+
import { isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast';
3+
import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils';
4+
import { type CliPlugin } from '@zenstackhq/sdk';
45
import colors from 'colors';
5-
import fs from 'node:fs';
66
import path from 'node:path';
7+
import ora from 'ora';
8+
import { CliError } from '../cli-error';
9+
import * as corePlugins from '../plugins';
710
import { getPkgJsonConfig, getSchemaFile, loadSchemaDocument } from './action-utils';
811

912
type Options = {
1013
schema?: string;
1114
output?: string;
1215
silent?: boolean;
13-
savePrismaSchema?: string | boolean;
1416
};
1517

1618
/**
@@ -24,25 +26,10 @@ export async function run(options: Options) {
2426
const model = await loadSchemaDocument(schemaFile);
2527
const outputPath = getOutputPath(options, schemaFile);
2628

27-
// generate TS schema
28-
const tsSchemaFile = path.join(outputPath, 'schema.ts');
29-
await new TsSchemaGenerator().generate(schemaFile, [], outputPath);
30-
31-
await runPlugins(model, outputPath, tsSchemaFile);
32-
33-
// generate Prisma schema
34-
if (options.savePrismaSchema) {
35-
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
36-
let prismaSchemaFile = path.join(outputPath, 'schema.prisma');
37-
if (typeof options.savePrismaSchema === 'string') {
38-
prismaSchemaFile = path.resolve(outputPath, options.savePrismaSchema);
39-
fs.mkdirSync(path.dirname(prismaSchemaFile), { recursive: true });
40-
}
41-
fs.writeFileSync(prismaSchemaFile, prismaSchema);
42-
}
29+
await runPlugins(schemaFile, model, outputPath);
4330

4431
if (!options.silent) {
45-
console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.`));
32+
console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.\n`));
4633
console.log(`You can now create a ZenStack client with it.
4734
4835
\`\`\`ts
@@ -68,18 +55,84 @@ function getOutputPath(options: Options, schemaFile: string) {
6855
}
6956
}
7057

71-
async function runPlugins(model: Model, outputPath: string, tsSchemaFile: string) {
58+
async function runPlugins(schemaFile: string, model: Model, outputPath: string) {
7259
const plugins = model.declarations.filter(isPlugin);
60+
const processedPlugins: { cliPlugin: CliPlugin; pluginOptions: Record<string, unknown> }[] = [];
61+
7362
for (const plugin of plugins) {
74-
const providerField = plugin.fields.find((f) => f.name === 'provider');
75-
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
76-
const provider = (providerField.value as LiteralExpr).value as string;
77-
let useProvider = provider;
78-
if (useProvider.startsWith('@core/')) {
79-
useProvider = `@zenstackhq/runtime/plugins/${useProvider.slice(6)}`;
63+
const provider = getPluginProvider(plugin);
64+
65+
let cliPlugin: CliPlugin;
66+
if (provider.startsWith('@core/')) {
67+
cliPlugin = (corePlugins as any)[provider.slice('@core/'.length)];
68+
if (!cliPlugin) {
69+
throw new CliError(`Unknown core plugin: ${provider}`);
70+
}
71+
} else {
72+
let moduleSpec = provider;
73+
if (moduleSpec.startsWith('.')) {
74+
// relative to schema's path
75+
moduleSpec = path.resolve(path.dirname(schemaFile), moduleSpec);
76+
}
77+
try {
78+
cliPlugin = (await import(moduleSpec)).default as CliPlugin;
79+
} catch (error) {
80+
throw new CliError(`Failed to load plugin ${provider}: ${error}`);
81+
}
82+
}
83+
84+
processedPlugins.push({ cliPlugin, pluginOptions: getPluginOptions(plugin) });
85+
}
86+
87+
const defaultPlugins = [corePlugins['typescript']].reverse();
88+
defaultPlugins.forEach((d) => {
89+
if (!processedPlugins.some((p) => p.cliPlugin === d)) {
90+
processedPlugins.push({ cliPlugin: d, pluginOptions: {} });
91+
}
92+
});
93+
94+
for (const { cliPlugin, pluginOptions } of processedPlugins) {
95+
invariant(
96+
typeof cliPlugin.generate === 'function',
97+
`Plugin ${cliPlugin.name} does not have a generate function`,
98+
);
99+
100+
// run plugin generator
101+
const spinner = ora(cliPlugin.statusText ?? `Running plugin ${cliPlugin.name}`).start();
102+
try {
103+
await cliPlugin.generate({
104+
schemaFile,
105+
model,
106+
defaultOutputPath: outputPath,
107+
pluginOptions,
108+
});
109+
spinner.succeed();
110+
} catch (err) {
111+
spinner.fail();
112+
console.error(err);
113+
}
114+
}
115+
}
116+
117+
function getPluginProvider(plugin: Plugin) {
118+
const providerField = plugin.fields.find((f) => f.name === 'provider');
119+
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
120+
const provider = (providerField.value as LiteralExpr).value as string;
121+
return provider;
122+
}
123+
124+
function getPluginOptions(plugin: Plugin): Record<string, unknown> {
125+
const result: Record<string, unknown> = {};
126+
for (const field of plugin.fields) {
127+
if (field.name === 'provider') {
128+
continue; // skip provider
129+
}
130+
const value = getLiteral(field.value) ?? getLiteralArray(field.value);
131+
if (value === undefined) {
132+
console.warn(`Plugin "${plugin.name}" option "${field.name}" has unsupported value, skipping`);
133+
continue;
80134
}
81-
const generator = (await import(useProvider)).default as CliGenerator;
82-
console.log('Running generator:', provider);
83-
await generator({ model, outputPath, tsSchemaFile });
135+
result[field.name] = value;
84136
}
137+
return result;
85138
}

packages/cli/src/index.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,6 @@ export function createProgram() {
5555
.description('Run code generation.')
5656
.addOption(schemaOption)
5757
.addOption(new Option('--silent', 'do not print any output'))
58-
.addOption(
59-
new Option(
60-
'--save-prisma-schema [path]',
61-
'save a Prisma schema file, by default into the output directory',
62-
),
63-
)
6458
.addOption(new Option('-o, --output <path>', 'default output directory for core plugins'))
6559
.action(generateAction);
6660

packages/cli/src/plugins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as prisma } from './prisma';
2+
export { default as typescript } from './typescript';

packages/cli/src/plugins/prisma.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { PrismaSchemaGenerator, type CliPlugin } from '@zenstackhq/sdk';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
const plugin: CliPlugin = {
6+
name: 'Prisma Schema Generator',
7+
statusText: 'Generating Prisma schema',
8+
async generate({ model, defaultOutputPath, pluginOptions }) {
9+
let outDir = defaultOutputPath;
10+
if (typeof pluginOptions['output'] === 'string') {
11+
outDir = path.resolve(defaultOutputPath, pluginOptions['output']);
12+
if (!fs.existsSync(outDir)) {
13+
fs.mkdirSync(outDir, { recursive: true });
14+
}
15+
}
16+
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
17+
fs.writeFileSync(path.join(outDir, 'schema.prisma'), prismaSchema);
18+
},
19+
};
20+
21+
export default plugin;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { CliPlugin } from '@zenstackhq/sdk';
2+
import { TsSchemaGenerator } from '@zenstackhq/sdk';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
6+
const plugin: CliPlugin = {
7+
name: 'TypeScript Schema Generator',
8+
statusText: 'Generating TypeScript schema',
9+
async generate({ model, defaultOutputPath, pluginOptions }) {
10+
let outDir = defaultOutputPath;
11+
if (typeof pluginOptions['output'] === 'string') {
12+
outDir = path.resolve(defaultOutputPath, pluginOptions['output']);
13+
if (!fs.existsSync(outDir)) {
14+
fs.mkdirSync(outDir, { recursive: true });
15+
}
16+
}
17+
await new TsSchemaGenerator().generate(model, outDir);
18+
},
19+
};
20+
21+
export default plugin;

packages/cli/test/generate.test.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,6 @@ describe('CLI generate command test', () => {
3030
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
3131
});
3232

33-
it('should respect save prisma schema option', () => {
34-
const workDir = createProject(model);
35-
runCli('generate --save-prisma-schema', workDir);
36-
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(true);
37-
});
38-
39-
it('should respect save prisma schema custom path option', () => {
40-
const workDir = createProject(model);
41-
runCli('generate --save-prisma-schema "../prisma/schema.prisma"', workDir);
42-
expect(fs.existsSync(path.join(workDir, 'prisma/schema.prisma'))).toBe(true);
43-
});
44-
4533
it('should respect package.json config', () => {
4634
const workDir = createProject(model);
4735
fs.mkdirSync(path.join(workDir, 'foo'));
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
import { createProject, runCli } from '../utils';
5+
import { execSync } from 'node:child_process';
6+
7+
describe('Custom plugins tests', () => {
8+
it('runs custom plugin generator', () => {
9+
const workDir = createProject(`
10+
plugin custom {
11+
provider = '../my-plugin.js'
12+
output = '../custom-output'
13+
}
14+
15+
model User {
16+
id String @id @default(cuid())
17+
}
18+
`);
19+
20+
fs.writeFileSync(
21+
path.join(workDir, 'my-plugin.ts'),
22+
`
23+
import type { CliPlugin } from '@zenstackhq/sdk';
24+
import fs from 'node:fs';
25+
import path from 'node:path';
26+
27+
const plugin: CliPlugin = {
28+
name: 'Custom Generator',
29+
statusText: 'Generating foo.txt',
30+
async generate({ model, defaultOutputPath, pluginOptions }) {
31+
let outDir = defaultOutputPath;
32+
if (typeof pluginOptions['output'] === 'string') {
33+
outDir = path.resolve(defaultOutputPath, pluginOptions['output']);
34+
if (!fs.existsSync(outDir)) {
35+
fs.mkdirSync(outDir, { recursive: true });
36+
}
37+
}
38+
fs.writeFileSync(path.join(outDir, 'foo.txt'), 'from my plugin');
39+
},
40+
};
41+
42+
export default plugin;
43+
`,
44+
);
45+
46+
execSync('npx tsc', { cwd: workDir });
47+
runCli('generate', workDir);
48+
expect(fs.existsSync(path.join(workDir, 'custom-output/foo.txt'))).toBe(true);
49+
});
50+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
import { createProject, runCli } from '../utils';
5+
6+
describe('Core plugins tests', () => {
7+
it('can automatically generate a TypeScript schema with default output', () => {
8+
const workDir = createProject(`
9+
model User {
10+
id String @id @default(cuid())
11+
}
12+
`);
13+
runCli('generate', workDir);
14+
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
15+
});
16+
17+
it('can automatically generate a TypeScript schema with custom output', () => {
18+
const workDir = createProject(`
19+
plugin typescript {
20+
provider = '@core/typescript'
21+
output = '../generated-schema'
22+
}
23+
24+
model User {
25+
id String @id @default(cuid())
26+
}
27+
`);
28+
runCli('generate', workDir);
29+
expect(fs.existsSync(path.join(workDir, 'generated-schema/schema.ts'))).toBe(true);
30+
});
31+
32+
it('can generate a Prisma schema with default output', () => {
33+
const workDir = createProject(`
34+
plugin prisma {
35+
provider = '@core/prisma'
36+
}
37+
38+
model User {
39+
id String @id @default(cuid())
40+
}
41+
`);
42+
runCli('generate', workDir);
43+
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(true);
44+
});
45+
46+
it('can generate a Prisma schema with custom output', () => {
47+
const workDir = createProject(`
48+
plugin prisma {
49+
provider = '@core/prisma'
50+
output = './prisma'
51+
}
52+
53+
model User {
54+
id String @id @default(cuid())
55+
}
56+
`);
57+
runCli('generate', workDir);
58+
expect(fs.existsSync(path.join(workDir, 'zenstack/prisma/schema.prisma'))).toBe(true);
59+
});
60+
});

0 commit comments

Comments
 (0)