@homespunapps/cli 1.6.53 → 1.6.55

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.
@@ -1,5 +1,6 @@
1
1
  // `homespun apps` — v2 app lifecycle management (spec-cli §3.2): list / show /
2
- // update / delete / wake, plus `watch` (spec-cli §3.4).
2
+ // update / delete / wake, plus `watch` (spec-cli §3.4) and `audit` (the owner
3
+ // security review, issue #1589 phase B).
3
4
  //
4
5
  // Naming note (deviation from spec-cli's literal top-level `homespun watch`):
5
6
  // this branch still carries the UNCHANGED v1 `homespun watch <homespun-id>` command
@@ -25,7 +26,7 @@ export async function runApps(args) {
25
26
  return;
26
27
  }
27
28
  if (verb === undefined) {
28
- fail("missing verb: homespun apps <list|show|update|share-link|delete|wake|watch|domain>", "invalid_args");
29
+ fail("missing verb: homespun apps <list|show|audit|update|share-link|delete|wake|watch|domain>", "invalid_args");
29
30
  }
30
31
  const sub = {
31
32
  positionals: args.positionals.slice(1),
@@ -40,6 +41,8 @@ export async function runApps(args) {
40
41
  return runList(sub);
41
42
  case "show":
42
43
  return runShow(sub);
44
+ case "audit":
45
+ return runAudit(sub);
43
46
  case "update":
44
47
  return runUpdate(sub);
45
48
  case "share-link":
@@ -53,7 +56,7 @@ export async function runApps(args) {
53
56
  case "domain":
54
57
  return runDomain(sub);
55
58
  default:
56
- fail(`unknown verb '${verb}': homespun apps <list|show|update|share-link|delete|wake|watch|domain>`, "invalid_args");
59
+ fail(`unknown verb '${verb}': homespun apps <list|show|audit|update|share-link|delete|wake|watch|domain>`, "invalid_args");
57
60
  }
58
61
  }
59
62
  // ---------------------------------------------------------------------------
@@ -103,6 +106,55 @@ async function runShow(args) {
103
106
  }
104
107
  }
105
108
  // ---------------------------------------------------------------------------
109
+ // audit
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * `homespun apps audit` — the owner security review (issue #1589 phase B).
113
+ *
114
+ * Exits 1 when any `high` finding exists, so this can gate a script or a CI
115
+ * step. That is a FINDINGS exit, not an error exit: the report still prints in
116
+ * full on the way out, and a transport or auth failure still goes through
117
+ * `failFromError` with its own code. A caller that only wants the data and
118
+ * never the gate reads the JSON and ignores the status.
119
+ */
120
+ async function runAudit(args) {
121
+ assertKnownFlags(args, ...specFor("apps", "audit"));
122
+ const severity = args.flags.get("severity");
123
+ if (severity !== undefined && !["high", "medium", "low"].includes(severity)) {
124
+ fail("--severity must be high|medium|low", "invalid_args");
125
+ }
126
+ const client = makeClient(args);
127
+ let report;
128
+ try {
129
+ report = await client.appAdvisories(severity !== undefined ? { severity } : {});
130
+ }
131
+ catch (e) {
132
+ failFromError(e);
133
+ return;
134
+ }
135
+ if (args.bools.has("json")) {
136
+ printJson(report);
137
+ }
138
+ else {
139
+ for (const app of report.items) {
140
+ for (const a of app.advisories) {
141
+ process.stdout.write(`${a.severity.padEnd(6)} ${app.slug} ${a.collection} ${a.code}\n`);
142
+ }
143
+ }
144
+ const { high, medium, low } = report.counts;
145
+ process.stdout.write(`\n${report.apps_affected} of ${report.apps_scanned} apps affected: ${high} high, ${medium} medium, ${low} low\n`);
146
+ if (report.truncated) {
147
+ // Never let a partial audit read as a clean one.
148
+ process.stdout.write("warning: the audit stopped before every app was scanned; this report is incomplete\n");
149
+ }
150
+ if (severity !== undefined && (high > 0 || medium > 0 || low > 0)) {
151
+ process.stdout.write(`(counts describe the whole audit, not the --severity ${severity} filter)\n`);
152
+ }
153
+ }
154
+ if (report.counts.high > 0)
155
+ process.exitCode = 1;
156
+ }
157
+ // ---------------------------------------------------------------------------
106
158
  // update
107
159
  // ---------------------------------------------------------------------------
108
160
  async function runUpdate(args) {
@@ -16,6 +16,14 @@
16
16
  // correctly with no socket at all. That is why the reconnect logic below is allowed
17
17
  // to give up on the socket and keep working.
18
18
  //
19
+ // ONE SOCKET FOR EVERY OWNED APP. Both halves of this command are owner-scoped: a
20
+ // claim with no `--app` drains every app the identity owns, and the wake socket
21
+ // (`/v1/agent-tasks/stream`) is subscribed to a per-owner channel, so `--app` is a
22
+ // filter over both and never a decision about transport. Worth stating because the
23
+ // first version was not like this: the hint rode the app's own feed socket, so `--app`
24
+ // silently doubled as "which app's socket carries my wakes" and a worker that gained a
25
+ // second app quietly stopped being pushed to.
26
+ //
19
27
  // WHY THIS FILE CONTAINS RECONNECT LOGIC AT ALL, when `apps watch` does not: nothing
20
28
  // in this CLI has it. `apps watch` falls back to HTTP long-polling permanently on any
21
29
  // pre-connect WS failure, never retries, handles SIGINT but not SIGTERM, and parks on
@@ -24,11 +32,48 @@
24
32
  // degrade to a slower path and nothing would say so. So this reconnects with capped
25
33
  // backoff, says so on stderr when it does, and exits cleanly on SIGTERM.
26
34
  import { spawn } from "node:child_process";
27
- import { openAppStream, appWsUrlFromAppUrl } from "@homespunapps/core";
35
+ import { openWorkerStream } from "@homespunapps/core";
28
36
  import { assertKnownFlags } from "../argv.js";
29
37
  import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
30
38
  import { resolveConfig } from "../config.js";
31
39
  import { fail, printJsonLine, warn } from "../output.js";
40
+ function createPool(limit, run, onSlotFree) {
41
+ const running = new Set();
42
+ return {
43
+ async admit(task) {
44
+ // `Promise.race` on the live set, so this wakes on the FIRST finisher rather
45
+ // than the oldest. Racing the oldest would idle a free slot behind a long task.
46
+ while (running.size >= limit)
47
+ await Promise.race(running);
48
+ const p = (async () => {
49
+ try {
50
+ await run(task);
51
+ }
52
+ catch (err) {
53
+ // `runTask` is written not to throw, and if that ever stops being true the
54
+ // failure must not escape into the pool: an unhandled rejection here would
55
+ // abandon every sibling child mid-flight for the sake of one task.
56
+ warn(`worker crashed on ${task.task_id}: ${err instanceof Error ? err.message : String(err)}`);
57
+ }
58
+ })();
59
+ const settled = p.finally(() => {
60
+ running.delete(settled);
61
+ // A freed slot is a reason to claim again NOW rather than at the end of the
62
+ // poll interval. Without this the pool would idle out the rest of a 15 s sleep
63
+ // with nothing running, which is slower than the sequential version it replaces.
64
+ onSlotFree();
65
+ });
66
+ running.add(settled);
67
+ },
68
+ capacity() {
69
+ return Math.max(0, limit - running.size);
70
+ },
71
+ async drain() {
72
+ while (running.size > 0)
73
+ await Promise.race(running);
74
+ },
75
+ };
76
+ }
32
77
  /** Backoff bounds for the wake socket. Capped so a long outage does not spin. */
33
78
  const RECONNECT_MIN_MS = 1_000;
34
79
  const RECONNECT_MAX_MS = 60_000;
@@ -78,22 +123,46 @@ export async function runWork(args) {
78
123
  // handler this function attached and did not own up to.
79
124
  process.on("SIGINT", stop);
80
125
  process.on("SIGTERM", stop);
126
+ // A finished child shortens the current sleep through the SAME `wake` the socket
127
+ // uses, because "there is capacity now" and "there is work now" both mean claim
128
+ // again, and one mechanism for both is one thing to keep correct.
129
+ const pool = createPool(opts.maxConcurrent, (task) => runTask(base, cfg.apiKey, task, opts.exec), () => wake?.());
130
+ // ONE BUDGET, SHARED BETWEEN PUSH AND POLL. The socket owns the credit accounting;
131
+ // this loop subtracts what it has promised. Capacity offered to the relay is capacity
132
+ // already spoken for, and a poll that claimed it as well would leave a worker told to
133
+ // run one task at a time holding two leases and starting one. That is the bug #1608
134
+ // fixed, arriving through a different door.
81
135
  const socket = opts.once
82
136
  ? null
83
- : openWakeSocket(opts, cfg.apiKey, base, () => wake?.());
137
+ : openWakeSocket(opts, cfg.apiKey, base, () => wake?.(), () => pool.capacity(), (task) => {
138
+ // Not awaited: this is a socket callback, and blocking it on a free slot would
139
+ // stall every other frame on the connection. The relay respected the credit, so
140
+ // a slot exists; `admit` waits only if it somehow did not.
141
+ void pool.admit(task);
142
+ });
84
143
  try {
85
144
  for (;;) {
86
- const claimed = await claim(base, cfg.apiKey, opts);
145
+ // Restate the offer every pass. See `reoffer`: it is the drift repair, not a
146
+ // belt-and-braces resend.
147
+ const promised = socket?.reoffer() ?? 0;
148
+ // Ask for what can actually be STARTED and is not already promised away. Claiming
149
+ // four while three children are running leases work that waits out its own lease
150
+ // before anything begins on it.
151
+ const want = Math.max(0, pool.capacity() - promised);
152
+ const claimed = want > 0 ? await claim(base, cfg.apiKey, opts, want) : [];
87
153
  for (const task of claimed) {
88
- await runTask(base, cfg.apiKey, task, opts.exec);
89
154
  if (stopping)
90
155
  break;
156
+ // Awaited, and only for a free SLOT: `admit` returns as soon as the child is
157
+ // spawned, so a batch larger than the cap is fed through rather than run in
158
+ // lockstep.
159
+ await pool.admit(task);
91
160
  }
92
161
  if (opts.once || stopping)
93
162
  break;
94
- // Sleep, interruptible by the wake frame. `wake` is re-armed each pass so a
95
- // frame that arrives WHILE tasks are running does not resolve a stale promise
96
- // and get lost; the next sleep is what it shortens.
163
+ // Sleep, interruptible by the wake frame OR by a child finishing. `wake` is
164
+ // re-armed each pass so a frame that arrives WHILE tasks are running does not
165
+ // resolve a stale promise and get lost; the next sleep is what it shortens.
97
166
  await new Promise((resolve) => {
98
167
  const timer = setTimeout(resolve, opts.pollSeconds * 1000);
99
168
  wake = () => {
@@ -105,6 +174,11 @@ export async function runWork(args) {
105
174
  }
106
175
  }
107
176
  finally {
177
+ // Wait for the children BEFORE closing the socket and dropping the handlers.
178
+ // Under `--once` this is what makes the command mean "drain a pass", and on
179
+ // SIGTERM it is the difference between finishing the work in flight and stranding
180
+ // every lease it holds until expiry.
181
+ await pool.drain();
108
182
  socket?.close();
109
183
  process.off("SIGINT", stop);
110
184
  process.off("SIGTERM", stop);
@@ -116,8 +190,11 @@ export async function runWork(args) {
116
190
  * worker that needs a supervisor to do its retrying. Logged and retried on the next
117
191
  * pass instead.
118
192
  */
119
- async function claim(base, apiKey, opts) {
120
- const body = { max: opts.maxConcurrent };
193
+ async function claim(base, apiKey, opts, max) {
194
+ // `max` is the pool's free capacity, NOT `--max-concurrent`. The two are the same
195
+ // only on an idle worker, and asking for the flag's value while children are running
196
+ // is what leased work that could not be started.
197
+ const body = { max };
121
198
  if (opts.appIds.length > 0)
122
199
  body.app_ids = opts.appIds;
123
200
  try {
@@ -224,52 +301,93 @@ async function report(base, apiKey, taskId, verb, text) {
224
301
  warn(`${verb} failed for ${taskId} (${err instanceof Error ? err.message : String(err)}); lease will lapse`);
225
302
  }
226
303
  }
227
- /**
228
- * The wake socket, with real reconnect.
229
- *
230
- * ONE APP ONLY, and this is the honest limitation of the frame rather than of this
231
- * command: the hint is published on the app's own `/_hs/ws`, so a worker draining
232
- * five apps would need five sockets. Polling already drains every app correctly, so
233
- * the socket is opened only when `--app` names exactly one and is simply skipped
234
- * otherwise. A multi-app worker polls, which costs latency and nothing else.
235
- *
236
- * Reconnects with capped exponential backoff and says so, once, per outage. It never
237
- * escalates to an exit: losing the socket makes this slower, not broken, and a
238
- * worker that killed itself over a lost optimisation would be worse than one that
239
- * kept polling.
240
- */
241
- function openWakeSocket(opts, apiKey, base, onWake) {
242
- if (opts.appIds.length !== 1)
243
- return null;
244
- const appId = opts.appIds[0];
304
+ export function openWakeSocket(opts, apiKey, base, onWake, capacity, onAssign) {
305
+ // `--app` filters the wake as well as the claim. A wake for an app this worker was
306
+ // told to ignore would otherwise cut the sleep short to run a claim that can only
307
+ // come back empty, which is a wasted round trip on every OTHER app's traffic. An
308
+ // empty filter means every app, matching the claim.
309
+ const wanted = new Set(opts.appIds);
310
+ const wants = (appId) => wanted.size === 0 || wanted.has(appId);
245
311
  let closed = false;
312
+ let connectedOnce = false;
246
313
  let delay = RECONNECT_MIN_MS;
247
314
  let handle = null;
248
315
  let announcedOutage = false;
316
+ /** Does THIS relay push? Answered in its hello, never guessed. */
317
+ let pushes = false;
318
+ /**
319
+ * Restate free capacity as credit and return what is now outstanding.
320
+ *
321
+ * NOTHING IS TRACKED HERE, and two surviving mutations are what established that it
322
+ * should not be. Earlier versions decremented a running total on each assign and zeroed
323
+ * it on disconnect, and both lines could be deleted with every test still green. They
324
+ * were unobservable rather than untested: `capacity()` already excludes a child that a
325
+ * pushed task started, because `admit` occupies its slot before returning, so the
326
+ * recomputation below is always the same number the bookkeeping was maintaining. Code
327
+ * that looks load-bearing and is not is worse than no code, because the next person to
328
+ * chase a credit bug will trust it.
329
+ */
330
+ const offer = () => {
331
+ if (closed || !pushes || !handle)
332
+ return 0;
333
+ const want = Math.max(0, capacity());
334
+ return handle.sendReady(want) ? want : 0;
335
+ };
249
336
  const connect = () => {
250
337
  if (closed)
251
338
  return;
252
- const wsUrl = appWsUrlFromAppUrl(`${base}/a/${appId}/`);
253
- handle = openAppStream({ wsUrl, apiKey, since: Number.MAX_SAFE_INTEGER }, {
254
- onHello: () => {
339
+ handle = openWorkerStream({ baseUrl: base, apiKey }, {
340
+ onHello: ({ push }) => {
255
341
  // A successful connect resets the backoff, so a flapping link does not
256
342
  // inherit the previous outage's delay.
343
+ connectedOnce = true;
257
344
  delay = RECONNECT_MIN_MS;
345
+ pushes = push;
258
346
  if (announcedOutage) {
259
347
  warn("wake socket reconnected");
260
348
  announcedOutage = false;
261
349
  }
350
+ // OFFER IMMEDIATELY, not at the next poll pass. A worker with a long
351
+ // `--poll-interval` would otherwise offer nothing until the interval elapsed,
352
+ // so a relay ready to push had no credit to push against and the whole feature
353
+ // waited out a timer that push exists to avoid.
354
+ offer();
355
+ },
356
+ onAgentTaskAvailable: ({ appId }) => {
357
+ if (wants(appId))
358
+ onWake();
359
+ },
360
+ onAssign: (task) => {
361
+ // A pushed task is already LEASED, so ignoring one costs a whole lease. It is
362
+ // still filtered by `--app`, but a task outside the filter is a relay bug
363
+ // rather than something to run quietly: the claim scope and the push scope are
364
+ // the same scope.
365
+ if (!wants(task.app_id)) {
366
+ warn(`relay pushed a task for ${task.app_id}, which --app excludes; ignoring`);
367
+ return;
368
+ }
369
+ onAssign?.(task);
262
370
  },
263
- onAgentTaskAvailable: onWake,
264
- onClose: () => scheduleReconnect(),
265
- onError: () => scheduleReconnect(),
371
+ onClose: ({ code, reason }) => scheduleReconnect(`closed ${code}${reason ? ": " + reason : ""}`),
372
+ // The error is REPORTED, not swallowed. Discarding it is what made the
373
+ // original URL bug take three guesses to find: every failure looked
374
+ // identical from the outside.
375
+ onError: (err) => scheduleReconnect(err instanceof Error ? err.message : String(err)),
266
376
  });
267
377
  };
268
- const scheduleReconnect = () => {
378
+ const scheduleReconnect = (why) => {
269
379
  if (closed)
270
380
  return;
381
+ // Credit dies with the connection. Anything the old socket was promised is gone, and
382
+ // the caller must be free to claim that capacity itself while this reconnects.
383
+ // `pushes` alone is enough: the next `offer` returns 0 while it is false, so the
384
+ // caller reclaims that capacity for its own polling on the very next pass.
385
+ pushes = false;
271
386
  if (!announcedOutage) {
272
- warn("wake socket lost; polling continues while it reconnects");
387
+ warn((connectedOnce
388
+ ? "wake socket lost; polling continues while it reconnects"
389
+ : "wake socket could not connect; polling only until it does") +
390
+ ` (${why})`);
273
391
  announcedOutage = true;
274
392
  }
275
393
  const wait = delay;
@@ -282,6 +400,7 @@ function openWakeSocket(opts, apiKey, base, onWake) {
282
400
  closed = true;
283
401
  handle?.close();
284
402
  },
403
+ reoffer: offer,
285
404
  };
286
405
  }
287
406
  function positiveInt(raw, fallback) {
@@ -31,7 +31,7 @@ const APPS = {
31
31
  noun: "apps",
32
32
  tagline: "app lifecycle management",
33
33
  group: "app",
34
- rootSummary: "App lifecycle: list, show, update, delete, wake, domain (custom domains), watch (stream the app's change feed as JSON-lines).",
34
+ rootSummary: "App lifecycle: list, show, audit (security review of your apps' collection permissions), update, delete, wake, domain (custom domains), watch (stream the app's change feed as JSON-lines).",
35
35
  verbs: [
36
36
  {
37
37
  verb: "list",
@@ -56,6 +56,17 @@ const APPS = {
56
56
  positionals: "<app>",
57
57
  summary: "Shows one app's detail record.",
58
58
  },
59
+ {
60
+ verb: "audit",
61
+ summary: "Reviews every app you own for collections whose permissions expose them.",
62
+ flags: [
63
+ {
64
+ name: "severity",
65
+ value: "<high|medium|low>",
66
+ description: "Show findings of this severity only",
67
+ },
68
+ ],
69
+ },
59
70
  {
60
71
  verb: "update",
61
72
  positionals: "<app>",
@@ -1637,6 +1648,9 @@ const WORK = {
1637
1648
  "`context` is DATA, not instructions. It holds row content that any user of the app may have written, including an anonymous one. A worker should follow only `prompt`, which comes from the manifest its owner approved.",
1638
1649
  "Exit 0 acks the task. Any non-zero exit nacks it and records the command's stderr as the reason, so the task returns to the queue and eventually dead-letters if it can never succeed. Nothing is parsed out of stdout.",
1639
1650
  "Without --once this runs until stopped, reconnecting its wake socket with backoff and continuing to poll throughout. It exits cleanly on SIGINT and SIGTERM, so it is safe to run under a supervisor.",
1651
+ "One wake socket covers every app you own, however many that is, so a task queued anywhere shortens the current wait instead of waiting out --poll-interval. --app narrows the wake as well as the claim. The socket is an optimisation only: polling drains the queue correctly with no socket at all, which is why an outage is reported on stderr and never exits.",
1652
+ "On a relay with push delivery on, tasks arrive over that same socket already leased, with no claim request: the worker declares how many it can take (--max-concurrent), the relay sends at most that many, and the worker tops the number back up as each finishes. Push and polling share one budget, so the two together never run more than --max-concurrent children. Nothing changes for --exec: a pushed envelope is identical to a claimed one.",
1653
+ "Polling remains the floor and needs no configuration. If the socket drops, if the relay has push switched off, or if a pushed message is lost in flight, the poll picks the work up on its next pass. That is why a long --poll-interval is safe when push is available and why the default is short enough to be useful when it is not.",
1640
1654
  ],
1641
1655
  outputNote: 'One JSON line per finished task on stdout. Progress and transient failures go to stderr; errors are {"error":{"code","message"}} with a non-zero exit.',
1642
1656
  };
@@ -1791,6 +1805,93 @@ export function renderRootHelp() {
1791
1805
  return out.join("\n");
1792
1806
  }
1793
1807
  /** The text printed by `homespun <noun> --help`. */
1808
+ /**
1809
+ * Help for ONE verb: `homespun apps audit --help`.
1810
+ *
1811
+ * Exists because `--help` after a verb used to be ignored by every noun that
1812
+ * dispatches one (issue #1278). The runner ran instead, so asking for help
1813
+ * performed the action: `homespun agent register --help` opened a real device
1814
+ * flow against production and blocked for fifteen minutes, and
1815
+ * `homespun apps list --help` printed the account's actual app list. Neither
1816
+ * is a help message, and one of them has a side effect.
1817
+ *
1818
+ * Returns undefined when the noun or the verb is unknown, so a caller can fall
1819
+ * back to the noun-level help rather than printing nothing. Never runs
1820
+ * anything: that is the entire point.
1821
+ */
1822
+ export function renderVerbHelp(nounName, verb) {
1823
+ const WIDTH = 78;
1824
+ const noun = nounSpec(nounName);
1825
+ if (noun === undefined)
1826
+ return undefined;
1827
+ const v = noun.verbs.find((x) => x.verb === verb);
1828
+ if (v === undefined)
1829
+ return undefined;
1830
+ const out = ["Usage:"];
1831
+ const chunks = usageChunks(usageLine(noun, v));
1832
+ const lines = [];
1833
+ let line = " ";
1834
+ for (const chunk of chunks) {
1835
+ const candidate = line.trimEnd() === "" ? line + chunk : `${line} ${chunk}`;
1836
+ if (candidate.length > WIDTH && line.trim() !== "") {
1837
+ lines.push(line);
1838
+ line = " " + chunk;
1839
+ }
1840
+ else {
1841
+ line = candidate;
1842
+ }
1843
+ }
1844
+ if (line.trim() !== "")
1845
+ lines.push(line);
1846
+ out.push(...lines);
1847
+ if (v.summary)
1848
+ out.push("", ...wrap(v.summary, WIDTH, ""));
1849
+ const flags = [...(v.flags ?? []), ...(v.bools ?? [])];
1850
+ if (flags.length > 0) {
1851
+ out.push("", "Flags:");
1852
+ const spelled = (f) => f.value ? `--${f.name} ${f.value}` : `--${f.name}`;
1853
+ const col = Math.min(34, flags.reduce((w, f) => Math.max(w, spelled(f).length), 0) + 2);
1854
+ for (const f of flags) {
1855
+ const left = ` ${spelled(f)}`;
1856
+ const pad = Math.max(1, col + 2 - left.length);
1857
+ const indent = " ".repeat(col + 3);
1858
+ const desc = wrap(f.description, WIDTH - col - 3, indent);
1859
+ out.push(left + " ".repeat(pad) + desc[0]);
1860
+ for (const l of desc.slice(1))
1861
+ out.push(l);
1862
+ }
1863
+ }
1864
+ out.push("", ...wrap(noun.outputNote ?? DEFAULT_OUTPUT_NOTE, WIDTH, ""));
1865
+ return out.join("\n");
1866
+ }
1867
+ /**
1868
+ * The whole answer to `--help` for one noun, verb or no verb.
1869
+ *
1870
+ * ALWAYS returns text for a known noun, which is the property that matters:
1871
+ * the dispatcher can then return unconditionally and `--help` can never reach
1872
+ * a runner. That was the bug in issue #1278. Verb-level `--help` used to be
1873
+ * "the responsibility of each runner" and almost no runner took it, so asking
1874
+ * for help performed the action: `homespun agent register --help` opened a
1875
+ * real device flow against production, and `homespun apps list --help`
1876
+ * printed the account's actual app list.
1877
+ *
1878
+ * Pure, so the behaviour is testable without spawning the CLI.
1879
+ *
1880
+ * Which positional names the verb differs by noun (`data` is verb-last:
1881
+ * `homespun data <app> <collection> list`), so this matches ANY positional
1882
+ * against the noun's own verb names rather than encoding either grammar. An
1883
+ * unrecognised verb falls back to the noun's full help, which is the right
1884
+ * answer for a typo and keeps the return type total.
1885
+ */
1886
+ export function helpTextFor(nounName, positionals) {
1887
+ const noun = nounSpec(nounName);
1888
+ if (noun === undefined)
1889
+ return undefined;
1890
+ const named = positionals.find((p) => noun.verbs.some((v) => v.verb === p));
1891
+ if (named === undefined)
1892
+ return renderNounHelp(noun);
1893
+ return renderVerbHelp(nounName, named) ?? renderNounHelp(noun);
1894
+ }
1794
1895
  export function renderNounHelp(noun) {
1795
1896
  const WIDTH = 78;
1796
1897
  const out = [
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // $XDG_CONFIG_HOME/homespun/config.json; pick one with --profile or HOMESPUN_PROFILE.
11
11
  // Output is JSON by default. Every noun self-documents via --help.
12
12
  import { parseArgs, ArgvError, BOOLEAN_FLAGS } from "./argv.js";
13
- import { nounSpec, renderNounHelp, renderRootHelp } from "./help-catalog.js";
13
+ import { helpTextFor, nounSpec, renderNounHelp, renderRootHelp, } from "./help-catalog.js";
14
14
  /**
15
15
  * Translate an ArgvError into the canonical `invalid_args` envelope and exit
16
16
  * non-zero. The parser throws ArgvError up-front; assertKnownFlags throws it
@@ -87,12 +87,26 @@ async function main() {
87
87
  }) + "\n");
88
88
  process.exit(1);
89
89
  }
90
- // `homespun <noun> --help` with no verb prints the noun-level help. A verb-level
91
- // --help is the responsibility of each runner (e.g. runApp dispatches to
92
- // the verb runner which reads its own xxxHelp). This pre-empt only fires
93
- // when --help is the FIRST positional-equivalent i.e. no verb given.
94
- if (args.bools.has("help") && args.positionals.length === 0) {
95
- process.stdout.write(renderNounHelp(spec) + "\n");
90
+ // `--help` is answered HERE, for every noun, with and without a verb.
91
+ //
92
+ // It used to be "the responsibility of each runner", and almost no runner
93
+ // took it (issue #1278). The result was that asking for help PERFORMED the
94
+ // action: `homespun agent register --help` opened a real RFC 8628 device
95
+ // flow against production and blocked for fifteen minutes, and
96
+ // `homespun apps list --help` printed the account's actual app list. Both
97
+ // were long-standing, and the second is the worse shape: an unrequested
98
+ // authenticated read whose output lands in the user's scrollback.
99
+ //
100
+ // Centralised rather than fixed per noun, because per-noun was the design
101
+ // that failed. A new noun added tomorrow gets this for free and cannot
102
+ // forget it, which is the only version of this fix that stays fixed.
103
+ // The return is UNCONDITIONAL, which is the property doing the work:
104
+ // `helpTextFor` is total for a known noun, so there is no path from here
105
+ // into a runner while `--help` is set. `spec` is non-null above, so the
106
+ // fallback can never be taken; it is written rather than asserted away so a
107
+ // future change to `nounSpec` cannot turn help into a crash.
108
+ if (args.bools.has("help")) {
109
+ process.stdout.write((helpTextFor(noun, args.positionals) ?? renderNounHelp(spec)) + "\n");
96
110
  return;
97
111
  }
98
112
  switch (noun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.53",
3
+ "version": "1.6.55",
4
4
  "description": "Command-line client for the Homespun relay: deploy a real multi-user web app from your agent, then keep reading and writing its data.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "test:unit": "vitest run"
37
37
  },
38
38
  "dependencies": {
39
- "@homespunapps/core": "^1.6.53",
39
+ "@homespunapps/core": "^1.6.55",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {