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

54
Makefile Normal file
View File

@@ -0,0 +1,54 @@
SHELL := /bin/bash
COMPOSE ?= docker compose
PROFILE ?= dev
# Select backend service name based on profile
ifeq ($(PROFILE),prod)
BACKEND_SVC := backend-prod
else
BACKEND_SVC := backend
endif
.PHONY: help dev-cli prod-cli \
dev-up dev-down dev-up-rebuild \
prod-up prod-down prod-up-rebuild
help:
@echo "Available targets:"
@echo " make dev-cli # Start admin REPL in dev backend"
@echo " make prod-cli # Start admin REPL in prod backend"
@echo " make dev-up # docker compose --profile dev up"
@echo " make dev-up-rebuild # docker compose --profile dev up --build"
@echo " make dev-down # docker compose --profile dev down"
@echo " make prod-up # docker compose --profile prod up -d"
@echo " make prod-up-rebuild # docker compose --profile prod up -d --build"
@echo " make prod-down # docker compose --profile prod down"
@echo "Notes: GNU Make nie wspiera subkomend z odstępami (np. 'make prod up')."
@echo " Użyj odpowiednich celów z myślnikiem."
# Admin CLI explicit targets
dev-cli:
$(COMPOSE) exec backend npm run cli
prod-cli:
$(COMPOSE) exec backend-prod npm run cli
# Development profile
dev-up:
$(COMPOSE) --profile dev up -d
dev-up-rebuild:
$(COMPOSE) --profile dev up -d --build
dev-down:
$(COMPOSE) --profile dev down
# Production profile
prod-up:
$(COMPOSE) --profile prod up -d
prod-up-rebuild:
$(COMPOSE) --profile prod up -d --build
prod-down:
$(COMPOSE) --profile prod down

View File

@@ -364,6 +364,30 @@ Adds:
- Click on another user's username
- View profile: avatar, location, social media, statistics
## 🧰 Admin CLI
Use an in-container admin console for quick maintenance.
- Start REPL (default): `docker compose exec backend npm run cli`
- Explicit REPL: `docker compose exec backend npm run cli -- repl`
- List users: `docker compose exec backend npm run cli -- users:list --limit 20`
- Create user: `docker compose exec backend npm run cli -- users:create --email admin@example.com --username admin --password 'Secret123!'`
- Verify email: `docker compose exec backend npm run cli -- users:verify --email admin@example.com`
- List events: `docker compose exec backend npm run cli -- events:list --limit 10`
- Event details by slug: `docker compose exec backend npm run cli -- events:details --slug warsaw-dance-2025 [--participants 25]`
- Event participants: `docker compose exec backend npm run cli -- events:participants --slug warsaw-dance-2025 --limit 100`
- Event participants CSV: `docker compose exec backend npm run cli -- events:participants --slug warsaw-dance-2025 --limit 200 --csv > participants.csv`
- List matches: `docker compose exec backend npm run cli -- matches:list --limit 20 [--status accepted|pending|completed]`
- Check-in user to event (simulate QR): `docker compose exec backend npm run cli -- events:checkin --username john_doe --slug warsaw-dance-2025`
- App logs (if LOG_FILE configured): `docker compose exec backend npm run cli -- logs:app --lines 200`
- Recent chat messages: `docker compose exec backend npm run cli -- logs:messages --limit 50`
Production equivalents use `backend-prod` instead of `backend`.
REPL specifics:
- Inside REPL use `run('users:list --limit 20')` or `.cli users:list --limit 20`.
- Top-level await works for Prisma: `await prisma.user.findMany({ take: 5 })`.
## 🔐 Security
### Implemented Security Features:
@@ -509,6 +533,7 @@ docker compose exec backend npm run test:coverage
- `docs/PHASE_1.5.md` - Phase 1.5 documentation (Email & WSDC)
- `docs/SECURITY_AUDIT.md` - Security audit & fixes
- `docs/DEPLOYMENT.md` - Deployment guide
- `docs/ADMIN_CLI.md` - Admin CLI & REPL usage
- `docs/COMPLETED.md` - Completed tasks archive
- `docs/RESOURCES.md` - Links to documentation and learning resources

10
backend/.repl_history Normal file
View File

@@ -0,0 +1,10 @@
dopisz do docs/ szczegóły jakie opcje są w CLI REPL
exit
Headers.
Headers
.cli logs
.cli lgos
.cli prisma:seed
.cli events:list
.cli users:list
.cli users:lists

View File

@@ -11,7 +11,8 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "node prisma/seed.js",
"prisma:studio": "prisma studio"
"prisma:studio": "prisma studio",
"cli": "node src/cli/index.js"
},
"keywords": [
"webrtc",

View 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
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,
};
}

137
docs/ADMIN_CLI.md Normal file
View File

@@ -0,0 +1,137 @@
# Admin CLI & REPL — spotlight.cam
Administrative console for maintenance tasks inside the backend container. Provides both one-shot commands and an interactive REPL with app context.
---
## Quick Start
- Development REPL: `docker compose exec backend npm run cli`
- Production REPL: `docker compose exec backend-prod npm run cli`
- One-shot examples:
- `docker compose exec backend npm run cli -- users:list --limit 20`
- `docker compose exec backend npm run cli -- events:checkin --username john_doe --slug warsaw-dance-2025`
With Makefile shortcuts:
- `make dev-cli` / `make prod-cli`
- `make dev-up`, `make dev-down`, `make prod-up`, `make prod-down`, rebuild variants also available
---
## REPL Features
- Default entry: running `npm run cli` starts a Node.js REPL with:
- `prisma` client and `bcrypt` in context
- Aliases: `u = prisma.user`, `e = prisma.event`, `m = prisma.match`, `ep = prisma.eventParticipant`, `r = prisma.rating`
- Top-level await: `await u.findMany({ take: 5 })`
- Autocomplete (TAB) from Node REPL
- Persistent history in `.repl_history` (best-effort)
- Run CLI subcommands from inside REPL:
- `.cli users:list --limit 20`
- `run('events:checkin --username john_doe --slug warsaw-dance-2025')`
---
## Commands
### users:list
- Description: List users
- Options:
- `--limit <n>`: number of rows (default: 50)
- Examples:
- CLI: `npm run cli -- users:list --limit 20`
- REPL: `.cli users:list --limit 20`
### users:create
- Description: Create a user
- Required:
- `--email <email>`
- `--username <username>`
- `--password <password>` (hashed with bcrypt)
- Optional:
- `--first <firstName>`
- `--last <lastName>`
- Examples:
- CLI: `npm run cli -- users:create --email admin@example.com --username admin --password 'Secret123!'`
- REPL: `run("users:create --email a@b.c --username admin --password 'Secret123!'")`
### users:verify
- Description: Mark user email as verified and clear verification fields
- Required:
- `--email <email>`
- Example: `npm run cli -- users:verify --email admin@example.com`
### events:list
- Description: List events
- Options:
- `--limit <n>` (default: 50)
- Example: `npm run cli -- events:list --limit 10`
### events:details
- Description: Show details for a specific event by slug
- Required:
- `--slug <event-slug>`
- Options:
- `--participants <n>`: number of recent participants to show (default: 10)
- Output: basic fields (id, slug, name, start/end, location, worldsdcId), participantsCount, optional check-in token, relation counts, event chat room id and message count, and up to N recent participants
- Examples:
- `npm run cli -- events:details --slug warsaw-dance-2025`
- `npm run cli -- events:details --slug warsaw-dance-2025 --participants 25`
### matches:list
- Description: List matches
- Options:
- `--limit <n>` (default: 50)
- `--status <pending|accepted|completed>` filter
- Example: `npm run cli -- matches:list --status accepted --limit 20`
### events:checkin
- Description: Attach user to event by username and event slug (simulate QR check-in)
- Required:
- `--username <username>`
- `--slug <event-slug>`
- Behavior: Upserts into `event_participants` (idempotent)
- Example: `npm run cli -- events:checkin --username john_doe --slug warsaw-dance-2025`
### events:participants
- Description: List participants for an event by slug
- Required:
- `--slug <event-slug>`
- Options:
- `--limit <n>`: number of rows (default: 100)
- `--csv`: output as CSV (stdout)
- Output: eventId, slug, userId, username, email, firstName, lastName, country, city, joinedAt
- Examples:
- `npm run cli -- events:participants --slug warsaw-dance-2025 --limit 50`
- `npm run cli -- events:participants --slug warsaw-dance-2025 --limit 200 --csv > participants.csv`
### logs:app
- Description: Tail application log file (if configured)
- Options:
- `--lines <n>` number of lines from end (default: 200)
- Env:
- `LOG_FILE` — absolute/relative path inside backend container (default: `./app.log` in working dir)
- Note: For container logs prefer `docker compose logs -f backend`
### logs:messages
- Description: Recent chat messages from DB
- Options:
- `--limit <n>` (default: 50)
- Output: message id, time, user, room (event/private), type, first 100 chars
---
## Troubleshooting
- REPL not interactive: ensure TTY: `docker compose exec -it backend npm run cli`
- DB access errors: verify backend container env and DB service are running
- `events:checkin` says not found: confirm `username` and `slug` exist in DB
- Autocomplete: works with Node REPL; press TAB after `prisma.` to see model methods
---
## Security Notes
- CLI has full DB access; restrict usage to SSH + container exec
- Do not expose CLI via HTTP endpoints
- Consider audit logs for sensitive actions if needed