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>
This commit is contained in:
90
server/src/cli.ts
Normal file
90
server/src/cli.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user