From ee17ff842e6b9fb9e635da00a23f608f7efeebfc Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 6 Nov 2023 22:00:37 -0800 Subject: [PATCH 1/3] feat: add zenstack CLI repl command --- packages/schema/package.json | 1 + packages/schema/src/cli/actions/index.ts | 1 + packages/schema/src/cli/actions/repl.ts | 128 +++++++++++++++++++++++ packages/schema/src/cli/index.ts | 17 +++ pnpm-lock.yaml | 61 +++++++++-- 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 packages/schema/src/cli/actions/repl.ts 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..a33357603 --- /dev/null +++ b/packages/schema/src/cli/actions/repl.ts @@ -0,0 +1,128 @@ +/* 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: { debug?: boolean; prismaClient?: string }) { + console.log('Welcome to ZenStack REPL.'); + 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(); + + if (options.debug) { + console.log('Debug mode:', options.debug); + } + + const prismaClientModule = options.prismaClient ?? path.join(projectPath, './node_modules/.prisma/client'); + const { PrismaClient } = require(prismaClientModule); + const prisma = new PrismaClient(options.debug ? { log: ['info'] } : undefined); + // workaround for https://github.com/prisma/prisma/issues/18292 + prisma[Symbol.for('nodejs.util.inspect.custom')] = 'PrismaClient'; + + const { enhance } = require('@zenstackhq/runtime'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let db = enhance(prisma, undefined, { logPrismaQuery: options.debug }); + + const auth = (user: unknown) => { + // recreate enhanced PrismaClient + db = replServer.context.db = enhance(prisma, { user }, { logPrismaQuery: options.debug }); + if (user) { + replServer.setPrompt(`${inspect(user)} > `); + } else { + replServer.setPrompt('anonymous > '); + } + }; + + let table = false; + + 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) { + callback(err as Error, undefined); + } + }, + }); + + 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(); + }, + }); + + replServer.defineCommand('auth', { + help: 'Set current user', + action(value: string) { + this.clearBufferedCommand(); + try { + const user = !value ? undefined : eval(`(${value})`); + auth(user); + console.log('Auth user:', user ?? '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); + } + }); + + replServer.context.prisma = prisma; + replServer.context.db = enhance(prisma, undefined, { logPrismaQuery: options.debug }); + replServer.context.auth = auth; +} + +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..8530d9551 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,13 @@ export function createProgram() { .addOption(noDependencyCheck) .action(generateAction); + program + .command('repl') + .description('Start a REPL session.') + .option('-d, --debug', 'enable debugging output') + .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 From b861adeb315ec2b042451b60b49867be73d7fe26 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 7 Nov 2023 09:19:23 -0800 Subject: [PATCH 2/3] minor improvements --- packages/schema/src/cli/actions/repl.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/schema/src/cli/actions/repl.ts b/packages/schema/src/cli/actions/repl.ts index a33357603..1830cc137 100644 --- a/packages/schema/src/cli/actions/repl.ts +++ b/packages/schema/src/cli/actions/repl.ts @@ -11,7 +11,7 @@ import { inspect } from 'util'; * CLI action for starting a REPL session */ export async function repl(projectPath: string, options: { debug?: boolean; prismaClient?: string }) { - console.log('Welcome to ZenStack REPL.'); + 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`); @@ -19,6 +19,7 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris 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.`); if (options.debug) { console.log('Debug mode:', options.debug); @@ -98,13 +99,26 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris }); replServer.defineCommand('auth', { - help: 'Set current user', + 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 { - const user = !value ? undefined : eval(`(${value})`); - auth(user); - console.log('Auth user:', user ?? 'anonymous'); + if (!value?.trim()) { + // set anonymous + auth(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; + } + auth(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); } From 38cfded7eb1ed6f1ecb6ec7ede4e7f4d25bf3b52 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 7 Nov 2023 11:54:57 -0800 Subject: [PATCH 3/3] add .debug command --- packages/schema/src/cli/actions/repl.ts | 94 +++++++++++++++++-------- packages/schema/src/cli/index.ts | 1 - 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/packages/schema/src/cli/actions/repl.ts b/packages/schema/src/cli/actions/repl.ts index 1830cc137..a9f6e9a8f 100644 --- a/packages/schema/src/cli/actions/repl.ts +++ b/packages/schema/src/cli/actions/repl.ts @@ -10,7 +10,7 @@ import { inspect } from 'util'; /** * CLI action for starting a REPL session */ -export async function repl(projectPath: string, options: { debug?: boolean; prismaClient?: string }) { +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`); @@ -21,34 +21,19 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris console.log(); console.log(`Running as anonymous user. Use ${colors.magenta('.auth')} to set current user.`); - if (options.debug) { - console.log('Debug mode:', options.debug); - } - const prismaClientModule = options.prismaClient ?? path.join(projectPath, './node_modules/.prisma/client'); const { PrismaClient } = require(prismaClientModule); - const prisma = new PrismaClient(options.debug ? { log: ['info'] } : undefined); - // workaround for https://github.com/prisma/prisma/issues/18292 - prisma[Symbol.for('nodejs.util.inspect.custom')] = 'PrismaClient'; - const { enhance } = require('@zenstackhq/runtime'); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let db = enhance(prisma, undefined, { logPrismaQuery: options.debug }); - - const auth = (user: unknown) => { - // recreate enhanced PrismaClient - db = replServer.context.db = enhance(prisma, { user }, { logPrismaQuery: options.debug }); - if (user) { - replServer.setPrompt(`${inspect(user)} > `); - } else { - replServer.setPrompt('anonymous > '); - } - }; + 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 > ', + prompt: '[anonymous] > ', eval: async (cmd, _context, _filename, callback) => { try { let r: any = undefined; @@ -77,12 +62,22 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris } else { callback(null, r); } - } catch (err) { - callback(err as Error, undefined); + } 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) { @@ -98,6 +93,25 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris }, }); + // .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) { @@ -105,7 +119,7 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris try { if (!value?.trim()) { // set anonymous - auth(undefined); + setAuth(undefined); console.log(`Auth user: anonymous. Use ".auth { id: ... }" to change.`); } else { // set current user @@ -116,7 +130,7 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris this.displayPrompt(); return; } - auth(user); + setAuth(user); console.log(`Auth user: ${inspect(user)}. Use ".auth" to switch to anonymous.`); } } catch (err: any) { @@ -132,9 +146,31 @@ export async function repl(projectPath: string, options: { debug?: boolean; pris } }); - replServer.context.prisma = prisma; - replServer.context.db = enhance(prisma, undefined, { logPrismaQuery: options.debug }); - replServer.context.auth = auth; + 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) { diff --git a/packages/schema/src/cli/index.ts b/packages/schema/src/cli/index.ts index 8530d9551..628331cc3 100644 --- a/packages/schema/src/cli/index.ts +++ b/packages/schema/src/cli/index.ts @@ -111,7 +111,6 @@ export function createProgram() { program .command('repl') .description('Start a REPL session.') - .option('-d, --debug', 'enable debugging output') .option('--prisma-client ', 'path to Prisma client module') .action(replAction);