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:
Radosław Gierwiało
2025-11-15 20:51:24 +01:00
parent c7a37b2f5c
commit 78f96e2849
7 changed files with 837 additions and 1 deletions

483
backend/src/cli/index.js Normal file
View 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,
};
}