@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.
- package/dist/commands/apps.js +55 -3
- package/dist/commands/work.js +154 -35
- package/dist/help-catalog.js +102 -1
- package/dist/index.js +21 -7
- package/package.json +2 -2
package/dist/commands/apps.js
CHANGED
|
@@ -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) {
|
package/dist/commands/work.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
95
|
-
// frame that arrives WHILE tasks are running does not
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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(
|
|
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) {
|
package/dist/help-catalog.js
CHANGED
|
@@ -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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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.
|
|
39
|
+
"@homespunapps/core": "^1.6.55",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|