mobygate 0.3.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,584 @@
1
+ /**
2
+ * Platform-aware service management for mobygate.
3
+ *
4
+ * Each supported OS has a native scheduler/supervisor we target:
5
+ * - macOS → launchd (plist files in ~/Library/LaunchAgents/)
6
+ * - Linux → systemd user units (~/.config/systemd/user/)
7
+ * - Windows → Task Scheduler + nssm (if available) or a simple
8
+ * Start Menu startup shortcut
9
+ *
10
+ * For v0.1 we ship full automation on macOS and emit clear manual
11
+ * instructions on Linux/Windows. Enough to unblock the team today;
12
+ * the other paths land as the brothers test on their boxes.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'fs';
16
+ import { homedir, platform } from 'os';
17
+ import { join, dirname, resolve } from 'path';
18
+ import { execSync, spawnSync } from 'child_process';
19
+ import { LOGS_DIR } from './config.js';
20
+
21
+ export const PLATFORM = platform(); // 'darwin' | 'linux' | 'win32'
22
+ export const IS_MAC = PLATFORM === 'darwin';
23
+ export const IS_LINUX = PLATFORM === 'linux';
24
+ export const IS_WIN = PLATFORM === 'win32';
25
+
26
+ const LAUNCH_AGENTS_DIR = join(homedir(), 'Library', 'LaunchAgents');
27
+ const SERVER_LABEL = 'ai.mobygate.server';
28
+ const AUTH_LABEL = 'ai.mobygate.auth-refresh';
29
+
30
+ /**
31
+ * Resolve the node binary to use for spawned services. launchd/systemd
32
+ * do NOT source shell rc files, so fnm/nvm paths won't exist unless we
33
+ * hand them a concrete path. Strategy:
34
+ * 1. Honor MOBYGATE_NODE_BIN env var if set (lets users override)
35
+ * 2. Try the current process.execPath (Node we're running under)
36
+ * 3. Fall back to `which node` via shell
37
+ */
38
+ export function resolveNodeBin() {
39
+ if (process.env.MOBYGATE_NODE_BIN) return process.env.MOBYGATE_NODE_BIN;
40
+ if (process.execPath && existsSync(process.execPath)) return process.execPath;
41
+ try {
42
+ const r = spawnSync(IS_WIN ? 'where' : 'which', ['node'], { encoding: 'utf8' });
43
+ if (r.status === 0) return r.stdout.trim().split('\n')[0];
44
+ } catch {}
45
+ return 'node';
46
+ }
47
+
48
+ /**
49
+ * Emit a launchd plist for the server. Writes and returns the file path.
50
+ * Caller is responsible for loading it via `launchctl load`.
51
+ */
52
+ export function writeMacServerPlist({ installPath, nodeBin, port, logsDir }) {
53
+ if (!IS_MAC) throw new Error('writeMacServerPlist called on non-macOS');
54
+ if (!existsSync(LAUNCH_AGENTS_DIR)) mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
55
+ const plistPath = join(LAUNCH_AGENTS_DIR, `${SERVER_LABEL}.plist`);
56
+ const pathChain = [
57
+ dirname(nodeBin),
58
+ '/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin',
59
+ join(homedir(), '.local/bin'),
60
+ ].join(':');
61
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
62
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
63
+ <!--
64
+ Generated by \`mobygate init\` on ${new Date().toISOString()}.
65
+ Manages the mobygate server as a user-level launchd service:
66
+ - Starts at login (RunAtLoad = true)
67
+ - Restarts on crash (KeepAlive = true)
68
+ - Logs to ${logsDir}/server.{log,err.log}
69
+
70
+ Install: launchctl load ~/Library/LaunchAgents/${SERVER_LABEL}.plist
71
+ Uninstall: launchctl unload ~/Library/LaunchAgents/${SERVER_LABEL}.plist
72
+ -->
73
+ <plist version="1.0">
74
+ <dict>
75
+ <key>Label</key>
76
+ <string>${SERVER_LABEL}</string>
77
+
78
+ <key>ProgramArguments</key>
79
+ <array>
80
+ <string>${nodeBin}</string>
81
+ <string>server.js</string>
82
+ </array>
83
+
84
+ <key>WorkingDirectory</key>
85
+ <string>${installPath}</string>
86
+
87
+ <key>EnvironmentVariables</key>
88
+ <dict>
89
+ <key>PATH</key>
90
+ <string>${pathChain}</string>
91
+ <key>HOME</key>
92
+ <string>${homedir()}</string>
93
+ <key>PORT</key>
94
+ <string>${port}</string>
95
+ </dict>
96
+
97
+ <key>RunAtLoad</key>
98
+ <true/>
99
+ <key>KeepAlive</key>
100
+ <true/>
101
+
102
+ <key>StandardOutPath</key>
103
+ <string>${logsDir}/server.log</string>
104
+ <key>StandardErrorPath</key>
105
+ <string>${logsDir}/server.err.log</string>
106
+ </dict>
107
+ </plist>
108
+ `;
109
+ writeFileSync(plistPath, xml);
110
+ return plistPath;
111
+ }
112
+
113
+ /**
114
+ * Install (copy + load) a plist. Returns {installed: true, path}.
115
+ * Safe to call when already loaded — we unload first.
116
+ */
117
+ export function launchctlLoad(plistPath) {
118
+ if (!IS_MAC) throw new Error('launchctlLoad called on non-macOS');
119
+ try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
120
+ execSync(`launchctl load "${plistPath}"`);
121
+ return { loaded: true, path: plistPath };
122
+ }
123
+
124
+ export function launchctlUnload(plistPath) {
125
+ if (!IS_MAC) return { unloaded: false, path: plistPath };
126
+ try { execSync(`launchctl unload "${plistPath}"`); } catch {}
127
+ return { unloaded: true, path: plistPath };
128
+ }
129
+
130
+ /** List of plist labels we own, in install order. */
131
+ export const MANAGED_LABELS = [SERVER_LABEL, AUTH_LABEL];
132
+
133
+ export function plistPathForLabel(label) {
134
+ return join(LAUNCH_AGENTS_DIR, `${label}.plist`);
135
+ }
136
+
137
+ /**
138
+ * Remove our plists (unload + delete the files in ~/Library/LaunchAgents).
139
+ * Returns a list of {label, removed} results.
140
+ */
141
+ export function uninstallAllServices() {
142
+ if (!IS_MAC) return [];
143
+ const results = [];
144
+ for (const label of MANAGED_LABELS) {
145
+ const p = plistPathForLabel(label);
146
+ if (existsSync(p)) {
147
+ try { execSync(`launchctl unload "${p}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
148
+ try { unlinkSync(p); results.push({ label, removed: true, path: p }); }
149
+ catch (e) { results.push({ label, removed: false, error: e.message }); }
150
+ } else {
151
+ results.push({ label, removed: false, reason: 'not installed' });
152
+ }
153
+ }
154
+ return results;
155
+ }
156
+
157
+ /**
158
+ * Ask launchd whether a given label is currently loaded. Returns
159
+ * {loaded, lastExit, pid} — pid is null if loaded but not running.
160
+ */
161
+ export function queryLaunchd(label) {
162
+ if (!IS_MAC) return { supported: false };
163
+ const r = spawnSync('launchctl', ['list', label], { encoding: 'utf8' });
164
+ if (r.status !== 0) return { loaded: false };
165
+ // Output is a plist-ish dict with Label, PID, LastExitStatus keys
166
+ const pidMatch = /"PID"\s*=\s*(\d+);/.exec(r.stdout);
167
+ const exitMatch = /"LastExitStatus"\s*=\s*(-?\d+);/.exec(r.stdout);
168
+ return {
169
+ loaded: true,
170
+ pid: pidMatch ? parseInt(pidMatch[1], 10) : null,
171
+ lastExit: exitMatch ? parseInt(exitMatch[1], 10) : null,
172
+ };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Windows — Task Scheduler automation
177
+ // ---------------------------------------------------------------------------
178
+ // We register two user-level scheduled tasks (no admin required):
179
+ // mobygate-server at logon, auto-restart on failure, KeepAlive
180
+ // mobygate-auth-refresh every 4h, one-shot
181
+ //
182
+ // User-level tasks run as the current user with their normal privileges
183
+ // (RunLevel: Limited), which is all we need. No nssm, no admin elevation.
184
+
185
+ const WIN_SERVER_TASK = 'mobygate-server';
186
+ const WIN_AUTH_TASK = 'mobygate-auth-refresh';
187
+
188
+ /** Run a PowerShell command, return {ok, stdout, stderr, code}. */
189
+ function runPowershell(script, { timeoutMs = 30_000 } = {}) {
190
+ if (!IS_WIN) throw new Error('runPowershell called on non-Windows');
191
+ const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
192
+ encoding: 'utf8',
193
+ timeout: timeoutMs,
194
+ windowsHide: true,
195
+ });
196
+ return {
197
+ ok: r.status === 0,
198
+ code: r.status,
199
+ stdout: (r.stdout || '').trim(),
200
+ stderr: (r.stderr || '').trim(),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Register both scheduled tasks and start the server task now.
206
+ * Returns { ok, installed: [names], errors: [] }.
207
+ */
208
+ export function installWindowsServices({ installPath, nodeBin, port, authRefreshHours = 4 }) {
209
+ if (!IS_WIN) throw new Error('installWindowsServices called on non-Windows');
210
+ const esc = (p) => p.replace(/'/g, "''"); // PowerShell single-quote escape
211
+ const installed = [];
212
+ const errors = [];
213
+
214
+ // ---- Logs dir must exist before cmd.exe >> tries to write to it ----
215
+ const logsDir = LOGS_DIR;
216
+ if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
217
+
218
+ // ---- Server: .cmd launcher ----
219
+ // Task Scheduler + cmd.exe + stdout redirection is a quote-escaping swamp.
220
+ // Instead of wrestling with /c "\"node\" ... >> \"log\"" gymnastics, we
221
+ // emit a standalone .cmd launcher that handles redirection itself, and
222
+ // Task Scheduler just executes that file directly. Standard Windows
223
+ // pattern, zero quoting ambiguity.
224
+ const launcherPath = join(installPath, '.mobygate-server.cmd');
225
+ const launcher = [
226
+ '@echo off',
227
+ `cd /d "${installPath}"`,
228
+ `"${nodeBin}" server.js >> "${join(logsDir, 'server.log').replace(/\//g, '\\')}" 2>&1`,
229
+ '',
230
+ ].join('\r\n');
231
+ writeFileSync(launcherPath, launcher);
232
+
233
+ // ---- Server task ----
234
+ // Executes the launcher directly. RestartCount=3 + RestartInterval=1min
235
+ // gives auto-restart on crash. ExecutionTimeLimit=0 = run indefinitely.
236
+ const serverScript = `
237
+ $action = New-ScheduledTaskAction -Execute '${esc(launcherPath)}' -WorkingDirectory '${esc(installPath)}'
238
+ $trigger = New-ScheduledTaskTrigger -AtLogon
239
+ $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
240
+ $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
241
+ Register-ScheduledTask -TaskName '${WIN_SERVER_TASK}' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
242
+ # Start it immediately so the server is running now, not only after next logon
243
+ Start-ScheduledTask -TaskName '${WIN_SERVER_TASK}'
244
+ Write-Output 'ok'
245
+ `;
246
+ const r1 = runPowershell(serverScript);
247
+ if (r1.ok) installed.push(WIN_SERVER_TASK);
248
+ else errors.push({ task: WIN_SERVER_TASK, stderr: r1.stderr });
249
+
250
+ // ---- Auth refresh task ----
251
+ const authIntervalHours = Math.max(1, Math.floor(Number(authRefreshHours) || 4));
252
+ const authScript = `
253
+ $action = New-ScheduledTaskAction -Execute '${esc(nodeBin)}' -Argument 'scripts/auth-refresh.js' -WorkingDirectory '${esc(installPath)}'
254
+ $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Hours ${authIntervalHours})
255
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
256
+ $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
257
+ Register-ScheduledTask -TaskName '${WIN_AUTH_TASK}' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
258
+ Write-Output 'ok'
259
+ `;
260
+ const r2 = runPowershell(authScript);
261
+ if (r2.ok) installed.push(WIN_AUTH_TASK);
262
+ else errors.push({ task: WIN_AUTH_TASK, stderr: r2.stderr });
263
+
264
+ return { ok: errors.length === 0, installed, errors };
265
+ }
266
+
267
+ /** Query Task Scheduler for one task. Returns {exists, state, lastResult}. */
268
+ export function queryWindowsTask(taskName) {
269
+ if (!IS_WIN) return { supported: false };
270
+ const r = runPowershell(`
271
+ $t = Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue
272
+ if (-not $t) { Write-Output 'NOTFOUND'; exit 0 }
273
+ $info = Get-ScheduledTaskInfo -TaskName '${taskName}'
274
+ Write-Output "STATE=$($t.State);LASTRESULT=$($info.LastTaskResult);NEXTRUN=$($info.NextRunTime)"
275
+ `);
276
+ if (!r.ok) return { exists: false, error: r.stderr };
277
+ const out = r.stdout;
278
+ if (out === 'NOTFOUND') return { exists: false };
279
+ const stateMatch = /STATE=([^;]+)/.exec(out);
280
+ const resultMatch = /LASTRESULT=(-?\d+)/.exec(out);
281
+ return {
282
+ exists: true,
283
+ state: stateMatch ? stateMatch[1] : null, // Ready | Running | Disabled
284
+ lastResult: resultMatch ? parseInt(resultMatch[1], 10) : null,
285
+ };
286
+ }
287
+
288
+ export function startWindowsTask(taskName) {
289
+ if (!IS_WIN) return { ok: false };
290
+ return runPowershell(`Start-ScheduledTask -TaskName '${taskName}'; Write-Output 'ok'`);
291
+ }
292
+
293
+ export function stopWindowsTask(taskName) {
294
+ if (!IS_WIN) return { ok: false };
295
+ // Stop-ScheduledTask errors if not running; silence that case.
296
+ return runPowershell(`Stop-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue; Write-Output 'ok'`);
297
+ }
298
+
299
+ export function uninstallWindowsServices({ installPath } = {}) {
300
+ if (!IS_WIN) return [];
301
+ const results = [];
302
+ for (const name of [WIN_SERVER_TASK, WIN_AUTH_TASK]) {
303
+ const r = runPowershell(`
304
+ $t = Get-ScheduledTask -TaskName '${name}' -ErrorAction SilentlyContinue
305
+ if ($t) {
306
+ Stop-ScheduledTask -TaskName '${name}' -ErrorAction SilentlyContinue
307
+ Unregister-ScheduledTask -TaskName '${name}' -Confirm:$false
308
+ Write-Output 'removed'
309
+ } else {
310
+ Write-Output 'notfound'
311
+ }
312
+ `);
313
+ if (!r.ok) results.push({ name, removed: false, error: r.stderr });
314
+ else results.push({ name, removed: r.stdout === 'removed', reason: r.stdout });
315
+ }
316
+ // Best-effort cleanup of the launcher file
317
+ if (installPath) {
318
+ const launcherPath = join(installPath, '.mobygate-server.cmd');
319
+ try {
320
+ if (existsSync(launcherPath)) unlinkSync(launcherPath);
321
+ } catch {}
322
+ }
323
+ return results;
324
+ }
325
+
326
+ export const WIN_LABELS = {
327
+ server: WIN_SERVER_TASK,
328
+ auth: WIN_AUTH_TASK,
329
+ };
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Linux — systemd user units
333
+ // ---------------------------------------------------------------------------
334
+ // Three units installed to ~/.config/systemd/user/:
335
+ // mobygate-server.service — long-running proxy, Restart=on-failure
336
+ // mobygate-auth.service — oneshot refresh probe (invoked by timer)
337
+ // mobygate-auth.timer — fires every 4h
338
+ //
339
+ // `systemctl --user` runs as the current user, no sudo needed. Note that
340
+ // without `loginctl enable-linger <user>`, user services only run while
341
+ // the user is logged in (interactive or SSH). That's fine for dev boxes;
342
+ // print a tip about enable-linger for headless servers.
343
+
344
+ const SYSTEMD_USER_DIR = join(homedir(), '.config', 'systemd', 'user');
345
+ const LINUX_SERVER_UNIT = 'mobygate-server.service';
346
+ const LINUX_AUTH_UNIT = 'mobygate-auth.service';
347
+ const LINUX_AUTH_TIMER = 'mobygate-auth.timer';
348
+
349
+ export const LINUX_UNITS = {
350
+ server: LINUX_SERVER_UNIT,
351
+ auth: LINUX_AUTH_UNIT,
352
+ timer: LINUX_AUTH_TIMER,
353
+ };
354
+
355
+ function systemctlUser(args, { timeoutMs = 15_000 } = {}) {
356
+ if (!IS_LINUX) throw new Error('systemctlUser called on non-Linux');
357
+ const r = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', timeout: timeoutMs });
358
+ return {
359
+ ok: r.status === 0,
360
+ code: r.status,
361
+ stdout: (r.stdout || '').trim(),
362
+ stderr: (r.stderr || '').trim(),
363
+ };
364
+ }
365
+
366
+ function writeSystemdUnit(name, content) {
367
+ if (!existsSync(SYSTEMD_USER_DIR)) mkdirSync(SYSTEMD_USER_DIR, { recursive: true });
368
+ const path = join(SYSTEMD_USER_DIR, name);
369
+ writeFileSync(path, content);
370
+ return path;
371
+ }
372
+
373
+ /**
374
+ * Generate + install + enable both systemd user units (server + auth timer).
375
+ * Returns { ok, installed, errors }.
376
+ */
377
+ export function installLinuxServices({ installPath, nodeBin, port, authRefreshHours = 4 }) {
378
+ if (!IS_LINUX) throw new Error('installLinuxServices called on non-Linux');
379
+ const installed = [];
380
+ const errors = [];
381
+
382
+ // Ensure logs dir exists before StandardOutput=append tries to write.
383
+ const logsDir = LOGS_DIR;
384
+ if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
385
+
386
+ // ---- Server unit ----
387
+ const serverUnit = `[Unit]
388
+ Description=mobygate — OpenAI → Claude Max gateway
389
+ After=network.target
390
+
391
+ [Service]
392
+ Type=simple
393
+ WorkingDirectory=${installPath}
394
+ ExecStart=${nodeBin} server.js
395
+ Environment=PORT=${port}
396
+ Environment=HOME=${homedir()}
397
+ Restart=on-failure
398
+ RestartSec=5
399
+ StandardOutput=append:${logsDir}/server.log
400
+ StandardError=append:${logsDir}/server.err.log
401
+
402
+ [Install]
403
+ WantedBy=default.target
404
+ `;
405
+ writeSystemdUnit(LINUX_SERVER_UNIT, serverUnit);
406
+
407
+ // ---- Auth service (oneshot, triggered by timer) ----
408
+ const authService = `[Unit]
409
+ Description=mobygate — OAuth refresh probe
410
+
411
+ [Service]
412
+ Type=oneshot
413
+ WorkingDirectory=${installPath}
414
+ ExecStart=${nodeBin} scripts/auth-refresh.js
415
+ Environment=HOME=${homedir()}
416
+ StandardOutput=append:${logsDir}/auth-refresh.log
417
+ StandardError=append:${logsDir}/auth-refresh.err.log
418
+ `;
419
+ writeSystemdUnit(LINUX_AUTH_UNIT, authService);
420
+
421
+ // ---- Auth timer (every Nh, also fires 1 min after boot) ----
422
+ const authIntervalHours = Math.max(1, Math.floor(Number(authRefreshHours) || 4));
423
+ const authTimer = `[Unit]
424
+ Description=mobygate — auth refresh every ${authIntervalHours}h
425
+
426
+ [Timer]
427
+ OnBootSec=1min
428
+ OnUnitActiveSec=${authIntervalHours}h
429
+ Unit=${LINUX_AUTH_UNIT}
430
+
431
+ [Install]
432
+ WantedBy=timers.target
433
+ `;
434
+ writeSystemdUnit(LINUX_AUTH_TIMER, authTimer);
435
+
436
+ // ---- Reload + enable + start ----
437
+ const reload = systemctlUser(['daemon-reload']);
438
+ if (!reload.ok) errors.push({ unit: 'daemon-reload', stderr: reload.stderr });
439
+
440
+ const enableServer = systemctlUser(['enable', '--now', LINUX_SERVER_UNIT]);
441
+ if (enableServer.ok) installed.push(LINUX_SERVER_UNIT);
442
+ else errors.push({ unit: LINUX_SERVER_UNIT, stderr: enableServer.stderr });
443
+
444
+ const enableTimer = systemctlUser(['enable', '--now', LINUX_AUTH_TIMER]);
445
+ if (enableTimer.ok) installed.push(LINUX_AUTH_TIMER);
446
+ else errors.push({ unit: LINUX_AUTH_TIMER, stderr: enableTimer.stderr });
447
+
448
+ return { ok: errors.length === 0, installed, errors };
449
+ }
450
+
451
+ /** Stop + disable + remove all mobygate systemd user units. */
452
+ export function uninstallLinuxServices() {
453
+ if (!IS_LINUX) return [];
454
+ const results = [];
455
+ // Stop + disable in reverse (timer first, then service)
456
+ for (const name of [LINUX_AUTH_TIMER, LINUX_SERVER_UNIT]) {
457
+ const r1 = systemctlUser(['disable', '--now', name]);
458
+ const path = join(SYSTEMD_USER_DIR, name);
459
+ let removed = false;
460
+ if (existsSync(path)) {
461
+ try { unlinkSync(path); removed = true; } catch (e) {/* ignore */}
462
+ }
463
+ results.push({ unit: name, removed, stopped: r1.ok });
464
+ }
465
+ // Auth .service is plain (only reachable via timer), remove it too
466
+ const authPath = join(SYSTEMD_USER_DIR, LINUX_AUTH_UNIT);
467
+ if (existsSync(authPath)) {
468
+ try { unlinkSync(authPath); results.push({ unit: LINUX_AUTH_UNIT, removed: true, stopped: true }); }
469
+ catch { results.push({ unit: LINUX_AUTH_UNIT, removed: false, stopped: false }); }
470
+ }
471
+ systemctlUser(['daemon-reload']);
472
+ return results;
473
+ }
474
+
475
+ /**
476
+ * Query a systemd user unit. Returns { exists, active, sub, mainPid, exitStatus }.
477
+ */
478
+ export function queryLinuxUnit(unitName) {
479
+ if (!IS_LINUX) return { supported: false };
480
+ const r = systemctlUser(['show', '-p', 'LoadState,ActiveState,SubState,MainPID,ExecMainStatus', unitName]);
481
+ if (!r.ok) return { exists: false, error: r.stderr };
482
+ const pairs = {};
483
+ for (const line of r.stdout.split('\n')) {
484
+ const [k, ...rest] = line.split('=');
485
+ if (k) pairs[k] = rest.join('=');
486
+ }
487
+ const exists = pairs.LoadState && pairs.LoadState !== 'not-found';
488
+ return {
489
+ exists: !!exists,
490
+ active: pairs.ActiveState, // active | inactive | failed | activating
491
+ sub: pairs.SubState, // running | dead | ...
492
+ mainPid: pairs.MainPID && pairs.MainPID !== '0' ? parseInt(pairs.MainPID, 10) : null,
493
+ exitStatus: pairs.ExecMainStatus ? parseInt(pairs.ExecMainStatus, 10) : null,
494
+ };
495
+ }
496
+
497
+ export function startLinuxUnit(unitName) {
498
+ if (!IS_LINUX) return { ok: false };
499
+ return systemctlUser(['start', unitName]);
500
+ }
501
+
502
+ export function stopLinuxUnit(unitName) {
503
+ if (!IS_LINUX) return { ok: false };
504
+ return systemctlUser(['stop', unitName]);
505
+ }
506
+
507
+ /**
508
+ * Render installation instructions for non-macOS platforms as a string.
509
+ * mobygate init uses this as a fallback when auto-install fails, and
510
+ * Linux still gets printed instructions (systemd automation is next).
511
+ */
512
+ export function nonMacInstallInstructions({ installPath, nodeBin, port }) {
513
+ if (IS_LINUX) {
514
+ return `
515
+ Linux install — systemd user unit:
516
+
517
+ mkdir -p ~/.config/systemd/user
518
+
519
+ cat > ~/.config/systemd/user/mobygate-server.service <<EOF
520
+ [Unit]
521
+ Description=mobygate — OpenAI → Claude Max gateway
522
+ After=network.target
523
+
524
+ [Service]
525
+ Type=simple
526
+ WorkingDirectory=${installPath}
527
+ ExecStart=${nodeBin} server.js
528
+ Environment=PORT=${port}
529
+ Restart=on-failure
530
+ RestartSec=5
531
+
532
+ [Install]
533
+ WantedBy=default.target
534
+ EOF
535
+
536
+ systemctl --user daemon-reload
537
+ systemctl --user enable --now mobygate-server
538
+
539
+ # Auth refresh every 4h (service + timer pair):
540
+ cat > ~/.config/systemd/user/mobygate-auth.service <<EOF
541
+ [Unit]
542
+ Description=mobygate — OAuth refresh probe
543
+ [Service]
544
+ Type=oneshot
545
+ WorkingDirectory=${installPath}
546
+ ExecStart=${nodeBin} scripts/auth-refresh.js
547
+ EOF
548
+
549
+ cat > ~/.config/systemd/user/mobygate-auth.timer <<EOF
550
+ [Unit]
551
+ Description=mobygate — auth refresh every 4h
552
+ [Timer]
553
+ OnBootSec=1min
554
+ OnUnitActiveSec=4h
555
+ [Install]
556
+ WantedBy=timers.target
557
+ EOF
558
+
559
+ systemctl --user enable --now mobygate-auth.timer
560
+ `;
561
+ }
562
+ if (IS_WIN) {
563
+ return `
564
+ Windows install — Task Scheduler (PowerShell, elevated):
565
+
566
+ $node = "${nodeBin.replace(/\\/g, '\\\\')}"
567
+ $script = "${installPath.replace(/\\/g, '\\\\')}"
568
+
569
+ # Server service (runs at login, restarts on crash via action retry):
570
+ $a = New-ScheduledTaskAction -Execute $node -Argument "server.js" -WorkingDirectory $script
571
+ $t = New-ScheduledTaskTrigger -AtLogon
572
+ Register-ScheduledTask -TaskName "mobygate-server" -Action $a -Trigger $t -RunLevel Highest
573
+
574
+ # Auth refresh every 4h:
575
+ $a2 = New-ScheduledTaskAction -Execute $node -Argument "scripts/auth-refresh.js" -WorkingDirectory $script
576
+ $t2 = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 4)
577
+ Register-ScheduledTask -TaskName "mobygate-auth-refresh" -Action $a2 -Trigger $t2
578
+
579
+ # For more robust crash-restart, install the \`nssm\` service wrapper
580
+ # (https://nssm.cc/) and register mobygate as a proper Windows service.
581
+ `;
582
+ }
583
+ return '(unsupported platform — install manually by running `node server.js`)';
584
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * File-backed session store.
3
+ *
4
+ * Sessions map (client-supplied key → SDK session state) was previously
5
+ * in-memory only, which meant every `mobygate restart` or service
6
+ * crash wiped all active sessions. Any in-flight Hermes conversation
7
+ * using session keys had to start a fresh SDK session on the next
8
+ * request, losing its Claude-side context.
9
+ *
10
+ * This module persists that map to ~/.mobygate/sessions.json on every
11
+ * mutation (debounced 500 ms to absorb bursts) and rehydrates on boot.
12
+ * JSON is safe here — entries are small (< 200 B each) and we expect
13
+ * O(tens) of concurrent sessions, not thousands. No SQLite needed.
14
+ *
15
+ * Invariants:
16
+ * - Expired sessions are filtered out on load, so the on-disk file
17
+ * never grows unbounded even if cleanup didn't run before shutdown.
18
+ * - All writes are atomic via write-to-tmp-then-rename, so a crash
19
+ * mid-write never leaves a corrupt sessions.json.
20
+ * - A corrupt file on load is logged and treated as empty — we never
21
+ * refuse to start the server because sessions.json is malformed.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
25
+ import { join } from 'path';
26
+ import { CONFIG_DIR } from './config.js';
27
+
28
+ export const SESSIONS_PATH = join(CONFIG_DIR, 'sessions.json');
29
+ const TMP_SUFFIX = '.tmp';
30
+ const DEBOUNCE_MS = 500;
31
+
32
+ /**
33
+ * Read sessions.json and return a Map<string, SessionEntry>.
34
+ * Expired entries (older than ttlMs since lastUsed) are dropped.
35
+ * A missing or corrupt file is treated as "no sessions".
36
+ */
37
+ export function loadSessions(ttlMs) {
38
+ if (!existsSync(SESSIONS_PATH)) return new Map();
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(readFileSync(SESSIONS_PATH, 'utf8'));
42
+ } catch (e) {
43
+ console.warn(`[session] failed to parse ${SESSIONS_PATH}: ${e.message} — starting fresh`);
44
+ return new Map();
45
+ }
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
47
+ return new Map();
48
+ }
49
+ const now = Date.now();
50
+ const map = new Map();
51
+ let skipped = 0;
52
+ for (const [key, entry] of Object.entries(parsed)) {
53
+ if (!entry || typeof entry !== 'object') continue;
54
+ if (typeof entry.lastUsed !== 'number' || typeof entry.sdkSessionId !== 'string') {
55
+ skipped++;
56
+ continue;
57
+ }
58
+ if (now - entry.lastUsed > ttlMs) {
59
+ skipped++;
60
+ continue;
61
+ }
62
+ map.set(key, entry);
63
+ }
64
+ if (skipped > 0) {
65
+ console.log(`[session] loaded ${map.size} from disk (skipped ${skipped} expired or malformed)`);
66
+ }
67
+ return map;
68
+ }
69
+
70
+ // --- debounced persistence ------------------------------------------------
71
+ let pendingTimer = null;
72
+ let lastMap = null;
73
+
74
+ /**
75
+ * Schedule a write of the given Map to sessions.json. If another save is
76
+ * already scheduled within DEBOUNCE_MS, the later call wins and the
77
+ * earlier one is coalesced. Any Map mutations the caller made since the
78
+ * previous save are captured because the Map is passed by reference.
79
+ */
80
+ export function saveSessions(map) {
81
+ lastMap = map;
82
+ if (pendingTimer) return;
83
+ pendingTimer = setTimeout(() => {
84
+ pendingTimer = null;
85
+ writeNow(lastMap);
86
+ }, DEBOUNCE_MS);
87
+ }
88
+
89
+ /**
90
+ * Force an immediate synchronous flush. Intended for SIGTERM / SIGINT
91
+ * handlers so a shutdown during the debounce window doesn't lose the
92
+ * last few session updates.
93
+ */
94
+ export function flushSessionsNow() {
95
+ if (pendingTimer) {
96
+ clearTimeout(pendingTimer);
97
+ pendingTimer = null;
98
+ }
99
+ if (lastMap) writeNow(lastMap);
100
+ }
101
+
102
+ function writeNow(map) {
103
+ try {
104
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
105
+ const obj = Object.fromEntries(map);
106
+ const tmp = SESSIONS_PATH + TMP_SUFFIX;
107
+ writeFileSync(tmp, JSON.stringify(obj, null, 2));
108
+ renameSync(tmp, SESSIONS_PATH); // atomic on POSIX, best-effort on Windows
109
+ } catch (e) {
110
+ console.warn(`[session] failed to save ${SESSIONS_PATH}: ${e.message}`);
111
+ }
112
+ }