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/CHANGELOG.md +88 -0
- package/bin/mobygate.js +9 -4
- package/index.html +154 -0
- package/lib/tool-bridge.js +257 -0
- package/lib/updater.js +275 -0
- package/package.json +1 -1
- package/server.js +219 -180
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
|
+
}
|