@amenophis1er/foreman 0.1.2 → 0.1.3
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/bin/foreman.mjs +2 -1
- package/package.json +1 -1
- package/src/cli.ts +60 -0
- package/src/server.ts +12 -0
- package/src/update.test.ts +27 -0
- package/src/update.ts +60 -0
- package/ui/dist/assets/index-oXWBvBdD.js +66 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-D56hYFVC.js +0 -66
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
|
|
@@ -53,7 +54,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
|
|
|
53
54
|
} else if (command === 'start') {
|
|
54
55
|
register();
|
|
55
56
|
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)) {
|
|
57
|
+
} else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update'].includes(command)) {
|
|
57
58
|
register();
|
|
58
59
|
const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
|
|
59
60
|
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.3",
|
|
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
|
@@ -20,6 +20,7 @@ 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');
|
|
@@ -253,6 +254,62 @@ async function serviceLogs(): Promise<number> {
|
|
|
253
254
|
return new Promise((r) => child.on('exit', (c) => r(c ?? 0)));
|
|
254
255
|
}
|
|
255
256
|
|
|
257
|
+
/** One quiet line when a newer version exists; nothing when current or unknown. */
|
|
258
|
+
async function updateHint(): Promise<void> {
|
|
259
|
+
const u = await checkForUpdate(currentVersion(), 2_000);
|
|
260
|
+
if (u?.newer) console.log(`\nForeman ${u.latest} is available (you have ${u.current}) — \`foreman update\``);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Anything a restart would cut off: a run, an ask waiting, a planner mid-reply. */
|
|
264
|
+
async function liveWork(): Promise<string | null> {
|
|
265
|
+
try {
|
|
266
|
+
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[] }> };
|
|
267
|
+
const running = d.projects.filter((p) => p.activeRun).map((p) => p.name);
|
|
268
|
+
const needs = d.projects.reduce((n, p) => n + (p.needs?.length ?? 0), 0);
|
|
269
|
+
const thinking: string[] = [];
|
|
270
|
+
for (const p of d.projects) {
|
|
271
|
+
try {
|
|
272
|
+
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 };
|
|
273
|
+
if (c.thinking) thinking.push(p.name);
|
|
274
|
+
} catch { /* a project whose chat cannot be read is not live work */ }
|
|
275
|
+
}
|
|
276
|
+
if (!running.length && !needs && !thinking.length) return null;
|
|
277
|
+
return [running.length ? `running: ${running.join(', ')}` : '', needs ? `${needs} ask(s) waiting` : '', thinking.length ? `planner replying: ${thinking.join(', ')}` : ''].filter(Boolean).join(' · ');
|
|
278
|
+
} catch { return null; } // not up — nothing to cut off
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Update the installed package and restart Foreman the way it is running.
|
|
283
|
+
* Refuses while anything would be cut off; --force overrides, eyes open.
|
|
284
|
+
* Never automatic: this is the one place the code under a mission changes,
|
|
285
|
+
* and it happens by a human's hand.
|
|
286
|
+
*/
|
|
287
|
+
async function update(bin: string, flags: string[]): Promise<number> {
|
|
288
|
+
const u = await checkForUpdate(currentVersion(), 5_000);
|
|
289
|
+
if (!u) { console.error('Could not reach the npm registry to check for a newer version.'); return 1; }
|
|
290
|
+
if (!u.newer) { console.log(`Already on the latest version (${u.current}).`); return 0; }
|
|
291
|
+
const busy = await liveWork();
|
|
292
|
+
if (busy && !flags.includes('--force')) {
|
|
293
|
+
console.error(`Not updating: ${busy}. An update restarts the server and would cut that off. Wait, or \`foreman update --force\`.`);
|
|
294
|
+
return 2;
|
|
295
|
+
}
|
|
296
|
+
console.log(`Updating ${u.current} → ${u.latest}…`);
|
|
297
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
298
|
+
const code = await new Promise<number>((r) => {
|
|
299
|
+
const c = spawn(npm, ['install', '-g', `${PACKAGE}@${u.latest}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
300
|
+
c.on('exit', (x) => r(x ?? 1)); c.on('error', () => r(1));
|
|
301
|
+
});
|
|
302
|
+
if (code !== 0) { console.error('npm install failed; nothing was restarted.'); return code; }
|
|
303
|
+
// Restart by the means it is running, so the new code actually serves.
|
|
304
|
+
const pid = await readPid();
|
|
305
|
+
if (pid && alive(pid)) { console.log('Restarting the background server…'); const c = await down(); if (c !== 0) return c; return up(bin); }
|
|
306
|
+
const svc = await serviceState();
|
|
307
|
+
if (svc.running || svc.installed) { console.log('Restarting the service…'); return serviceRestart(); }
|
|
308
|
+
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; }
|
|
309
|
+
console.log(`Installed ${u.latest}. Start it with \`foreman\`, \`foreman up\` or \`foreman service install\`.`);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
256
313
|
async function doctor(): Promise<number> {
|
|
257
314
|
const tailnet = await detectTailscale();
|
|
258
315
|
const distDir = fileURLToPath(new URL('../ui/dist', import.meta.url));
|
|
@@ -262,6 +319,7 @@ async function doctor(): Promise<number> {
|
|
|
262
319
|
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
320
|
}
|
|
264
321
|
const ok = reportPreflight(checks);
|
|
322
|
+
await updateHint();
|
|
265
323
|
return ok ? 0 : 1;
|
|
266
324
|
}
|
|
267
325
|
|
|
@@ -342,6 +400,7 @@ async function status(): Promise<number> {
|
|
|
342
400
|
const svc = await serviceState();
|
|
343
401
|
const how = pid && alive(pid) ? `background, pid ${pid}` : svc.running ? `the service${svc.pid ? `, pid ${svc.pid}` : ''}` : 'a terminal';
|
|
344
402
|
console.log(`Up at http://localhost:${PORT} (${how})`);
|
|
403
|
+
await updateHint();
|
|
345
404
|
return 0;
|
|
346
405
|
}
|
|
347
406
|
if (pid) await rm(PID_FILE, { force: true });
|
|
@@ -419,6 +478,7 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
|
|
|
419
478
|
switch (command) {
|
|
420
479
|
case 'up': return up(bin);
|
|
421
480
|
case 'down': return down();
|
|
481
|
+
case 'update': return update(bin, rest);
|
|
422
482
|
case 'stop': return stop();
|
|
423
483
|
case 'restart': return restart(bin);
|
|
424
484
|
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']);
|
|
@@ -1377,6 +1387,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1377
1387
|
authMode: auth.mode,
|
|
1378
1388
|
authSource: auth.source,
|
|
1379
1389
|
authAccount: auth.account ?? null,
|
|
1390
|
+
version: currentVersion(),
|
|
1391
|
+
update: updateInfo?.newer ? { latest: updateInfo.latest } : null,
|
|
1380
1392
|
projects: cards.sort(fleetOrder),
|
|
1381
1393
|
});
|
|
1382
1394
|
|
|
@@ -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
|
+
}
|