spendwise 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Headless server: runs the app without Electron, for self-hosting on a box
4
+ * that's always on. Shares the exact same api-core + web server the desktop
5
+ * app uses - the only thing missing is the desktop window (and with it the
6
+ * native-dialog features: export, move database, legacy import; do those in
7
+ * the desktop app against the same db.json, or pre-seed the file).
8
+ *
9
+ * spendwise-server # serve
10
+ * spendwise-server --set-password # set/change the login (then exit)
11
+ * spendwise-server --help
12
+ *
13
+ * Config comes from the environment (all optional):
14
+ * FINANCES_DB_PATH default ./data/db.json
15
+ * FINANCES_PORT / PORT default 4180
16
+ * FINANCES_HOST default 0.0.0.0
17
+ * FINANCES_PASSWORD bootstrap only - sets the login if none exists yet
18
+ * FINANCES_TRUST_PROXY number of reverse proxies in front (1 for the usual
19
+ * nginx/Caddy/Traefik setup). Needed for per-client
20
+ * rate limiting; leave unset when directly exposed.
21
+ * FINANCES_SECURE_COOKIE 1 to force the Secure cookie flag (auto-detected
22
+ * from X-Forwarded-Proto when a proxy is trusted)
23
+ */
24
+ 'use strict';
25
+
26
+ import path from 'node:path';
27
+ import fs from 'node:fs';
28
+ import readline from 'node:readline';
29
+ import { webcrypto } from 'node:crypto';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { openDb } from '../main/db-open.js';
32
+
33
+ // node 18 has no global `crypto` (it became one in 19). The shared engine is
34
+ // UMD and reads uuid() off the global, so without this it would silently drop
35
+ // to its Math.random fallback here - fine for uniqueness, but needlessly weak
36
+ // when a CSPRNG is one import away. Electron and node 20+ already have it.
37
+ if (!globalThis.crypto) globalThis.crypto = webcrypto;
38
+ import { createCore } from '../main/api-core.js';
39
+ import { createWebServer } from '../main/webserver.js';
40
+
41
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
43
+
44
+ const argv = process.argv.slice(2);
45
+ const flag = (name) => argv.includes(name);
46
+ const flagValue = (name) => {
47
+ const i = argv.indexOf(name);
48
+ return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : null;
49
+ };
50
+
51
+ if (flag('--help') || flag('-h')) {
52
+ process.stdout.write(`
53
+ Spend Wise - self-hosted server (v${pkg.version})
54
+
55
+ Usage
56
+ spendwise-server serve the app
57
+ spendwise-server --set-password set or change the login password, then exit
58
+ spendwise-server --version
59
+
60
+ Environment
61
+ FINANCES_DB_PATH path to db.json (default ./data/db.json)
62
+ FINANCES_PORT | PORT listen port (default 4180)
63
+ FINANCES_HOST bind address (default 0.0.0.0)
64
+ FINANCES_PASSWORD sets the login on first run only
65
+ FINANCES_TRUST_PROXY reverse proxies in front (e.g. 1 behind nginx)
66
+ FINANCES_SECURE_COOKIE 1 to force Secure cookies
67
+
68
+ Everyone signs in with the one app password; the desktop app uses the same
69
+ db.json, so you can point it at this file too (one writer at a time - or let
70
+ people co-edit through this server, which merges concurrent edits).
71
+
72
+ `);
73
+ process.exit(0);
74
+ }
75
+
76
+ if (flag('--version') || flag('-v')) {
77
+ process.stdout.write(pkg.version + '\n');
78
+ process.exit(0);
79
+ }
80
+
81
+ const dbPath = path.resolve(process.env.FINANCES_DB_PATH || 'data/db.json');
82
+ const port = Number(process.env.FINANCES_PORT || process.env.PORT || 4180);
83
+ const host = process.env.FINANCES_HOST || '0.0.0.0';
84
+
85
+ const db = await openDb(dbPath);
86
+ const ctx = { db, dbPath, version: pkg.version };
87
+ const core = createCore(ctx);
88
+
89
+ /** Read a password from a TTY without echoing it, or from piped stdin. */
90
+ function askPassword (prompt) {
91
+ return new Promise((resolve) => {
92
+ if (!process.stdin.isTTY) { // echo "pw" | spendwise-server --set-password
93
+ let data = '';
94
+ process.stdin.on('data', (c) => { data += c; });
95
+ process.stdin.on('end', () => resolve(data.trim()));
96
+ return;
97
+ }
98
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
99
+ rl._writeToOutput = function (s) { // mute everything after the prompt
100
+ if (s.includes(prompt)) rl.output.write(prompt);
101
+ };
102
+ rl.question(prompt, (answer) => { rl.output.write('\n'); rl.close(); resolve(answer.trim()); });
103
+ });
104
+ }
105
+
106
+ if (flag('--set-password')) {
107
+ const existing = core.authHas().hasPassword;
108
+ let next = flagValue('--set-password');
109
+ let current = '';
110
+ if (existing) {
111
+ current = process.env.FINANCES_CURRENT_PASSWORD || await askPassword('Current password: ');
112
+ }
113
+ if (!next) {
114
+ next = await askPassword(existing ? 'New password: ' : 'Password: ');
115
+ const confirm = await askPassword('Confirm: ');
116
+ if (next !== confirm) {
117
+ console.error('Passwords did not match - nothing changed.');
118
+ process.exit(1);
119
+ }
120
+ }
121
+ if (!next) {
122
+ console.error('Empty password - nothing changed. (Removing the password disables web access.)');
123
+ process.exit(1);
124
+ }
125
+ try {
126
+ await core.authSet({ current, next });
127
+ console.log(`Password ${existing ? 'changed' : 'set'} for ${dbPath}`);
128
+ process.exit(0);
129
+ } catch (e) {
130
+ console.error(e.message);
131
+ process.exit(1);
132
+ }
133
+ }
134
+
135
+ // Bootstrap (first run / container start): only ever sets a password when the
136
+ // database has none - it must not clobber one the user later changed.
137
+ if (!core.authHas().hasPassword && process.env.FINANCES_PASSWORD) {
138
+ await core.authSet({ next: process.env.FINANCES_PASSWORD });
139
+ console.log('[auth] password set from FINANCES_PASSWORD');
140
+ }
141
+
142
+ const web = createWebServer(ctx, core, {
143
+ host,
144
+ trustProxy: process.env.FINANCES_TRUST_PROXY,
145
+ secureCookie: process.env.FINANCES_SECURE_COOKIE === '1' || process.env.FINANCES_SECURE_COOKIE === 'true',
146
+ });
147
+
148
+ await web.start(port, host);
149
+
150
+ console.log(`Spend Wise v${pkg.version}`);
151
+ console.log(` serving http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`);
152
+ console.log(` database ${dbPath}`);
153
+ if (!core.authHas().hasPassword) {
154
+ console.log('\n ⚠ No app password set - the server will refuse to serve the app until you run:');
155
+ console.log(' spendwise-server --set-password\n');
156
+ }
157
+
158
+ const shutdown = async (signal) => {
159
+ console.log(`\n[${signal}] shutting down…`);
160
+ await web.stop();
161
+ process.exit(0);
162
+ };
163
+ process.on('SIGINT', () => shutdown('SIGINT'));
164
+ process.on('SIGTERM', () => shutdown('SIGTERM'));