@homespunapps/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lalit Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @homespunapps/cli
2
+
3
+ Command-line client for the [Homespun](https://github.com/aerolalit/homespun) relay:
4
+ hand a human a rich interactive UI by URL and capture their answer as structured
5
+ data — from any agent (cron job, chat bot, CI, headless server).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install -g @homespunapps/cli
11
+ # or, no install:
12
+ npx @homespunapps/cli <command>
13
+ ```
14
+
15
+ The binary is `homespun`.
16
+
17
+ ## Try it
18
+
19
+ Register once, then `homespun demo` spins up a short-lived sample app on the hosted
20
+ relay, opens it in your browser, and prints the structured event back in your
21
+ terminal the moment you interact (the demo app is cleaned up on exit):
22
+
23
+ ```sh
24
+ npx @homespunapps/cli agent register --name "my-agent" # one-time, hosted relay
25
+ npx @homespunapps/cli demo # Node 20+ — round-trip in ~60s
26
+ ```
27
+
28
+ Add `--no-open` on a headless / SSH box and it just prints the URL.
29
+
30
+ ## Setup
31
+
32
+ ```sh
33
+ export HOMESPUN_URL=https://homespun.dev # or your self-hosted relay
34
+ homespun agent register --name "my-agent" # provisions and saves an API key
35
+ ```
36
+
37
+ `homespun agent register` writes the URL + API key to
38
+ `${XDG_CONFIG_HOME:-~/.config}/homespun/config.json`. Subsequent commands need
39
+ only `HOMESPUN_URL` (or nothing) in the environment.
40
+
41
+ Override per-invocation with `--url <url>` and `--api-key <key>`.
42
+
43
+ ## Commands
44
+
45
+ Uniform `homespun <noun> <verb> [options]`:
46
+
47
+ ```
48
+ homespun demo Zero-setup guided tour — see the round-trip live
49
+ homespun agent register Provision an agent API key and save it locally
50
+ homespun agent logout Clear the locally-saved URL + API key
51
+ homespun create Create an app — returns app_id, urls, tokens
52
+ homespun show <id> Non-blocking snapshot: metadata + event log
53
+ homespun send <id> Emit an agent event into an app
54
+ homespun watch <id> Stream an app's events as JSON-lines on stdout
55
+ homespun delete <id> Close / delete an app
56
+ homespun template <verb> Manage reusable, versioned templates
57
+ homespun key list | revoke Inspect or revoke your agent's API key
58
+ homespun taste get | set | clear Read / write / clear UI-taste notes
59
+ homespun feedback create | list Submit / list one-shot feedback to the operator
60
+ homespun config show Show the resolved relay config (no network call)
61
+ homespun skill show | version Fetch the relay's SKILL.md (or its version)
62
+ ```
63
+
64
+ Run `homespun <noun> --help` for that noun's verbs, and
65
+ `homespun <noun> <verb> --help` for verb-specific options.
66
+
67
+ ## Output
68
+
69
+ stdout is machine-readable JSON. Errors go to stderr as
70
+ `{"error":{"code","message"}}` with a non-zero exit.
71
+
72
+ ```sh
73
+ SESSION=$(homespun create --template ./form.html --name "Quick poll" --event-schema ./q.json | jq -r .app_id)
74
+ homespun watch "$SESSION" | jq 'select(.type == "human_response")'
75
+ ```
76
+
77
+ ## Links
78
+
79
+ - Repo: <https://github.com/aerolalit/homespun>
80
+ - Spec: <https://github.com/aerolalit/homespun/blob/main/docs/SPEC.md>
81
+ - License: MIT
package/dist/argv.js ADDED
@@ -0,0 +1,140 @@
1
+ // Tiny hand-rolled argv parser. No CLI framework.
2
+ //
3
+ // Supports:
4
+ // --flag value --flag=value --bool -h
5
+ // Everything that isn't a flag (or a flag's value) is a positional.
6
+ /**
7
+ * Thrown for any argv-level user error: missing value, duplicate flag, or
8
+ * (when a runner calls assertKnownFlags) an unknown flag. `hint` rides
9
+ * alongside the message and ends up in the error envelope so callers see a
10
+ * single line pointing them at the right --help.
11
+ */
12
+ export class ArgvError extends Error {
13
+ hint;
14
+ constructor(message, hint) {
15
+ super(message);
16
+ this.name = "ArgvError";
17
+ if (hint !== undefined)
18
+ this.hint = hint;
19
+ }
20
+ }
21
+ /**
22
+ * Parse argv tokens. `booleanFlags` lists flags that never consume a value
23
+ * (e.g. --json, --once, --help); everything else with a `--name` form
24
+ * consumes the next token unless written as `--name=value`.
25
+ *
26
+ * Bails with ArgvError on the first duplicate (`--foo x --foo y` or
27
+ * `--once --once`) so a typo'd repeat doesn't silently overwrite the first
28
+ * value the way a plain `Map.set` would.
29
+ *
30
+ * Does NOT throw on a value-flag with no following value. Instead it
31
+ * records the name in `danglingValueFlags` so `assertKnownFlags` can
32
+ * produce the right message — "unknown flag(s)" for typos, "requires a
33
+ * value" for genuine known-flag-missing-value cases. Without this split,
34
+ * the message was non-uniform (a `--bogus` at end of argv said "requires
35
+ * a value" while `--bogus something` said "unknown flag(s)" — same root
36
+ * cause, two messages).
37
+ */
38
+ export function parseArgs(tokens, booleanFlags) {
39
+ const positionals = [];
40
+ const flags = new Map();
41
+ const bools = new Set();
42
+ const danglingValueFlags = new Set();
43
+ for (let i = 0; i < tokens.length; i++) {
44
+ const tok = tokens[i];
45
+ if (tok === "-h" || tok === "--help") {
46
+ bools.add("help");
47
+ continue;
48
+ }
49
+ if (tok.startsWith("--")) {
50
+ const body = tok.slice(2);
51
+ const eq = body.indexOf("=");
52
+ if (eq !== -1) {
53
+ const key = body.slice(0, eq);
54
+ if (flags.has(key)) {
55
+ throw new ArgvError(`duplicate flag: --${key}`);
56
+ }
57
+ flags.set(key, body.slice(eq + 1));
58
+ continue;
59
+ }
60
+ if (booleanFlags.has(body)) {
61
+ if (bools.has(body)) {
62
+ throw new ArgvError(`duplicate flag: --${body}`);
63
+ }
64
+ bools.add(body);
65
+ continue;
66
+ }
67
+ const next = tokens[i + 1];
68
+ if (next === undefined || next.startsWith("--")) {
69
+ // No value follows. Don't decide whether this is a typo or a
70
+ // forgotten value — record it; assertKnownFlags resolves both
71
+ // with one consistent message shape (see the field doc on
72
+ // ParsedArgs).
73
+ danglingValueFlags.add(body);
74
+ continue;
75
+ }
76
+ if (flags.has(body)) {
77
+ throw new ArgvError(`duplicate flag: --${body}`);
78
+ }
79
+ flags.set(body, next);
80
+ i++;
81
+ continue;
82
+ }
83
+ positionals.push(tok);
84
+ }
85
+ return { positionals, flags, bools, danglingValueFlags };
86
+ }
87
+ /**
88
+ * Flags every command accepts. Kept here (not in each command's allow-list)
89
+ * so adding a new global flag updates one place. `url` / `api-key` are the
90
+ * relay-target overrides; `help` / `json` are universal display modes.
91
+ */
92
+ const GLOBAL_FLAGS = ["url", "api-key", "profile"];
93
+ const GLOBAL_BOOLS = ["help", "json"];
94
+ /**
95
+ * Reject anything the per-command allow-list (plus the globals above) does
96
+ * not name. Run from each leaf runner before it starts pulling values out of
97
+ * `args`. The thrown ArgvError carries a hint pointing at the verb's own
98
+ * --help, so a user fixing a typo lands on the canonical list of flags.
99
+ *
100
+ * Why per-command and not at parse time: the parser is single-pass and
101
+ * generic on purpose — adding a new flag to one verb should not require a
102
+ * shared registry. Keeping the allow-list co-located with the runner that
103
+ * consumes it means the two cannot drift.
104
+ *
105
+ * Also resolves the parser's `danglingValueFlags`: an unknown name there
106
+ * is reported alongside other unknowns ("unknown flag(s): --bogus"); a
107
+ * known name there apps as "--name requires a value". This is what
108
+ * keeps the error message uniform for a typo whether or not a value
109
+ * follows it.
110
+ */
111
+ export function assertKnownFlags(args, knownFlags, knownBools, helpCommand) {
112
+ const flagSet = new Set([...GLOBAL_FLAGS, ...knownFlags]);
113
+ const boolSet = new Set([...GLOBAL_BOOLS, ...knownBools]);
114
+ const dangling = args.danglingValueFlags ?? new Set();
115
+ const unknown = [];
116
+ for (const k of args.flags.keys()) {
117
+ if (!flagSet.has(k) && !boolSet.has(k))
118
+ unknown.push(`--${k}`);
119
+ }
120
+ for (const k of args.bools) {
121
+ if (!boolSet.has(k) && !flagSet.has(k))
122
+ unknown.push(`--${k}`);
123
+ }
124
+ for (const k of dangling) {
125
+ if (!flagSet.has(k) && !boolSet.has(k))
126
+ unknown.push(`--${k}`);
127
+ }
128
+ if (unknown.length > 0) {
129
+ throw new ArgvError(`unknown flag(s): ${unknown.join(", ")}`, `run \`${helpCommand} --help\` for the supported flags`);
130
+ }
131
+ // No unknowns — but a known value-flag may still have been left without
132
+ // a value. Handle the first such case with the pre-existing message
133
+ // shape ("--name requires a value"). Reporting only the first keeps the
134
+ // message simple; the user fixes that flag, re-runs, sees the next one.
135
+ for (const k of dangling) {
136
+ if (flagSet.has(k)) {
137
+ throw new ArgvError(`--${k} requires a value`);
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,62 @@
1
+ // `homespun agent` — agent-lifecycle operations: register a new API key, or
2
+ // clear the locally-saved one.
3
+ //
4
+ // Both verbs are about the calling agent's identity on this machine:
5
+ // register provision an API key from the relay (one-shot bootstrap)
6
+ // logout clear the locally-saved relay URL + API key
7
+ //
8
+ // This file is a thin dispatcher — actual logic lives in register.ts and
9
+ // logout.ts.
10
+ import { runRegister } from "./register.js";
11
+ import { runLogout } from "./logout.js";
12
+ import { runClaim } from "./claim.js";
13
+ import { runSetKey } from "./set-key.js";
14
+ import { fail } from "../output.js";
15
+ export const agentHelp = `homespun agent — manage this agent's identity on the relay
16
+
17
+ Usage:
18
+ homespun agent <verb> [options]
19
+
20
+ Verbs:
21
+ register Provision an agent API key (POST /v1/register) and save it
22
+ to the CLI config file. Run this once before other commands.
23
+ claim <code> Bind this agent to a human via a one-shot claim code the
24
+ human generated in their Settings UI (POST /v1/agents/claim).
25
+ One-way; no unclaim in v1.
26
+ set-key <key> Save a new API key into the CLI config file. Used after
27
+ regenerating the agent's key in the relay's My-agents UI:
28
+ the human pastes the new key here so subsequent commands
29
+ authenticate as the same agent.
30
+ logout Clear the locally-saved relay URL + API key. Does NOT
31
+ revoke the key on the relay — use 'homespun key revoke' for
32
+ that.
33
+
34
+ Run \`homespun agent <verb> --help\` for verb-specific options.`;
35
+ export async function runAgent(args) {
36
+ // Strip the first positional (the verb) so each verb runner sees its
37
+ // own arguments at positionals[0..n].
38
+ const verbArgs = {
39
+ ...args,
40
+ positionals: args.positionals.slice(1),
41
+ };
42
+ const verb = args.positionals[0];
43
+ switch (verb) {
44
+ case "register":
45
+ await runRegister(verbArgs);
46
+ break;
47
+ case "claim":
48
+ await runClaim(verbArgs);
49
+ break;
50
+ case "set-key":
51
+ await runSetKey(verbArgs);
52
+ break;
53
+ case "logout":
54
+ await runLogout(verbArgs);
55
+ break;
56
+ case undefined:
57
+ fail("missing verb — usage: homespun agent <register|claim|set-key|logout> (run 'homespun agent --help')", "invalid_args");
58
+ break;
59
+ default:
60
+ fail(`unknown agent verb '${verb}' — expected register|claim|set-key|logout (run 'homespun agent --help')`, "invalid_args");
61
+ }
62
+ }
@@ -0,0 +1,368 @@
1
+ // `homespun apps` — v2 app lifecycle management (spec-cli §3.2): list / show /
2
+ // update / delete / wake, plus `watch` (spec-cli §3.4).
3
+ //
4
+ // Naming note (deviation from spec-cli's literal top-level `homespun watch`):
5
+ // this branch still carries the UNCHANGED v1 `homespun watch <homespun-id>` command
6
+ // (packages/cli/src/commands/watch.ts) — v2's schema/routes are additive
7
+ // during this expand/contract migration (spec-schema §6 sequencing note),
8
+ // and the v1 command's existing tests must keep passing. Reusing the bare
9
+ // `watch` noun for a different resource (App vs. Homespun) would silently break
10
+ // it. `homespun apps watch <app>` gets the identical behavior spec-cli §3.4
11
+ // describes, nested under the noun that already owns every other v2 app
12
+ // lifecycle verb; the noun collision is resolved (not worked around) at the
13
+ // v1-removal cutover PR, where `homespun watch`/`homespun share` are freed up for v2
14
+ // to reclaim verbatim.
15
+ import { HomespunApiError, appWsUrlFromAppUrl, openAppStream, } from "@homespunapps/core";
16
+ import { assertKnownFlags } from "../argv.js";
17
+ import { makeClient, resolveConfig } from "../config.js";
18
+ import { fail, failFromError, printJson, printJsonLine } from "../output.js";
19
+ import { resolveAppId } from "../resolve-app.js";
20
+ export const appsHelp = `homespun apps — app lifecycle management
21
+
22
+ Usage:
23
+ homespun apps list [--status active|dormant|archived|all] [--limit] [--cursor] [--slug <slug>]
24
+ homespun apps show <app>
25
+ homespun apps update <app> --visibility <private|link|public>
26
+ homespun apps delete <app> [--yes]
27
+ homespun apps wake <app>
28
+ homespun apps watch <app> [--since <cursor>] [--collection <name[,name2,...]>]
29
+ [--once] [--timeout <secs>]
30
+
31
+ <app> accepts either the app_id or its slug (resolved via GET /v1/apps?slug=
32
+ when it doesn't look like a cuid).
33
+
34
+ watch streams the app's change feed as JSON-lines on stdout — one compact
35
+ SerializedFeedEntry object per line, identical whether served over the live
36
+ WebSocket (primary) or the long-poll fallback (used automatically when the
37
+ WS upgrade fails, e.g. self-host mode has no WS support yet, or a locked-down
38
+ network blocks outbound WS). A dormancy transition mid-watch emits a single
39
+ {"type":"_dormant"} line and exits 0.
40
+
41
+ Output (JSON; JSON-lines for watch). Errors on stderr:
42
+ {"error":{"code","message"}} with non-zero exit.`;
43
+ export async function runApps(args) {
44
+ const verb = args.positionals[0];
45
+ if ((verb === undefined || verb === "help") && args.bools.has("help")) {
46
+ process.stdout.write(appsHelp + "\n");
47
+ return;
48
+ }
49
+ if (verb === undefined) {
50
+ fail("missing verb — homespun apps <list|show|update|delete|wake|watch>", "invalid_args");
51
+ }
52
+ const sub = {
53
+ positionals: args.positionals.slice(1),
54
+ flags: args.flags,
55
+ bools: args.bools,
56
+ ...(args.danglingValueFlags !== undefined
57
+ ? { danglingValueFlags: args.danglingValueFlags }
58
+ : {}),
59
+ };
60
+ switch (verb) {
61
+ case "list":
62
+ return runList(sub);
63
+ case "show":
64
+ return runShow(sub);
65
+ case "update":
66
+ return runUpdate(sub);
67
+ case "delete":
68
+ return runDelete(sub);
69
+ case "wake":
70
+ return runWake(sub);
71
+ case "watch":
72
+ return runWatch(sub);
73
+ default:
74
+ fail(`unknown verb '${verb}' — homespun apps <list|show|update|delete|wake|watch>`, "invalid_args");
75
+ }
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // list
79
+ // ---------------------------------------------------------------------------
80
+ async function runList(args) {
81
+ assertKnownFlags(args, ["status", "limit", "cursor", "slug", "url", "api-key"], ["help"], "homespun apps list");
82
+ const status = args.flags.get("status");
83
+ if (status !== undefined &&
84
+ !["active", "dormant", "archived", "all"].includes(status)) {
85
+ fail("--status must be active|dormant|archived|all", "invalid_args");
86
+ }
87
+ const limitRaw = args.flags.get("limit");
88
+ const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
89
+ if (limit !== undefined && !Number.isInteger(limit)) {
90
+ fail("--limit must be an integer", "invalid_args");
91
+ }
92
+ const client = makeClient(args);
93
+ try {
94
+ const page = await client.listApps({
95
+ status,
96
+ limit,
97
+ cursor: args.flags.get("cursor"),
98
+ slug: args.flags.get("slug"),
99
+ });
100
+ printJson(page);
101
+ }
102
+ catch (e) {
103
+ failFromError(e);
104
+ }
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // show
108
+ // ---------------------------------------------------------------------------
109
+ async function runShow(args) {
110
+ assertKnownFlags(args, ["url", "api-key"], ["help"], "homespun apps show");
111
+ const appArg = args.positionals[0];
112
+ if (!appArg)
113
+ fail("usage: homespun apps show <app>", "invalid_args");
114
+ const client = makeClient(args);
115
+ const id = await resolveAppId(client, appArg);
116
+ try {
117
+ printJson(await client.getApp(id));
118
+ }
119
+ catch (e) {
120
+ failFromError(e);
121
+ }
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // update
125
+ // ---------------------------------------------------------------------------
126
+ async function runUpdate(args) {
127
+ assertKnownFlags(args, ["visibility", "url", "api-key"], ["help"], "homespun apps update");
128
+ const appArg = args.positionals[0];
129
+ if (!appArg) {
130
+ fail("usage: homespun apps update <app> --visibility <private|link|public>", "invalid_args");
131
+ }
132
+ const visibility = args.flags.get("visibility");
133
+ if (visibility === undefined) {
134
+ fail("--visibility is required", "invalid_args");
135
+ }
136
+ if (!["private", "link", "public"].includes(visibility)) {
137
+ fail("--visibility must be private|link|public", "invalid_args");
138
+ }
139
+ const client = makeClient(args);
140
+ const id = await resolveAppId(client, appArg);
141
+ try {
142
+ printJson(await client.updateApp(id, visibility));
143
+ }
144
+ catch (e) {
145
+ failFromError(e);
146
+ }
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // delete
150
+ // ---------------------------------------------------------------------------
151
+ async function runDelete(args) {
152
+ assertKnownFlags(args, ["url", "api-key"], ["yes", "help"], "homespun apps delete");
153
+ const appArg = args.positionals[0];
154
+ if (!appArg)
155
+ fail("usage: homespun apps delete <app> [--yes]", "invalid_args");
156
+ if (!args.bools.has("yes")) {
157
+ fail("'homespun apps delete' permanently removes the app and all its data — it is destructive. Pass --yes to confirm.", "invalid_args");
158
+ }
159
+ const client = makeClient(args);
160
+ const id = await resolveAppId(client, appArg);
161
+ try {
162
+ await client.deleteApp(id);
163
+ printJson({ deleted: true, app_id: id });
164
+ }
165
+ catch (e) {
166
+ failFromError(e);
167
+ }
168
+ }
169
+ // ---------------------------------------------------------------------------
170
+ // wake
171
+ // ---------------------------------------------------------------------------
172
+ async function runWake(args) {
173
+ assertKnownFlags(args, ["url", "api-key"], ["help"], "homespun apps wake");
174
+ const appArg = args.positionals[0];
175
+ if (!appArg)
176
+ fail("usage: homespun apps wake <app>", "invalid_args");
177
+ const client = makeClient(args);
178
+ const id = await resolveAppId(client, appArg);
179
+ try {
180
+ printJson(await client.wakeApp(id));
181
+ }
182
+ catch (e) {
183
+ failFromError(e);
184
+ }
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // watch — WS primary, long-poll fallback; identical JSON-lines either way
188
+ // ---------------------------------------------------------------------------
189
+ /**
190
+ * The one function that turns an `AppFeedEntry` into stdout output — called
191
+ * from BOTH the WS entry handler and the long-poll loop below, so the two
192
+ * transports are provably byte-identical in what they print (spec-cli §5).
193
+ * Exported for unit testing.
194
+ */
195
+ export function printFeedEntryLine(entry, collectionFilter) {
196
+ if (collectionFilter !== null &&
197
+ !collectionFilter.has(entry.collection_name)) {
198
+ return;
199
+ }
200
+ printJsonLine(entry);
201
+ }
202
+ /** Parse `--collection a,b,c` into a filter set, or null when omitted. */
203
+ export function parseCollectionFilter(raw) {
204
+ if (raw === undefined)
205
+ return null;
206
+ const names = raw
207
+ .split(",")
208
+ .map((s) => s.trim())
209
+ .filter((s) => s.length > 0);
210
+ return new Set(names);
211
+ }
212
+ /**
213
+ * True iff `err` is the relay's "app is dormant" 409 (assertAppActiveForData
214
+ * on the long-poll route) — the long-poll transport's ONLY signal for the
215
+ * same dormancy transition the WS path gets as an explicit `_dormant` frame.
216
+ * Exported for unit testing.
217
+ */
218
+ export function isDormantConflict(err) {
219
+ return (err instanceof HomespunApiError &&
220
+ err.code === "conflict" &&
221
+ err.message === "app is dormant");
222
+ }
223
+ async function runWatch(args) {
224
+ assertKnownFlags(args, ["since", "collection", "timeout", "url", "api-key"], ["once", "help"], "homespun apps watch");
225
+ const appArg = args.positionals[0];
226
+ if (!appArg)
227
+ fail("usage: homespun apps watch <app>", "invalid_args");
228
+ const sinceRaw = args.flags.get("since");
229
+ let since = 0;
230
+ if (sinceRaw !== undefined) {
231
+ const n = Number(sinceRaw);
232
+ if (!Number.isInteger(n) || n < 0) {
233
+ fail("--since must be a non-negative integer cursor", "invalid_args");
234
+ }
235
+ since = n;
236
+ }
237
+ const collectionFilter = parseCollectionFilter(args.flags.get("collection"));
238
+ const once = args.bools.has("once");
239
+ let timeoutSec = null;
240
+ const timeoutRaw = args.flags.get("timeout");
241
+ if (timeoutRaw !== undefined) {
242
+ const t = Number(timeoutRaw);
243
+ if (!Number.isFinite(t) || t <= 0) {
244
+ fail("--timeout must be a positive number", "invalid_args");
245
+ }
246
+ timeoutSec = t;
247
+ }
248
+ const cfg = resolveConfig(args);
249
+ const client = makeClient(args);
250
+ let appId;
251
+ let appUrl;
252
+ try {
253
+ appId = await resolveAppId(client, appArg);
254
+ appUrl = (await client.getApp(appId)).url;
255
+ }
256
+ catch (e) {
257
+ failFromError(e);
258
+ }
259
+ let exited = false;
260
+ let timer;
261
+ const finish = (code) => {
262
+ if (exited)
263
+ return;
264
+ exited = true;
265
+ if (timer)
266
+ clearTimeout(timer);
267
+ process.exit(code);
268
+ };
269
+ if (timeoutSec !== null) {
270
+ timer = setTimeout(() => {
271
+ fail(`no terminal condition met within ${timeoutSec}s`, "ws_timeout");
272
+ }, timeoutSec * 1000);
273
+ }
274
+ const emitDormant = () => {
275
+ printJsonLine({ type: "_dormant" });
276
+ finish(0);
277
+ };
278
+ // Long-poll fallback loop (spec-cli §5) — GET /v1/apps/:id/feed?wait=25.
279
+ // Uses the SAME printFeedEntryLine as the WS entry handler below, so a
280
+ // caller piping `homespun apps watch` output can never tell which transport
281
+ // served a given line.
282
+ async function runLongPoll(startSince) {
283
+ let cursor = startSince;
284
+ while (!exited) {
285
+ let page;
286
+ try {
287
+ page = await client.getAppFeed(appId, { since: cursor, wait: 25 });
288
+ }
289
+ catch (e) {
290
+ if (isDormantConflict(e)) {
291
+ emitDormant();
292
+ return;
293
+ }
294
+ failFromError(e);
295
+ return;
296
+ }
297
+ for (const entry of page.entries) {
298
+ printFeedEntryLine(entry, collectionFilter);
299
+ cursor = Math.max(cursor, entry.seq);
300
+ if (once) {
301
+ finish(0);
302
+ return;
303
+ }
304
+ }
305
+ cursor = Math.max(cursor, page.cursor);
306
+ }
307
+ }
308
+ let wsConnected = false;
309
+ let fellBack = false;
310
+ const wsUrl = appWsUrlFromAppUrl(appUrl);
311
+ const handle = openAppStream({ wsUrl, apiKey: cfg.apiKey, since }, {
312
+ onHello: () => {
313
+ wsConnected = true;
314
+ },
315
+ onEntry: (entry) => {
316
+ printFeedEntryLine(entry, collectionFilter);
317
+ if (once)
318
+ finish(0);
319
+ },
320
+ onDormant: () => {
321
+ emitDormant();
322
+ },
323
+ onResync: () => {
324
+ printJsonLine({ type: "resync" });
325
+ },
326
+ onClose: ({ code, reason }) => {
327
+ if (exited)
328
+ return;
329
+ // If we've already fallen back (this close is very likely the
330
+ // trailing `close` frame ~50ms behind the `error` event that
331
+ // triggered the fallback), the long-poll loop now OWNS termination —
332
+ // this handler must be a no-op, not call fail() and exit(1) out from
333
+ // under the just-started long-poll (the HIGH bug this guards).
334
+ if (fellBack)
335
+ return;
336
+ if (!wsConnected) {
337
+ fellBack = true;
338
+ void runLongPoll(since);
339
+ return;
340
+ }
341
+ if (code === 1000 || code === 1001) {
342
+ finish(0);
343
+ return;
344
+ }
345
+ fail(`app stream closed abnormally (code ${code})${reason ? ": " + reason : ""}`, "ws_closed_abnormally", { code, reason });
346
+ },
347
+ onError: () => {
348
+ if (exited)
349
+ return;
350
+ if (fellBack)
351
+ return;
352
+ if (!wsConnected) {
353
+ fellBack = true;
354
+ void runLongPoll(since);
355
+ return;
356
+ }
357
+ // A transport error after a successful connect — fall through to
358
+ // the same terminal handling `onClose` would give a non-clean close.
359
+ },
360
+ });
361
+ process.on("SIGINT", () => {
362
+ handle.close();
363
+ finish(0);
364
+ });
365
+ await new Promise(() => {
366
+ /* never resolves — SIGINT or a terminal condition exits */
367
+ });
368
+ }