feat(cli): add admin REPL + commands and docs
- Add CLI entry in backend with default REPL, persistent history, aliases - Add commands: users:list/create/verify, events:list/details/participants/checkin, matches:list, logs:app, logs:messages - Support running subcommands inside REPL via .cli and run() - Add Makefile targets: dev-cli, prod-cli, dev/prod up/down (+rebuild) - Update README and add docs/ADMIN_CLI.md - Add CLI tests with mocked Prisma
This commit is contained in:
126
backend/src/__tests__/cli.test.js
Normal file
126
backend/src/__tests__/cli.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
const {
|
||||
parseArgs,
|
||||
runDispatch,
|
||||
cmdUsersList,
|
||||
cmdEventsCheckin,
|
||||
cmdEventsDetails,
|
||||
cmdEventsParticipants,
|
||||
cmdMatchesList,
|
||||
} = require('../cli/index');
|
||||
|
||||
jest.mock('../utils/db', () => {
|
||||
const mock = {
|
||||
user: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 1, username: 'alice', email: 'alice@example.com', emailVerified: true, createdAt: new Date('2025-01-01') },
|
||||
{ id: 2, username: 'bob', email: 'bob@example.com', emailVerified: false, createdAt: new Date('2025-02-01') },
|
||||
]),
|
||||
findUnique: jest.fn().mockImplementation(({ where }) => {
|
||||
if (where && where.username === 'john_doe') return Promise.resolve({ id: 10, username: 'john_doe' });
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
create: jest.fn().mockResolvedValue({ id: 3, email: 'new@example.com', username: 'newuser', emailVerified: false }),
|
||||
update: jest.fn().mockResolvedValue({ id: 1, email: 'alice@example.com', emailVerified: true }),
|
||||
},
|
||||
event: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 100, slug: 'event-1', name: 'Event 1', startDate: new Date('2025-06-01'), participantsCount: 5 },
|
||||
]),
|
||||
findUnique: jest.fn().mockImplementation(({ where }) => {
|
||||
if (where && where.slug === 'warsaw-dance-2025') {
|
||||
return Promise.resolve({
|
||||
id: 200,
|
||||
slug: 'warsaw-dance-2025',
|
||||
name: 'Warsaw Dance 2025',
|
||||
location: 'Warsaw',
|
||||
startDate: new Date('2025-06-01'),
|
||||
endDate: new Date('2025-06-03'),
|
||||
worldsdcId: 'wdc-2025',
|
||||
participantsCount: 2,
|
||||
description: 'Great event',
|
||||
checkinToken: { token: 'tok123' },
|
||||
participants: [
|
||||
{ joinedAt: new Date('2025-05-01'), user: { id: 10, username: 'john_doe' } },
|
||||
{ joinedAt: new Date('2025-05-02'), user: { id: 11, username: 'sarah' } },
|
||||
],
|
||||
_count: { participants: 2, userHeats: 0, chatRooms: 1, matches: 0 },
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
},
|
||||
eventParticipant: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 1, userId: 10, eventId: 200, joinedAt: new Date('2025-05-01') }),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ joinedAt: new Date('2025-05-01'), user: { id: 10, username: 'john_doe', email: 'john@example.com', firstName: 'John', lastName: 'D', country: 'PL', city: 'Warsaw' } },
|
||||
]),
|
||||
},
|
||||
chatRoom: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 999 }),
|
||||
},
|
||||
message: {
|
||||
count: jest.fn().mockResolvedValue(12),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
match: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 301, slug: 'm1', status: 'pending', createdAt: new Date('2025-05-01'), event: { id: 200, slug: 'warsaw-dance-2025', name: 'Warsaw Dance 2025' }, user1: { id: 10, username: 'john_doe' }, user2: { id: 11, username: 'sarah' } },
|
||||
]),
|
||||
},
|
||||
};
|
||||
return { prisma: mock };
|
||||
});
|
||||
|
||||
describe('CLI helpers', () => {
|
||||
test('parseArgs parses flags and positionals', () => {
|
||||
const res = parseArgs(['users:list', '--limit', '20', '--csv', '--name', 'alice']);
|
||||
expect(res._).toEqual(['users:list']);
|
||||
expect(res.limit).toBe('20');
|
||||
expect(res.csv).toBe(true);
|
||||
expect(res.name).toBe('alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI commands (mocked prisma)', () => {
|
||||
const origLog = console.log;
|
||||
const origTable = console.table;
|
||||
const logs = [];
|
||||
beforeEach(() => {
|
||||
logs.length = 0;
|
||||
console.log = (/** @type {any[]} */ ...args) => logs.push(args.map(String).join(' '));
|
||||
console.table = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
console.log = origLog;
|
||||
console.table = origTable;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('users:list outputs a table', async () => {
|
||||
await cmdUsersList({ limit: 2 });
|
||||
expect(console.table).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('events:checkin upserts participant', async () => {
|
||||
await cmdEventsCheckin({ username: 'john_doe', slug: 'warsaw-dance-2025' });
|
||||
// Check info logs contain confirmation
|
||||
expect(logs.join('\n')).toMatch(/Checked-in john_doe to warsaw-dance-2025/);
|
||||
});
|
||||
|
||||
test('events:details prints summary and participants', async () => {
|
||||
await cmdEventsDetails({ slug: 'warsaw-dance-2025', participants: 5 });
|
||||
expect(logs.join('\n')).toMatch(/Event details:/);
|
||||
expect(console.table).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('events:participants prints table', async () => {
|
||||
await cmdEventsParticipants({ slug: 'warsaw-dance-2025', limit: 10 });
|
||||
expect(console.table).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('matches:list outputs a table', async () => {
|
||||
await cmdMatchesList({ limit: 10 });
|
||||
expect(console.table).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
483
backend/src/cli/index.js
Normal file
483
backend/src/cli/index.js
Normal file
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Admin CLI / REPL for spotlight.cam
|
||||
* Usage examples (inside repo or via docker compose exec backend):
|
||||
* npm run cli -- repl
|
||||
* npm run cli -- users:list --limit 20
|
||||
* npm run cli -- users:create --email admin@example.com --username admin --password "Secret123!"
|
||||
* npm run cli -- users:verify --email admin@example.com
|
||||
* npm run cli -- events:list --limit 10
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const repl = require('repl');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { prisma } = require('../utils/db');
|
||||
|
||||
function printHelp() {
|
||||
console.log(`spotlight.cam admin CLI\n\nCommands:\n repl Start interactive REPL with context (prisma, bcrypt)\n users:list [--limit <n>] List users\n users:create --email <e> --username <u> --password <p> [--first <f>] [--last <l>]\n Create a user (email verified = false)\n users:verify --email <e> Mark user's email as verified\n events:list [--limit <n>] List events\n\nExamples:\n npm run cli -- repl\n npm run cli -- users:list --limit 20\n npm run cli -- users:create --email admin@example.com --username admin --password "Secret123!"\n npm run cli -- users:verify --email admin@example.com\n npm run cli -- events:list --limit 10\n`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
// Very small flag parser: --key value -> { key: value }, flags without value -> true
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token.startsWith('--')) {
|
||||
const key = token.replace(/^--/, '');
|
||||
const next = argv[i + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
out[key] = true;
|
||||
continue;
|
||||
}
|
||||
out[key] = next;
|
||||
i += 1;
|
||||
} else {
|
||||
out._.push(token);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const COMMAND_HANDLERS = {
|
||||
'users:list': cmdUsersList,
|
||||
'users:create': cmdUsersCreate,
|
||||
'users:verify': cmdUsersVerify,
|
||||
'events:list': cmdEventsList,
|
||||
'events:details': cmdEventsDetails,
|
||||
'events:participants': cmdEventsParticipants,
|
||||
'matches:list': cmdMatchesList,
|
||||
'events:checkin': cmdEventsCheckin,
|
||||
'logs:app': cmdLogsApp,
|
||||
'logs:messages': cmdLogsMessages,
|
||||
};
|
||||
|
||||
async function runDispatch(line) {
|
||||
const trimmed = (line || '').trim();
|
||||
if (!trimmed) return;
|
||||
const [name, ...rest] = trimmed.split(/\s+/);
|
||||
const handler = COMMAND_HANDLERS[name];
|
||||
if (!handler) {
|
||||
console.error(`Unknown command in REPL: ${name}`);
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const opts = parseArgs(rest);
|
||||
await handler(opts);
|
||||
}
|
||||
|
||||
async function cmdRepl() {
|
||||
const r = repl.start({ prompt: 'spotlight> ' });
|
||||
r.context.prisma = prisma;
|
||||
r.context.bcrypt = bcrypt;
|
||||
// Handy aliases
|
||||
r.context.u = prisma.user;
|
||||
r.context.e = prisma.event;
|
||||
r.context.m = prisma.match;
|
||||
r.context.ep = prisma.eventParticipant;
|
||||
r.context.r = prisma.rating;
|
||||
// CLI runner inside REPL
|
||||
r.context.run = async (s) => { await runDispatch(String(s)); };
|
||||
r.context.cli = r.context.run;
|
||||
|
||||
// .cli users:list --limit 20
|
||||
r.defineCommand('cli', {
|
||||
help: 'Run admin CLI subcommand, e.g. .cli users:list --limit 20',
|
||||
async action(input) {
|
||||
try {
|
||||
await runDispatch(input);
|
||||
} catch (err) {
|
||||
console.error('CLI error:', err.message);
|
||||
}
|
||||
this.displayPrompt();
|
||||
},
|
||||
});
|
||||
|
||||
// Persistent history (best-effort). Use /app/.repl_history by default.
|
||||
const historyFile = process.env.REPL_HISTORY_FILE || path.join(process.cwd(), '.repl_history');
|
||||
try {
|
||||
r.setupHistory(historyFile, () => {});
|
||||
} catch (e) {
|
||||
// ignore if not supported
|
||||
}
|
||||
r.on('exit', async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function cmdUsersList(opts) {
|
||||
const limit = Number(opts.limit || 50);
|
||||
const users = await prisma.user.findMany({
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
console.table(users);
|
||||
}
|
||||
|
||||
async function cmdUsersCreate(opts) {
|
||||
const { email, username, password } = opts;
|
||||
if (!email || !username || !password) {
|
||||
console.error('Missing required flags: --email --username --password');
|
||||
process.exit(1);
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash: hash,
|
||||
firstName: opts.first || null,
|
||||
lastName: opts.last || null,
|
||||
},
|
||||
select: { id: true, email: true, username: true, emailVerified: true },
|
||||
});
|
||||
console.log('Created user:', user);
|
||||
}
|
||||
|
||||
async function cmdUsersVerify(opts) {
|
||||
const { email } = opts;
|
||||
if (!email) {
|
||||
console.error('Missing required flag: --email');
|
||||
process.exit(1);
|
||||
}
|
||||
const updated = await prisma.user.update({
|
||||
where: { email },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
verificationToken: null,
|
||||
verificationCode: null,
|
||||
verificationTokenExpiry: null,
|
||||
},
|
||||
select: { id: true, email: true, emailVerified: true },
|
||||
});
|
||||
console.log(updated);
|
||||
}
|
||||
|
||||
async function cmdEventsList(opts) {
|
||||
const limit = Number(opts.limit || 50);
|
||||
const events = await prisma.event.findMany({
|
||||
take: limit,
|
||||
orderBy: { startDate: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
startDate: true,
|
||||
participantsCount: true,
|
||||
},
|
||||
});
|
||||
console.table(events);
|
||||
}
|
||||
|
||||
async function cmdEventsDetails(opts) {
|
||||
const { slug } = opts;
|
||||
if (!slug) {
|
||||
console.error('Missing required flag: --slug');
|
||||
process.exit(1);
|
||||
}
|
||||
const participantsLimitRaw = opts.participants;
|
||||
const participantsLimit = Number.isFinite(Number(participantsLimitRaw)) ? Math.max(0, parseInt(participantsLimitRaw, 10)) : 10;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
checkinToken: { select: { token: true } },
|
||||
participants: {
|
||||
take: participantsLimit,
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
orderBy: { joinedAt: 'desc' },
|
||||
},
|
||||
_count: { select: { participants: true, userHeats: true, chatRooms: true, matches: true } },
|
||||
},
|
||||
});
|
||||
if (!event) {
|
||||
console.error(`Event not found by slug: ${slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Count messages in the event chat room (if exists)
|
||||
let eventChatMessagesCount = 0;
|
||||
let eventChatRoomId = null;
|
||||
try {
|
||||
const chatRoom = await prisma.chatRoom.findFirst({ where: { eventId: event.id, type: 'event' }, select: { id: true } });
|
||||
if (chatRoom) {
|
||||
eventChatRoomId = chatRoom.id;
|
||||
eventChatMessagesCount = await prisma.message.count({ where: { roomId: chatRoom.id } });
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore counting errors in CLI context
|
||||
}
|
||||
const summary = {
|
||||
id: event.id,
|
||||
slug: event.slug,
|
||||
name: event.name,
|
||||
location: event.location,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
worldsdcId: event.worldsdcId,
|
||||
participantsCount: event.participantsCount,
|
||||
description: event.description?.slice(0, 160) || null,
|
||||
checkinToken: event.checkinToken?.token || null,
|
||||
eventChatRoomId,
|
||||
eventChatMessagesCount,
|
||||
counts: event._count,
|
||||
};
|
||||
console.log('Event details:');
|
||||
console.log(summary);
|
||||
|
||||
if (event.participants && event.participants.length) {
|
||||
console.log(`Recent participants (up to ${participantsLimit}):`);
|
||||
const rows = event.participants.map((p) => ({
|
||||
id: p.user?.id,
|
||||
username: p.user?.username,
|
||||
joinedAt: p.joinedAt,
|
||||
}));
|
||||
console.table(rows);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdEventsParticipants(opts) {
|
||||
const { slug } = opts;
|
||||
if (!slug) {
|
||||
console.error('Missing required flag: --slug');
|
||||
process.exit(1);
|
||||
}
|
||||
const limit = Number.isFinite(Number(opts.limit)) ? Math.max(1, parseInt(opts.limit, 10)) : 100;
|
||||
const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true, name: true } });
|
||||
if (!event) {
|
||||
console.error(`Event not found by slug: ${slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const participants = await prisma.eventParticipant.findMany({
|
||||
where: { eventId: event.id },
|
||||
take: limit,
|
||||
orderBy: { joinedAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
country: true,
|
||||
city: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows = participants.map((p) => ({
|
||||
eventId: event.id,
|
||||
slug: event.slug,
|
||||
userId: p.user?.id,
|
||||
username: p.user?.username,
|
||||
email: p.user?.email,
|
||||
firstName: p.user?.firstName || null,
|
||||
lastName: p.user?.lastName || null,
|
||||
country: p.user?.country || null,
|
||||
city: p.user?.city || null,
|
||||
joinedAt: p.joinedAt,
|
||||
}));
|
||||
|
||||
if (opts.csv) {
|
||||
// Simple CSV output
|
||||
const headers = Object.keys(rows[0] || {
|
||||
eventId: '', slug: '', userId: '', username: '', email: '', firstName: '', lastName: '', country: '', city: '', joinedAt: ''
|
||||
});
|
||||
const esc = (v) => {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v);
|
||||
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
||||
return s;
|
||||
};
|
||||
console.log(headers.join(','));
|
||||
for (const row of rows) {
|
||||
console.log(headers.map((h) => esc(row[h])).join(','));
|
||||
}
|
||||
} else {
|
||||
console.log(`Participants for ${event.slug} (${event.name}) — showing up to ${limit}`);
|
||||
console.table(rows);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdMatchesList(opts) {
|
||||
const limit = Number(opts.limit || 50);
|
||||
const status = opts.status || undefined;
|
||||
const matches = await prisma.match.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
event: { select: { id: true, slug: true, name: true } },
|
||||
user1: { select: { id: true, username: true } },
|
||||
user2: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
const rows = matches.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
status: m.status,
|
||||
event: m.event ? `${m.event.slug} (${m.event.name})` : null,
|
||||
user1: m.user1?.username,
|
||||
user2: m.user2?.username,
|
||||
createdAt: m.createdAt,
|
||||
}));
|
||||
console.table(rows);
|
||||
}
|
||||
|
||||
async function cmdEventsCheckin(opts) {
|
||||
const { username, slug } = opts;
|
||||
if (!username || !slug) {
|
||||
console.error('Missing required flags: --username --slug');
|
||||
process.exit(1);
|
||||
}
|
||||
const user = await prisma.user.findUnique({ where: { username }, select: { id: true, username: true } });
|
||||
if (!user) {
|
||||
console.error(`User not found: ${username}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true, name: true } });
|
||||
if (!event) {
|
||||
console.error(`Event not found by slug: ${slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const ep = await prisma.eventParticipant.upsert({
|
||||
where: { userId_eventId: { userId: user.id, eventId: event.id } },
|
||||
update: {},
|
||||
create: { userId: user.id, eventId: event.id },
|
||||
select: { id: true, userId: true, eventId: true, joinedAt: true },
|
||||
});
|
||||
console.log(`Checked-in ${username} to ${event.slug} (${event.name})`);
|
||||
console.log(ep);
|
||||
}
|
||||
|
||||
async function cmdLogsApp(opts) {
|
||||
const lines = Number(opts.lines || 200);
|
||||
const logfile = process.env.LOG_FILE || path.join(process.cwd(), 'app.log');
|
||||
if (!fs.existsSync(logfile)) {
|
||||
console.error(`Log file not found: ${logfile}`);
|
||||
console.error('Tip: view container logs with: docker compose logs -f backend');
|
||||
process.exit(1);
|
||||
}
|
||||
const content = fs.readFileSync(logfile, 'utf8');
|
||||
const arr = content.split('\n');
|
||||
const tail = arr.slice(-lines).join('\n');
|
||||
console.log(tail);
|
||||
}
|
||||
|
||||
async function cmdLogsMessages(opts) {
|
||||
const limit = Number(opts.limit || 50);
|
||||
const messages = await prisma.message.findMany({
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
user: { select: { username: true } },
|
||||
room: { select: { id: true, type: true, eventId: true } },
|
||||
},
|
||||
});
|
||||
const rows = messages.map((m) => ({
|
||||
id: m.id,
|
||||
when: m.createdAt,
|
||||
user: m.user?.username,
|
||||
room: `${m.room?.type}#${m.room?.eventId || m.room?.id}`,
|
||||
type: m.type,
|
||||
content: m.content?.slice(0, 100),
|
||||
}));
|
||||
console.table(rows);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const [command, ...rest] = argv;
|
||||
const opts = parseArgs(rest);
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case undefined: // default to REPL for convenience
|
||||
await cmdRepl();
|
||||
break;
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
printHelp();
|
||||
break;
|
||||
case 'repl':
|
||||
await cmdRepl();
|
||||
break;
|
||||
case 'users:list':
|
||||
await cmdUsersList(opts);
|
||||
break;
|
||||
case 'users:create':
|
||||
await cmdUsersCreate(opts);
|
||||
break;
|
||||
case 'users:verify':
|
||||
await cmdUsersVerify(opts);
|
||||
break;
|
||||
case 'events:list':
|
||||
await cmdEventsList(opts);
|
||||
break;
|
||||
case 'matches:list':
|
||||
await cmdMatchesList(opts);
|
||||
break;
|
||||
case 'events:checkin':
|
||||
await cmdEventsCheckin(opts);
|
||||
break;
|
||||
case 'logs:app':
|
||||
await cmdLogsApp(opts);
|
||||
break;
|
||||
case 'logs:messages':
|
||||
await cmdLogsMessages(opts);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}\n`);
|
||||
printHelp();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('CLI error:', err.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
// Keep REPL alive; otherwise disconnect
|
||||
if (command !== 'repl') {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
} else {
|
||||
module.exports = {
|
||||
parseArgs,
|
||||
runDispatch,
|
||||
COMMAND_HANDLERS,
|
||||
// Export handlers for direct unit testing
|
||||
cmdUsersList,
|
||||
cmdUsersCreate,
|
||||
cmdUsersVerify,
|
||||
cmdEventsList,
|
||||
cmdEventsDetails,
|
||||
cmdEventsParticipants,
|
||||
cmdMatchesList,
|
||||
cmdEventsCheckin,
|
||||
cmdLogsApp,
|
||||
cmdLogsMessages,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user