Skip to content

feat: add zenstack CLI repl command #808

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 7, 2023
Merged
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
1 change: 1 addition & 0 deletions packages/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/schema/src/cli/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './generate';
export * from './info';
export * from './init';
export * from './repl';
178 changes: 178 additions & 0 deletions packages/schema/src/cli/actions/repl.ts
Original file line number Diff line number Diff line change
@@ -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';
}
16 changes: 16 additions & 0 deletions packages/schema/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export const generateAction = async (options: Parameters<typeof actions.generate
);
};

export const replAction = async (options: Parameters<typeof actions.repl>[1]): Promise<void> => {
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');

Expand Down Expand Up @@ -98,6 +108,12 @@ export function createProgram() {
.addOption(noDependencyCheck)
.action(generateAction);

program
.command('repl')
.description('Start a REPL session.')
.option('--prisma-client <module>', '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;
Expand Down
61 changes: 52 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.