Files
Spendlight/server/src/cli.ts
Chris Olson c3cf0f3eb8 Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:52:07 -04:00

91 lines
3.3 KiB
TypeScript

import { IDEA_STATUSES, type IdeaStatus } from '@spendlight/shared';
import { eq } from 'drizzle-orm';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { hashPassword, temporaryPassword } from './auth/password';
import { revokeUserSessions } from './auth/sessions';
import { createBackup, pruneBackups } from './backups/backup';
import { loadConfig } from './config';
import type { AppContext } from './context';
import { openDatabase } from './db/client';
import { users } from './db/schema';
import { ideasToMarkdown } from './ideas/markdown';
import { listIdeas } from './ideas/queries';
import { silentLogger } from './log';
import { findActiveUser } from './users';
// Admin commands, run inside the container:
// spendlight ideas [--status=new|planned|done|declined]
// spendlight reset-password <username>
// spendlight backup
// spendlight version
const HELP = `Spendlight admin commands
spendlight ideas [--status=new] Print ideas as Markdown (all statuses by default)
spendlight reset-password <user> Give someone a temporary password
spendlight backup Back up the database now
spendlight version Show the running version
`;
const config = loadConfig();
const [command, ...args] = process.argv.slice(2);
function openExisting() {
const file = join(config.dataDir, 'spendlight.db');
if (!existsSync(file)) {
console.error(`No Spendlight database found at ${file}.`);
process.exit(1);
}
return openDatabase(file);
}
switch (command) {
case 'ideas': {
const statusArg = args.find((a) => a.startsWith('--status='))?.split('=')[1];
if (statusArg && !IDEA_STATUSES.includes(statusArg as IdeaStatus)) {
console.error(`Unknown status "${statusArg}". Use one of: ${IDEA_STATUSES.join(', ')}.`);
process.exit(1);
}
const { db } = openExisting();
process.stdout.write(ideasToMarkdown(listIdeas(db, statusArg as IdeaStatus | undefined)));
break;
}
case 'reset-password': {
const username = args[0];
if (!username) {
console.error('Usage: spendlight reset-password <username>');
process.exit(1);
}
const { db, sqlite } = openExisting();
const ctx = { db, sqlite, config, log: silentLogger } as unknown as AppContext;
const user = findActiveUser(ctx, { username });
if (!user) {
console.error(`Nobody uses the username "${username}".`);
process.exit(1);
}
const password = temporaryPassword();
db.update(users)
.set({ passwordHash: await hashPassword(password), mustChangePassword: true, updatedAt: new Date().toISOString() })
.where(eq(users.id, user.id))
.run();
revokeUserSessions(ctx, user.id);
console.log(`Temporary password for ${user.displayName} (${user.username}): ${password}`);
console.log('They will choose a new one when they sign in.');
break;
}
case 'backup': {
const { sqlite } = openExisting();
const backup = await createBackup(sqlite, config.backupDir, 'manual');
pruneBackups(config.backupDir, { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly });
console.log(`Backed up to ${join(config.backupDir, backup.name)}`);
break;
}
case 'version':
console.log(config.version);
break;
default:
process.stdout.write(HELP);
process.exit(command ? 1 : 0);
}