@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 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.2",
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
+ }