@homespunapps/cli 1.6.52 → 1.6.54
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/work.js +351 -0
- package/dist/help-catalog.js +46 -0
- package/dist/index.js +4 -0
- package/package.json +2 -2
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// `homespun work` - the long-running worker that drains this identity's agent-task
|
|
2
|
+
// queue and hands each task to whatever agent its owner uses.
|
|
3
|
+
//
|
|
4
|
+
// THE ENVELOPE GOES TO A CHILD PROCESS ON STDIN, and that is the entire integration
|
|
5
|
+
// contract. No SDK, no library, no assumption that the consumer is Claude or that it
|
|
6
|
+
// can call tools: `--exec` names any command, and a shell script reading stdin is a
|
|
7
|
+
// first-class consumer. That is also how the harness-agnostic claim gets tested, by
|
|
8
|
+
// pointing `--exec` at a script rather than at a model.
|
|
9
|
+
//
|
|
10
|
+
// EXIT CODE IS THE ANSWER. Zero acks, non-zero nacks with the child's stderr as the
|
|
11
|
+
// report. Nothing is parsed out of stdout, deliberately: requiring a structured
|
|
12
|
+
// reply would mean every worker needs a wrapper that produces it, and the one thing
|
|
13
|
+
// every program on every platform already reports reliably is its exit status.
|
|
14
|
+
//
|
|
15
|
+
// POLLING IS THE FLOOR. The wake frame only shortens the wait, so this drains
|
|
16
|
+
// correctly with no socket at all. That is why the reconnect logic below is allowed
|
|
17
|
+
// to give up on the socket and keep working.
|
|
18
|
+
//
|
|
19
|
+
// WHY THIS FILE CONTAINS RECONNECT LOGIC AT ALL, when `apps watch` does not: nothing
|
|
20
|
+
// in this CLI has it. `apps watch` falls back to HTTP long-polling permanently on any
|
|
21
|
+
// pre-connect WS failure, never retries, handles SIGINT but not SIGTERM, and parks on
|
|
22
|
+
// `await new Promise(() => {})`. That is fine for a person watching a terminal and
|
|
23
|
+
// wrong for a process meant to run under a supervisor for weeks: it would silently
|
|
24
|
+
// degrade to a slower path and nothing would say so. So this reconnects with capped
|
|
25
|
+
// backoff, says so on stderr when it does, and exits cleanly on SIGTERM.
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import { openAppStream, appWsUrlFromAppUrl } from "@homespunapps/core";
|
|
28
|
+
import { assertKnownFlags } from "../argv.js";
|
|
29
|
+
import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
|
|
30
|
+
import { resolveConfig } from "../config.js";
|
|
31
|
+
import { fail, printJsonLine, warn } from "../output.js";
|
|
32
|
+
/** Backoff bounds for the wake socket. Capped so a long outage does not spin. */
|
|
33
|
+
const RECONNECT_MIN_MS = 1_000;
|
|
34
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
35
|
+
export async function runWork(args) {
|
|
36
|
+
if (args.flags.has("help") ||
|
|
37
|
+
args.bools.has("help") ||
|
|
38
|
+
args.positionals[0] === "help") {
|
|
39
|
+
const spec = nounSpec("work");
|
|
40
|
+
if (spec)
|
|
41
|
+
process.stdout.write(renderNounHelp(spec));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// The catalogue entry IS the flag allowlist, so a flag documented in help and a
|
|
45
|
+
// flag accepted here cannot drift apart.
|
|
46
|
+
assertKnownFlags(args, ...specFor("work"));
|
|
47
|
+
const exec = args.flags.get("exec");
|
|
48
|
+
if (!exec) {
|
|
49
|
+
fail("work requires --exec <command>: the program each task envelope is piped to", "invalid_request");
|
|
50
|
+
}
|
|
51
|
+
const opts = {
|
|
52
|
+
// Repeatable OR comma-separated, because both are things a person types.
|
|
53
|
+
appIds: (args.flags.get("app") ?? "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean),
|
|
57
|
+
exec: exec,
|
|
58
|
+
maxConcurrent: positiveInt(args.flags.get("max-concurrent"), 1),
|
|
59
|
+
once: args.bools.has("once") || args.flags.has("once"),
|
|
60
|
+
pollSeconds: positiveInt(args.flags.get("poll-interval"), 15),
|
|
61
|
+
};
|
|
62
|
+
const cfg = resolveConfig(args);
|
|
63
|
+
const base = cfg.url.replace(/\/$/, "");
|
|
64
|
+
let stopping = false;
|
|
65
|
+
/** Resolves early when the wake frame arrives, so a sleep can be interrupted. */
|
|
66
|
+
let wake = null;
|
|
67
|
+
const stop = () => {
|
|
68
|
+
stopping = true;
|
|
69
|
+
wake?.();
|
|
70
|
+
};
|
|
71
|
+
// BOTH signals. A supervisor sends SIGTERM, and a worker that only handles SIGINT
|
|
72
|
+
// gets killed mid-task, which strands its lease until it expires.
|
|
73
|
+
//
|
|
74
|
+
// Removed again in the `finally` below. In production this runs once and process
|
|
75
|
+
// exit would clean up anyway, but leaving them attached leaks a listener per call,
|
|
76
|
+
// which surfaced as a MaxListenersExceededWarning once the test suite called this
|
|
77
|
+
// fifteen times in one process. A warning that only appears under test is still a
|
|
78
|
+
// handler this function attached and did not own up to.
|
|
79
|
+
process.on("SIGINT", stop);
|
|
80
|
+
process.on("SIGTERM", stop);
|
|
81
|
+
const socket = opts.once
|
|
82
|
+
? null
|
|
83
|
+
: await openWakeSocket(opts, cfg.apiKey, base, () => wake?.());
|
|
84
|
+
try {
|
|
85
|
+
for (;;) {
|
|
86
|
+
const claimed = await claim(base, cfg.apiKey, opts);
|
|
87
|
+
for (const task of claimed) {
|
|
88
|
+
await runTask(base, cfg.apiKey, task, opts.exec);
|
|
89
|
+
if (stopping)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (opts.once || stopping)
|
|
93
|
+
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.
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
const timer = setTimeout(resolve, opts.pollSeconds * 1000);
|
|
99
|
+
wake = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
resolve();
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
wake = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
socket?.close();
|
|
109
|
+
process.off("SIGINT", stop);
|
|
110
|
+
process.off("SIGTERM", stop);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Claim a batch. A claim failure is NOT fatal outside `--once`: the relay may be
|
|
115
|
+
* restarting or briefly unreachable, and a worker that exits on the first 503 is a
|
|
116
|
+
* worker that needs a supervisor to do its retrying. Logged and retried on the next
|
|
117
|
+
* pass instead.
|
|
118
|
+
*/
|
|
119
|
+
async function claim(base, apiKey, opts) {
|
|
120
|
+
const body = { max: opts.maxConcurrent };
|
|
121
|
+
if (opts.appIds.length > 0)
|
|
122
|
+
body.app_ids = opts.appIds;
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(`${base}/v1/agent-tasks/claim`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
authorization: `Bearer ${apiKey}`,
|
|
128
|
+
"content-type": "application/json",
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(body),
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
const text = await res.text();
|
|
134
|
+
if (opts.once) {
|
|
135
|
+
fail(`claim failed (${res.status}): ${text}`, "claim_failed");
|
|
136
|
+
}
|
|
137
|
+
warn(`claim failed (${res.status}), retrying next pass: ${text}`);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return (await res.json()).tasks ?? [];
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
144
|
+
if (opts.once)
|
|
145
|
+
fail(`claim failed: ${msg}`, "claim_failed");
|
|
146
|
+
warn(`claim failed, retrying next pass: ${msg}`);
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Hand one task to the child and report the outcome.
|
|
152
|
+
*
|
|
153
|
+
* The whole envelope goes on stdin as one JSON line, including the credential, so a
|
|
154
|
+
* worker needs no configuration of its own to write results back: everything it
|
|
155
|
+
* needs to act is in the thing it was handed.
|
|
156
|
+
*/
|
|
157
|
+
async function runTask(base, apiKey, task, exec) {
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
const result = await runChild(exec, JSON.stringify(task));
|
|
160
|
+
const seconds = Math.round((Date.now() - started) / 100) / 10;
|
|
161
|
+
if (result.code === 0) {
|
|
162
|
+
await report(base, apiKey, task.task_id, "ack", trim(result.stdout));
|
|
163
|
+
printJsonLine({
|
|
164
|
+
task: task.task_id,
|
|
165
|
+
app: task.app_slug,
|
|
166
|
+
type: task.task_type,
|
|
167
|
+
status: "done",
|
|
168
|
+
seconds,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Non-zero: nack, with the child's STDERR as the report. Stderr rather than stdout
|
|
173
|
+
// because that is where a failing program explains itself, and the report is read
|
|
174
|
+
// by a person working out why their task did not run.
|
|
175
|
+
await report(base, apiKey, task.task_id, "nack", trim(result.stderr || result.stdout) ||
|
|
176
|
+
`worker exited ${result.code ?? "on a signal"}`);
|
|
177
|
+
printJsonLine({
|
|
178
|
+
task: task.task_id,
|
|
179
|
+
app: task.app_slug,
|
|
180
|
+
type: task.task_type,
|
|
181
|
+
status: "failed",
|
|
182
|
+
exit: result.code,
|
|
183
|
+
seconds,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function runChild(exec, stdin) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
// Through a shell, so `--exec "claude -p"` and `--exec ./parse.sh` both work the
|
|
189
|
+
// way a person expects when they type them.
|
|
190
|
+
const child = spawn(exec, { shell: true });
|
|
191
|
+
let stdout = "";
|
|
192
|
+
let stderr = "";
|
|
193
|
+
child.stdout.on("data", (d) => (stdout += String(d)));
|
|
194
|
+
child.stderr.on("data", (d) => (stderr += String(d)));
|
|
195
|
+
child.on("error", (err) => {
|
|
196
|
+
resolve({ code: 127, stdout, stderr: stderr + String(err) });
|
|
197
|
+
});
|
|
198
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
199
|
+
child.stdin.write(stdin);
|
|
200
|
+
child.stdin.end();
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Ack or nack. A REPORTING failure is logged and swallowed rather than thrown: the
|
|
205
|
+
* work has already happened, and the lease will lapse and return the task to the
|
|
206
|
+
* queue on its own, which is the correct recovery. Crashing the worker here would
|
|
207
|
+
* lose every other task it holds for the sake of one it could not report on.
|
|
208
|
+
*/
|
|
209
|
+
async function report(base, apiKey, taskId, verb, text) {
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(`${base}/v1/agent-tasks/${taskId}/${verb}`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: {
|
|
214
|
+
authorization: `Bearer ${apiKey}`,
|
|
215
|
+
"content-type": "application/json",
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify(text ? { report: text } : {}),
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
warn(`${verb} failed for ${taskId} (${res.status}); lease will lapse`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
warn(`${verb} failed for ${taskId} (${err instanceof Error ? err.message : String(err)}); lease will lapse`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
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
|
+
/**
|
|
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
|
+
}
|
|
281
|
+
let closed = false;
|
|
282
|
+
let connectedOnce = false;
|
|
283
|
+
let delay = RECONNECT_MIN_MS;
|
|
284
|
+
let handle = null;
|
|
285
|
+
let announcedOutage = false;
|
|
286
|
+
const connect = () => {
|
|
287
|
+
if (closed)
|
|
288
|
+
return;
|
|
289
|
+
const wsUrl = appWsUrlFromAppUrl(appUrl);
|
|
290
|
+
handle = openAppStream(
|
|
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: () => {
|
|
298
|
+
// A successful connect resets the backoff, so a flapping link does not
|
|
299
|
+
// inherit the previous outage's delay.
|
|
300
|
+
connectedOnce = true;
|
|
301
|
+
delay = RECONNECT_MIN_MS;
|
|
302
|
+
if (announcedOutage) {
|
|
303
|
+
warn("wake socket reconnected");
|
|
304
|
+
announcedOutage = false;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
onAgentTaskAvailable: onWake,
|
|
308
|
+
onClose: ({ code, reason }) => scheduleReconnect(`closed ${code}${reason ? ": " + reason : ""}`),
|
|
309
|
+
// The error is REPORTED, not swallowed. Discarding it is what made the
|
|
310
|
+
// original URL bug take three guesses to find: every failure looked
|
|
311
|
+
// identical from the outside.
|
|
312
|
+
onError: (err) => scheduleReconnect(err instanceof Error ? err.message : String(err)),
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
const scheduleReconnect = (why) => {
|
|
316
|
+
if (closed)
|
|
317
|
+
return;
|
|
318
|
+
if (!announcedOutage) {
|
|
319
|
+
warn((connectedOnce
|
|
320
|
+
? "wake socket lost; polling continues while it reconnects"
|
|
321
|
+
: "wake socket could not connect; polling only until it does") +
|
|
322
|
+
` (${why})`);
|
|
323
|
+
announcedOutage = true;
|
|
324
|
+
}
|
|
325
|
+
const wait = delay;
|
|
326
|
+
delay = Math.min(delay * 2, RECONNECT_MAX_MS);
|
|
327
|
+
setTimeout(connect, wait).unref?.();
|
|
328
|
+
};
|
|
329
|
+
connect();
|
|
330
|
+
return {
|
|
331
|
+
close: () => {
|
|
332
|
+
closed = true;
|
|
333
|
+
handle?.close();
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function positiveInt(raw, fallback) {
|
|
338
|
+
if (raw === undefined)
|
|
339
|
+
return fallback;
|
|
340
|
+
const n = Number(raw);
|
|
341
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
342
|
+
fail(`expected a positive integer, got '${raw}'`, "invalid_request");
|
|
343
|
+
}
|
|
344
|
+
return n;
|
|
345
|
+
}
|
|
346
|
+
/** Cap the report at what the ack route accepts, keeping the TAIL of a long one:
|
|
347
|
+
* the end of a stack trace says more about a failure than its beginning. */
|
|
348
|
+
function trim(s) {
|
|
349
|
+
const t = s.trim();
|
|
350
|
+
return t.length > 3900 ? t.slice(-3900) : t;
|
|
351
|
+
}
|
package/dist/help-catalog.js
CHANGED
|
@@ -1595,9 +1595,55 @@ const REVIEW = {
|
|
|
1595
1595
|
};
|
|
1596
1596
|
// Order here is the order in `homespun --help` and in the generated reference
|
|
1597
1597
|
// page: app commands first, then the rest.
|
|
1598
|
+
const WORK = {
|
|
1599
|
+
noun: "work",
|
|
1600
|
+
tagline: "run the agent-task worker",
|
|
1601
|
+
group: "app",
|
|
1602
|
+
rootSummary: "Runs a long-lived worker that claims agent tasks for your apps and pipes each one to a command you name.",
|
|
1603
|
+
verbs: [
|
|
1604
|
+
{
|
|
1605
|
+
verb: "",
|
|
1606
|
+
summary: "Claims tasks and pipes each envelope to --exec on stdin, acking on exit 0 and nacking otherwise.",
|
|
1607
|
+
flags: [
|
|
1608
|
+
{
|
|
1609
|
+
name: "exec",
|
|
1610
|
+
value: "<command>",
|
|
1611
|
+
description: "Required. The command each task envelope is piped to",
|
|
1612
|
+
},
|
|
1613
|
+
{
|
|
1614
|
+
name: "app",
|
|
1615
|
+
value: "<id[,id]>",
|
|
1616
|
+
description: "Only these apps (default: every app you own)",
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
name: "max-concurrent",
|
|
1620
|
+
value: "<n>",
|
|
1621
|
+
description: "Tasks to claim per pass (default 1)",
|
|
1622
|
+
},
|
|
1623
|
+
{
|
|
1624
|
+
name: "poll-interval",
|
|
1625
|
+
value: "<seconds>",
|
|
1626
|
+
description: "Seconds between claims (default 15)",
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
name: "once",
|
|
1630
|
+
description: "Drain one pass and exit, for cron",
|
|
1631
|
+
},
|
|
1632
|
+
],
|
|
1633
|
+
},
|
|
1634
|
+
],
|
|
1635
|
+
notes: [
|
|
1636
|
+
"The whole task envelope arrives on the command's stdin as one JSON line: a `prompt` from the app manifest, a `context` holding the row that triggered it, and a short-lived credential scoped to exactly the collections the rule declared. The command needs no configuration of its own; everything it needs to write results back is in what it was handed.",
|
|
1637
|
+
"`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
|
+
"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
|
+
"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.",
|
|
1640
|
+
],
|
|
1641
|
+
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
|
+
};
|
|
1598
1643
|
const NOUNS = [
|
|
1599
1644
|
DEPLOY,
|
|
1600
1645
|
APPS,
|
|
1646
|
+
WORK,
|
|
1601
1647
|
DATA,
|
|
1602
1648
|
MEMBERS,
|
|
1603
1649
|
GRANTS,
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ import { runBlob } from "./commands/attachment.js";
|
|
|
35
35
|
import { runSkill } from "./commands/skill.js";
|
|
36
36
|
import { runDeploy } from "./commands/deploy.js";
|
|
37
37
|
import { runApps } from "./commands/apps.js";
|
|
38
|
+
import { runWork } from "./commands/work.js";
|
|
38
39
|
import { runData } from "./commands/data.js";
|
|
39
40
|
import { runMembers } from "./commands/members.js";
|
|
40
41
|
import { runGrant } from "./commands/grant.js";
|
|
@@ -122,6 +123,9 @@ async function main() {
|
|
|
122
123
|
case "apps":
|
|
123
124
|
await runApps(args);
|
|
124
125
|
break;
|
|
126
|
+
case "work":
|
|
127
|
+
await runWork(args);
|
|
128
|
+
break;
|
|
125
129
|
case "data":
|
|
126
130
|
await runData(args);
|
|
127
131
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homespunapps/cli",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.54",
|
|
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.54",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|