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:
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