mobygate 0.5.3 → 0.6.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/lib/updater.js ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * mobygate updater
3
+ *
4
+ * Shared helpers powering the dashboard's "update available → update now"
5
+ * flow (and re-usable from the CLI later). Two concerns:
6
+ *
7
+ * 1. Version lookup — read local package.json + query npm registry for
8
+ * the latest published version. Cached for 15 min so the dashboard
9
+ * can poll `/update/check` on load + every 30 min without hammering
10
+ * the registry (or getting rate-limited).
11
+ *
12
+ * 2. Apply — spawn the upgrade as a **detached** child process so the
13
+ * restart-the-service step can kill the running mobygate server
14
+ * without orphaning the update or losing log lines. Progress is
15
+ * streamed to `~/.mobygate/logs/update.log`, which the dashboard
16
+ * polls via `/update/status`.
17
+ *
18
+ * Install-mode routing matches `bin/mobygate.js cmdUpdate`:
19
+ * - `npm` → `npm install -g mobygate@latest` → restart service
20
+ * - `git` → `git pull && npm install` → restart service
21
+ * - `unknown` → refuse, surface a readable message
22
+ */
23
+
24
+ import { spawn, spawnSync } from 'child_process';
25
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync } from 'fs';
26
+ import { join, sep, dirname } from 'path';
27
+ import { fileURLToPath } from 'url';
28
+ import { LOGS_DIR } from './config.js';
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const REPO_ROOT = dirname(dirname(__filename)); // lib/updater.js → repo root
32
+
33
+ const IS_WIN = process.platform === 'win32';
34
+ const IS_MAC = process.platform === 'darwin';
35
+ const IS_LINUX = process.platform === 'linux';
36
+
37
+ const SERVER_LABEL = 'ai.mobygate.server';
38
+ const WIN_SERVER_TASK = 'ai.mobygate.server';
39
+ const LINUX_SERVER_UNIT = 'mobygate-server.service';
40
+
41
+ const UPDATE_LOG = join(LOGS_DIR, 'update.log');
42
+ const UPDATE_MARKER = join(LOGS_DIR, 'update.state.json');
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Version lookup
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export function getCurrentVersion() {
49
+ try {
50
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
51
+ return pkg.version;
52
+ } catch {
53
+ return 'unknown';
54
+ }
55
+ }
56
+
57
+ export 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
+
63
+ // 15-min in-memory cache so `/update/check` on dashboard load + 30-min
64
+ // repolls don't hit the npm registry every time. Bust with { force: true }.
65
+ const NPM_CACHE_MS = 15 * 60 * 1000;
66
+ let _npmCache = { version: null, fetchedAt: 0, error: null };
67
+
68
+ /**
69
+ * Resolve the latest published version on npm. Returns `{ version, cached, error }`.
70
+ * Never throws — on failure returns an error string so the endpoint can
71
+ * report "check failed" without 500'ing the dashboard.
72
+ */
73
+ export async function getLatestVersion({ force = false } = {}) {
74
+ const now = Date.now();
75
+ if (!force && _npmCache.version && (now - _npmCache.fetchedAt) < NPM_CACHE_MS) {
76
+ return { version: _npmCache.version, cached: true, error: null };
77
+ }
78
+ // `npm view` is a network call; cap at 10s so a bad connection doesn't
79
+ // wedge the dashboard.
80
+ const r = spawnSync('npm', ['view', 'mobygate', 'version'], {
81
+ encoding: 'utf8',
82
+ timeout: 10_000,
83
+ shell: IS_WIN, // on Windows, npm is a .cmd — needs shell resolution
84
+ });
85
+ if (r.status !== 0) {
86
+ const err = r.stderr?.trim() || r.error?.message || `npm exited ${r.status}`;
87
+ _npmCache = { version: null, fetchedAt: now, error: err };
88
+ return { version: null, cached: false, error: err };
89
+ }
90
+ const version = r.stdout.trim();
91
+ _npmCache = { version, fetchedAt: now, error: null };
92
+ return { version, cached: false, error: null };
93
+ }
94
+
95
+ /**
96
+ * Compare two semver-ish strings (x.y.z). Returns -1/0/1.
97
+ * Non-numeric suffixes (`-beta.1`) are ignored for simplicity — this
98
+ * matches what the registry returns for mobygate's release channel.
99
+ */
100
+ export function compareVersions(a, b) {
101
+ const pa = String(a).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
102
+ const pb = String(b).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
103
+ for (let i = 0; i < 3; i++) {
104
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
105
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
106
+ }
107
+ return 0;
108
+ }
109
+
110
+ export async function getUpdateCheck({ force = false } = {}) {
111
+ const current = getCurrentVersion();
112
+ const mode = detectInstallMode();
113
+ const { version: latest, cached, error } = await getLatestVersion({ force });
114
+ const updateAvailable = !!latest && compareVersions(latest, current) > 0;
115
+ return {
116
+ current,
117
+ latest: latest || null,
118
+ updateAvailable,
119
+ installMode: mode,
120
+ checkedAt: new Date().toISOString(),
121
+ cached,
122
+ error,
123
+ // If the install layout can't self-update, surface it so the UI can
124
+ // show "run manually" instead of a live-updating button.
125
+ canApply: updateAvailable && (mode === 'npm' || mode === 'git'),
126
+ };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Apply
131
+ // ---------------------------------------------------------------------------
132
+
133
+ function ensureLogsDir() {
134
+ if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
135
+ }
136
+
137
+ export function readUpdateState() {
138
+ if (!existsSync(UPDATE_MARKER)) return { running: false, startedAt: null, finishedAt: null, exitCode: null, pid: null };
139
+ try { return JSON.parse(readFileSync(UPDATE_MARKER, 'utf8')); } catch { return { running: false, error: 'marker-unreadable' }; }
140
+ }
141
+
142
+ export function readUpdateLogTail({ lines = 500 } = {}) {
143
+ if (!existsSync(UPDATE_LOG)) return [];
144
+ try {
145
+ const raw = readFileSync(UPDATE_LOG, 'utf8');
146
+ const split = raw.split(/\r?\n/);
147
+ return split.slice(-lines - 1, -1);
148
+ } catch { return []; }
149
+ }
150
+
151
+ function writeUpdateState(patch) {
152
+ ensureLogsDir();
153
+ const prev = readUpdateState();
154
+ const merged = { ...prev, ...patch, updatedAt: new Date().toISOString() };
155
+ writeFileSync(UPDATE_MARKER, JSON.stringify(merged, null, 2));
156
+ return merged;
157
+ }
158
+
159
+ /**
160
+ * Build the shell command that performs update + restart. Returned as a
161
+ * single string we can hand to `sh -c` / `cmd /c`. Written as a string
162
+ * (not an array) because we want shell redirection for log capture.
163
+ */
164
+ function buildUpdateCommand({ mode, repoRoot, logPath }) {
165
+ if (IS_WIN) {
166
+ // cmd.exe — `>>` for append, `2>&1` to merge. Each step on its own
167
+ // line so failures short-circuit via `||`.
168
+ const steps = [];
169
+ steps.push(`echo [mobygate-update] start at %DATE% %TIME%`);
170
+ if (mode === 'npm') {
171
+ steps.push(`npm install -g mobygate@latest`);
172
+ } else if (mode === 'git') {
173
+ steps.push(`cd /d "${repoRoot}"`);
174
+ steps.push(`git pull --ff-only`);
175
+ steps.push(`npm install`);
176
+ }
177
+ steps.push(`echo [mobygate-update] restarting service`);
178
+ steps.push(`schtasks /End /TN "${WIN_SERVER_TASK}"`);
179
+ steps.push(`schtasks /Run /TN "${WIN_SERVER_TASK}"`);
180
+ steps.push(`echo [mobygate-update] done`);
181
+ // Join with && so any failure stops the chain. Final redirect to log.
182
+ const inner = steps.map((s) => `(${s})`).join(' && ');
183
+ return { shell: 'cmd', cmd: `${inner} >> "${logPath}" 2>&1` };
184
+ }
185
+ // POSIX: sh -c, bail-on-first-failure via set -e
186
+ const parts = [`set -e`, `echo "[mobygate-update] start $(date)"`];
187
+ if (mode === 'npm') {
188
+ parts.push(`npm install -g mobygate@latest`);
189
+ } else if (mode === 'git') {
190
+ parts.push(`cd "${repoRoot}"`);
191
+ parts.push(`git pull --ff-only`);
192
+ parts.push(`npm install`);
193
+ }
194
+ parts.push(`echo "[mobygate-update] restarting service"`);
195
+ if (IS_MAC) {
196
+ const plist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
197
+ // unload may fail if not loaded — tolerate that specific case
198
+ parts.push(`launchctl unload "${plist}" 2>/dev/null || true`);
199
+ parts.push(`launchctl load "${plist}"`);
200
+ } else if (IS_LINUX) {
201
+ parts.push(`systemctl --user restart ${LINUX_SERVER_UNIT}`);
202
+ }
203
+ parts.push(`echo "[mobygate-update] done"`);
204
+ const script = parts.join('\n');
205
+ return { shell: 'sh', cmd: script };
206
+ }
207
+
208
+ /**
209
+ * Kick off the update in a **detached** child process. The running
210
+ * mobygate server returns immediately and is then killed by the restart
211
+ * step. The dashboard polls `/update/status` to see progress, and the
212
+ * new server comes up with the upgraded code.
213
+ *
214
+ * Returns `{ started, pid, error }` — never throws. If another update
215
+ * is already running, returns `{ started: false, error: 'in-progress' }`.
216
+ */
217
+ export function applyUpdate({ mode, repoRoot = REPO_ROOT } = {}) {
218
+ const resolvedMode = mode || detectInstallMode();
219
+ if (resolvedMode !== 'npm' && resolvedMode !== 'git') {
220
+ return { started: false, error: `install-mode ${resolvedMode} can't auto-update` };
221
+ }
222
+
223
+ ensureLogsDir();
224
+ const prev = readUpdateState();
225
+ if (prev.running && prev.pid) {
226
+ // Treat unknown-alive PIDs as in-progress. Dead PIDs fall through.
227
+ let alive = false;
228
+ try { process.kill(prev.pid, 0); alive = true; } catch {}
229
+ if (alive) return { started: false, error: 'update-already-running', pid: prev.pid };
230
+ }
231
+
232
+ // Truncate the log so each update starts fresh (the old content is
233
+ // still available via `mobygate logs` up to the boundary).
234
+ writeFileSync(UPDATE_LOG, '');
235
+
236
+ const { shell, cmd } = buildUpdateCommand({ mode: resolvedMode, repoRoot, logPath: UPDATE_LOG });
237
+
238
+ let child;
239
+ try {
240
+ if (shell === 'cmd') {
241
+ // Windows: use cmd.exe /c; redirection is inside cmd string.
242
+ child = spawn('cmd.exe', ['/c', cmd], {
243
+ detached: true,
244
+ stdio: 'ignore',
245
+ windowsHide: true,
246
+ });
247
+ } else {
248
+ // POSIX: use sh -c; redirect stdout/stderr into the log file via Node
249
+ // (simpler than embedding `>>` into the script, and avoids quoting
250
+ // headaches across macOS sh versions).
251
+ const fd = openSync(UPDATE_LOG, 'a');
252
+ child = spawn('sh', ['-c', cmd], {
253
+ detached: true,
254
+ stdio: ['ignore', fd, fd],
255
+ });
256
+ }
257
+ child.unref();
258
+ } catch (e) {
259
+ return { started: false, error: `spawn failed: ${e.message}` };
260
+ }
261
+
262
+ writeUpdateState({
263
+ running: true,
264
+ startedAt: new Date().toISOString(),
265
+ finishedAt: null,
266
+ exitCode: null,
267
+ pid: child.pid,
268
+ mode: resolvedMode,
269
+ });
270
+
271
+ // We don't await the child — it's detached and will outlive us. The
272
+ // dashboard determines completion by polling /update/status, which
273
+ // checks `process.kill(pid, 0)` to test liveness.
274
+ return { started: true, pid: child.pid, mode: resolvedMode };
275
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.5.3",
3
+ "version": "0.6.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",