diff --git a/packages/cli/package.json b/packages/cli/package.json index 74c3d43f..7cefd787 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "@zenstackhq/sdk": "workspace:*", "colors": "1.4.0", "commander": "^8.3.0", + "execa": "^9.6.0", "langium": "catalog:", "mixpanel": "^0.18.1", "ora": "^5.4.1", diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index a96bdb89..ed5ed238 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -78,7 +78,11 @@ export async function generateTempPrismaSchema(zmodelPath: string, folder?: stri } export function getPkgJsonConfig(startPath: string) { - const result: { schema: string | undefined; output: string | undefined } = { schema: undefined, output: undefined }; + const result: { schema: string | undefined; output: string | undefined; seed: string | undefined } = { + schema: undefined, + output: undefined, + seed: undefined, + }; const pkgJsonFile = findUp(['package.json'], startPath, false); if (!pkgJsonFile) { @@ -93,8 +97,15 @@ export function getPkgJsonConfig(startPath: string) { } if (pkgJson.zenstack && typeof pkgJson.zenstack === 'object') { - result.schema = pkgJson.zenstack.schema && path.resolve(path.dirname(pkgJsonFile), pkgJson.zenstack.schema); - result.output = pkgJson.zenstack.output && path.resolve(path.dirname(pkgJsonFile), pkgJson.zenstack.output); + result.schema = + pkgJson.zenstack.schema && + typeof pkgJson.zenstack.schema === 'string' && + path.resolve(path.dirname(pkgJsonFile), pkgJson.zenstack.schema); + result.output = + pkgJson.zenstack.output && + typeof pkgJson.zenstack.output === 'string' && + path.resolve(path.dirname(pkgJsonFile), pkgJson.zenstack.output); + result.seed = typeof pkgJson.zenstack.seed === 'string' && pkgJson.zenstack.seed; } return result; diff --git a/packages/cli/src/actions/index.ts b/packages/cli/src/actions/index.ts index f878bac0..88bce15c 100644 --- a/packages/cli/src/actions/index.ts +++ b/packages/cli/src/actions/index.ts @@ -5,5 +5,6 @@ import { run as generate } from './generate'; import { run as info } from './info'; import { run as init } from './init'; import { run as migrate } from './migrate'; +import { run as seed } from './seed'; -export { check, db, format, generate, info, init, migrate }; +export { check, db, format, generate, info, init, migrate, seed }; diff --git a/packages/cli/src/actions/migrate.ts b/packages/cli/src/actions/migrate.ts index d2f3595b..916111c1 100644 --- a/packages/cli/src/actions/migrate.ts +++ b/packages/cli/src/actions/migrate.ts @@ -3,10 +3,12 @@ import path from 'node:path'; import { CliError } from '../cli-error'; import { execPrisma } from '../utils/exec-utils'; import { generateTempPrismaSchema, getSchemaFile } from './action-utils'; +import { run as runSeed } from './seed'; type CommonOptions = { schema?: string; migrations?: string; + skipSeed?: boolean; }; type DevOptions = CommonOptions & { @@ -70,6 +72,7 @@ function runDev(prismaSchemaFile: string, options: DevOptions) { 'migrate dev', ` --schema "${prismaSchemaFile}"`, ' --skip-generate', + ' --skip-seed', options.name ? ` --name "${options.name}"` : '', options.createOnly ? ' --create-only' : '', ].join(''); @@ -79,18 +82,23 @@ function runDev(prismaSchemaFile: string, options: DevOptions) { } } -function runReset(prismaSchemaFile: string, options: ResetOptions) { +async function runReset(prismaSchemaFile: string, options: ResetOptions) { try { const cmd = [ 'migrate reset', ` --schema "${prismaSchemaFile}"`, ' --skip-generate', + ' --skip-seed', options.force ? ' --force' : '', ].join(''); execPrisma(cmd); } catch (err) { handleSubProcessError(err); } + + if (!options.skipSeed) { + await runSeed({ noWarnings: true, printStatus: true }, []); + } } function runDeploy(prismaSchemaFile: string, _options: DeployOptions) { diff --git a/packages/cli/src/actions/seed.ts b/packages/cli/src/actions/seed.ts new file mode 100644 index 00000000..8661c433 --- /dev/null +++ b/packages/cli/src/actions/seed.ts @@ -0,0 +1,38 @@ +import colors from 'colors'; +import { execaCommand } from 'execa'; +import { CliError } from '../cli-error'; +import { getPkgJsonConfig } from './action-utils'; + +type Options = { + noWarnings?: boolean; + printStatus?: boolean; +}; + +/** + * CLI action for seeding the database. + */ +export async function run(options: Options, args: string[]) { + const pkgJsonConfig = getPkgJsonConfig(process.cwd()); + if (!pkgJsonConfig.seed) { + if (!options.noWarnings) { + console.warn(colors.yellow('No seed script defined in package.json. Skipping seeding.')); + } + return; + } + + const command = `${pkgJsonConfig.seed}${args.length > 0 ? ' ' + args.join(' ') : ''}`; + + if (options.printStatus) { + console.log(colors.gray(`Running seed script "${command}"...`)); + } + + try { + await execaCommand(command, { + stdout: 'inherit', + stderr: 'inherit', + }); + } catch (err) { + console.error(colors.red(err instanceof Error ? err.message : String(err))); + throw new CliError('Failed to seed the database. Please check the error message above for details.'); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 89eb6fcd..a271efc8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -34,6 +34,10 @@ const formatAction = async (options: Parameters[0]): Prom await telemetry.trackCommand('format', () => actions.format(options)); }; +const seedAction = async (options: Parameters[0], args: string[]): Promise => { + await telemetry.trackCommand('db seed', () => actions.seed(options, args)); +}; + function createProgram() { const program = new Command('zen') .alias('zenstack') @@ -87,6 +91,7 @@ function createProgram() { .addOption(schemaOption) .addOption(new Option('--force', 'skip the confirmation prompt')) .addOption(migrationsOption) + .addOption(new Option('--skip-seed', 'skip seeding the database after reset')) .addOption(noVersionCheckOption) .description('Reset your database and apply all migrations, all data will be lost') .action((options) => migrateAction('reset', options)); @@ -128,6 +133,26 @@ function createProgram() { .addOption(new Option('--force-reset', 'force a reset of the database before push')) .action((options) => dbAction('push', options)); + dbCommand + .command('seed') + .description('Seed the database') + .allowExcessArguments(true) + .addHelpText( + 'after', + ` +Seed script is configured under the "zenstack.seed" field in package.json. +E.g.: +{ + "zenstack": { + "seed": "ts-node ./zenstack/seed.ts" + } +} + +Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --users 10"`, + ) + .addOption(noVersionCheckOption) + .action((options, command) => seedAction(options, command.args)); + program .command('info') .description('Get information of installed ZenStack packages') diff --git a/packages/cli/test/db.test.ts b/packages/cli/test/db.test.ts index 162d09d6..636dcff8 100644 --- a/packages/cli/test/db.test.ts +++ b/packages/cli/test/db.test.ts @@ -15,4 +15,47 @@ describe('CLI db commands test', () => { runCli('db push', workDir); expect(fs.existsSync(path.join(workDir, 'zenstack/dev.db'))).toBe(true); }); + + it('should seed the database with db seed with seed script', () => { + const workDir = createProject(model); + const pkgJson = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8')); + pkgJson.zenstack = { + seed: 'node seed.js', + }; + fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify(pkgJson, null, 2)); + fs.writeFileSync( + path.join(workDir, 'seed.js'), + ` +import fs from 'node:fs'; +fs.writeFileSync('seed.txt', 'success'); + `, + ); + + runCli('db seed', workDir); + expect(fs.readFileSync(path.join(workDir, 'seed.txt'), 'utf8')).toBe('success'); + }); + + it('should seed the database after migrate reset', () => { + const workDir = createProject(model); + const pkgJson = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8')); + pkgJson.zenstack = { + seed: 'node seed.js', + }; + fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify(pkgJson, null, 2)); + fs.writeFileSync( + path.join(workDir, 'seed.js'), + ` +import fs from 'node:fs'; +fs.writeFileSync('seed.txt', 'success'); + `, + ); + + runCli('migrate reset --force', workDir); + expect(fs.readFileSync(path.join(workDir, 'seed.txt'), 'utf8')).toBe('success'); + }); + + it('should skip seeding the database without seed script', () => { + const workDir = createProject(model); + runCli('db seed', workDir); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eb5eaf2..8e778cd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: commander: specifier: ^8.3.0 version: 8.3.0 + execa: + specifier: ^9.6.0 + version: 9.6.0 langium: specifier: 'catalog:' version: 3.5.0 @@ -11259,7 +11262,7 @@ snapshots: eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.29.0(jiti@2.6.1)) @@ -11292,7 +11295,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -11307,7 +11310,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9