mobygate 0.4.0 → 0.5.1

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 CHANGED
@@ -4,6 +4,82 @@ All notable changes to mobygate are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers are
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.5.1] — 2026-04-19
8
+
9
+ ### Fixed
10
+
11
+ - **Dashboard 404** on `/` after `npm install -g mobygate@latest` on
12
+ some fnm / npm-global setups. Root cause: Express 5's
13
+ `res.sendFile(absolutePath)` + `send` middleware was throwing a
14
+ spurious `NotFoundError` even when `index.html` was present on
15
+ disk. Replaced with `fs.readFile` + `res.send(html)` — more direct,
16
+ no middleware in between, and surfaces any real file-missing
17
+ problem as a readable HTML error page pointing at the fix.
18
+
19
+ ## [0.5.0] — 2026-04-19
20
+
21
+ The "upgrade should just work" release. Closes an entire class of
22
+ user-reported friction where a fresh `npm install -g mobygate@latest`
23
+ would seemingly succeed but leave the dashboard showing the old
24
+ version, the wrong server on :3456, or the browser serving stale HTML.
25
+
26
+ ### Added
27
+
28
+ - **`Cache-Control: no-cache` headers on `/`** — browsers now always
29
+ re-fetch `index.html`, so a dashboard hard-reload is no longer
30
+ required after an upgrade. Fixes the most common "I see the old
31
+ dashboard after upgrade" report.
32
+ - **`postinstall` npm hook** — on `npm install -g mobygate[@…]`, if a
33
+ mobygate service (launchd / systemd / Task Scheduler) is already
34
+ installed on the box, it gets auto-restarted so the new code is
35
+ the one running on :3456. Users no longer need to remember
36
+ `mobygate restart` or `mobygate update` after an `npm install`.
37
+ Silent on first install and in dev-clone `npm install` (doesn't
38
+ bounce your live server while you're editing).
39
+ - **`mobygate doctor`** — diagnostic that reports:
40
+ - service-reported version vs CLI version (mismatch warning)
41
+ - port 3456 owner (flags non-mobygate processes holding the port)
42
+ - legacy launchd / systemd / Task Scheduler entries from prior
43
+ project names (claude-gate, claude-max-sdk-proxy) still present
44
+ - CLI-install path vs service-install path mismatch
45
+ (multi-node-version / fnm / nvm splits)
46
+ - auth state (logged-in, plan)
47
+ Prints a Recommended fix section with copy-pasteable commands.
48
+ - **`mobygate init --yes` / `-y`** — non-interactive mode. Accepts
49
+ all prompts using existing config values or built-in defaults.
50
+ Makes the tool scriptable: you can now write a one-liner recovery
51
+ command for anyone stuck on an old install.
52
+ - **Legacy service auto-cleanup in `mobygate init`** — before
53
+ installing new services, detects and removes launchd plists /
54
+ systemd units / Task Scheduler tasks under prior project names
55
+ (`ai.claude-gate.*`, `ai.claude-max-sdk-proxy.*`, etc.). Fixes the
56
+ "zombie old proxy still holding :3456" case that had friends
57
+ seeing the pre-v0.1.0 stub dashboard long after upgrading.
58
+ - **Port auto-kill in `mobygate init`** — if anything is bound to
59
+ the target port when init runs, it's killed (LISTEN sockets
60
+ only — won't kill active clients). Guarantees the new service
61
+ can bind :3456 cleanly.
62
+
63
+ ### Fixed
64
+
65
+ - `doctor` / `killPort` filter `lsof -sTCP:LISTEN` so a browser tab
66
+ or `curl` connection *to* the port is no longer misidentified as
67
+ the owner of the port.
68
+
69
+ ### Recovery flow
70
+
71
+ A user on any prior generation (including the pre-rename
72
+ claude-max-sdk-proxy) can now catch up with a single command:
73
+
74
+ ```
75
+ npm install -g mobygate@latest && mobygate init --yes
76
+ ```
77
+
78
+ The postinstall hook restarts any existing mobygate service, and
79
+ `init --yes` cleans up legacy services, frees the port, re-writes
80
+ the service definition for this machine's paths, and restarts.
81
+ No manual `lsof | xargs kill`, no browser hard-reload required.
82
+
7
83
  ## [0.4.0] — 2026-04-19
8
84
 
9
85
  ### Added
package/bin/mobygate.js CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  installLinuxServices, uninstallLinuxServices,
36
36
  queryLinuxUnit, startLinuxUnit, stopLinuxUnit, LINUX_UNITS,
37
37
  nonMacInstallInstructions,
38
+ cleanupLegacyServices, killPort,
38
39
  } from '../lib/platform.js';
39
40
  import { getAuthStatus, forceRefresh } from '../scripts/auth-helper.js';
40
41
  import { banner, compactBanner } from '../lib/ascii.js';
@@ -96,8 +97,12 @@ async function confirm(q, def = true) {
96
97
 
97
98
  async function cmdInit() {
98
99
  const pkg = readPackage();
100
+ // --yes / -y skips prompts (uses defaults or existing config values),
101
+ // makes `mobygate init` scriptable for unattended upgrades.
102
+ const nonInteractive = process.argv.includes('--yes') || process.argv.includes('-y');
99
103
  print(banner({ version: pkg.version }));
100
104
  section('mobygate init');
105
+ if (nonInteractive) print(c.dim('(non-interactive — using existing config and defaults)'));
101
106
  print(c.dim(`Platform: ${PLATFORM} Install path: ${REPO_ROOT}`));
102
107
  print(c.dim(`Config: ${CONFIG_PATH}`));
103
108
  print('');
@@ -113,8 +118,10 @@ async function cmdInit() {
113
118
  warn('`claude` CLI not found on PATH.');
114
119
  print(c.dim(' The proxy uses Claude Code\'s CLI to resolve OAuth credentials.'));
115
120
  print(c.dim(' Install: https://docs.claude.com/en/docs/claude-code'));
116
- const proceed = await confirm('Continue anyway? (you can set CLAUDE_BIN in config.yaml later)', false);
117
- if (!proceed) die('Aborted.');
121
+ if (!nonInteractive) {
122
+ const proceed = await confirm('Continue anyway? (you can set CLAUDE_BIN in config.yaml later)', false);
123
+ if (!proceed) die('Aborted.');
124
+ }
118
125
  } else {
119
126
  ok(`claude CLI at ${claudeProbe.stdout.trim().split('\n')[0]}`);
120
127
  }
@@ -127,22 +134,47 @@ async function cmdInit() {
127
134
  } else {
128
135
  warn('Not logged into Claude Max.');
129
136
  print(c.dim(' Run `claude auth login` in another terminal, then re-run `mobygate init`.'));
130
- const proceed = await confirm('Continue without login?', false);
131
- if (!proceed) die('Aborted.');
137
+ if (!nonInteractive) {
138
+ const proceed = await confirm('Continue without login?', false);
139
+ if (!proceed) die('Aborted.');
140
+ }
132
141
  }
133
142
  } catch (e) {
134
143
  warn(`Couldn't check auth status: ${e.message}`);
135
144
  }
136
145
 
146
+ // ---- Clean up any legacy service / zombie process on our port ----
147
+ // Idempotent — runs every init. Removes launchd/systemd/Task Scheduler
148
+ // entries left behind by claude-max-sdk-proxy / claude-gate / any prior
149
+ // generation, and kills anything currently bound to :3456 so the new
150
+ // service can claim it without collision.
151
+ section('Cleanup');
152
+ const cleaned = cleanupLegacyServices();
153
+ const removed = cleaned.filter((r) => r.removed);
154
+ if (removed.length) {
155
+ for (const r of removed) ok(`Removed legacy ${r.label}`);
156
+ } else {
157
+ print(c.dim(' No legacy services found.'));
158
+ }
159
+ const targetPort = parseInt(process.env.PORT || String(loadConfig().port), 10) || 3456;
160
+ const killed = killPort(targetPort);
161
+ if (killed.length) ok(`Freed port ${targetPort} (killed pid${killed.length > 1 ? 's' : ''} ${killed.join(', ')})`);
162
+ else print(c.dim(` Port ${targetPort} is free.`));
163
+
137
164
  // ---- Config ----
138
165
  section('Configuration');
139
166
  const existing = loadConfig();
140
- const port = await prompt('HTTP port', String(existing.port));
141
- const defaultModel = await prompt('Default model', existing.default_model);
142
- const sessionTtl = await prompt('Session TTL (minutes)', String(existing.session_ttl_minutes));
143
- const claudeBin = claudeProbe.status === 0
144
- ? (await prompt('CLAUDE_BIN override (blank = PATH)', existing.claude_bin || ''))
145
- : (await prompt('CLAUDE_BIN (absolute path to claude binary)', existing.claude_bin || ''));
167
+ // With --yes, inherit existing values / defaults without prompting
168
+ const port = nonInteractive ? String(existing.port)
169
+ : await prompt('HTTP port', String(existing.port));
170
+ const defaultModel = nonInteractive ? existing.default_model
171
+ : await prompt('Default model', existing.default_model);
172
+ const sessionTtl = nonInteractive ? String(existing.session_ttl_minutes)
173
+ : await prompt('Session TTL (minutes)', String(existing.session_ttl_minutes));
174
+ const claudeBin = nonInteractive ? (existing.claude_bin || '')
175
+ : (claudeProbe.status === 0
176
+ ? (await prompt('CLAUDE_BIN override (blank = PATH)', existing.claude_bin || ''))
177
+ : (await prompt('CLAUDE_BIN (absolute path to claude binary)', existing.claude_bin || '')));
146
178
 
147
179
  const configPath = writeConfig({
148
180
  port: parseInt(port, 10) || 3456,
@@ -408,6 +440,105 @@ function cmdVersion() {
408
440
  print(`mobygate v${pkg.version} · ${detectInstallMode()} install at ${REPO_ROOT}`);
409
441
  }
410
442
 
443
+ /**
444
+ * Diagnostic command — identifies the classes of problem users have hit
445
+ * during upgrades (CLI vs service version mismatch, zombie old services,
446
+ * port-3456 owned by something else, node-version split). Prints one
447
+ * line per finding + a recommended fix at the end.
448
+ */
449
+ async function cmdDoctor() {
450
+ const pkg = readPackage();
451
+ const mode = detectInstallMode();
452
+ section('mobygate doctor');
453
+ print(c.dim(`CLI: v${pkg.version} · ${mode} install at ${REPO_ROOT}`));
454
+ const issues = [];
455
+ const fixes = [];
456
+
457
+ // 1. Version reported by the running service (via /dashboard/recent)
458
+ let serviceVersion = null;
459
+ try {
460
+ const port = loadConfig().port;
461
+ const r = await fetch(`http://localhost:${port}/dashboard/recent`, { signal: AbortSignal.timeout(2000) });
462
+ const j = await r.json();
463
+ serviceVersion = j.build?.version || null;
464
+ } catch (e) { /* server down or pre-v0.2.0 */ }
465
+ if (!serviceVersion) {
466
+ warn('Service on :3456 is not responding / older than v0.2.0');
467
+ issues.push('service-down-or-ancient');
468
+ } else {
469
+ const match = serviceVersion === pkg.version;
470
+ (match ? ok : warn)(`Service on :3456 reports v${serviceVersion}${match ? '' : ` (CLI is v${pkg.version} — mismatch)`}`);
471
+ if (!match) {
472
+ issues.push('version-mismatch');
473
+ fixes.push('mobygate restart');
474
+ }
475
+ }
476
+
477
+ // 2. Port owner — filter to LISTEN state so we don't flag clients that
478
+ // happen to be *connected to* :3456 (browser tabs, curl) as owners.
479
+ if (IS_MAC || IS_LINUX) {
480
+ const r = spawnSync('sh', ['-c', `lsof -ti :${loadConfig().port} -sTCP:LISTEN 2>/dev/null`], { encoding: 'utf8' });
481
+ const pid = (r.stdout || '').trim().split('\n').filter(Boolean)[0];
482
+ if (pid) {
483
+ const ps = spawnSync('ps', ['-p', pid, '-o', 'command='], { encoding: 'utf8' });
484
+ const cmdline = (ps.stdout || '').trim();
485
+ if (cmdline && !cmdline.includes('mobygate')) {
486
+ warn(`:${loadConfig().port} is owned by NON-mobygate pid ${pid}: ${cmdline.slice(0, 80)}`);
487
+ issues.push('port-owned-by-foreign-process');
488
+ fixes.push(`lsof -ti :${loadConfig().port} -sTCP:LISTEN | xargs kill && mobygate init --yes`);
489
+ } else {
490
+ print(c.dim(` port ${loadConfig().port} held by mobygate pid ${pid}.`));
491
+ }
492
+ }
493
+ }
494
+
495
+ // 3. Legacy service entries we haven't cleaned yet
496
+ if (IS_MAC) {
497
+ const legacyFound = [];
498
+ for (const lbl of ['ai.claude-gate.server','ai.claude-gate.auth-refresh','ai.claude-max-sdk-proxy.server','ai.claude-max-sdk-proxy.auth-refresh']) {
499
+ const p = join(process.env.HOME, 'Library', 'LaunchAgents', `${lbl}.plist`);
500
+ if (existsSync(p)) legacyFound.push(lbl);
501
+ }
502
+ if (legacyFound.length) {
503
+ warn(`Legacy launchd plists found: ${legacyFound.join(', ')}`);
504
+ issues.push('legacy-services');
505
+ fixes.push('mobygate init --yes # cleans up legacy entries');
506
+ }
507
+ }
508
+
509
+ // 4. CLI mobygate vs service install path (multi-node-version detection)
510
+ const state = readState();
511
+ if (state && state.install_path && state.install_path !== REPO_ROOT) {
512
+ warn(`CLI install (${REPO_ROOT}) differs from service install (${state.install_path})`);
513
+ issues.push('install-path-split');
514
+ fixes.push('mobygate init --yes # rebinds service to the current CLI location');
515
+ }
516
+
517
+ // 5. /auth/status
518
+ try {
519
+ const r = await fetch(`http://localhost:${loadConfig().port}/auth/status?quick=1`, { signal: AbortSignal.timeout(2000) });
520
+ const j = await r.json();
521
+ if (!j.loggedIn) {
522
+ warn('Not logged in to Claude — run `claude auth login`');
523
+ issues.push('not-logged-in');
524
+ fixes.push('claude auth login');
525
+ } else {
526
+ ok(`Auth: ${j.email || 'logged in'} (${j.subscriptionType || j.authMethod})`);
527
+ }
528
+ } catch { /* already reported via service-down */ }
529
+
530
+ // Summary
531
+ print('');
532
+ if (!issues.length) {
533
+ ok('No problems detected. Everything looks healthy.');
534
+ } else {
535
+ section('Recommended fix');
536
+ for (const f of fixes) print(` ${c.cyan(f)}`);
537
+ print('');
538
+ print(c.dim(`${issues.length} issue${issues.length > 1 ? 's' : ''} found: ${issues.join(', ')}`));
539
+ }
540
+ }
541
+
411
542
  /**
412
543
  * Upgrade mobygate to the latest version from its source of truth:
413
544
  * - `npm` mode → `npm install -g mobygate@latest`
@@ -474,8 +605,9 @@ function usage() {
474
605
  print(`mobygate — OpenAI → Claude Max local gateway
475
606
 
476
607
  Usage:
477
- mobygate init Interactive setup: config, services, smoke test
608
+ mobygate init Interactive setup (add --yes to skip prompts)
478
609
  mobygate update Upgrade to the latest version + restart service
610
+ mobygate doctor Diagnose version mismatches, zombie services, port conflicts
479
611
  mobygate start Start the proxy service
480
612
  mobygate stop Stop the proxy service
481
613
  mobygate restart Stop + start
@@ -497,6 +629,7 @@ const COMMANDS = {
497
629
  init: cmdInit,
498
630
  update: cmdUpdate,
499
631
  upgrade: cmdUpdate,
632
+ doctor: cmdDoctor,
500
633
  start: cmdStart,
501
634
  stop: cmdStop,
502
635
  restart: cmdRestart,
package/lib/platform.js CHANGED
@@ -328,6 +328,110 @@ export const WIN_LABELS = {
328
328
  auth: WIN_AUTH_TASK,
329
329
  };
330
330
 
331
+ // ---------------------------------------------------------------------------
332
+ // Legacy / zombie service cleanup — invoked by `mobygate init` so upgrades
333
+ // from any prior generation (claude-max-sdk-proxy / claude-gate / brother's
334
+ // forks) never leave an old process competing with the new one on :3456.
335
+ // ---------------------------------------------------------------------------
336
+
337
+ const LEGACY_MAC_LABELS = [
338
+ 'ai.claude-max-sdk-proxy.server',
339
+ 'ai.claude-max-sdk-proxy.auth-refresh',
340
+ 'ai.claude-gate.server',
341
+ 'ai.claude-gate.auth-refresh',
342
+ ];
343
+ const LEGACY_LINUX_UNITS = [
344
+ 'claude-max-sdk-proxy.service',
345
+ 'claude-gate-server.service',
346
+ 'claude-gate-auth.service',
347
+ 'claude-gate-auth.timer',
348
+ ];
349
+ const LEGACY_WIN_TASKS = [
350
+ 'claude-max-sdk-proxy-server',
351
+ 'claude-max-sdk-proxy-auth-refresh',
352
+ 'claude-gate-server',
353
+ 'claude-gate-auth-refresh',
354
+ ];
355
+
356
+ /**
357
+ * Remove all known legacy service entries. Returns a list of
358
+ * { platform, label, removed } for each attempt so callers can log.
359
+ * Safe to run every `mobygate init` — no-op if nothing matches.
360
+ */
361
+ export function cleanupLegacyServices() {
362
+ const results = [];
363
+ if (IS_MAC) {
364
+ for (const label of LEGACY_MAC_LABELS) {
365
+ const p = join(LAUNCH_AGENTS_DIR, `${label}.plist`);
366
+ if (!existsSync(p)) { results.push({ label, removed: false, reason: 'absent' }); continue; }
367
+ try { execSync(`launchctl unload "${p}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
368
+ try { unlinkSync(p); results.push({ label, removed: true }); }
369
+ catch (e) { results.push({ label, removed: false, error: e.message }); }
370
+ }
371
+ } else if (IS_LINUX) {
372
+ for (const unit of LEGACY_LINUX_UNITS) {
373
+ const r = systemctlUser(['disable', '--now', unit]);
374
+ const p = join(SYSTEMD_USER_DIR, unit);
375
+ if (existsSync(p)) {
376
+ try { unlinkSync(p); results.push({ label: unit, removed: true }); }
377
+ catch (e) { results.push({ label: unit, removed: false, error: e.message }); }
378
+ } else {
379
+ results.push({ label: unit, removed: false, reason: 'absent' });
380
+ }
381
+ }
382
+ systemctlUser(['daemon-reload']);
383
+ } else if (IS_WIN) {
384
+ for (const task of LEGACY_WIN_TASKS) {
385
+ const r = runPowershell(`
386
+ $t = Get-ScheduledTask -TaskName '${task}' -ErrorAction SilentlyContinue
387
+ if ($t) {
388
+ Stop-ScheduledTask -TaskName '${task}' -ErrorAction SilentlyContinue
389
+ Unregister-ScheduledTask -TaskName '${task}' -Confirm:$false
390
+ Write-Output 'removed'
391
+ } else { Write-Output 'absent' }
392
+ `);
393
+ if (r.ok) results.push({ label: task, removed: r.stdout === 'removed', reason: r.stdout });
394
+ else results.push({ label: task, removed: false, error: r.stderr });
395
+ }
396
+ }
397
+ return results;
398
+ }
399
+
400
+ /**
401
+ * Kill whatever currently owns the given port. Best-effort across platforms.
402
+ * Returns the list of PIDs killed (empty if nothing was bound).
403
+ */
404
+ export function killPort(port) {
405
+ const killed = [];
406
+ if (IS_MAC || IS_LINUX) {
407
+ try {
408
+ // Filter to LISTEN state so we don't kill clients connected to :port
409
+ const r = spawnSync('sh', ['-c', `lsof -ti :${port} -sTCP:LISTEN 2>/dev/null || true`], { encoding: 'utf8' });
410
+ const pids = (r.stdout || '').trim().split('\n').filter(Boolean);
411
+ for (const pid of pids) {
412
+ try { process.kill(parseInt(pid, 10), 'SIGTERM'); killed.push(pid); } catch {}
413
+ }
414
+ if (pids.length) {
415
+ // Give graceful shutdown a moment, then force
416
+ setTimeout(() => {
417
+ for (const pid of pids) { try { process.kill(parseInt(pid, 10), 'SIGKILL'); } catch {} }
418
+ }, 500);
419
+ }
420
+ } catch {}
421
+ } else if (IS_WIN) {
422
+ const r = runPowershell(`
423
+ $c = Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue
424
+ if ($c) {
425
+ $c | ForEach-Object {
426
+ try { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Output $_.OwningProcess } catch {}
427
+ }
428
+ }
429
+ `);
430
+ if (r.ok && r.stdout) killed.push(...r.stdout.split(/\r?\n/).filter(Boolean));
431
+ }
432
+ return killed;
433
+ }
434
+
331
435
  // ---------------------------------------------------------------------------
332
436
  // Linux — systemd user units
333
437
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
5
  "type": "module",
6
6
  "main": "server.js",
7
7
  "bin": {
8
- "mobygate": "./bin/mobygate.js"
8
+ "mobygate": "bin/mobygate.js"
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
@@ -13,7 +13,8 @@
13
13
  "up": "npm install && node server.js",
14
14
  "auth:status": "node scripts/auth-status.js",
15
15
  "auth:status:quick": "node scripts/auth-status.js --quick",
16
- "auth:refresh": "node scripts/auth-refresh.js"
16
+ "auth:refresh": "node scripts/auth-refresh.js",
17
+ "postinstall": "node scripts/postinstall.js || true"
17
18
  },
18
19
  "dependencies": {
19
20
  "@anthropic-ai/claude-agent-sdk": "^0.2.112",
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mobygate postinstall hook.
4
+ *
5
+ * Runs automatically after `npm install -g mobygate` (and any fresh
6
+ * `npm install` in a dev clone). Its job: if a previous mobygate
7
+ * service is already registered on this box (launchd / systemd /
8
+ * Task Scheduler), restart it so the newly-updated code is what's
9
+ * actually running on :3456.
10
+ *
11
+ * Without this, `npm install -g mobygate@latest` updates files on
12
+ * disk but leaves the old process running — users have to remember
13
+ * to also run `mobygate restart` or `mobygate update`. This hook
14
+ * makes upgrades one-command.
15
+ *
16
+ * Silent on first install (no service exists yet → nothing to
17
+ * restart). Silent on CI / npm install in odd contexts (no
18
+ * permission, no launchctl available, etc.) — never fails the
19
+ * install. Worst case: prints a line saying it couldn't auto-
20
+ * restart, user runs `mobygate restart` themselves.
21
+ */
22
+
23
+ import { existsSync } from 'fs';
24
+ import { homedir, platform } from 'os';
25
+ import { join } from 'path';
26
+ import { execSync, spawnSync } from 'child_process';
27
+
28
+ // Skip during `npm install` inside this package's own dev clone —
29
+ // you don't want `npm install` as a developer to restart your live
30
+ // service every save. Skip when npm's lifecycle event is "install"
31
+ // and the cwd isn't a global install.
32
+ const isGlobalInstall = (process.env.npm_config_global === 'true')
33
+ || !!process.env.npm_lifecycle_event && process.env.npm_config_prefix;
34
+ if (!isGlobalInstall && process.env.npm_package_name === 'mobygate') {
35
+ // Developer install — skip, don't surprise them by bouncing their server
36
+ process.exit(0);
37
+ }
38
+
39
+ const plat = platform();
40
+ const log = (...args) => process.stderr.write('[mobygate postinstall] ' + args.join(' ') + '\n');
41
+
42
+ try {
43
+ if (plat === 'darwin') {
44
+ const plist = join(homedir(), 'Library', 'LaunchAgents', 'ai.mobygate.server.plist');
45
+ if (!existsSync(plist)) process.exit(0); // first install, no service yet
46
+ try { execSync(`launchctl unload "${plist}"`, { stdio: 'ignore' }); } catch {}
47
+ try { execSync(`launchctl load "${plist}"`, { stdio: 'ignore' }); } catch (e) {
48
+ log(`couldn't reload ai.mobygate.server — run \`mobygate restart\` manually`);
49
+ process.exit(0);
50
+ }
51
+ log('auto-restarted ai.mobygate.server (launchd)');
52
+ } else if (plat === 'linux') {
53
+ const r = spawnSync('systemctl', ['--user', 'is-active', 'mobygate-server.service'], { encoding: 'utf8' });
54
+ if (r.stdout?.trim() !== 'active') process.exit(0); // not running → first install or disabled
55
+ const restart = spawnSync('systemctl', ['--user', 'restart', 'mobygate-server.service'], { encoding: 'utf8' });
56
+ if (restart.status !== 0) {
57
+ log('couldn\'t restart mobygate-server.service — run `mobygate restart` manually');
58
+ process.exit(0);
59
+ }
60
+ log('auto-restarted mobygate-server (systemd user)');
61
+ } else if (plat === 'win32') {
62
+ // Detect task presence, then restart via PowerShell
63
+ const ps = spawnSync('powershell', [
64
+ '-NoProfile', '-Command',
65
+ `$t = Get-ScheduledTask -TaskName 'mobygate-server' -ErrorAction SilentlyContinue
66
+ if (-not $t) { exit 0 }
67
+ Stop-ScheduledTask -TaskName 'mobygate-server' -ErrorAction SilentlyContinue
68
+ Start-Sleep -Milliseconds 500
69
+ Start-ScheduledTask -TaskName 'mobygate-server'
70
+ Write-Output 'ok'`,
71
+ ], { encoding: 'utf8', windowsHide: true });
72
+ if (ps.status === 0 && ps.stdout?.includes('ok')) {
73
+ log('auto-restarted mobygate-server (Task Scheduler)');
74
+ } else if (ps.status === 0) {
75
+ // Task didn't exist — first install, silent
76
+ } else {
77
+ log("couldn't restart mobygate-server — run `mobygate restart` manually");
78
+ }
79
+ }
80
+ } catch (e) {
81
+ // Never fail the npm install for any reason
82
+ log(`postinstall skipped: ${e.message}`);
83
+ process.exit(0);
84
+ }
package/server.js CHANGED
@@ -785,9 +785,36 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
785
785
  const app = express();
786
786
  app.use(express.json({ limit: '10mb' }));
787
787
 
788
- // GET / — serve dashboard
789
- app.get('/', (_req, res) => {
790
- res.sendFile(join(__dirname, 'index.html'));
788
+ // GET / — serve dashboard. No-cache headers so browsers always re-fetch
789
+ // after a mobygate upgrade; otherwise they keep serving the old index.html
790
+ // from cache and users see a stale dashboard long after the service updated.
791
+ //
792
+ // We use fs.readFile + res.send instead of res.sendFile because Express 5's
793
+ // sendFile + send middleware has produced spurious 404s in npm-global fnm
794
+ // installs even when the file definitely exists — likely an interaction
795
+ // between ESM __dirname resolution and send's internal path checks.
796
+ // readFile is straightforward and gives us a real error to log if the
797
+ // file genuinely is missing.
798
+ app.get('/', async (_req, res) => {
799
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
800
+ res.setHeader('Pragma', 'no-cache');
801
+ res.setHeader('Expires', '0');
802
+ try {
803
+ const { readFile } = await import('fs/promises');
804
+ const html = await readFile(join(__dirname, 'index.html'), 'utf8');
805
+ res.type('html').send(html);
806
+ } catch (e) {
807
+ res.status(500).type('html').send(
808
+ `<!doctype html><meta charset=utf-8><title>mobygate — dashboard unavailable</title>
809
+ <body style="background:#0B0B09;color:#F3EFE4;font-family:ui-monospace,monospace;padding:2rem">
810
+ <h1>Dashboard failed to load</h1>
811
+ <p>Server is running fine — the dashboard HTML is just missing from the install.</p>
812
+ <pre>Path tried: ${join(__dirname, 'index.html')}</pre>
813
+ <pre>Error: ${e.code || ''} ${e.message}</pre>
814
+ <p>Fix: <code>npm install -g mobygate@latest --force</code></p>
815
+ </body>`
816
+ );
817
+ }
791
818
  });
792
819
 
793
820
  // POST /v1/chat/completions