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 +90 -0
- package/bin/mobygate.js +232 -18
- package/lib/platform.js +104 -0
- package/package.json +4 -3
- package/scripts/postinstall.js +84 -0
- package/server.js +6 -1
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 =
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
+
"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": "
|
|
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
|
|