@amenophis1er/foreman 0.1.2 → 0.1.4
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/README.md +23 -0
- package/bin/foreman.mjs +3 -1
- package/package.json +1 -1
- package/src/cli.ts +71 -3
- package/src/server.ts +18 -0
- package/src/update.test.ts +27 -0
- package/src/update.ts +60 -0
- package/ui/dist/assets/index-D_tuMLCb.js +66 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-D56hYFVC.js +0 -66
package/README.md
CHANGED
|
@@ -60,6 +60,19 @@ which account pays, the Claude Code install, Ollama and Codex if present, the
|
|
|
60
60
|
browser missions will use, the port, Tailscale, the data directory — and
|
|
61
61
|
exits. Nothing blocks unless it says so.
|
|
62
62
|
|
|
63
|
+
**Environment.** Everything the CLI reads; `foreman --help` prints the same list.
|
|
64
|
+
|
|
65
|
+
| Variable | Meaning | Default |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `PORT` | listen port | `4177` |
|
|
68
|
+
| `FOREMAN_HOME` | state directory: runs, settings, logs | `~/.foreman` |
|
|
69
|
+
| `FOREMAN_BIND` | `auto` (loopback + Tailscale when present), `local`, or `all` | `auto` |
|
|
70
|
+
| `FOREMAN_BROWSER` | browser for missions: `chrome`, `chromium`, `msedge`, `firefox` | `chrome` |
|
|
71
|
+
| `FOREMAN_CLAUDE_CONFIG_DIR` | the Claude Code install missions run under | inherited |
|
|
72
|
+
| `FOREMAN_CLAUDE_EXECUTABLE` | the Claude Code executable | bundled |
|
|
73
|
+
| `FOREMAN_AUTH_MODE` | assert `api-key` or `subscription`; fail at start on mismatch | unset |
|
|
74
|
+
| `FOREMAN_NO_TELEGRAM` | `1` to start without the Telegram bot: for a second server beside the main one, since Telegram allows one poller per bot | unset |
|
|
75
|
+
|
|
63
76
|
## A mission, start to finish
|
|
64
77
|
|
|
65
78
|
1. **Fleet.** The board: what needs you (answerable right there), what is
|
|
@@ -213,6 +226,16 @@ npm run dev # API + Vite together
|
|
|
213
226
|
scripts/dev-restart.sh # restarts the server only when nothing would be lost
|
|
214
227
|
```
|
|
215
228
|
|
|
229
|
+
To run a checkout beside an installed Foreman on the same machine, give it its
|
|
230
|
+
own port and leave the bot to the installed one:
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
PORT=4178 FOREMAN_BIND=local FOREMAN_NO_TELEGRAM=1 npm start
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Both read `~/.foreman`; look from the second, drive from the first — two
|
|
237
|
+
servers running missions against one store is the one case nothing guards.
|
|
238
|
+
|
|
216
239
|
Releases: `npm version <patch|minor|major> && git push --follow-tags` — CI
|
|
217
240
|
tests, publishes to npm with provenance, and creates the GitHub release.
|
|
218
241
|
|
package/bin/foreman.mjs
CHANGED
|
@@ -25,6 +25,7 @@ const USAGE = `foreman ${pkg.version}
|
|
|
25
25
|
foreman logs Tail the log
|
|
26
26
|
foreman open Open the dashboard in your browser
|
|
27
27
|
foreman doctor Check credentials, providers, browser, port, Tailscale — and exit
|
|
28
|
+
foreman update Install the latest version and restart the same way (refuses mid-mission; --force)
|
|
28
29
|
|
|
29
30
|
foreman service install Keep Foreman running: start at login, restart if it dies
|
|
30
31
|
foreman service start|stop|restart|status|logs
|
|
@@ -42,6 +43,7 @@ Environment:
|
|
|
42
43
|
FOREMAN_CLAUDE_CONFIG_DIR Claude Code install missions run under
|
|
43
44
|
FOREMAN_CLAUDE_EXECUTABLE Claude Code executable (default: bundled)
|
|
44
45
|
FOREMAN_AUTH_MODE Assert 'api-key' or 'subscription'; fail on mismatch
|
|
46
|
+
FOREMAN_NO_TELEGRAM=1 Do not attach the Telegram bot (a second server beside the main one)
|
|
45
47
|
`;
|
|
46
48
|
|
|
47
49
|
const [command = 'start', ...rest] = process.argv.slice(2);
|
|
@@ -53,7 +55,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
|
|
|
53
55
|
} else if (command === 'start') {
|
|
54
56
|
register();
|
|
55
57
|
await import(new URL('../src/server.ts', import.meta.url).href);
|
|
56
|
-
} else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall'].includes(command)) {
|
|
58
|
+
} else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update'].includes(command)) {
|
|
57
59
|
register();
|
|
58
60
|
const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
|
|
59
61
|
process.exitCode = await runCli(command, rest, { version: pkg.version, bin: new URL(import.meta.url) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli.ts
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
* be up when the laptop lid is closed; this is that.
|
|
14
14
|
*/
|
|
15
15
|
import { execFile, spawn } from 'node:child_process';
|
|
16
|
-
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { access, chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { openSync } from 'node:fs';
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { preflight, reportPreflight } from './preflight.js';
|
|
22
22
|
import { detectTailscale } from './tailscale.js';
|
|
23
|
+
import { PACKAGE, checkForUpdate, currentVersion } from './update.js';
|
|
23
24
|
|
|
24
25
|
const PORT = Number(process.env.PORT ?? 4177);
|
|
25
26
|
const HOME_DIR = process.env.FOREMAN_HOME || path.join(os.homedir(), '.foreman');
|
|
@@ -169,8 +170,12 @@ async function serviceUninstall(): Promise<number> {
|
|
|
169
170
|
/** Is the service registered, and is it running? Null pid when registered but idle. */
|
|
170
171
|
async function serviceState(): Promise<{ installed: boolean; running: boolean; pid?: number }> {
|
|
171
172
|
if (process.platform === 'darwin') {
|
|
173
|
+
// Installed means the plist is on disk; a stopped service (booted out
|
|
174
|
+
// by `service stop`) is still installed, and `service start` brings it
|
|
175
|
+
// back. launchctl only knows about loaded jobs, so it answers "running".
|
|
176
|
+
const installed = await access(path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)).then(() => true, () => false);
|
|
172
177
|
const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
|
|
173
|
-
if (r.code !== 0) return { installed
|
|
178
|
+
if (r.code !== 0) return { installed, running: false };
|
|
174
179
|
const pid = Number(/pid = (\d+)/.exec(r.out)?.[1]);
|
|
175
180
|
return { installed: true, running: Number.isFinite(pid) && pid > 0, ...(pid ? { pid } : {}) };
|
|
176
181
|
}
|
|
@@ -229,7 +234,11 @@ async function serviceRestart(): Promise<number> {
|
|
|
229
234
|
async function serviceStatus(): Promise<number> {
|
|
230
235
|
if (process.platform === 'darwin') {
|
|
231
236
|
const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
|
|
232
|
-
if (r.code !== 0) {
|
|
237
|
+
if (r.code !== 0) {
|
|
238
|
+
const st = await serviceState();
|
|
239
|
+
console.log(st.installed ? 'Installed · stopped — `foreman service start` starts it (it also comes back at next login).' : 'Not installed. `foreman service install` keeps Foreman running.');
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
233
242
|
const state = /state = (\w+)/.exec(r.out)?.[1] ?? 'unknown';
|
|
234
243
|
const pid = /pid = (\d+)/.exec(r.out)?.[1];
|
|
235
244
|
console.log(`Installed · ${state}${pid ? ` · pid ${pid}` : ''} · http://localhost:${PORT}`);
|
|
@@ -253,6 +262,62 @@ async function serviceLogs(): Promise<number> {
|
|
|
253
262
|
return new Promise((r) => child.on('exit', (c) => r(c ?? 0)));
|
|
254
263
|
}
|
|
255
264
|
|
|
265
|
+
/** One quiet line when a newer version exists; nothing when current or unknown. */
|
|
266
|
+
async function updateHint(): Promise<void> {
|
|
267
|
+
const u = await checkForUpdate(currentVersion(), 2_000);
|
|
268
|
+
if (u?.newer) console.log(`\nForeman ${u.latest} is available (you have ${u.current}) — \`foreman update\``);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Anything a restart would cut off: a run, an ask waiting, a planner mid-reply. */
|
|
272
|
+
async function liveWork(): Promise<string | null> {
|
|
273
|
+
try {
|
|
274
|
+
const d = await fetch(`http://127.0.0.1:${PORT}/projects`, { signal: AbortSignal.timeout(2000) }).then((r) => r.json()) as { projects: Array<{ id: string; name: string; activeRun?: unknown; needs?: unknown[] }> };
|
|
275
|
+
const running = d.projects.filter((p) => p.activeRun).map((p) => p.name);
|
|
276
|
+
const needs = d.projects.reduce((n, p) => n + (p.needs?.length ?? 0), 0);
|
|
277
|
+
const thinking: string[] = [];
|
|
278
|
+
for (const p of d.projects) {
|
|
279
|
+
try {
|
|
280
|
+
const c = await fetch(`http://127.0.0.1:${PORT}/chat?projectId=${encodeURIComponent(p.id)}`, { signal: AbortSignal.timeout(2000) }).then((r) => r.json()) as { thinking?: boolean };
|
|
281
|
+
if (c.thinking) thinking.push(p.name);
|
|
282
|
+
} catch { /* a project whose chat cannot be read is not live work */ }
|
|
283
|
+
}
|
|
284
|
+
if (!running.length && !needs && !thinking.length) return null;
|
|
285
|
+
return [running.length ? `running: ${running.join(', ')}` : '', needs ? `${needs} ask(s) waiting` : '', thinking.length ? `planner replying: ${thinking.join(', ')}` : ''].filter(Boolean).join(' · ');
|
|
286
|
+
} catch { return null; } // not up — nothing to cut off
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Update the installed package and restart Foreman the way it is running.
|
|
291
|
+
* Refuses while anything would be cut off; --force overrides, eyes open.
|
|
292
|
+
* Never automatic: this is the one place the code under a mission changes,
|
|
293
|
+
* and it happens by a human's hand.
|
|
294
|
+
*/
|
|
295
|
+
async function update(bin: string, flags: string[]): Promise<number> {
|
|
296
|
+
const u = await checkForUpdate(currentVersion(), 5_000);
|
|
297
|
+
if (!u) { console.error('Could not reach the npm registry to check for a newer version.'); return 1; }
|
|
298
|
+
if (!u.newer) { console.log(`Already on the latest version (${u.current}).`); return 0; }
|
|
299
|
+
const busy = await liveWork();
|
|
300
|
+
if (busy && !flags.includes('--force')) {
|
|
301
|
+
console.error(`Not updating: ${busy}. An update restarts the server and would cut that off. Wait, or \`foreman update --force\`.`);
|
|
302
|
+
return 2;
|
|
303
|
+
}
|
|
304
|
+
console.log(`Updating ${u.current} → ${u.latest}…`);
|
|
305
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
306
|
+
const code = await new Promise<number>((r) => {
|
|
307
|
+
const c = spawn(npm, ['install', '-g', `${PACKAGE}@${u.latest}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
308
|
+
c.on('exit', (x) => r(x ?? 1)); c.on('error', () => r(1));
|
|
309
|
+
});
|
|
310
|
+
if (code !== 0) { console.error('npm install failed; nothing was restarted.'); return code; }
|
|
311
|
+
// Restart by the means it is running, so the new code actually serves.
|
|
312
|
+
const pid = await readPid();
|
|
313
|
+
if (pid && alive(pid)) { console.log('Restarting the background server…'); const c = await down(); if (c !== 0) return c; return up(bin); }
|
|
314
|
+
const svc = await serviceState();
|
|
315
|
+
if (svc.running || svc.installed) { console.log('Restarting the service…'); return serviceRestart(); }
|
|
316
|
+
if (await listening(PORT)) { console.log(`Installed ${u.latest}. The server on :${PORT} runs in a terminal — restart it there to pick it up.`); return 0; }
|
|
317
|
+
console.log(`Installed ${u.latest}. Start it with \`foreman\`, \`foreman up\` or \`foreman service install\`.`);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
256
321
|
async function doctor(): Promise<number> {
|
|
257
322
|
const tailnet = await detectTailscale();
|
|
258
323
|
const distDir = fileURLToPath(new URL('../ui/dist', import.meta.url));
|
|
@@ -262,6 +327,7 @@ async function doctor(): Promise<number> {
|
|
|
262
327
|
if (c.name.startsWith('Port') && c.status === 'error') { c.status = 'warn'; c.detail = 'in use — Foreman is probably already running'; c.fix = `foreman open · or PORT=${PORT + 1} foreman`; }
|
|
263
328
|
}
|
|
264
329
|
const ok = reportPreflight(checks);
|
|
330
|
+
await updateHint();
|
|
265
331
|
return ok ? 0 : 1;
|
|
266
332
|
}
|
|
267
333
|
|
|
@@ -342,6 +408,7 @@ async function status(): Promise<number> {
|
|
|
342
408
|
const svc = await serviceState();
|
|
343
409
|
const how = pid && alive(pid) ? `background, pid ${pid}` : svc.running ? `the service${svc.pid ? `, pid ${svc.pid}` : ''}` : 'a terminal';
|
|
344
410
|
console.log(`Up at http://localhost:${PORT} (${how})`);
|
|
411
|
+
await updateHint();
|
|
345
412
|
return 0;
|
|
346
413
|
}
|
|
347
414
|
if (pid) await rm(PID_FILE, { force: true });
|
|
@@ -419,6 +486,7 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
|
|
|
419
486
|
switch (command) {
|
|
420
487
|
case 'up': return up(bin);
|
|
421
488
|
case 'down': return down();
|
|
489
|
+
case 'update': return update(bin, rest);
|
|
422
490
|
case 'stop': return stop();
|
|
423
491
|
case 'restart': return restart(bin);
|
|
424
492
|
case 'logs': return logs();
|
package/src/server.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { DEFAULT_TOOL_POLICY } from './policy.js';
|
|
56
56
|
import { saveAttachments } from './attachments.js';
|
|
57
57
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
58
|
+
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
58
59
|
import { ServiceRegistry, SVC_PREFIX, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
|
|
59
60
|
import { HELP_TEXT, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
60
61
|
import { escapeHtml as escTg } from './notify.js';
|
|
@@ -421,6 +422,15 @@ const BIND = (process.env.FOREMAN_BIND ?? 'auto') as 'auto' | 'all' | 'local';
|
|
|
421
422
|
const tailnet = BIND === 'local' ? null : await detectTailscale();
|
|
422
423
|
/** Dev servers the crew put behind /svc/ — see services.ts. */
|
|
423
424
|
const services = new ServiceRegistry();
|
|
425
|
+
/**
|
|
426
|
+
* Whether a newer Foreman exists, for the header's quiet pill. Checked at
|
|
427
|
+
* start and every six hours, never acted on: updating is `foreman update`,
|
|
428
|
+
* by hand, and never under a running mission.
|
|
429
|
+
*/
|
|
430
|
+
let updateInfo: UpdateInfo | null = null;
|
|
431
|
+
const refreshUpdateInfo = () => { void checkForUpdate(currentVersion(), 4_000).then((u) => { updateInfo = u; }); };
|
|
432
|
+
refreshUpdateInfo();
|
|
433
|
+
setInterval(refreshUpdateInfo, 6 * 60 * 60_000).unref();
|
|
424
434
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
425
435
|
const DIST_DIR = path.join(__dirname, '..', 'ui', 'dist');
|
|
426
436
|
const ROOT_ASSETS = new Set(['/favicon.svg']);
|
|
@@ -598,6 +608,11 @@ let telegramBot: TelegramBot | null = null;
|
|
|
598
608
|
/** (Re)build the Telegram side from the stored token and linked chat. */
|
|
599
609
|
async function reattachTelegram(): Promise<boolean> {
|
|
600
610
|
notifyHub.detach('telegram');
|
|
611
|
+
// A second server beside the installed one (a dev checkout on another
|
|
612
|
+
// port) must not start a second poller: Telegram allows one per bot, and
|
|
613
|
+
// two fight over getUpdates. FOREMAN_NO_TELEGRAM=1 leaves the channel to
|
|
614
|
+
// whichever server does not set it.
|
|
615
|
+
if (process.env.FOREMAN_NO_TELEGRAM === '1') return false;
|
|
601
616
|
const s = await notifySettings();
|
|
602
617
|
const token = await getSecret(store.root, 'telegram');
|
|
603
618
|
if (!token) { void telegramBot?.stop(); telegramBot = null; return false; }
|
|
@@ -1338,6 +1353,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1338
1353
|
providerHasKey: await providerHasKeyOf(p),
|
|
1339
1354
|
activeRun: run ? { ...run.meta } : null,
|
|
1340
1355
|
lastRun: lastRun && {
|
|
1356
|
+
id: lastRun.id,
|
|
1341
1357
|
mission: lastRun.mission, title: lastRun.title, status: lastRun.status,
|
|
1342
1358
|
createdAt: lastRun.createdAt, costUsd: lastRun.costUsd,
|
|
1343
1359
|
// The card may print a dollar only where the dollar was real.
|
|
@@ -1377,6 +1393,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1377
1393
|
authMode: auth.mode,
|
|
1378
1394
|
authSource: auth.source,
|
|
1379
1395
|
authAccount: auth.account ?? null,
|
|
1396
|
+
version: currentVersion(),
|
|
1397
|
+
update: updateInfo?.newer ? { latest: updateInfo.latest } : null,
|
|
1380
1398
|
projects: cards.sort(fleetOrder),
|
|
1381
1399
|
});
|
|
1382
1400
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { compareVersions, latestVersion } from './update.js';
|
|
5
|
+
|
|
6
|
+
test('compareVersions: numeric, not lexical; pre-release below release', () => {
|
|
7
|
+
assert.equal(compareVersions('0.1.2', '0.1.10'), -1);
|
|
8
|
+
assert.equal(compareVersions('1.0.0', '0.9.9'), 1);
|
|
9
|
+
assert.equal(compareVersions('v0.1.2', '0.1.2'), 0);
|
|
10
|
+
assert.equal(compareVersions('1.0.0-beta.1', '1.0.0'), -1);
|
|
11
|
+
assert.equal(compareVersions('1.0.0', '1.0.0-rc.1'), 1);
|
|
12
|
+
assert.equal(compareVersions('0.2', '0.2.0'), 0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('latestVersion: reads the latest tag; a silent registry is null, not an error', async () => {
|
|
16
|
+
const srv = http.createServer((req, res) => {
|
|
17
|
+
if (req.url === '/@x%2Fy/latest') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ version: '9.9.9' })); }
|
|
18
|
+
else { res.writeHead(404); res.end(); }
|
|
19
|
+
});
|
|
20
|
+
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', r));
|
|
21
|
+
const port = (srv.address() as { port: number }).port;
|
|
22
|
+
try {
|
|
23
|
+
assert.equal(await latestVersion('@x/y', 2000, `http://127.0.0.1:${port}`), '9.9.9');
|
|
24
|
+
assert.equal(await latestVersion('@x/missing', 2000, `http://127.0.0.1:${port}/nope`), null);
|
|
25
|
+
} finally { srv.close(); }
|
|
26
|
+
assert.equal(await latestVersion('@x/y', 200, 'http://127.0.0.1:9'), null);
|
|
27
|
+
});
|
package/src/update.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is there a newer Foreman?" — asked of the npm registry, answered quietly.
|
|
3
|
+
*
|
|
4
|
+
* Read-only and best-effort: a registry that does not answer within the
|
|
5
|
+
* timeout means "unknown", never an error, and nothing here ever installs
|
|
6
|
+
* anything. Installing is `foreman update`'s job, on purpose and by hand;
|
|
7
|
+
* Foreman runs agents on your files while you are away, and it must never
|
|
8
|
+
* change under a running mission without a human's hand.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
export const PACKAGE = '@amenophis1er/foreman';
|
|
13
|
+
|
|
14
|
+
export interface UpdateInfo {
|
|
15
|
+
current: string;
|
|
16
|
+
latest: string;
|
|
17
|
+
/** `latest` is strictly newer than `current`. */
|
|
18
|
+
newer: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `1.2.3` vs `1.10.0` the numeric way; a pre-release tag sorts below its release. */
|
|
22
|
+
export function compareVersions(a: string, b: string): number {
|
|
23
|
+
const parse = (v: string) => {
|
|
24
|
+
const [core, pre] = v.replace(/^v/, '').split('-', 2);
|
|
25
|
+
return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre ?? '' };
|
|
26
|
+
};
|
|
27
|
+
const A = parse(a), B = parse(b);
|
|
28
|
+
for (let i = 0; i < 3; i++) {
|
|
29
|
+
const d = (A.nums[i] ?? 0) - (B.nums[i] ?? 0);
|
|
30
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
31
|
+
}
|
|
32
|
+
if (A.pre === B.pre) return 0;
|
|
33
|
+
if (!A.pre) return 1; // release > pre-release
|
|
34
|
+
if (!B.pre) return -1;
|
|
35
|
+
return A.pre < B.pre ? -1 : 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The version of the package this code runs from. */
|
|
39
|
+
export function currentVersion(pkgUrl = new URL('../package.json', import.meta.url)): string {
|
|
40
|
+
try { return String(JSON.parse(readFileSync(pkgUrl, 'utf8')).version ?? '0.0.0'); } catch { return '0.0.0'; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The registry's `latest` tag, or null when it cannot be reached in time. */
|
|
44
|
+
export async function latestVersion(pkg = PACKAGE, timeoutMs = 2_500, registry = 'https://registry.npmjs.org'): Promise<string | null> {
|
|
45
|
+
try {
|
|
46
|
+
const r = await fetch(`${registry}/${encodeURIComponent(pkg).replace('%40', '@')}/latest`, {
|
|
47
|
+
signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/json' },
|
|
48
|
+
});
|
|
49
|
+
if (!r.ok) return null;
|
|
50
|
+
const d = await r.json() as { version?: string };
|
|
51
|
+
return typeof d.version === 'string' ? d.version : null;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Current vs latest, or null when the registry did not answer. */
|
|
56
|
+
export async function checkForUpdate(current = currentVersion(), timeoutMs?: number): Promise<UpdateInfo | null> {
|
|
57
|
+
const latest = await latestVersion(PACKAGE, timeoutMs);
|
|
58
|
+
if (!latest) return null;
|
|
59
|
+
return { current, latest, newer: compareVersions(latest, current) > 0 };
|
|
60
|
+
}
|