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 +14 -0
- package/README.md +2 -0
- package/bareloop.context.md +30 -11
- package/package.json +1 -1
- package/src/cli.js +29 -45
- package/src/launcher.js +409 -0
- package/src/learn.js +24 -16
- package/src/learnstore.js +9 -17
- package/src/opener.js +30 -0
- package/src/panel/authorroutes.js +1 -1
- package/src/panel/server.js +48 -2
- package/src/panel/settingsroutes.js +3 -17
- package/src/tty-colour.js +27 -0
- package/src/userrun.js +2 -2
- package/types/cli.d.ts +2 -1
- package/types/launcher.d.ts +234 -0
- package/types/learn.d.ts +8 -7
- package/types/learnstore.d.ts +4 -5
- package/types/opener.d.ts +12 -0
- package/types/panel/authorroutes.d.ts +1 -0
- package/types/panel/server.d.ts +24 -0
- package/types/tty-colour.d.ts +33 -0
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.
|
package/bareloop.context.md
CHANGED
|
@@ -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)
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
`
|
|
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` (
|
|
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.
|
|
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
|
|
276
|
-
* SAME
|
|
277
|
-
* `
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/launcher.js
ADDED
|
@@ -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
|
+
}
|