@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 +21 -0
- package/README.md +81 -0
- package/dist/argv.js +140 -0
- package/dist/commands/agent.js +62 -0
- package/dist/commands/apps.js +368 -0
- package/dist/commands/attachment-delete.js +37 -0
- package/dist/commands/attachment-download.js +53 -0
- package/dist/commands/attachment-list.js +50 -0
- package/dist/commands/attachment-show.js +37 -0
- package/dist/commands/attachment-token.js +133 -0
- package/dist/commands/attachment-upload.js +65 -0
- package/dist/commands/attachment.js +133 -0
- package/dist/commands/claim.js +68 -0
- package/dist/commands/config.js +262 -0
- package/dist/commands/data.js +168 -0
- package/dist/commands/deploy.js +136 -0
- package/dist/commands/feedback.js +133 -0
- package/dist/commands/key.js +82 -0
- package/dist/commands/logout.js +59 -0
- package/dist/commands/members.js +143 -0
- package/dist/commands/register.js +131 -0
- package/dist/commands/set-key.js +92 -0
- package/dist/commands/skill.js +145 -0
- package/dist/commands/taste.js +165 -0
- package/dist/config.js +165 -0
- package/dist/format.js +133 -0
- package/dist/index.js +230 -0
- package/dist/input.js +42 -0
- package/dist/output.js +77 -0
- package/dist/resolve-app.js +51 -0
- package/dist/store.js +205 -0
- package/dist/upgrade.js +115 -0
- package/dist/version.js +11 -0
- package/package.json +53 -0
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
|
+
}
|