diff --git a/packages/schema/package.json b/packages/schema/package.json index d125dad49..828656dd0 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -93,6 +93,7 @@ "node-machine-id": "^1.1.12", "ora": "^5.4.1", "pluralize": "^8.0.0", + "pretty-repl": "^4.0.0", "promisify": "^0.0.3", "semver": "^7.3.8", "sleep-promise": "^9.1.0", diff --git a/packages/schema/src/cli/actions/index.ts b/packages/schema/src/cli/actions/index.ts index 19f6fcec2..0795902d7 100644 --- a/packages/schema/src/cli/actions/index.ts +++ b/packages/schema/src/cli/actions/index.ts @@ -1,3 +1,4 @@ export * from './generate'; export * from './info'; export * from './init'; +export * from './repl'; diff --git a/packages/schema/src/cli/actions/repl.ts b/packages/schema/src/cli/actions/repl.ts new file mode 100644 index 000000000..a9f6e9a8f --- /dev/null +++ b/packages/schema/src/cli/actions/repl.ts @@ -0,0 +1,178 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-var-requires */ +import colors from 'colors'; +import path from 'path'; +import prettyRepl from 'pretty-repl'; +import { inspect } from 'util'; + +// inspired by: https://github.com/Kinjalrk2k/prisma-console + +/** + * CLI action for starting a REPL session + */ +export async function repl(projectPath: string, options: { prismaClient?: string }) { + console.log('Welcome to ZenStack REPL. See help with the ".help" command.'); + console.log('Global variables:'); + console.log(` ${colors.cyan('db')} to access enhanced PrismaClient`); + console.log(` ${colors.cyan('prisma')} to access raw PrismaClient`); + console.log('Commands:'); + console.log(` ${colors.magenta('.auth { id: ... }')} - set current user`); + console.log(` ${colors.magenta('.table')} - toggle table output`); + console.log(); + console.log(`Running as anonymous user. Use ${colors.magenta('.auth')} to set current user.`); + + const prismaClientModule = options.prismaClient ?? path.join(projectPath, './node_modules/.prisma/client'); + const { PrismaClient } = require(prismaClientModule); + const { enhance } = require('@zenstackhq/runtime'); + + let debug = false; + let table = false; + let prisma: any; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let db: any; + let user: any; + + const replServer = prettyRepl.start({ + prompt: '[anonymous] > ', + eval: async (cmd, _context, _filename, callback) => { + try { + let r: any = undefined; + let isPrismaCall = false; + + if (cmd.includes('await ')) { + // eval can't handle top-level await, so we wrap it in an async function + cmd = `(async () => (${cmd}))()`; + r = eval(cmd); + if (isPrismaPromise(r)) { + isPrismaCall = true; + } + r = await r; + } else { + r = eval(cmd); + if (isPrismaPromise(r)) { + isPrismaCall = true; + // automatically await Prisma promises + r = await r; + } + } + + if (isPrismaCall && table) { + console.table(r); + callback(null, undefined); + } else { + callback(null, r); + } + } catch (err: any) { + if (err.code) { + console.error(colors.red(err.message)); + console.error('Code:', err.code); + if (err.meta) { + console.error('Meta:', err.meta); + } + callback(null, undefined); + } else { + callback(err as Error, undefined); + } + } + }, + }); + + // .table command + replServer.defineCommand('table', { + help: 'Toggle table output', + action(value: string) { + if (value && value !== 'on' && value !== 'off' && value !== 'true' && value !== 'false') { + console.error('Invalid argument. Usage: .table [on|off|true|false]'); + this.displayPrompt(); + return; + } + this.clearBufferedCommand(); + table = value ? value === 'on' || value === 'true' : !table; + console.log('Table output:', table); + this.displayPrompt(); + }, + }); + + // .debug command + replServer.defineCommand('debug', { + help: 'Toggle debug output', + async action(value: string) { + if (value && value !== 'on' && value !== 'off' && value !== 'true' && value !== 'false') { + console.error('Invalid argument. Usage: .debug [on|off|true|false]'); + this.displayPrompt(); + return; + } + this.clearBufferedCommand(); + debug = value ? value === 'on' || value === 'true' : !debug; + console.log('Debug mode:', debug); + await createClient(); + setPrompt(); + this.displayPrompt(); + }, + }); + + // .auth command + replServer.defineCommand('auth', { + help: 'Set current user. Run without argument to switch to anonymous. Pass an user object to set current user.', + action(value: string) { + this.clearBufferedCommand(); + try { + if (!value?.trim()) { + // set anonymous + setAuth(undefined); + console.log(`Auth user: anonymous. Use ".auth { id: ... }" to change.`); + } else { + // set current user + const user = eval(`(${value})`); + console.log(user); + if (!user || typeof user !== 'object') { + console.error(`Invalid argument. Pass a user object like { id: ... }`); + this.displayPrompt(); + return; + } + setAuth(user); + console.log(`Auth user: ${inspect(user)}. Use ".auth" to switch to anonymous.`); + } + } catch (err: any) { + console.error('Unable to set auth user:', err.message); + } + this.displayPrompt(); + }, + }); + + replServer.setupHistory(path.join(projectPath, './.zenstack_repl_history'), (err) => { + if (err) { + console.error('unable to setup REPL history:', err); + } + }); + + setPrompt(); + await createClient(); + + async function createClient() { + if (prisma) { + prisma.$disconnect(); + } + prisma = new PrismaClient(debug ? { log: ['info'] } : undefined); + prisma[Symbol.for('nodejs.util.inspect.custom')] = 'PrismaClient'; + db = enhance(prisma, { user }, { logPrismaQuery: debug }); + + replServer.context.prisma = prisma; + replServer.context.db = db; + } + + function setPrompt() { + replServer.setPrompt(`[${debug ? colors.yellow('D ') : ''}${user ? inspect(user) : 'anonymous'}] > `); + } + + function setAuth(_user: unknown) { + user = _user; + // recreate enhanced PrismaClient + db = replServer.context.db = enhance(prisma, { user }, { logPrismaQuery: debug }); + setPrompt(); + } +} + +function isPrismaPromise(r: any) { + return r?.[Symbol.toStringTag] === 'PrismaPromise' || r?.[Symbol.toStringTag] === 'ZenStackPromise'; +} diff --git a/packages/schema/src/cli/index.ts b/packages/schema/src/cli/index.ts index ae8f901cf..628331cc3 100644 --- a/packages/schema/src/cli/index.ts +++ b/packages/schema/src/cli/index.ts @@ -40,6 +40,16 @@ export const generateAction = async (options: Parameters[1]): Promise => { + await telemetry.trackSpan( + 'cli:command:start', + 'cli:command:complete', + 'cli:command:error', + { command: 'repl' }, + () => actions.repl(process.cwd(), options) + ); +}; + export function createProgram() { const program = new Command('zenstack'); @@ -98,6 +108,12 @@ export function createProgram() { .addOption(noDependencyCheck) .action(generateAction); + program + .command('repl') + .description('Start a REPL session.') + .option('--prisma-client ', 'path to Prisma client module') + .action(replAction); + // make sure config is loaded before actions run program.hook('preAction', async (_, actionCommand) => { let configFile: string | undefined = actionCommand.opts().config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2da65eea2..ff22a3292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) + version: 29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) typescript: specifier: ^4.9.5 version: 4.9.5 @@ -521,6 +521,9 @@ importers: pluralize: specifier: ^8.0.0 version: 8.0.0 + pretty-repl: + specifier: ^4.0.0 + version: 4.0.0 promisify: specifier: ^0.0.3 version: 0.0.3 @@ -647,7 +650,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.3 - version: 29.0.3(@babel/core@7.23.2)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4) + version: 29.0.3(@babel/core@7.22.5)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4) ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@18.0.0)(typescript@4.8.4) @@ -7751,6 +7754,14 @@ packages: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} dev: true + /emphasize@4.2.0: + resolution: {integrity: sha512-yGKvcFUHlBsUPwlxTlzKLR8+zhpbitkFOMCUxN8fTJng9bdH3WNzUGkhdaGdjndSUgqmMPBN7umfwnUdLz5Axg==} + dependencies: + chalk: 4.1.2 + highlight.js: 10.4.1 + lowlight: 1.17.0 + dev: false + /encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -8814,6 +8825,12 @@ packages: dependencies: reusify: 1.0.4 + /fault@1.0.4: + resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} + dependencies: + format: 0.2.2 + dev: false + /fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} dependencies: @@ -8956,6 +8973,11 @@ packages: mime-types: 2.1.35 dev: true + /format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + dev: false + /formidable@2.1.2: resolution: {integrity: sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==} dependencies: @@ -9409,6 +9431,10 @@ packages: engines: {node: '>=8'} dev: true + /highlight.js@10.4.1: + resolution: {integrity: sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg==} + dev: false + /hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} dev: true @@ -11263,6 +11289,13 @@ packages: tslib: 2.6.0 dev: false + /lowlight@1.17.0: + resolution: {integrity: sha512-vmtBgYKD+QVNy7tIa7ulz5d//Il9R4MooOVh4nkOf9R9Cb/Dk5TXMSTieg/vDulkBkIWj59/BIlyFQxT9X1oAQ==} + dependencies: + fault: 1.0.4 + highlight.js: 10.4.1 + dev: false + /lru-cache@10.0.1: resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==} engines: {node: 14 || >=16.14} @@ -13166,6 +13199,16 @@ packages: react-is: 18.2.0 dev: true + /pretty-repl@4.0.0: + resolution: {integrity: sha512-2WmwcEXvMDQ3UVb/emuYb0M7dVVU1NSm7L7lf9nwGxvzWovUbLaXWUve8VqOoAO34GQBQ2l+nYcXY0HGllNc5Q==} + engines: {node: '>=14'} + dependencies: + ansi-regex: 5.0.1 + chalk: 4.1.2 + emphasize: 4.2.0 + strip-ansi: 6.0.1 + dev: false + /printj@1.3.1: resolution: {integrity: sha512-GA3TdL8szPK4AQ2YnOe/b+Y1jUFwmmGMMK/qbY7VcE3Z7FU8JstbKiKRzO6CIiAKPhTO8m01NoQ0V5f3jc4OGg==} engines: {node: '>=0.8'} @@ -14773,7 +14816,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.3(@babel/core@7.23.2)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4): + /ts-jest@29.0.3(@babel/core@7.22.5)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4): resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -14794,7 +14837,7 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.22.5 bs-logger: 0.2.6 esbuild: 0.15.12 fast-json-stable-stringify: 2.1.0 @@ -14808,7 +14851,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): + /ts-jest@29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -14829,7 +14872,7 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.22.5 + '@babel/core': 7.23.2 bs-logger: 0.2.6 esbuild: 0.18.13 fast-json-stable-stringify: 2.1.0 @@ -14839,11 +14882,11 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.5 + typescript: 4.9.4 yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): + /ts-jest@29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -14874,7 +14917,7 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.4 + typescript: 4.9.5 yargs-parser: 21.1.1 dev: true