bareloop 0.46.1 → 0.47.0

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 CHANGED
@@ -5,6 +5,20 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.47.0] - 2026-10-24
9
+
10
+ ### Added
11
+
12
+ - **`bareloop` is a launcher (PANEL-BUILD.md addendum L, hamr 2026-10-24).** Bare `bareloop` shows a menu (`1 start 2 stop 3 status 4 install q quit`) under two header lines (installed or not, panel running or not, `runs: N running · N need you · N in all`); the same four work as `bareloop start | stop | status | install`. No pid file: the panel is found by asking ports 4700-4709 for the new read-only `GET /api/whoami`. `start` runs the panel detached on the first free port (log: `~/.config/bareloop/panel.log`) and opens the page; `stop` stops every panel it finds (one panel at a time; no `--port`, no which-one question), warns about open drafts summed over them and asks (`--yes` skips); `status` prints `Panel not running.` and exits 3 when no panel runs (0 from the menu); `install` creates only what is missing. The old `1 export / 2 run / 3 history` menu is gone (the sub-commands stay). `bareloop panel [--port N]` is unchanged. Coloured on a terminal (off when piped, `NO_COLOR` set or `TERM=dumb`). `status` and the header show the running panel's version, with a stop-and-start note when it differs from the CLI's. The header's `need you` count matches the panel's Needs-you list, schedules that gave up included. `GET /api/whoami` (app, version, pid, port, drafts, schedules) sits behind the Host guard and needs no token. The panel address has one owner (`panelUrl`); no address literal lives in the launcher, and the OS opener is shared with Settings.
13
+
14
+ ### Changed
15
+
16
+ - **A rubric run's lessons count at run end, like a deterministic run's (hamr 2026-10-24, option A).** `foldRunIntoLedger` no longer holds a `rubric` run's cards in `pending` until the human Accept: they go through `countLedger` now, FIXED and NEVER FIXED alike, with `trust: 'rubric'` kept on new rows; `held` is always 0. A hold already in a ledger file (written by v0.46.x) still releases on the door Accept. Reuse and bridge credit is unchanged and still waits for the Accept. Noise runs stay dropped.
17
+
18
+ ### Fixed
19
+
20
+ - **A resumed leg updates its own run's ledger row.** `countLedger` skipped a row that already held the run id, so a NEVER FIXED lesson from leg 1 stayed NEVER FIXED when leg 2 (same run id) fixed it. The row is now refreshed from the new card (sample, lesson, key, lastSeen, hash, place) while its count, model tally and run ids stay one per run; an older run's cards re-folded later never step a row back. Found by fwdloop.
21
+
8
22
  ## [0.46.1] - 2026-10-24
9
23
 
10
24
  ### Fixed
package/README.md CHANGED
@@ -68,6 +68,8 @@ building it.
68
68
  npm install bareloop
69
69
  ```
70
70
 
71
+ Quick start: `npm install -g bareloop`, then `bareloop` — a menu to start the panel, stop it, see its status, and set up `~/.config/bareloop/`.
72
+
71
73
  ## Usage
72
74
 
73
75
  A signed job spec goes in. A verdict comes out.
@@ -3291,16 +3291,35 @@ real key check is skipped entirely — a scripted provider *is* the run, the
3291
3291
  same seam `tests/planrun-*.test.js` uses. `deps` also accepts `env`, `stdout`, `stderr`, `cwd`,
3292
3292
  `now`, `stdin` — omit any of them and the real `process.*` equivalent is used.
3293
3293
 
3294
- Bare `bareloop` (no sub-command) prints a numbered menu — `1 export 2 run 3 history q
3295
- quit` — and asks each sub-command's arguments one line at a time over the SAME code paths
3296
- below. `run-u`, `interview` and `author` (below) are not on this menu (PANEL-BUILD.md P0
3297
- tasks 2/4-4/4): each one's own flag grammar is too wide for a line-at-a-time wizard, and the
3298
- menu's job is to ask the SAME questions the sub-command below already answers, never to
3299
- invent a new interview — this is doubly true for `interview`/`author`, which already ARE
3300
- interviews of their own. Choice `2 run` is handed the SAME resolved keys home `bareloop run <bundle>` gets, so the
3301
- monthly limit, your price and the key-row choice apply exactly as on the sub-command (before this it
3302
- got none, and a run started from the menu skipped the limit). All three are dispatched by name only: `bareloop run-u <flags…>`,
3303
- `bareloop interview <flags…>`, `bareloop author <flags…>`.
3294
+ Bare `bareloop` (no sub-command) is the **launcher menu** (PANEL-BUILD.md addendum L, `src/launcher.js`): two header lines, then
3295
+ `1 start 2 stop 3 status 4 install q quit`. The header is printed first by EVERY call (menu and each of
3296
+ `bareloop start | stop | status | install`), from one function:
3297
+
3298
+ ```
3299
+ bareloop 0.47.0 · installed ✓ · panel running at http://127.0.0.1:4701
3300
+ runs: 1 running · 2 need you · 375 in all
3301
+ ```
3302
+
3303
+ Colour (`src/tty-colour.js`): on only when stdout is a terminal, `NO_COLOR` is empty/unset and `TERM` is not `dumb`; it wraps words and never
3304
+ changes them, and a piped or `NO_COLOR` run prints the plain words above (the thin `─` rules around the header appear only with colour).
3305
+
3306
+ - `installed ✓` = `~/.config/bareloop/` exists, `config.json` is there and reads with no problem, `.env` exists with mode 600, node >= 22;
3307
+ otherwise `not installed — <first missing thing>, pick 4 install`. The run counts use the panel's own status words and Needs-you rule over the
3308
+ run list file ($0, works with the panel stopped).
3309
+ - **No pid file.** The panel is found by asking `127.0.0.1:4700..4709` for `GET /api/whoami` (300 ms each, in parallel); a reply with `app: "bareloop"` is a panel.
3310
+ `GET /api/whoami` → `{ app: "bareloop", version, pid, port, drafts, schedules }` (open drafting sessions, jobs with a schedule), read-only, same Host guard as every
3311
+ route, no token.
3312
+ - **`bareloop start`** → already running: says so, opens the page, starts nothing (if the running panel's version differs from this bareloop's it adds `It is version X; this bareloop is Y. Run bareloop stop, then bareloop start, to use Y.`). Else starts `bareloop panel --port N` DETACHED (`setsid`; log appended to
3313
+ `~/.config/bareloop/panel.log`) on the first free port from 4700 (a taken port is skipped and named), waits up to 10 s for it to answer, opens the page with
3314
+ `xdg-open`/`open` (no opener: prints the URL). `bareloop panel [--port N]` is unchanged (foreground, fixed port).
3315
+ - **`bareloop stop [--yes]`** → SIGTERM to the pid of EVERY panel the probe finds (one panel at a time; no which-one question), waits until each port is quiet. Open drafts (in memory, summed over all found panels) are warned about and a y/N is asked;
3316
+ `--yes` skips it. Not running: `Panel not running.`, exit 0. Several found: all stopped, `N panels stopped.`. Any other option (e.g. `--port`) is refused in plain words. Runs are separate processes and keep going.
3317
+ - **`bareloop status`** → header plus `pid N · version X · N draft(s) open · N schedule(s) on`. Exit 0 running, 3 not running. A panel whose version differs from the CLI's shows `(version X — stop and start to use Y)` after its URL in the header (yellow on a terminal).
3318
+ - **`bareloop install`** → creates only what is missing (folder mode 700, `.env` with the keys-file comment lines, mode 600, `config.json`); never reads a key value;
3319
+ `Already installed, nothing to do.` when there is nothing to add. Needs node >= 22.
3320
+
3321
+ The old `1 export 2 run 3 history` menu is gone; `bareloop export|run|history` and the other sub-commands are unchanged. `run-u`, `interview` and `author` are dispatched
3322
+ by name only: `bareloop run-u <flags…>`, `bareloop interview <flags…>`, `bareloop author <flags…>`.
3304
3323
 
3305
3324
  When two key rows share one provider and endpoint, a CLI run uses the FIRST row in the keys list, so put the
3306
3325
  key you want first (the panel picks the row by the Model menu).
@@ -3890,7 +3909,7 @@ No model is called anywhere in learnings (a test walks the import closure). Pure
3890
3909
  - **Run end:** `execute` (`src/userrun.js`, shared by the panel, the CLI and the bundle runner) calls `learnAtRunEnd({ home, runid, events, auditFiles, spec, specHash, workdir, trust })` once, right after the claim settles, in its own try/catch: a failure prints one WARNING and never changes the verdict, exit code or spend. It writes nothing when the engine has no config home.
3891
3910
  - **Cards:** `buildCards({ runid, events, audit, workdir, spec, model, questions })` → `{ cards, suspect, thin, noise }`. FIXED = a check red then the same check passes later in the run; NEVER FIXED = still red at run end. Excluded and counted: noise runs (provider-red, close-crashed, worker-crash, no close spoke), CHECK-SUSPECT (a no-suppressions hit on a prose comment), TOO-THIN (no error kind and no fix files). Written files come from `attempt-diff` events and the gate audit's allow write/edit rows relative to the tree.
3892
3911
  - **Fold:** `cardsMatch` / `foldCards`: same job + check, a shared error kind, Jaccard >= 60% on flagged-text tokens (a fit on known cards, not a proof). Code-less cards (kind `other`) fold on the text rule alone, only when both token sets are non-empty (R1, 2026-10-23: UNTESTED on real data; the 60% was fitted on cards with codes). `kindAgrees` is the one kind test.
3893
- - **Ledger** `<config home>/learn/ledger.json` (mode 600, atomic, secrets masked): rows `{ id, lesson, count, sample, models, workflow, place, level, trust, runIds, firstSeen, lastSeen, specHash }`, plus `pending` (held rubric cards), `suspect`, `health`. There is no check-wide set-aside (R2, 2026-10-23): a lesson seen in several jobs climbs by the climb rules and is read at its level; all-jobs hints come last. `countLedger` (+1 per run per row), `holdPending` / `releasePending` (rubric cards count only after the human Accept; `releaseRun` is called from the door's accept path), `climb` (the level of a lesson, derived at read time by `readableRows` from the rows not decayed at that moment: it can go back down when siblings age past 56 days; the file stores `workflow` only and ignores an older stored level). The ledger keeps EVERY row (nothing is deleted, no `expired` flag is stored; hamr 2026-10-23). Memory is the filter: `readableRows` / `renderHints` skip a row last seen more than 56 days ago (`isDecayed`) and a workflow row of another signed hash (`otherJobVersion`); a lesson seen again counts +1 on its old row. Old files with `status`/`expiredBy` still load; the fields are ignored and dropped at the next write.
3912
+ - **Ledger** `<config home>/learn/ledger.json` (mode 600, atomic, secrets masked): rows `{ id, lesson, count, sample, models, workflow, place, level, trust, runIds, firstSeen, lastSeen, specHash }`, plus `pending` (holds an older version wrote for rubric runs; new runs write none), `suspect`, `health`. There is no check-wide set-aside (R2, 2026-10-23): a lesson seen in several jobs climbs by the climb rules and is read at its level; all-jobs hints come last. `countLedger` (+1 per run per row), `releasePending` (an OLD hold, written by v0.46.x, counts on the human Accept; `releaseRun` is called from the door's accept path; a rubric run's lessons now count at run end like a deterministic run's, trust `rubric` kept, and `foldRunIntoLedger` returns `held: 0`; reuse/bridge credit still waits for the Accept). A row already holding the run id (a resumed leg, one id) is refreshed from the new card (sample, lesson, key, lastSeen, hash, place) but its count, model tally and run ids do not move, `climb` (the level of a lesson, derived at read time by `readableRows` from the rows not decayed at that moment: it can go back down when siblings age past 56 days; the file stores `workflow` only and ignores an older stored level). The ledger keeps EVERY row (nothing is deleted, no `expired` flag is stored; hamr 2026-10-23). Memory is the filter: `readableRows` / `renderHints` skip a row last seen more than 56 days ago (`isDecayed`) and a workflow row of another signed hash (`otherJobVersion`); a lesson seen again counts +1 on its old row. Old files with `status`/`expiredBy` still load; the fields are ignored and dropped at the next write.
3894
3913
  - **Reading:** `hintsForRun({ home, spec, specHash })` renders once at run start; `runJob(..., { learnHints: { text, ids } })` → `runPlan` hands it to the PLANNER at the draft and the first replan only, and emits a `learnings` spine record with the exact text. `renderHints(ledger, { workflow, place, specHash, now })` is the one render (this job, then this place, then all-jobs, highest count first, 2,000 characters, a hint whole or not at all; all-jobs hints last); a FIXED hint adds `What changed:` (first 3 `+`/`-` lines of the stored diff, 150 chars each, then `(+N more)`; never on NEVER FIXED) and a repeated path in a hint reads as its basename (R3, R5, 2026-10-23); the spine's recorded text is byte-equal to a fresh `renderHints(...).text` from the ledger as it stood. The Audit run detail carries `learnings` (read-only); its header counts DISTINCT hints (the same ledger row handed at the draft and the first replan counts once).
3895
3914
 
3896
3915
  ### Settings — the keys file, `config.json`, the monthly $ limit, the Providers tab (PANEL-BUILD.md P4a, `src/keysfile.js`, `src/config.js`, `src/monthly.js`, `src/providerrows.js`, `src/panel/settingsroutes.js`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/cli.js CHANGED
@@ -36,6 +36,8 @@ import { bundleMain } from './bundlerun.js';
36
36
  // rather than re-parsing argv a second time here keeps exactly one code
37
37
  // path for the flag grammar; `src/cli.js` only supplies the deps shape
38
38
  // (`out`/`err`/`env`) this command's own callers already use.
39
+ import { startCmd, stopCmd, statusCmd, installCmd, menuCmd } from './launcher.js';
40
+ import { makePaint } from './tty-colour.js';
39
41
  import { main as runUMain } from './userrun.js';
40
42
  // PANEL-BUILD.md P0 task 3/4 — `bareloop interview` and `bareloop author`
41
43
  // wrap the close-authoring interview and authoring-pipeline flows' own
@@ -272,50 +274,30 @@ function doRuns(args, { out, err, cwd }) {
272
274
  }
273
275
 
274
276
  /**
275
- * The bare `bareloop` menu: `1 export 2 run 3 history q quit`, then the
276
- * SAME code path as the sub-command, asking its args line by line. `run-u`,
277
- * `interview`, `author` and `replay` are NOT wizarded here — a line-at-a-time
278
- * prompt for `run-u`'s 13 flags (or the others' own argv shapes) would be
279
- * inventing new interactive UX, not wiring, and that judgement stands. This
280
- * menu is fixed instead: it now says those four commands exist and how to
281
- * reach them, rather than implying only three commands do at all.
282
- * `keysHome` is the home `main` already resolved (`keysForDoor`), handed on so choice 2 reads the
283
- * SAME config.json (monthly limit, price, key row) as `bareloop run <bundle>` — never resolved twice.
284
- * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx @param {string|undefined} keysHome
277
+ * The launcher doors (L, PANEL-BUILD.md addendum 2026-10-24): the bare `bareloop` menu and `start | stop | status | install`. All logic is
278
+ * `src/launcher.js`; this only supplies the home (the SAME one `main` resolved for the keys file and config.json) and the line reader.
279
+ * `deps.launcher` is the test seam for every OS touch (ports, spawn, kill, opener, clock).
280
+ * @param {string} cmd 'menu' | 'start' | 'stop' | 'status' | 'install' @param {string[]} rest
281
+ * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void }} io @param {string|undefined} home
282
+ * @returns {Promise<number>}
285
283
  */
286
- async function runMenu(deps, ctx, keysHome) {
287
- const stdin = deps.stdin;
288
- const stdout = deps.stdout;
289
- const rl = createInterface({ input: stdin, output: stdout });
284
+ async function runLauncher(cmd, rest, deps, io, home) {
285
+ const rl = createInterface({ input: deps.stdin ?? process.stdin, output: deps.stdout ?? process.stdout });
286
+ let closed = false;
287
+ rl.once('close', () => { closed = true; });
288
+ // a closed stdin answers '' (= the safe default: quit / "no"), never hangs
289
+ const ask = (/** @type {string} */ q) => (closed ? Promise.resolve('') : Promise.race([
290
+ rl.question(q),
291
+ new Promise((resolve) => { rl.once('close', () => resolve('')); }),
292
+ ]).then(String));
293
+ const rawEnv = deps.processEnv ?? process.env;
294
+ const d = { home, ask, paint: makePaint(deps.stdout ?? process.stdout, rawEnv), paintErr: makePaint(deps.stderr ?? process.stderr, rawEnv), ...(deps.launcher ?? {}) };
290
295
  try {
291
- ctx.out('1 export 2 run 3 history q quit');
292
- ctx.out('(also on the command line, not wizarded here: run-u, interview, author, replay, runs, panel — run `bareloop <name>` with its own flags)');
293
- const choice = (await rl.question('> ')).trim().toLowerCase();
294
- if (choice === '' || choice === 'q' || choice === 'quit') return 0;
295
- if (choice === '1') {
296
- const specPath = (await rl.question('job spec path: ')).trim();
297
- const registry = (await rl.question('registry dir: ')).trim();
298
- const outDir = (await rl.question('out dir: ')).trim();
299
- return doExport([specPath, '--registry', registry, '--out', outDir], ctx);
300
- }
301
- if (choice === '2') {
302
- const bundleDir = (await rl.question('bundle dir: ')).trim();
303
- const repo = (await rl.question('repo path: ')).trim();
304
- const budget = (await rl.question('budget (blank to skip): ')).trim();
305
- const wall = (await rl.question('wall minutes (blank to skip): ')).trim();
306
- const approve = (await rl.question('approve hash (blank to skip): ')).trim();
307
- const args = [bundleDir, '--repo', repo];
308
- if (budget) args.push('--budget', budget);
309
- if (wall) args.push('--wall', wall);
310
- if (approve) args.push('--approve', approve);
311
- return await bundleMain(args, { ...ctx, deps, keysHome, parseFlags, printReds });
312
- }
313
- if (choice === '3') {
314
- const bundleDir = (await rl.question('bundle dir: ')).trim();
315
- return doHistory([bundleDir], ctx);
316
- }
317
- ctx.err(`unknown choice ${JSON.stringify(choice)} — one of: 1, 2, 3, q`);
318
- return 1;
296
+ if (cmd === 'start') return await startCmd(io, d);
297
+ if (cmd === 'stop') return await stopCmd(rest, io, d);
298
+ if (cmd === 'status') return await statusCmd(io, d);
299
+ if (cmd === 'install') return await installCmd(io, d);
300
+ return await menuCmd(io, d);
319
301
  } finally {
320
302
  rl.close();
321
303
  }
@@ -326,7 +308,7 @@ async function runMenu(deps, ctx, keysHome) {
326
308
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
327
309
  * queued stdout on its own (F-something: `process.exit()` can discard it).
328
310
  * @param {string[]} argv
329
- * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string }} deps
311
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string, processEnv?: Record<string,string|undefined> }} deps
330
312
  * @returns {Promise<number>}
331
313
  */
332
314
  export async function main(argv, deps = {}) {
@@ -345,7 +327,9 @@ export async function main(argv, deps = {}) {
345
327
 
346
328
  const [cmd, ...rest] = argv;
347
329
  if (keys.warning && cmd && ['run', 'run-u', 'interview', 'author', 'panel'].includes(cmd)) err(`WARNING: ${keys.warning}`);
348
- if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx, keys.home);
330
+ const launcherHome = keys.home ?? deps.runlistHome;
331
+ if (!cmd) return runLauncher('menu', [], { ...deps, stdout }, ctx, launcherHome);
332
+ if (cmd === 'start' || cmd === 'stop' || cmd === 'status' || cmd === 'install') return runLauncher(cmd, rest, { ...deps, stdout }, ctx, launcherHome);
349
333
  if (cmd === 'export') return doExport(rest, ctx);
350
334
  if (cmd === 'run') return bundleMain(rest, { ...ctx, keysHome: keys.home, parseFlags, printReds });
351
335
  if (cmd === 'history') return doHistory(rest, ctx);
@@ -388,6 +372,6 @@ export async function main(argv, deps = {}) {
388
372
  // and spine/gate-audit reads over `127.0.0.1` only. `deps.runlistHome`
389
373
  // rides through the same injectable seam `run`/`run-u` already use.
390
374
  if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome, env: deps.env });
391
- err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history, run-u, interview, author, replay, runs, panel`);
375
+ err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history, run-u, interview, author, replay, runs, panel, start, stop, status, install`);
392
376
  return 1;
393
377
  }
@@ -0,0 +1,409 @@
1
+ // L (PANEL-BUILD.md addendum 2026-10-24) — the `bareloop` launcher: the bare-`bareloop` menu and `bareloop start | stop | status | install`.
2
+ //
3
+ // No pid file: the panel is FOUND by asking 127.0.0.1:4700..4709 for `GET /api/whoami` (src/panel/server.js), several at once, 300 ms
4
+ // each; a reply with `app === "bareloop"` is a panel. Every call prints the same two header lines first, from {@link buildHeader}.
5
+ // The run counts are the panel's own (`runsListForCount`: the `listRuns` rows' `status.key` and `needs`, plus the schedules that gave up) — never a second spelling of "running" or "needs you".
6
+ // This file owns no arbiter state: it starts/stops the panel process and reads the run list at $0. Every OS touch (spawn, kill, open,
7
+ // port bind, clock, stdin) comes through `deps` so tests inject all of them and never see a real panel.
8
+
9
+ import {
10
+ existsSync, statSync, mkdirSync, chmodSync, openSync, closeSync,
11
+ } from 'node:fs';
12
+ import { createServer } from 'node:net';
13
+ import { spawn as realSpawn } from 'node:child_process';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { readConfig, updateConfig, ConfigError } from './config.js';
17
+ import { keysHome, keysFilePath, ensureKeysFile } from './keysfile.js';
18
+ import { openInOs } from './opener.js';
19
+ import { PLAIN } from './tty-colour.js';
20
+ import { runsListForCount, packageVersion, panelUrl } from './panel/server.js';
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ /** the first port tried, and how many in a row (4700..4709) */
24
+ export const PORT_START = 4700;
25
+ export const PORT_COUNT = 10;
26
+ const PROBE_TIMEOUT_MS = 300;
27
+ const WAIT_DEADLINE_MS = 10_000;
28
+ const POLL_MS = 100;
29
+ const MIN_NODE_MAJOR = 22;
30
+ const RULE_WIDTH = 56;
31
+
32
+ /**
33
+ * @typedef {object} Panel one answered probe
34
+ * @property {number} port @property {number} pid @property {string} version @property {number} drafts @property {number} schedules
35
+ * @property {string} url
36
+ */
37
+
38
+ /**
39
+ * @typedef {object} LauncherDeps every OS touch, injectable (a test passes all of them)
40
+ * @property {string} [home] the bareloop home (`~/.config/bareloop`); undefined = the real one
41
+ * @property {number} [portStart] @property {number} [portCount]
42
+ * @property {number[]} [ports] an explicit port list (tests); wins over portStart/portCount
43
+ * @property {number} [timeoutMs] probe timeout per port
44
+ * @property {number} [deadlineMs] start/stop wait deadline
45
+ * @property {string} [nodeVersion] default process.versions.node
46
+ * @property {typeof fetch} [fetchImpl]
47
+ * @property {(cmd: string, args: string[], o: object) => any} [spawnFn] default node:child_process spawn
48
+ * @property {(pid: number, sig: string) => void} [kill] default process.kill
49
+ * @property {((target: string) => Promise<boolean>)} [open] default the shared OS opener
50
+ * @property {(port: number) => Promise<boolean>} [portFree] default: try to bind 127.0.0.1:port
51
+ * @property {(ms: number) => Promise<void>} [sleep]
52
+ * @property {(question: string) => Promise<string>} [ask] the y/N and menu reader
53
+ * @property {string} [bareloopBin] default bin/bareloop.mjs of this package
54
+ * @property {import('./tty-colour.js').Paint} [paint] colour for stdout lines (default none); {@link makePaint} decides TTY / NO_COLOR / TERM
55
+ * @property {import('./tty-colour.js').Paint} [paintErr] colour for stderr lines (default: `paint`)
56
+ * @property {number} [now] ms clock for the run list's age rules (default: real time); tests pass their own
57
+ * @property {string} [platform]
58
+ * @property {Record<string, string|undefined>} [env] raw env handed to the panel child (default process.env)
59
+ */
60
+
61
+ /**
62
+ * The run counts, from the panel's own `listRuns` rows. `status.key === 'running'` is the panel's word; `needs` is the one tag
63
+ * {@link import('./panel/server.js').needsStateFor} decides. A part that is 0 still shows.
64
+ * @param {any[]} rows
65
+ * @param {number} [gaveUp] schedules that gave up: the panel's Needs-you holds them too
66
+ * @returns {{running: number, needYou: number, total: number}}
67
+ */
68
+ export function countRuns(rows, gaveUp = 0) {
69
+ let running = 0;
70
+ let needYou = gaveUp;
71
+ for (const r of rows) {
72
+ if (r && r.status && r.status.key === 'running') running += 1;
73
+ if (r && typeof r.needs === 'string' && r.needs !== '') needYou += 1;
74
+ }
75
+ return { running, needYou, total: rows.length };
76
+ }
77
+
78
+ /**
79
+ * "installed ✓" = the folder exists, config.json is there and reads with no problem, `.env` exists with mode 600, node >= 22.
80
+ * Otherwise the FIRST missing thing in plain words.
81
+ * @param {{home?: string, nodeVersion?: string}} o
82
+ * @returns {{ok: boolean, problem: string|null}}
83
+ */
84
+ export function installState(o) {
85
+ const dir = keysHome(o.home);
86
+ if (!existsSync(dir)) return { ok: false, problem: 'no bareloop folder yet' };
87
+ const cfg = readConfig({ home: o.home });
88
+ if (cfg.problem) return { ok: false, problem: 'config.json cannot be read' };
89
+ if (!cfg.exists) return { ok: false, problem: 'no settings file yet' };
90
+ const env = keysFilePath(o.home);
91
+ if (!existsSync(env)) return { ok: false, problem: 'no keys file yet' };
92
+ try {
93
+ if ((statSync(env).mode & 0o077) !== 0) return { ok: false, problem: 'the keys file is readable by other users' };
94
+ } catch { return { ok: false, problem: 'the keys file cannot be read' }; }
95
+ const major = Number((o.nodeVersion ?? process.versions.node).split('.')[0]);
96
+ if (!(major >= MIN_NODE_MAJOR)) return { ok: false, problem: `node ${MIN_NODE_MAJOR} or newer is needed` };
97
+ return { ok: true, problem: null };
98
+ }
99
+
100
+ /**
101
+ * Ask 127.0.0.1 across the ports in parallel; a reply with `app === "bareloop"` is a panel. Never throws.
102
+ * @param {{ports: number[], timeoutMs?: number, fetchImpl?: typeof fetch}} o
103
+ * @returns {Promise<Panel[]>} in port order
104
+ */
105
+ export async function probePanels(o) {
106
+ const f = o.fetchImpl ?? fetch;
107
+ const ms = o.timeoutMs ?? PROBE_TIMEOUT_MS;
108
+ const answers = await Promise.all(o.ports.map(async (port) => {
109
+ try {
110
+ const res = await f(`${panelUrl(port)}/api/whoami`, { signal: AbortSignal.timeout(ms) });
111
+ if (!res.ok) return null;
112
+ const j = /** @type {any} */ (await res.json());
113
+ if (!j || j.app !== 'bareloop') return null;
114
+ return /** @type {Panel} */ ({
115
+ port,
116
+ pid: Number(j.pid),
117
+ version: String(j.version ?? ''),
118
+ drafts: Number(j.drafts) || 0,
119
+ schedules: Number(j.schedules) || 0,
120
+ url: panelUrl(port),
121
+ });
122
+ } catch { return null; }
123
+ }));
124
+ return /** @type {Panel[]} */ (answers.filter((a) => a !== null));
125
+ }
126
+
127
+ /** @param {LauncherDeps} d @returns {number[]} */
128
+ function portsOf(d) {
129
+ if (d.ports) return d.ports;
130
+ const start = d.portStart ?? PORT_START;
131
+ return Array.from({ length: d.portCount ?? PORT_COUNT }, (_, i) => start + i);
132
+ }
133
+
134
+ /** @param {LauncherDeps} d @returns {Promise<Panel[]>} */
135
+ function probeAll(d) {
136
+ return probePanels({ ports: portsOf(d), timeoutMs: d.timeoutMs, fetchImpl: d.fetchImpl });
137
+ }
138
+
139
+ /**
140
+ * The header, ONCE per call: the two lines every `bareloop` command prints first, plus the panels the probe found (the commands
141
+ * reuse them, so nothing is probed twice).
142
+ * @param {LauncherDeps & {version?: string}} d
143
+ * @returns {Promise<{lines: string[], panels: Panel[]}>}
144
+ */
145
+ export async function buildHeader(d) {
146
+ const version = d.version ?? packageVersion();
147
+ const panels = await probeAll(d);
148
+ const inst = installState({ home: d.home, nodeVersion: d.nodeVersion });
149
+ const P = d.paint ?? PLAIN;
150
+ const sep = P.grey(' · ');
151
+ const where = panels.length === 0
152
+ ? `panel ${P.grey('not running')}`
153
+ : `panel${panels.length > 1 ? 's' : ''} ${P.green('running')} at ${panels.map((p) => P.cyan(p.url) + (p.version !== version ? P.yellow(` (version ${p.version} — stop and start to use ${version})`) : '')).join(', ')}`;
154
+ const state = inst.ok ? P.green('installed ✓') : `${P.red('not installed')} — ${inst.problem}, pick ${P.boldCyan('4')} install`;
155
+ const first = `${P.boldCyan('bareloop')} ${P.grey(version)}${sep}${state}${sep}${where}`;
156
+ let second;
157
+ try {
158
+ const l = runsListForCount({ home: d.home, ...(typeof d.now === 'number' ? { now: d.now } : {}) });
159
+ const c = countRuns(l.runs, l.gaveUp.length);
160
+ second = `${P.grey('runs:')} ${c.running > 0 ? P.green(`${c.running} running`) : P.grey(`${c.running} running`)}${sep}`
161
+ + `${c.needYou > 0 ? P.yellow(`${c.needYou} need you`) : P.grey(`${c.needYou} need you`)}${sep}${c.total} in all`;
162
+ } catch {
163
+ second = `${P.grey('runs:')} the run list could not be read`;
164
+ }
165
+ // the thin rule frames the header only on a colour terminal (plain output keeps exactly the two lines)
166
+ const rule = P.on ? [P.grey('─'.repeat(RULE_WIDTH))] : [];
167
+ return { lines: [...rule, first, second, ...rule], panels };
168
+ }
169
+
170
+ /**
171
+ * @param {LauncherDeps & {version?: string}} d
172
+ * @returns {Promise<string[]>} the two header lines
173
+ */
174
+ export async function headerLines(d) {
175
+ return (await buildHeader(d)).lines;
176
+ }
177
+
178
+ /**
179
+ * Print the header unless the caller (the menu) already did; hand back the panels either way.
180
+ * @param {{out: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
181
+ */
182
+ async function headerOnce(io, d, shown) {
183
+ if (shown) return shown;
184
+ const h = await buildHeader(d);
185
+ for (const l of h.lines) io.out(l);
186
+ return h;
187
+ }
188
+
189
+ /** stderr line, red on a colour terminal @param {{err: (s: string) => void}} io @param {LauncherDeps} d @param {string} text */
190
+ const fail = (io, d, text) => io.err((d.paintErr ?? d.paint ?? PLAIN).red(text));
191
+
192
+ /** @param {number} n @param {string} one @param {string} many */
193
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
194
+
195
+ /** @param {LauncherDeps} d */
196
+ const sleepOf = (d) => d.sleep ?? ((/** @type {number} */ ms) => new Promise((r) => { setTimeout(r, ms); }));
197
+
198
+ /** @param {number} port @returns {Promise<boolean>} true when nothing holds 127.0.0.1:port */
199
+ function bindFree(port) {
200
+ return new Promise((resolve) => {
201
+ const s = createServer();
202
+ s.once('error', () => resolve(false));
203
+ s.listen(port, '127.0.0.1', () => { s.close(() => resolve(true)); });
204
+ });
205
+ }
206
+
207
+ /**
208
+ * `bareloop install` — creates only what is missing, never reads a key value.
209
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
210
+ * @returns {Promise<number>}
211
+ */
212
+ export async function installCmd(io, d, shown) {
213
+ await headerOnce(io, d, shown);
214
+ const major = Number((d.nodeVersion ?? process.versions.node).split('.')[0]);
215
+ if (!(major >= MIN_NODE_MAJOR)) {
216
+ fail(io, d, `bareloop needs node ${MIN_NODE_MAJOR} or newer; this is node ${d.nodeVersion ?? process.versions.node}. Nothing was changed.`);
217
+ return 1;
218
+ }
219
+ const dir = keysHome(d.home);
220
+ /** @type {string[]} */
221
+ const made = [];
222
+ if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); made.push(`${dir} (only you can open it)`); }
223
+ const envFile = keysFilePath(d.home);
224
+ if (ensureKeysFile(d.home)) made.push(`${envFile} (the keys file, only you can read it)`);
225
+ else {
226
+ try {
227
+ if ((statSync(envFile).mode & 0o077) !== 0) { chmodSync(envFile, 0o600); made.push(`${envFile} set so only you can read it`); }
228
+ } catch { /* unreadable: the header says so */ }
229
+ }
230
+ const cfg = readConfig({ home: d.home });
231
+ if (cfg.problem) {
232
+ fail(io, d, `${cfg.problem}. Install did not touch it.`);
233
+ return 1;
234
+ }
235
+ if (!cfg.exists) {
236
+ try { updateConfig({}, { home: d.home }); made.push(`${join(dir, 'config.json')} (your settings)`); } catch (e) {
237
+ if (!(e instanceof ConfigError)) throw e;
238
+ fail(io, d, `${e.message}. Install did not touch it.`);
239
+ return 1;
240
+ }
241
+ }
242
+ if (made.length === 0) { io.out('Already installed, nothing to do.'); return 0; }
243
+ io.out('Installed. Created:');
244
+ for (const m of made) io.out(` ${m}`);
245
+ return 0;
246
+ }
247
+
248
+ /**
249
+ * `bareloop status` — the header, then one line per panel (or `Panel not running.`). Exit 0 running, 3 not running; 0 when the menu ran it.
250
+ * @param {{out: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown] header the menu already printed
251
+ * @returns {Promise<number>}
252
+ */
253
+ export async function statusCmd(io, d, shown) {
254
+ const h = await headerOnce(io, d, shown);
255
+ if (h.panels.length === 0) {
256
+ io.out('Panel not running.');
257
+ // the sub-command's exit says "not running" (3); from the menu the menu itself worked (0)
258
+ return shown ? 0 : 3;
259
+ }
260
+ for (const p of h.panels) {
261
+ const body = `pid ${p.pid} · version ${p.version} · ${plural(p.drafts, 'draft', 'drafts')} open · ${plural(p.schedules, 'schedule', 'schedules')} on`;
262
+ io.out(h.panels.length > 1 ? `${p.url} · ${body}` : body);
263
+ }
264
+ return 0;
265
+ }
266
+
267
+ /**
268
+ * Wait until `port` stops (gone=true) or starts (gone=false) answering whoami as bareloop.
269
+ * @param {LauncherDeps} d @param {number} port @param {boolean} gone
270
+ * @returns {Promise<boolean>} false when the deadline passed first
271
+ */
272
+ async function waitFor(d, port, gone) {
273
+ const sleep = sleepOf(d);
274
+ const deadline = d.deadlineMs ?? WAIT_DEADLINE_MS;
275
+ for (let waited = 0; waited <= deadline; waited += POLL_MS) {
276
+ const found = await probePanels({ ports: [port], timeoutMs: d.timeoutMs, fetchImpl: d.fetchImpl });
277
+ if (gone ? found.length === 0 : found.length > 0) return true;
278
+ await sleep(POLL_MS);
279
+ }
280
+ return false;
281
+ }
282
+
283
+ /**
284
+ * `bareloop start` — find a running panel first; else start one detached on the first free port 4700..4709, wait until it answers,
285
+ * open the page.
286
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
287
+ * @returns {Promise<number>}
288
+ */
289
+ export async function startCmd(io, d, shown) {
290
+ const h = await headerOnce(io, d, shown);
291
+ const P = d.paint ?? PLAIN;
292
+ const open = d.open ?? ((/** @type {string} */ t) => openInOs(t, { platform: d.platform, underTest: false }));
293
+ /** @param {string} url @param {string} said */
294
+ const finish = async (url, said) => {
295
+ const opened = await open(url);
296
+ io.out(opened ? `${said} — opening the page.` : `${said} — open it in your browser.`);
297
+ return 0;
298
+ };
299
+ if (h.panels.length > 0) {
300
+ const rc = await finish(h.panels[0].url, `Panel already running at ${P.cyan(h.panels[0].url)}`);
301
+ const mine = d.version ?? packageVersion();
302
+ if (h.panels[0].version !== mine) io.out(P.yellow(`It is version ${h.panels[0].version}; this bareloop is ${mine}. Run bareloop stop, then bareloop start, to use ${mine}.`));
303
+ return rc;
304
+ }
305
+ const free = d.portFree ?? bindFree;
306
+ const ports = portsOf(d);
307
+ /** @type {number|null} */
308
+ let chosen = null;
309
+ for (const p of ports) { if (await free(p)) { chosen = p; break; } }
310
+ if (chosen === null) {
311
+ fail(io, d, `Every port from ${ports[0]} to ${ports[ports.length - 1]} is taken by something else, so the panel was not started.`);
312
+ return 1;
313
+ }
314
+ if (chosen !== ports[0]) io.out(`${ports[0]} is taken, using ${chosen}.`);
315
+ const dir = keysHome(d.home);
316
+ try { mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* the log open below reports it */ }
317
+ const logFile = join(dir, 'panel.log');
318
+ /** @type {number|null} */
319
+ let logFd = null;
320
+ try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
321
+ const bin = d.bareloopBin ?? join(HERE, '..', 'bin', 'bareloop.mjs');
322
+ const platform = d.platform ?? process.platform;
323
+ const argv = [process.execPath, bin, 'panel', '--port', String(chosen)];
324
+ const spawnFn = d.spawnFn ?? realSpawn;
325
+ const [cmd, args] = platform === 'linux' ? ['setsid', argv] : [argv[0], argv.slice(1)];
326
+ try {
327
+ const child = spawnFn(cmd, args, { detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: d.env ?? process.env });
328
+ if (child && typeof child.once === 'function') child.once('error', () => { /* seen as "never answered" below */ });
329
+ if (child && typeof child.unref === 'function') child.unref();
330
+ } catch (e) {
331
+ fail(io, d, `Could not start the panel: ${/** @type {Error} */ (e).message}`);
332
+ return 1;
333
+ } finally {
334
+ if (logFd !== null) { try { closeSync(logFd); } catch { /* the child holds its own copy */ } }
335
+ }
336
+ if (!(await waitFor(d, chosen, false))) {
337
+ fail(io, d, `The panel did not answer within ${(d.deadlineMs ?? WAIT_DEADLINE_MS) / 1000} seconds. See ${logFile}.`);
338
+ return 1;
339
+ }
340
+ return finish(panelUrl(chosen), `${P.green('Panel started')} at ${P.cyan(panelUrl(chosen))}`);
341
+ }
342
+
343
+ /**
344
+ * `bareloop stop [--yes]` — SIGTERM the pid of EVERY panel the probe finds (one panel at a time is the rule; no "which one"), wait until each
345
+ * port stops answering. Drafts are in memory: warn (summed over all found panels) and ask y/N unless `--yes`.
346
+ * @param {string[]} args
347
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
348
+ * @returns {Promise<number>}
349
+ */
350
+ export async function stopCmd(args, io, d, shown) {
351
+ const bad = args.find((a) => a !== '--yes');
352
+ if (bad !== undefined) {
353
+ fail(io, d, `Unknown option ${JSON.stringify(bad)} — bareloop stop takes only --yes. It stops every panel it finds.`);
354
+ return 1;
355
+ }
356
+ const h = await headerOnce(io, d, shown);
357
+ const yes = args.includes('--yes');
358
+ const panels = h.panels;
359
+ if (panels.length === 0) { io.out('Panel not running.'); return 0; }
360
+ const ask = d.ask;
361
+ const drafts = panels.reduce((n, p) => n + p.drafts, 0);
362
+ if (drafts > 0 && !yes) {
363
+ io.out(`${drafts === 1 ? '1 draft is' : `${drafts} drafts are`} open in the panel and will be lost.`);
364
+ io.out('Runs keep going. Scheduled runs wait until the panel is started again.');
365
+ const a = ask ? (await ask('Stop the panel? [y/N] ')).trim().toLowerCase() : '';
366
+ if (a !== 'y' && a !== 'yes') { io.out('Not stopped.'); return 0; }
367
+ }
368
+ const kill = d.kill ?? ((/** @type {number} */ pid, /** @type {string} */ sig) => process.kill(pid, /** @type {NodeJS.Signals} */ (sig)));
369
+ for (const p of panels) {
370
+ if (!Number.isInteger(p.pid) || p.pid <= 1) { fail(io, d, `The panel at ${p.url} reported no usable pid, so it was not stopped.`); return 1; }
371
+ try { kill(p.pid, 'SIGTERM'); } catch (e) {
372
+ fail(io, d, `Could not stop the panel at ${p.url} (pid ${p.pid}): ${/** @type {Error} */ (e).message}`);
373
+ return 1;
374
+ }
375
+ }
376
+ for (const p of panels) {
377
+ if (!(await waitFor(d, p.port, true))) {
378
+ fail(io, d, `The panel at ${p.url} still answers after ${(d.deadlineMs ?? WAIT_DEADLINE_MS) / 1000} seconds (pid ${p.pid}).`);
379
+ return 1;
380
+ }
381
+ }
382
+ io.out((d.paint ?? PLAIN).green(panels.length > 1 ? `${panels.length} panels stopped.` : 'Panel stopped.'));
383
+ return 0;
384
+ }
385
+
386
+ /**
387
+ * The bare `bareloop` menu: header, the four choices, one question. Runs the chosen command and returns its exit code.
388
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d
389
+ * @returns {Promise<number>}
390
+ */
391
+ export async function menuCmd(io, d) {
392
+ const ask = d.ask;
393
+ const h = await buildHeader(d);
394
+ for (const l of h.lines) io.out(l);
395
+ const P = d.paint ?? PLAIN;
396
+ if (!P.on) io.out('');
397
+ const n = (/** @type {string} */ k) => P.boldCyan(k);
398
+ const items = `${n('1')} start ${n('2')} stop ${n('3')} status ${n('4')} install ${n('q')} quit`;
399
+ io.out(P.on ? ` ${items}` : items);
400
+ const choice = ask ? (await ask(`${P.cyan('>')} `)).trim().toLowerCase() : 'q';
401
+ // the header is already on screen: the chosen command reuses it instead of printing it a second time
402
+ if (choice === '1' || choice === 'start') return startCmd(io, d, h);
403
+ if (choice === '2' || choice === 'stop') return stopCmd([], io, d, h);
404
+ if (choice === '3' || choice === 'status') return statusCmd(io, d, h);
405
+ if (choice === '4' || choice === 'install') return installCmd(io, d, h);
406
+ if (choice === '' || choice === 'q' || choice === 'quit') return 0;
407
+ fail(io, d, `unknown choice ${JSON.stringify(choice)} — one of: 1, 2, 3, 4, q`);
408
+ return 1;
409
+ }