fix(cli): keep REPL alive on errors and consolidate help\n\n- Replace process.exit(1) with thrown errors in handlers\n- REPL catches and prints CLI errors without exiting\n- Consolidated help to include all commands and examples\n- Add events:import:wsdc command mapping and alias

This commit is contained in:
Radosław Gierwiało
2025-11-15 21:41:01 +01:00
parent 78f96e2849
commit 457de6c1c4
5 changed files with 202 additions and 28 deletions

View File

@@ -374,6 +374,7 @@ Use an in-container admin console for quick maintenance.
- Create user: `docker compose exec backend npm run cli -- users:create --email admin@example.com --username admin --password 'Secret123!'` - 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` - 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` - List events: `docker compose exec backend npm run cli -- events:list --limit 10`
- Import WSDC calendar (dry-run): `docker compose exec backend npm run cli -- events:import:wsdc --dry-run --since 2024-01-01 --until 2024-12-31`
- Event details by slug: `docker compose exec backend npm run cli -- events:details --slug warsaw-dance-2025 [--participants 25]` - 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: `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` - Event participants CSV: `docker compose exec backend npm run cli -- events:participants --slug warsaw-dance-2025 --limit 200 --csv > participants.csv`

View File

@@ -1,10 +1,30 @@
dopisz do docs/ szczegóły jakie opcje są w CLI REPL .cli users:verify --email test@radziel.com
exit .cli users:create --email test@radziel.com --username radziel --password QWEqwe123 --first Radek --last Gierwialo
Headers. users:create --mail test@radziel.com --username radziel --password QWEqwe123 --first Radek --last Gierwialo
Headers users:create --email test@radziel.com --username radziel --password QWEqwe123 --first Radek --last Gierwialo
.cli logs .cli users
.cli lgos .cli users:crete
.cli prisma:seed
.cli events:list
.cli users:list .cli users:list
.cli users:lists .cli events:list
.cli events:import:worldsdc --limit 20
.cli events:details --slug cmi0g1dtf0001tbvvwraelctn
.cli events:list
.cli clear
clear
.cli help
.cli
.cli events
.events:
events:
events:lists
events:list
users
event:let
:
event
.
help
.exity
.cli events:import:worldsdc --dry-run --limit 20
exit
.cli events:import:worldsdc --dry-run --limit 20<32> w CLI REPL

View File

@@ -18,6 +18,7 @@ const { prisma } = require('../utils/db');
function printHelp() { 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`); 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`);
printHelpExtra();
} }
function parseArgs(argv) { function parseArgs(argv) {
@@ -41,6 +42,10 @@ function parseArgs(argv) {
return out; return out;
} }
function printHelpExtra() {
console.log(`More commands:\n events:details --slug <slug>\n events:participants --slug <slug> [--limit <n>] [--csv]\n events:import:wsdc [--dry-run] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit <n>]\n matches:list [--limit <n>] [--status <pending|accepted|completed>]\n events:checkin --username <u> --slug <s>\n logs:app [--lines <n>]\n logs:messages [--limit <n>]\n`);
}
const COMMAND_HANDLERS = { const COMMAND_HANDLERS = {
'users:list': cmdUsersList, 'users:list': cmdUsersList,
'users:create': cmdUsersCreate, 'users:create': cmdUsersCreate,
@@ -48,6 +53,9 @@ const COMMAND_HANDLERS = {
'events:list': cmdEventsList, 'events:list': cmdEventsList,
'events:details': cmdEventsDetails, 'events:details': cmdEventsDetails,
'events:participants': cmdEventsParticipants, 'events:participants': cmdEventsParticipants,
'events:import:wsdc': cmdEventsImportWsdc,
// Backward-compat alias
'events:import:worldsdc': cmdEventsImportWsdc,
'matches:list': cmdMatchesList, 'matches:list': cmdMatchesList,
'events:checkin': cmdEventsCheckin, 'events:checkin': cmdEventsCheckin,
'logs:app': cmdLogsApp, 'logs:app': cmdLogsApp,
@@ -62,6 +70,7 @@ async function runDispatch(line) {
if (!handler) { if (!handler) {
console.error(`Unknown command in REPL: ${name}`); console.error(`Unknown command in REPL: ${name}`);
printHelp(); printHelp();
printHelpExtra();
return; return;
} }
const opts = parseArgs(rest); const opts = parseArgs(rest);
@@ -127,8 +136,7 @@ async function cmdUsersList(opts) {
async function cmdUsersCreate(opts) { async function cmdUsersCreate(opts) {
const { email, username, password } = opts; const { email, username, password } = opts;
if (!email || !username || !password) { if (!email || !username || !password) {
console.error('Missing required flags: --email --username --password'); throw new Error('Missing required flags: --email --username --password');
process.exit(1);
} }
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({ const user = await prisma.user.create({
@@ -147,8 +155,7 @@ async function cmdUsersCreate(opts) {
async function cmdUsersVerify(opts) { async function cmdUsersVerify(opts) {
const { email } = opts; const { email } = opts;
if (!email) { if (!email) {
console.error('Missing required flag: --email'); throw new Error('Missing required flag: --email');
process.exit(1);
} }
const updated = await prisma.user.update({ const updated = await prisma.user.update({
where: { email }, where: { email },
@@ -182,8 +189,7 @@ async function cmdEventsList(opts) {
async function cmdEventsDetails(opts) { async function cmdEventsDetails(opts) {
const { slug } = opts; const { slug } = opts;
if (!slug) { if (!slug) {
console.error('Missing required flag: --slug'); throw new Error('Missing required flag: --slug');
process.exit(1);
} }
const participantsLimitRaw = opts.participants; const participantsLimitRaw = opts.participants;
const participantsLimit = Number.isFinite(Number(participantsLimitRaw)) ? Math.max(0, parseInt(participantsLimitRaw, 10)) : 10; const participantsLimit = Number.isFinite(Number(participantsLimitRaw)) ? Math.max(0, parseInt(participantsLimitRaw, 10)) : 10;
@@ -200,8 +206,7 @@ async function cmdEventsDetails(opts) {
}, },
}); });
if (!event) { if (!event) {
console.error(`Event not found by slug: ${slug}`); throw new Error(`Event not found by slug: ${slug}`);
process.exit(1);
} }
// Count messages in the event chat room (if exists) // Count messages in the event chat room (if exists)
let eventChatMessagesCount = 0; let eventChatMessagesCount = 0;
@@ -247,14 +252,12 @@ async function cmdEventsDetails(opts) {
async function cmdEventsParticipants(opts) { async function cmdEventsParticipants(opts) {
const { slug } = opts; const { slug } = opts;
if (!slug) { if (!slug) {
console.error('Missing required flag: --slug'); throw new Error('Missing required flag: --slug');
process.exit(1);
} }
const limit = Number.isFinite(Number(opts.limit)) ? Math.max(1, parseInt(opts.limit, 10)) : 100; 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 } }); const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true, name: true } });
if (!event) { if (!event) {
console.error(`Event not found by slug: ${slug}`); throw new Error(`Event not found by slug: ${slug}`);
process.exit(1);
} }
const participants = await prisma.eventParticipant.findMany({ const participants = await prisma.eventParticipant.findMany({
where: { eventId: event.id }, where: { eventId: event.id },
@@ -309,6 +312,31 @@ async function cmdEventsParticipants(opts) {
} }
} }
async function cmdEventsImportWsdc(opts) {
const { importWorldsdc } = require('../services/import/worldsdc');
const since = opts.since ? new Date(opts.since) : null;
const until = opts.until ? new Date(opts.until) : null;
const limit = opts.limit ? parseInt(String(opts.limit), 10) : undefined;
const dryRun = Boolean(opts['dry-run'] || opts.dry || opts.dry_run);
const result = await importWorldsdc({ since, until, limit, dryRun });
console.log('Import summary:', {
fetched: result.fetched,
considered: result.considered,
created: result.created.length,
skipped: result.skipped.length,
dryRun,
});
if (result.created.length) {
console.log('To create / created:');
console.table(result.created.map(e => ({ name: e.name, startDate: e.startDate, endDate: e.endDate, location: e.location || null, sourceUrl: e.sourceUrl })));
}
if (result.skipped.length) {
console.log('Skipped:');
console.table(result.skipped.map(e => ({ name: e.name, startDate: e.startDate, reason: e.reason })));
}
}
async function cmdMatchesList(opts) { async function cmdMatchesList(opts) {
const limit = Number(opts.limit || 50); const limit = Number(opts.limit || 50);
const status = opts.status || undefined; const status = opts.status || undefined;
@@ -341,18 +369,15 @@ async function cmdMatchesList(opts) {
async function cmdEventsCheckin(opts) { async function cmdEventsCheckin(opts) {
const { username, slug } = opts; const { username, slug } = opts;
if (!username || !slug) { if (!username || !slug) {
console.error('Missing required flags: --username --slug'); throw new Error('Missing required flags: --username --slug');
process.exit(1);
} }
const user = await prisma.user.findUnique({ where: { username }, select: { id: true, username: true } }); const user = await prisma.user.findUnique({ where: { username }, select: { id: true, username: true } });
if (!user) { if (!user) {
console.error(`User not found: ${username}`); throw new Error(`User not found: ${username}`);
process.exit(1);
} }
const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true, name: true } }); const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true, name: true } });
if (!event) { if (!event) {
console.error(`Event not found by slug: ${slug}`); throw new Error(`Event not found by slug: ${slug}`);
process.exit(1);
} }
const ep = await prisma.eventParticipant.upsert({ const ep = await prisma.eventParticipant.upsert({
where: { userId_eventId: { userId: user.id, eventId: event.id } }, where: { userId_eventId: { userId: user.id, eventId: event.id } },
@@ -370,7 +395,7 @@ async function cmdLogsApp(opts) {
if (!fs.existsSync(logfile)) { if (!fs.existsSync(logfile)) {
console.error(`Log file not found: ${logfile}`); console.error(`Log file not found: ${logfile}`);
console.error('Tip: view container logs with: docker compose logs -f backend'); console.error('Tip: view container logs with: docker compose logs -f backend');
process.exit(1); throw new Error('Log file not found');
} }
const content = fs.readFileSync(logfile, 'utf8'); const content = fs.readFileSync(logfile, 'utf8');
const arr = content.split('\n'); const arr = content.split('\n');
@@ -417,6 +442,7 @@ async function main() {
case '--help': case '--help':
case '-h': case '-h':
printHelp(); printHelp();
printHelpExtra();
break; break;
case 'repl': case 'repl':
await cmdRepl(); await cmdRepl();
@@ -433,6 +459,16 @@ async function main() {
case 'events:list': case 'events:list':
await cmdEventsList(opts); await cmdEventsList(opts);
break; break;
case 'events:details':
await cmdEventsDetails(opts);
break;
case 'events:participants':
await cmdEventsParticipants(opts);
break;
case 'events:import:wsdc':
case 'events:import:worldsdc':
await cmdEventsImportWsdc(opts);
break;
case 'matches:list': case 'matches:list':
await cmdMatchesList(opts); await cmdMatchesList(opts);
break; break;
@@ -448,6 +484,7 @@ async function main() {
default: default:
console.error(`Unknown command: ${command}\n`); console.error(`Unknown command: ${command}\n`);
printHelp(); printHelp();
printHelpExtra();
process.exitCode = 1; process.exitCode = 1;
} }
} catch (err) { } catch (err) {
@@ -475,6 +512,7 @@ if (require.main === module) {
cmdEventsList, cmdEventsList,
cmdEventsDetails, cmdEventsDetails,
cmdEventsParticipants, cmdEventsParticipants,
cmdEventsImportWsdc,
cmdMatchesList, cmdMatchesList,
cmdEventsCheckin, cmdEventsCheckin,
cmdLogsApp, cmdLogsApp,

View File

@@ -0,0 +1,103 @@
const https = require('https');
function fetchUrl(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
// Follow redirect
return resolve(fetchUrl(res.headers.location));
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data));
}).on('error', reject);
});
}
function parseEventsFromCalendar(html) {
const idx = html.indexOf('"events":[');
if (idx === -1) return [];
let i = idx + '"events":'.length; // at [
// Extract the JSON array by bracket counting
let bracket = 0;
let started = false;
let out = '';
while (i < html.length) {
const ch = html[i];
out += ch;
if (ch === '[') { bracket++; started = true; }
else if (ch === ']') { bracket--; if (started && bracket === 0) break; }
i++;
}
const jsonText = out.trim();
let arr;
try {
arr = JSON.parse(jsonText);
} catch (e) {
// Attempt to unescape slashes and retry
arr = JSON.parse(jsonText.replace(/\\\//g, '/'));
}
if (!Array.isArray(arr)) return [];
return arr.map((e) => ({
name: e.title || null,
startDate: e.start || null,
endDate: e.end || null,
sourceUrl: e.url || null,
})).filter((e) => e.name && e.startDate);
}
async function importWorldsdc({ since = null, until = null, limit, dryRun = false } = {}) {
const { prisma } = require('../../utils/db');
const url = 'https://www.worldsdc.com/events/calendar/';
const html = await fetchUrl(url);
const events = parseEventsFromCalendar(html);
const normalizeDate = (s) => new Date(s.split('T')[0] || s);
let list = events.map((e) => ({
name: e.name.trim(),
startDate: normalizeDate(e.startDate),
endDate: e.endDate ? normalizeDate(e.endDate) : normalizeDate(e.startDate),
location: null, // not provided by calendar source
sourceUrl: e.sourceUrl || null,
}));
const fetched = list.length;
if (since) list = list.filter((e) => e.startDate >= since);
if (until) list = list.filter((e) => e.startDate <= until);
if (limit) list = list.slice(0, limit);
const created = [];
const skipped = [];
for (const e of list) {
const existing = await prisma.event.findFirst({
where: { name: e.name, startDate: e.startDate },
select: { id: true },
});
if (existing) {
skipped.push({ ...e, reason: 'exists' });
continue;
}
if (dryRun) {
created.push(e);
continue;
}
const saved = await prisma.event.create({
data: {
name: e.name,
location: 'Unknown',
startDate: e.startDate,
endDate: e.endDate,
description: null,
// worldsdcId and participantsCount are intentionally not set here
},
select: { id: true, slug: true },
});
created.push({ ...e, id: saved.id, slug: saved.slug });
}
return { fetched, considered: list.length, created, skipped };
}
module.exports = { parseEventsFromCalendar, importWorldsdc };

View File

@@ -105,6 +105,18 @@ With Makefile shortcuts:
- `npm run cli -- events:participants --slug warsaw-dance-2025 --limit 50` - `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` - `npm run cli -- events:participants --slug warsaw-dance-2025 --limit 200 --csv > participants.csv`
### events:import:wsdc
- Description: Import events from worldsdc.com calendar page.
- Notes: Uses the calendars embedded data (title/start/end/url). Does not set `participants` or `worldsdcId`.
- Options:
- `--dry-run`: show what would be created without writing to DB
- `--since YYYY-MM-DD`: only events on/after date
- `--until YYYY-MM-DD`: only events on/before date
- `--limit <n>`: limit considered items after filtering
- Examples:
- `npm run cli -- events:import:wsdc --dry-run --since 2024-01-01 --until 2024-12-31`
- `npm run cli -- events:import:wsdc --limit 50`
### logs:app ### logs:app
- Description: Tail application log file (if configured) - Description: Tail application log file (if configured)
- Options: - Options: