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.
- package/CHANGELOG.md +207 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/bin/mobygate.js +443 -0
- package/index.html +805 -0
- package/launchd/ai.mobygate.auth-refresh.plist +83 -0
- package/lib/ascii.js +108 -0
- package/lib/config.js +131 -0
- package/lib/dashboard-bus.js +158 -0
- package/lib/platform.js +584 -0
- package/lib/session-store.js +112 -0
- package/mcp-inspect.mjs +186 -0
- package/package.json +62 -0
- package/scripts/auth-helper.js +198 -0
- package/scripts/auth-refresh.js +41 -0
- package/scripts/auth-status.js +36 -0
- package/server.js +1076 -0
package/lib/platform.js
ADDED
|
@@ -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
|
+
}
|