@homespunapps/cli 1.6.54 → 1.6.56
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 +166 -73
- 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
|
-
:
|
|
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 {
|
|
@@ -196,6 +273,30 @@ function runChild(exec, stdin) {
|
|
|
196
273
|
resolve({ code: 127, stdout, stderr: stderr + String(err) });
|
|
197
274
|
});
|
|
198
275
|
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
276
|
+
// The envelope write races the child's exit, and losing that race used to
|
|
277
|
+
// kill the WORKER, not the task.
|
|
278
|
+
//
|
|
279
|
+
// A broken pipe surfaces on the STREAM, and an unhandled stream "error"
|
|
280
|
+
// event is fatal to the process. `child.on("error")` above does not cover
|
|
281
|
+
// it: that fires for a failed SPAWN, which with `shell: true` almost never
|
|
282
|
+
// happens because the shell itself starts fine. So a child that exits
|
|
283
|
+
// before reading stdin (a typo in `--exec`, where the shell prints
|
|
284
|
+
// "not found" and exits 127; or any script that simply does not read)
|
|
285
|
+
// raised an uncaught EPIPE and took down every other task the worker held.
|
|
286
|
+
//
|
|
287
|
+
// Measured 2026-08-09: it is the race, not the payload. A 500-byte write to
|
|
288
|
+
// a child running `exit 0` crashed a Node 24 process every time, and an
|
|
289
|
+
// agent-task envelope is comfortably larger than that. It reached CI as an
|
|
290
|
+
// intermittent "write EPIPE" in this file's own tests (issue #1616) while
|
|
291
|
+
// every assertion passed, which is why it read as flake rather than as the
|
|
292
|
+
// bug it is.
|
|
293
|
+
//
|
|
294
|
+
// Swallowed rather than reported, because it is a SYMPTOM and the real
|
|
295
|
+
// outcome is already on its way: the child's exit code and stderr arrive on
|
|
296
|
+
// "close" and decide ack versus nack. Reporting here as well would nack
|
|
297
|
+
// twice for one failure. This mirrors `report`'s own rule a few lines
|
|
298
|
+
// below, that one task's trouble must never cost the worker the others.
|
|
299
|
+
child.stdin.on("error", () => { });
|
|
199
300
|
child.stdin.write(stdin);
|
|
200
301
|
child.stdin.end();
|
|
201
302
|
});
|
|
@@ -224,87 +325,73 @@ async function report(base, apiKey, taskId, verb, text) {
|
|
|
224
325
|
warn(`${verb} failed for ${taskId} (${err instanceof Error ? err.message : String(err)}); lease will lapse`);
|
|
225
326
|
}
|
|
226
327
|
}
|
|
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
|
-
/**
|
|
242
|
-
* Exported for testing. This function is the one part of `work` that had NO test and
|
|
243
|
-
* shipped broken twice over (a URL built from the app id rather than its real
|
|
244
|
-
* location, and a swallowed connect error), so it is worth being able to point a test
|
|
245
|
-
* straight at it rather than only at the command that calls it.
|
|
246
|
-
*/
|
|
247
|
-
export async function openWakeSocket(opts, apiKey, base, onWake) {
|
|
248
|
-
if (opts.appIds.length !== 1)
|
|
249
|
-
return null;
|
|
250
|
-
const appId = opts.appIds[0];
|
|
251
|
-
// ASK THE RELAY where the app lives. This was reconstructed as `${base}/a/${appId}/`,
|
|
252
|
-
// which is wrong in two ways at once: an app is served under its SLUG, not its id,
|
|
253
|
-
// and on a usercontent deployment it is a different origin entirely
|
|
254
|
-
// (`<slug>.homespunapps.com`) rather than a path on the API host. The socket
|
|
255
|
-
// therefore never connected, and the reconnect logic below reported it as
|
|
256
|
-
// "wake socket lost", which reads like a transient blip rather than a URL that
|
|
257
|
-
// could never work. Found by running a worker against a live relay; no test
|
|
258
|
-
// covered this function at all.
|
|
259
|
-
//
|
|
260
|
-
// A failure here is NOT fatal: polling still drains the queue, so a worker that
|
|
261
|
-
// cannot resolve its app keeps working, just without the wake hint.
|
|
262
|
-
let appUrl;
|
|
263
|
-
try {
|
|
264
|
-
const res = await fetch(`${base}/v1/apps/${appId}`, {
|
|
265
|
-
headers: { authorization: `Bearer ${apiKey}` },
|
|
266
|
-
});
|
|
267
|
-
if (!res.ok) {
|
|
268
|
-
warn(`cannot resolve app ${appId} for the wake socket (${res.status}); polling only`);
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
appUrl = (await res.json()).url ?? "";
|
|
272
|
-
if (!appUrl) {
|
|
273
|
-
warn(`app ${appId} reports no url; polling only`);
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
catch (err) {
|
|
278
|
-
warn(`cannot resolve app ${appId} for the wake socket (${err instanceof Error ? err.message : String(err)}); polling only`);
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
328
|
+
export function openWakeSocket(opts, apiKey, base, onWake, capacity, onAssign) {
|
|
329
|
+
// `--app` filters the wake as well as the claim. A wake for an app this worker was
|
|
330
|
+
// told to ignore would otherwise cut the sleep short to run a claim that can only
|
|
331
|
+
// come back empty, which is a wasted round trip on every OTHER app's traffic. An
|
|
332
|
+
// empty filter means every app, matching the claim.
|
|
333
|
+
const wanted = new Set(opts.appIds);
|
|
334
|
+
const wants = (appId) => wanted.size === 0 || wanted.has(appId);
|
|
281
335
|
let closed = false;
|
|
282
336
|
let connectedOnce = false;
|
|
283
337
|
let delay = RECONNECT_MIN_MS;
|
|
284
338
|
let handle = null;
|
|
285
339
|
let announcedOutage = false;
|
|
340
|
+
/** Does THIS relay push? Answered in its hello, never guessed. */
|
|
341
|
+
let pushes = false;
|
|
342
|
+
/**
|
|
343
|
+
* Restate free capacity as credit and return what is now outstanding.
|
|
344
|
+
*
|
|
345
|
+
* NOTHING IS TRACKED HERE, and two surviving mutations are what established that it
|
|
346
|
+
* should not be. Earlier versions decremented a running total on each assign and zeroed
|
|
347
|
+
* it on disconnect, and both lines could be deleted with every test still green. They
|
|
348
|
+
* were unobservable rather than untested: `capacity()` already excludes a child that a
|
|
349
|
+
* pushed task started, because `admit` occupies its slot before returning, so the
|
|
350
|
+
* recomputation below is always the same number the bookkeeping was maintaining. Code
|
|
351
|
+
* that looks load-bearing and is not is worse than no code, because the next person to
|
|
352
|
+
* chase a credit bug will trust it.
|
|
353
|
+
*/
|
|
354
|
+
const offer = () => {
|
|
355
|
+
if (closed || !pushes || !handle)
|
|
356
|
+
return 0;
|
|
357
|
+
const want = Math.max(0, capacity());
|
|
358
|
+
return handle.sendReady(want) ? want : 0;
|
|
359
|
+
};
|
|
286
360
|
const connect = () => {
|
|
287
361
|
if (closed)
|
|
288
362
|
return;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
// `since: 0`, not a huge sentinel. The intent was "do not replay history", and
|
|
292
|
-
// MAX_SAFE_INTEGER expressed that by sending a cursor the relay will not accept,
|
|
293
|
-
// so the socket was refused and the wake frame never arrived. The worker sets no
|
|
294
|
-
// `onEntry` handler at all, so a replayed batch is discarded as it is parsed:
|
|
295
|
-
// there is nothing to avoid.
|
|
296
|
-
{ wsUrl, apiKey, since: 0 }, {
|
|
297
|
-
onHello: () => {
|
|
363
|
+
handle = openWorkerStream({ baseUrl: base, apiKey }, {
|
|
364
|
+
onHello: ({ push }) => {
|
|
298
365
|
// A successful connect resets the backoff, so a flapping link does not
|
|
299
366
|
// inherit the previous outage's delay.
|
|
300
367
|
connectedOnce = true;
|
|
301
368
|
delay = RECONNECT_MIN_MS;
|
|
369
|
+
pushes = push;
|
|
302
370
|
if (announcedOutage) {
|
|
303
371
|
warn("wake socket reconnected");
|
|
304
372
|
announcedOutage = false;
|
|
305
373
|
}
|
|
374
|
+
// OFFER IMMEDIATELY, not at the next poll pass. A worker with a long
|
|
375
|
+
// `--poll-interval` would otherwise offer nothing until the interval elapsed,
|
|
376
|
+
// so a relay ready to push had no credit to push against and the whole feature
|
|
377
|
+
// waited out a timer that push exists to avoid.
|
|
378
|
+
offer();
|
|
379
|
+
},
|
|
380
|
+
onAgentTaskAvailable: ({ appId }) => {
|
|
381
|
+
if (wants(appId))
|
|
382
|
+
onWake();
|
|
383
|
+
},
|
|
384
|
+
onAssign: (task) => {
|
|
385
|
+
// A pushed task is already LEASED, so ignoring one costs a whole lease. It is
|
|
386
|
+
// still filtered by `--app`, but a task outside the filter is a relay bug
|
|
387
|
+
// rather than something to run quietly: the claim scope and the push scope are
|
|
388
|
+
// the same scope.
|
|
389
|
+
if (!wants(task.app_id)) {
|
|
390
|
+
warn(`relay pushed a task for ${task.app_id}, which --app excludes; ignoring`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
onAssign?.(task);
|
|
306
394
|
},
|
|
307
|
-
onAgentTaskAvailable: onWake,
|
|
308
395
|
onClose: ({ code, reason }) => scheduleReconnect(`closed ${code}${reason ? ": " + reason : ""}`),
|
|
309
396
|
// The error is REPORTED, not swallowed. Discarding it is what made the
|
|
310
397
|
// original URL bug take three guesses to find: every failure looked
|
|
@@ -315,6 +402,11 @@ export async function openWakeSocket(opts, apiKey, base, onWake) {
|
|
|
315
402
|
const scheduleReconnect = (why) => {
|
|
316
403
|
if (closed)
|
|
317
404
|
return;
|
|
405
|
+
// Credit dies with the connection. Anything the old socket was promised is gone, and
|
|
406
|
+
// the caller must be free to claim that capacity itself while this reconnects.
|
|
407
|
+
// `pushes` alone is enough: the next `offer` returns 0 while it is false, so the
|
|
408
|
+
// caller reclaims that capacity for its own polling on the very next pass.
|
|
409
|
+
pushes = false;
|
|
318
410
|
if (!announcedOutage) {
|
|
319
411
|
warn((connectedOnce
|
|
320
412
|
? "wake socket lost; polling continues while it reconnects"
|
|
@@ -332,6 +424,7 @@ export async function openWakeSocket(opts, apiKey, base, onWake) {
|
|
|
332
424
|
closed = true;
|
|
333
425
|
handle?.close();
|
|
334
426
|
},
|
|
427
|
+
reoffer: offer,
|
|
335
428
|
};
|
|
336
429
|
}
|
|
337
430
|
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.56",
|
|
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.56",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|