mobygate 0.3.0 → 0.5.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 CHANGED
@@ -4,6 +4,96 @@ 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.0] — 2026-04-19
8
+
9
+ The "upgrade should just work" release. Closes an entire class of
10
+ user-reported friction where a fresh `npm install -g mobygate@latest`
11
+ would seemingly succeed but leave the dashboard showing the old
12
+ version, the wrong server on :3456, or the browser serving stale HTML.
13
+
14
+ ### Added
15
+
16
+ - **`Cache-Control: no-cache` headers on `/`** — browsers now always
17
+ re-fetch `index.html`, so a dashboard hard-reload is no longer
18
+ required after an upgrade. Fixes the most common "I see the old
19
+ dashboard after upgrade" report.
20
+ - **`postinstall` npm hook** — on `npm install -g mobygate[@…]`, if a
21
+ mobygate service (launchd / systemd / Task Scheduler) is already
22
+ installed on the box, it gets auto-restarted so the new code is
23
+ the one running on :3456. Users no longer need to remember
24
+ `mobygate restart` or `mobygate update` after an `npm install`.
25
+ Silent on first install and in dev-clone `npm install` (doesn't
26
+ bounce your live server while you're editing).
27
+ - **`mobygate doctor`** — diagnostic that reports:
28
+ - service-reported version vs CLI version (mismatch warning)
29
+ - port 3456 owner (flags non-mobygate processes holding the port)
30
+ - legacy launchd / systemd / Task Scheduler entries from prior
31
+ project names (claude-gate, claude-max-sdk-proxy) still present
32
+ - CLI-install path vs service-install path mismatch
33
+ (multi-node-version / fnm / nvm splits)
34
+ - auth state (logged-in, plan)
35
+ Prints a Recommended fix section with copy-pasteable commands.
36
+ - **`mobygate init --yes` / `-y`** — non-interactive mode. Accepts
37
+ all prompts using existing config values or built-in defaults.
38
+ Makes the tool scriptable: you can now write a one-liner recovery
39
+ command for anyone stuck on an old install.
40
+ - **Legacy service auto-cleanup in `mobygate init`** — before
41
+ installing new services, detects and removes launchd plists /
42
+ systemd units / Task Scheduler tasks under prior project names
43
+ (`ai.claude-gate.*`, `ai.claude-max-sdk-proxy.*`, etc.). Fixes the
44
+ "zombie old proxy still holding :3456" case that had friends
45
+ seeing the pre-v0.1.0 stub dashboard long after upgrading.
46
+ - **Port auto-kill in `mobygate init`** — if anything is bound to
47
+ the target port when init runs, it's killed (LISTEN sockets
48
+ only — won't kill active clients). Guarantees the new service
49
+ can bind :3456 cleanly.
50
+
51
+ ### Fixed
52
+
53
+ - `doctor` / `killPort` filter `lsof -sTCP:LISTEN` so a browser tab
54
+ or `curl` connection *to* the port is no longer misidentified as
55
+ the owner of the port.
56
+
57
+ ### Recovery flow
58
+
59
+ A user on any prior generation (including the pre-rename
60
+ claude-max-sdk-proxy) can now catch up with a single command:
61
+
62
+ ```
63
+ npm install -g mobygate@latest && mobygate init --yes
64
+ ```
65
+
66
+ The postinstall hook restarts any existing mobygate service, and
67
+ `init --yes` cleans up legacy services, frees the port, re-writes
68
+ the service definition for this machine's paths, and restarts.
69
+ No manual `lsof | xargs kill`, no browser hard-reload required.
70
+
71
+ ## [0.4.0] — 2026-04-19
72
+
73
+ ### Added
74
+
75
+ - **`mobygate update` command** — upgrades to the latest version and
76
+ restarts the service in one step. Detects the install mode:
77
+ - `npm install -g` install → `npm install -g mobygate@latest`
78
+ - git clone → `git pull --ff-only` + `npm install`
79
+ After the update lands, reloads the managed service (launchd /
80
+ Task Scheduler / systemd) so the new code is actually running.
81
+ Aliased as `mobygate upgrade`. Exits early with a friendly message
82
+ if you're already on the latest version.
83
+
84
+ ### Fixed
85
+
86
+ - `installed_version` in `~/.mobygate/state.json` was hardcoded to
87
+ `"0.1.0"` for every install regardless of the actual version. Now
88
+ read from `package.json` so `mobygate version` and the dashboard
89
+ don't lie about what's running. Also records `install_mode`
90
+ (`git` | `npm` | `unknown`) in the state file.
91
+ - `mobygate version` output now also shows install mode + path, so
92
+ you can tell at a glance whether a machine is running the git
93
+ clone or the npm install.
94
+ - Three duplicated `readFileSync(package.json)` call sites
95
+ collapsed into one `readPackage()` helper in bin/mobygate.js.
96
+
7
97
  ## [0.3.0] — 2026-04-19
8
98
 
9
99
  Shippable on npm. Sessions survive restarts. Logs live in a canonical
package/bin/mobygate.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
22
- import { resolve, dirname, join } from 'path';
22
+ import { resolve, dirname, join, sep } from 'path';
23
23
  import { fileURLToPath } from 'url';
24
24
  import { spawnSync } from 'child_process';
25
25
  import { createInterface } from 'readline';
@@ -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';
@@ -44,6 +45,21 @@ const REPO_ROOT = resolve(dirname(__filename), '..');
44
45
  const SERVER_LABEL = 'ai.mobygate.server';
45
46
  const AUTH_LABEL = 'ai.mobygate.auth-refresh';
46
47
 
48
+ /** Read this package's metadata once. Version is the single source of truth
49
+ * for banners, state.json, and version comparisons — no more hardcoded
50
+ * strings drifting from package.json. */
51
+ function readPackage() {
52
+ return JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
53
+ }
54
+
55
+ /** Detect how mobygate is installed. Matters for `update` routing —
56
+ * git clones pull; npm-globals `npm install -g`. */
57
+ function detectInstallMode() {
58
+ if (existsSync(join(REPO_ROOT, '.git'))) return 'git';
59
+ if (REPO_ROOT.includes(`${sep}node_modules${sep}mobygate`)) return 'npm';
60
+ return 'unknown';
61
+ }
62
+
47
63
  // ---------- tiny helpers ----------
48
64
 
49
65
  const c = {
@@ -80,9 +96,13 @@ async function confirm(q, def = true) {
80
96
  // ---------- commands ----------
81
97
 
82
98
  async function cmdInit() {
83
- const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
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');
84
103
  print(banner({ version: pkg.version }));
85
104
  section('mobygate init');
105
+ if (nonInteractive) print(c.dim('(non-interactive — using existing config and defaults)'));
86
106
  print(c.dim(`Platform: ${PLATFORM} Install path: ${REPO_ROOT}`));
87
107
  print(c.dim(`Config: ${CONFIG_PATH}`));
88
108
  print('');
@@ -98,8 +118,10 @@ async function cmdInit() {
98
118
  warn('`claude` CLI not found on PATH.');
99
119
  print(c.dim(' The proxy uses Claude Code\'s CLI to resolve OAuth credentials.'));
100
120
  print(c.dim(' Install: https://docs.claude.com/en/docs/claude-code'));
101
- const proceed = await confirm('Continue anyway? (you can set CLAUDE_BIN in config.yaml later)', false);
102
- 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
+ }
103
125
  } else {
104
126
  ok(`claude CLI at ${claudeProbe.stdout.trim().split('\n')[0]}`);
105
127
  }
@@ -112,22 +134,47 @@ async function cmdInit() {
112
134
  } else {
113
135
  warn('Not logged into Claude Max.');
114
136
  print(c.dim(' Run `claude auth login` in another terminal, then re-run `mobygate init`.'));
115
- const proceed = await confirm('Continue without login?', false);
116
- if (!proceed) die('Aborted.');
137
+ if (!nonInteractive) {
138
+ const proceed = await confirm('Continue without login?', false);
139
+ if (!proceed) die('Aborted.');
140
+ }
117
141
  }
118
142
  } catch (e) {
119
143
  warn(`Couldn't check auth status: ${e.message}`);
120
144
  }
121
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
+
122
164
  // ---- Config ----
123
165
  section('Configuration');
124
166
  const existing = loadConfig();
125
- const port = await prompt('HTTP port', String(existing.port));
126
- const defaultModel = await prompt('Default model', existing.default_model);
127
- const sessionTtl = await prompt('Session TTL (minutes)', String(existing.session_ttl_minutes));
128
- const claudeBin = claudeProbe.status === 0
129
- ? (await prompt('CLAUDE_BIN override (blank = PATH)', existing.claude_bin || ''))
130
- : (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 || '')));
131
178
 
132
179
  const configPath = writeConfig({
133
180
  port: parseInt(port, 10) || 3456,
@@ -210,7 +257,8 @@ async function cmdInit() {
210
257
 
211
258
  writeState({
212
259
  install_path: REPO_ROOT,
213
- installed_version: '0.1.0',
260
+ installed_version: pkg.version,
261
+ install_mode: detectInstallMode(),
214
262
  installed_at: new Date().toISOString(),
215
263
  platform: PLATFORM,
216
264
  node_bin: nodeBin,
@@ -284,7 +332,7 @@ function cmdRestart() {
284
332
  }
285
333
 
286
334
  async function cmdStatus() {
287
- const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
335
+ const pkg = readPackage();
288
336
  print(compactBanner({ version: pkg.version }));
289
337
  section('mobygate status');
290
338
  const config = loadConfig();
@@ -388,15 +436,178 @@ async function cmdUninstall() {
388
436
  }
389
437
 
390
438
  function cmdVersion() {
391
- const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
392
- print(`mobygate v${pkg.version}`);
439
+ const pkg = readPackage();
440
+ print(`mobygate v${pkg.version} · ${detectInstallMode()} install at ${REPO_ROOT}`);
441
+ }
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
+
542
+ /**
543
+ * Upgrade mobygate to the latest version from its source of truth:
544
+ * - `npm` mode → `npm install -g mobygate@latest`
545
+ * - `git` mode → `git pull` + `npm install`
546
+ * After the update lands, restart the service so the running process
547
+ * picks up the new code.
548
+ */
549
+ async function cmdUpdate() {
550
+ const pkg = readPackage();
551
+ const mode = detectInstallMode();
552
+ section('mobygate update');
553
+ print(c.dim(`Current: v${pkg.version} · ${mode} install at ${REPO_ROOT}`));
554
+
555
+ // ---- Look up latest published version on npm
556
+ info('Checking npm for the latest release...');
557
+ const view = spawnSync('npm', ['view', 'mobygate', 'version'], { encoding: 'utf8', timeout: 10_000 });
558
+ if (view.status !== 0) {
559
+ return die(`Couldn't reach npm registry: ${view.stderr?.trim() || view.error?.message || 'unknown'}`);
560
+ }
561
+ const latest = view.stdout.trim();
562
+ print(c.dim(`Latest: v${latest}`));
563
+ if (latest === pkg.version) {
564
+ return ok(`Already on v${latest}. Nothing to do.`);
565
+ }
566
+ print('');
567
+
568
+ // ---- Perform the upgrade
569
+ if (mode === 'npm') {
570
+ info(`Running \`npm install -g mobygate@latest\`...`);
571
+ const r = spawnSync('npm', ['install', '-g', 'mobygate@latest'], { stdio: 'inherit' });
572
+ if (r.status !== 0) return die('npm install failed. See output above.');
573
+ ok(`Installed mobygate@${latest}`);
574
+ } else if (mode === 'git') {
575
+ info(`Running \`git pull\` in ${REPO_ROOT}...`);
576
+ const pull = spawnSync('git', ['-C', REPO_ROOT, 'pull', '--ff-only'], { stdio: 'inherit' });
577
+ if (pull.status !== 0) return die('git pull failed. Resolve conflicts and retry.');
578
+ info(`Running \`npm install\`...`);
579
+ const install = spawnSync('npm', ['install'], { cwd: REPO_ROOT, stdio: 'inherit' });
580
+ if (install.status !== 0) return die('npm install failed. See output above.');
581
+ ok(`Pulled and installed. See git log for what changed.`);
582
+ } else {
583
+ return die(`Install mode is "${mode}" — can't auto-update. Reinstall via npm or git.`);
584
+ }
585
+
586
+ // ---- Restart the managed service so the new code is running
587
+ section('Restart');
588
+ info('Restarting service so the new build is running...');
589
+ if (IS_MAC) {
590
+ const p = plistPathForLabel(SERVER_LABEL);
591
+ launchctlUnload(p); launchctlLoad(p);
592
+ ok(`Reloaded ${SERVER_LABEL}`);
593
+ } else if (IS_WIN) {
594
+ stopWindowsTask(WIN_LABELS.server); startWindowsTask(WIN_LABELS.server);
595
+ ok(`Restarted ${WIN_LABELS.server}`);
596
+ } else if (IS_LINUX) {
597
+ stopLinuxUnit(LINUX_UNITS.server); startLinuxUnit(LINUX_UNITS.server);
598
+ ok(`Restarted ${LINUX_UNITS.server}`);
599
+ }
600
+ print('');
601
+ info(`Tip: if the install-layout changed (new service file, new paths), run \`mobygate init\` to re-install the service definitions.`);
393
602
  }
394
603
 
395
604
  function usage() {
396
605
  print(`mobygate — OpenAI → Claude Max local gateway
397
606
 
398
607
  Usage:
399
- mobygate init Interactive setup: config, services, smoke test
608
+ mobygate init Interactive setup (add --yes to skip prompts)
609
+ mobygate update Upgrade to the latest version + restart service
610
+ mobygate doctor Diagnose version mismatches, zombie services, port conflicts
400
611
  mobygate start Start the proxy service
401
612
  mobygate stop Stop the proxy service
402
613
  mobygate restart Stop + start
@@ -404,7 +615,7 @@ Usage:
404
615
  mobygate logs Tail the server log
405
616
  mobygate auth Show auth status + run a refresh probe
406
617
  mobygate uninstall Remove installed services
407
- mobygate version Print version
618
+ mobygate version Print version + install mode + path
408
619
 
409
620
  Config: ~/.mobygate/config.yaml (env vars override)
410
621
  Repo: ${REPO_ROOT}
@@ -416,6 +627,9 @@ Repo: ${REPO_ROOT}
416
627
  const cmd = process.argv[2];
417
628
  const COMMANDS = {
418
629
  init: cmdInit,
630
+ update: cmdUpdate,
631
+ upgrade: cmdUpdate,
632
+ doctor: cmdDoctor,
419
633
  start: cmdStart,
420
634
  stop: cmdStop,
421
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.3.0",
3
+ "version": "0.5.0",
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,8 +785,13 @@ 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
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.
789
791
  app.get('/', (_req, res) => {
792
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
793
+ res.setHeader('Pragma', 'no-cache');
794
+ res.setHeader('Expires', '0');
790
795
  res.sendFile(join(__dirname, 'index.html'));
791
796
  });
792
797