@homespunapps/cli 1.0.0 → 1.0.1
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/README.md +15 -5
- package/dist/commands/register.js +119 -23
- package/dist/device-flow.js +162 -0
- package/dist/index.js +3 -0
- package/dist/version.js +20 -6
- package/package.json +3 -8
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @homespunapps/cli
|
|
2
2
|
|
|
3
|
-
Command-line client for the [Homespun](https://
|
|
3
|
+
Command-line client for the [Homespun](https://homespun.dev) relay:
|
|
4
4
|
hand a human a rich interactive UI by URL and capture their answer as structured
|
|
5
5
|
data — from any agent (cron job, chat bot, CI, headless server).
|
|
6
6
|
|
|
@@ -30,10 +30,19 @@ Add `--no-open` on a headless / SSH box and it just prints the URL.
|
|
|
30
30
|
## Setup
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
export HOMESPUN_URL=https://homespun.dev # or
|
|
33
|
+
export HOMESPUN_URL=https://homespun.dev # or a different relay origin
|
|
34
34
|
homespun agent register --name "my-agent" # provisions and saves an API key
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
By default `homespun agent register` uses browser approval (an RFC 8628 style
|
|
38
|
+
device flow): it prints a link and a short code like `ABCD-EFGH`; open the
|
|
39
|
+
link on any device (your phone works), sign in, and approve. The agent comes
|
|
40
|
+
out already linked to your account, ready to deploy. On an older relay
|
|
41
|
+
without the flow the CLI falls back to direct registration automatically
|
|
42
|
+
(such agents need a one-time `homespun agent claim <code>` afterwards; mint
|
|
43
|
+
the code in the relay's Settings). `--no-device` forces the direct path,
|
|
44
|
+
and `--secret <s>` (for `REGISTRATION_MODE=secret` relays) implies it.
|
|
45
|
+
|
|
37
46
|
`homespun agent register` writes the URL + API key to
|
|
38
47
|
`${XDG_CONFIG_HOME:-~/.config}/homespun/config.json`. Subsequent commands need
|
|
39
48
|
only `HOMESPUN_URL` (or nothing) in the environment.
|
|
@@ -46,7 +55,9 @@ Uniform `homespun <noun> <verb> [options]`:
|
|
|
46
55
|
|
|
47
56
|
```
|
|
48
57
|
homespun demo Zero-setup guided tour — see the round-trip live
|
|
49
|
-
homespun agent register Provision an agent API key
|
|
58
|
+
homespun agent register Provision an agent API key (browser approval
|
|
59
|
+
by default; --no-device for direct) and save it
|
|
60
|
+
homespun agent claim <code> Bind this agent to a human via a one-shot code
|
|
50
61
|
homespun agent logout Clear the locally-saved URL + API key
|
|
51
62
|
homespun create Create an app — returns app_id, urls, tokens
|
|
52
63
|
homespun show <id> Non-blocking snapshot: metadata + event log
|
|
@@ -76,6 +87,5 @@ homespun watch "$SESSION" | jq 'select(.type == "human_response")'
|
|
|
76
87
|
|
|
77
88
|
## Links
|
|
78
89
|
|
|
79
|
-
-
|
|
80
|
-
- Spec: <https://github.com/aerolalit/homespun/blob/main/docs/SPEC.md>
|
|
90
|
+
- Docs: <https://docs.homespun.dev>
|
|
81
91
|
- License: MIT
|
|
@@ -1,26 +1,46 @@
|
|
|
1
|
-
// `homespun agent register`
|
|
1
|
+
// `homespun agent register` - provision an agent API key from the relay.
|
|
2
2
|
//
|
|
3
3
|
// This is the one command that needs no API key: it is the call that obtains
|
|
4
|
-
// one.
|
|
5
|
-
//
|
|
6
|
-
// (
|
|
7
|
-
//
|
|
4
|
+
// one. Two paths:
|
|
5
|
+
//
|
|
6
|
+
// DEVICE FLOW (default) - RFC 8628 style browser approval. The CLI asks the
|
|
7
|
+
// relay for a device_code + user_code pair, prints a verification URL the
|
|
8
|
+
// human can open on ANY device, and polls until the human approves. The
|
|
9
|
+
// resulting agent is already OWNED by the approving human (no separate
|
|
10
|
+
// `homespun agent claim` step needed).
|
|
11
|
+
//
|
|
12
|
+
// DIRECT (fallback) - plain POST /v1/register, the pre-device-flow path.
|
|
13
|
+
// Used when the relay 404s the device endpoints (older relay), when a
|
|
14
|
+
// registration secret is supplied (REGISTRATION_MODE=secret relays), or on
|
|
15
|
+
// --no-device. Direct-registered agents are unowned until claimed.
|
|
16
|
+
//
|
|
17
|
+
// On success the key (and relay URL) are persisted under a named profile in
|
|
18
|
+
// the CLI config file, so every later command works with only HOMESPUN_URL (or
|
|
19
|
+
// nothing) set.
|
|
20
|
+
import { hostname } from "node:os";
|
|
8
21
|
import { registerAgent, HomespunApiError } from "@homespunapps/core";
|
|
9
22
|
import { assertKnownFlags } from "../argv.js";
|
|
10
23
|
import { DEFAULT_RELAY_URL } from "../config.js";
|
|
24
|
+
import { runDeviceFlow } from "../device-flow.js";
|
|
11
25
|
import { printJson, fail, failUpgradeRequired } from "../output.js";
|
|
12
26
|
import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
|
|
13
27
|
import { VERSION } from "../version.js";
|
|
14
28
|
const KNOWN_FLAGS = ["name", "secret"];
|
|
15
|
-
const KNOWN_BOOLS = ["print-key"];
|
|
16
|
-
export const registerHelp = `homespun agent register
|
|
29
|
+
const KNOWN_BOOLS = ["print-key", "no-device"];
|
|
30
|
+
export const registerHelp = `homespun agent register - register this agent with the relay and save the key locally
|
|
17
31
|
|
|
18
32
|
Usage:
|
|
19
33
|
homespun agent register [options]
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
a
|
|
23
|
-
|
|
35
|
+
By default this runs the browser device-authorization flow: it prints a link
|
|
36
|
+
and a short code, you (or the account owner) open the link on any device,
|
|
37
|
+
sign in, and approve - and the agent comes out already linked to that
|
|
38
|
+
account. Older relays without the flow fall back to plain POST /v1/register
|
|
39
|
+
automatically (such agents need a separate 'homespun agent claim' afterwards).
|
|
40
|
+
|
|
41
|
+
The returned API key (and relay URL) are saved under a named profile in the
|
|
42
|
+
CLI config file - so afterwards every other command works with only HOMESPUN_URL
|
|
43
|
+
set (no HOMESPUN_API_KEY needed).
|
|
24
44
|
|
|
25
45
|
If --profile is omitted, the registered key goes under the currently-active
|
|
26
46
|
profile (or 'default' for a fresh install). Pass --profile <name> to keep
|
|
@@ -28,8 +48,9 @@ multiple environments (dev/staging/prod) side by side; switch between them
|
|
|
28
48
|
with 'homespun config use <name>' or '--profile <name>' / HOMESPUN_PROFILE.
|
|
29
49
|
|
|
30
50
|
Options:
|
|
31
|
-
--name <n> Agent display name on the relay
|
|
32
|
-
|
|
51
|
+
--name <n> Agent display name on the relay (shown on the approval
|
|
52
|
+
screen). Defaults to cli-<hostname> for the device flow;
|
|
53
|
+
the relay defaults it for the direct path.
|
|
33
54
|
--profile <name> Local profile name to save under. Defaults to the active
|
|
34
55
|
profile, or 'default' on a fresh install. Letters,
|
|
35
56
|
digits, _ and -, up to 32 chars.
|
|
@@ -37,23 +58,48 @@ Options:
|
|
|
37
58
|
profile, then the hosted Homespun relay. Self-hosters set
|
|
38
59
|
this.
|
|
39
60
|
--secret <s> Registration secret, sent as a Bearer token. Only needed
|
|
40
|
-
when the relay uses REGISTRATION_MODE=secret.
|
|
41
|
-
|
|
61
|
+
when the relay uses REGISTRATION_MODE=secret. Implies the
|
|
62
|
+
direct path (no browser approval). Falls back to the
|
|
63
|
+
HOMESPUN_REGISTER_SECRET env var.
|
|
64
|
+
--no-device Skip the browser approval and register directly
|
|
65
|
+
(POST /v1/register). The agent is unowned until claimed.
|
|
42
66
|
--print-key Also echo the full api_key in the output. By default the
|
|
43
67
|
key is only persisted to the config file, never printed.
|
|
44
68
|
-h, --help Show this help.
|
|
45
69
|
|
|
46
70
|
Output (stdout, JSON):
|
|
47
|
-
{ agent_id, key_prefix, profile, saved_to }
|
|
71
|
+
{ agent_id, key_prefix, profile, saved_to, registered_via }
|
|
72
|
+
(+ api_key when --print-key)
|
|
48
73
|
|
|
49
74
|
The API key is saved to the CLI config file (mode 0600); it is not printed
|
|
50
75
|
unless --print-key is passed.`;
|
|
76
|
+
/**
|
|
77
|
+
* Default agent name for the device flow: the consent screen must name what
|
|
78
|
+
* the human is approving, so an unnamed agent gets "cli-<hostname>" instead
|
|
79
|
+
* of the relay's unhelpful generic default. Control characters are stripped
|
|
80
|
+
* and the result clamped to the relay's 64-char cap.
|
|
81
|
+
*/
|
|
82
|
+
export function defaultDeviceAgentName(host = hostname()) {
|
|
83
|
+
let cleaned = "";
|
|
84
|
+
for (const ch of host.trim()) {
|
|
85
|
+
const codePoint = ch.codePointAt(0) ?? 0;
|
|
86
|
+
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f))
|
|
87
|
+
continue;
|
|
88
|
+
cleaned += ch;
|
|
89
|
+
}
|
|
90
|
+
const name = `cli-${cleaned}`.slice(0, 64).trim();
|
|
91
|
+
return name.length > "cli-".length ? name : "cli-agent";
|
|
92
|
+
}
|
|
93
|
+
/** Compute the display prefix of an API key, mirroring the relay's rule. */
|
|
94
|
+
function apiKeyPrefix(key) {
|
|
95
|
+
return key.startsWith("hs_") ? key.slice(0, 9) : key.slice(0, 8);
|
|
96
|
+
}
|
|
51
97
|
export async function runRegister(args) {
|
|
52
98
|
assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun agent register");
|
|
53
99
|
// Profile selection for the WRITE side: --profile flag → HOMESPUN_PROFILE env
|
|
54
100
|
// → the store's current profile → DEFAULT_PROFILE_NAME ('default') for
|
|
55
101
|
// a fresh install. We deliberately don't fall through to "no profile, use
|
|
56
|
-
// a fresh name"
|
|
102
|
+
// a fresh name" - the agent needs to end up somewhere callable, and
|
|
57
103
|
// 'default' is a stable, predictable home.
|
|
58
104
|
const profileFlag = args.flags.get("profile") ?? process.env.HOMESPUN_PROFILE;
|
|
59
105
|
const store = readStore();
|
|
@@ -61,7 +107,7 @@ export async function runRegister(args) {
|
|
|
61
107
|
? profileFlag
|
|
62
108
|
: (store.currentProfile ?? DEFAULT_PROFILE_NAME);
|
|
63
109
|
if (!isValidProfileName(profileName)) {
|
|
64
|
-
fail(`invalid profile name '${profileName}'
|
|
110
|
+
fail(`invalid profile name '${profileName}' - letters, digits, _ and -, up to 32 chars`, "invalid_args");
|
|
65
111
|
}
|
|
66
112
|
// URL precedence for the relay we're registering against:
|
|
67
113
|
// --url flag > HOMESPUN_URL env > target-profile's existing url > default.
|
|
@@ -74,21 +120,70 @@ export async function runRegister(args) {
|
|
|
74
120
|
activeUrl = active?.profile.url;
|
|
75
121
|
}
|
|
76
122
|
catch {
|
|
77
|
-
// Selector didn't resolve
|
|
123
|
+
// Selector didn't resolve - fine on register: we're about to create it.
|
|
78
124
|
activeUrl = undefined;
|
|
79
125
|
}
|
|
80
|
-
const url = args.flags.get("url") ??
|
|
126
|
+
const url = (args.flags.get("url") ??
|
|
81
127
|
process.env.HOMESPUN_URL ??
|
|
82
128
|
activeUrl ??
|
|
83
|
-
DEFAULT_RELAY_URL;
|
|
129
|
+
DEFAULT_RELAY_URL).replace(/\/$/, "");
|
|
84
130
|
const name = args.flags.get("name");
|
|
85
131
|
const secret = args.flags.get("secret") ??
|
|
86
132
|
process.env.HOMESPUN_REGISTER_SECRET ??
|
|
87
133
|
undefined;
|
|
134
|
+
// The device flow is the default. A registration secret implies a
|
|
135
|
+
// REGISTRATION_MODE=secret relay whose operator hands out direct access,
|
|
136
|
+
// and --no-device is the explicit opt-out (CI, headless-with-no-human).
|
|
137
|
+
const wantDevice = !args.bools.has("no-device") && (secret === undefined || secret === "");
|
|
138
|
+
if (wantDevice) {
|
|
139
|
+
try {
|
|
140
|
+
const outcome = await runDeviceFlow({
|
|
141
|
+
url,
|
|
142
|
+
name: name ?? defaultDeviceAgentName(),
|
|
143
|
+
cliVersion: VERSION,
|
|
144
|
+
});
|
|
145
|
+
if (outcome.supported) {
|
|
146
|
+
const savedTo = upsertProfile(profileName, { url, apiKey: outcome.agent_key }, true);
|
|
147
|
+
const out = {
|
|
148
|
+
agent_id: outcome.agent_id,
|
|
149
|
+
key_prefix: apiKeyPrefix(outcome.agent_key),
|
|
150
|
+
profile: profileName,
|
|
151
|
+
saved_to: savedTo,
|
|
152
|
+
registered_via: "device",
|
|
153
|
+
};
|
|
154
|
+
if (args.bools.has("print-key")) {
|
|
155
|
+
out["api_key"] = outcome.agent_key;
|
|
156
|
+
}
|
|
157
|
+
printJson(out);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// 404 on /v1/device/code: an older relay. Fall through to the direct
|
|
161
|
+
// path with a note so the behavior change is visible, not silent.
|
|
162
|
+
process.stderr.write("note: this relay does not support browser approval (older relay); " +
|
|
163
|
+
"falling back to direct registration. The agent will need " +
|
|
164
|
+
"'homespun agent claim <code>' to get an owner.\n");
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
if (e instanceof HomespunApiError) {
|
|
168
|
+
if (e.status === 426 && e.code === "cli_upgrade_required") {
|
|
169
|
+
failUpgradeRequired(e);
|
|
170
|
+
}
|
|
171
|
+
if (e.status === 429) {
|
|
172
|
+
fail("device authorization rate limit exceeded - try again later", "rate_limited", undefined, { hint: e.hint, retryable: true, docs_url: e.docsUrl });
|
|
173
|
+
}
|
|
174
|
+
fail(e.message, e.code, e.details, {
|
|
175
|
+
hint: e.hint,
|
|
176
|
+
retryable: e.retryable,
|
|
177
|
+
docs_url: e.docsUrl,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
fail(e instanceof Error ? e.message : String(e), "internal");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
88
183
|
let result;
|
|
89
184
|
try {
|
|
90
185
|
result = await registerAgent({
|
|
91
|
-
url
|
|
186
|
+
url,
|
|
92
187
|
...(name !== undefined ? { name } : {}),
|
|
93
188
|
...(secret !== undefined && secret !== "" ? { secret } : {}),
|
|
94
189
|
cliVersion: VERSION,
|
|
@@ -103,7 +198,7 @@ export async function runRegister(args) {
|
|
|
103
198
|
failUpgradeRequired(e);
|
|
104
199
|
}
|
|
105
200
|
if (e.status === 429) {
|
|
106
|
-
fail("registration rate limit exceeded
|
|
201
|
+
fail("registration rate limit exceeded - try again later", "rate_limited", undefined, { hint: e.hint, retryable: e.retryable, docs_url: e.docsUrl });
|
|
107
202
|
}
|
|
108
203
|
fail(e.message, e.code, e.details, {
|
|
109
204
|
hint: e.hint,
|
|
@@ -117,12 +212,13 @@ export async function runRegister(args) {
|
|
|
117
212
|
// registered against this relay, so the only sensible follow-up is to
|
|
118
213
|
// start using it. The previous behaviour (one global URL+key) is exactly
|
|
119
214
|
// the single-profile case of this.
|
|
120
|
-
const savedTo = upsertProfile(profileName, { url
|
|
215
|
+
const savedTo = upsertProfile(profileName, { url, apiKey: result.api_key }, true);
|
|
121
216
|
const out = {
|
|
122
217
|
agent_id: result.agent_id,
|
|
123
218
|
key_prefix: result.key_prefix,
|
|
124
219
|
profile: profileName,
|
|
125
220
|
saved_to: savedTo,
|
|
221
|
+
registered_via: "direct",
|
|
126
222
|
};
|
|
127
223
|
if (args.bools.has("print-key")) {
|
|
128
224
|
out["api_key"] = result.api_key;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Device-authorization registration (RFC 8628 style) - the browser-approval
|
|
2
|
+
// path behind `homespun agent register`.
|
|
3
|
+
//
|
|
4
|
+
// The CLI asks the relay for a device_code + user_code pair, prints the
|
|
5
|
+
// verification URL + code for the human (who can open it on ANY device),
|
|
6
|
+
// and polls POST /v1/device/token until the human approves, denies, or the
|
|
7
|
+
// codes expire. On approval the relay creates an agent OWNED by the
|
|
8
|
+
// approving human and returns its key exactly once.
|
|
9
|
+
//
|
|
10
|
+
// Everything human-facing goes to stderr - stdout stays reserved for the
|
|
11
|
+
// final JSON envelope, like every other command. fetch/sleep/print are
|
|
12
|
+
// injectable so the polling loop is unit-testable with a mocked relay.
|
|
13
|
+
import { HomespunApiError } from "@homespunapps/core";
|
|
14
|
+
function defaultSleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
function defaultPrint(line) {
|
|
18
|
+
process.stderr.write(line + "\n");
|
|
19
|
+
}
|
|
20
|
+
/** POST JSON, returning { status, body } with the body parsed best-effort. */
|
|
21
|
+
async function postJson(fetchImpl, url, body, cliVersion) {
|
|
22
|
+
const headers = {
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
};
|
|
25
|
+
if (cliVersion !== undefined && cliVersion !== "") {
|
|
26
|
+
headers["x-homespun-cli-version"] = cliVersion;
|
|
27
|
+
}
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await fetchImpl(url, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers,
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
38
|
+
throw new HomespunApiError(0, "fetch_error", msg);
|
|
39
|
+
}
|
|
40
|
+
const text = await res.text().catch(() => "");
|
|
41
|
+
let parsed = null;
|
|
42
|
+
if (text !== "") {
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(text);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
parsed = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { status: res.status, body: parsed };
|
|
51
|
+
}
|
|
52
|
+
/** The relay-envelope error code/message, when the body carries one. */
|
|
53
|
+
function envelopeError(body) {
|
|
54
|
+
const err = body?.error;
|
|
55
|
+
if (err && typeof err === "object") {
|
|
56
|
+
const e = err;
|
|
57
|
+
return {
|
|
58
|
+
code: typeof e.code === "string" ? e.code : "relay_error",
|
|
59
|
+
message: typeof e.message === "string" ? e.message : "relay error",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** The RFC 8628 error string ({ error: "authorization_pending" }), if any. */
|
|
65
|
+
function rfcErrorCode(body) {
|
|
66
|
+
const err = body?.error;
|
|
67
|
+
return typeof err === "string" ? err : null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Run the device-authorization flow end to end. Returns `supported: false`
|
|
71
|
+
* when the relay 404s the code request (an older relay - the caller falls
|
|
72
|
+
* back to plain POST /v1/register). Throws HomespunApiError on every other
|
|
73
|
+
* failure, including denial and expiry, with actionable codes:
|
|
74
|
+
*
|
|
75
|
+
* device_flow_denied the human clicked Deny
|
|
76
|
+
* device_flow_expired nobody approved within the code's lifetime
|
|
77
|
+
*/
|
|
78
|
+
export async function runDeviceFlow(opts) {
|
|
79
|
+
const base = opts.url.replace(/\/$/, "");
|
|
80
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
81
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
82
|
+
const print = opts.print ?? defaultPrint;
|
|
83
|
+
// ---- 1. Request the code pair -----------------------------------------
|
|
84
|
+
const start = await postJson(fetchImpl, `${base}/v1/device/code`, { name: opts.name }, opts.cliVersion);
|
|
85
|
+
if (start.status === 404) {
|
|
86
|
+
// Older relay without the device flow - signal the caller to fall back.
|
|
87
|
+
return { supported: false };
|
|
88
|
+
}
|
|
89
|
+
if (start.status !== 200) {
|
|
90
|
+
const env = envelopeError(start.body);
|
|
91
|
+
throw new HomespunApiError(start.status, env?.code ?? "relay_error", env?.message ?? `relay returned ${start.status} for /v1/device/code`);
|
|
92
|
+
}
|
|
93
|
+
const code = start.body;
|
|
94
|
+
if (!code ||
|
|
95
|
+
typeof code.device_code !== "string" ||
|
|
96
|
+
typeof code.user_code !== "string" ||
|
|
97
|
+
typeof code.verification_uri_complete !== "string") {
|
|
98
|
+
throw new HomespunApiError(200, "invalid_response", "relay returned an unexpected /v1/device/code body");
|
|
99
|
+
}
|
|
100
|
+
// ---- 2. Hand the human their marching orders ---------------------------
|
|
101
|
+
const expiresMin = Math.max(1, Math.round((code.expires_in ?? 900) / 60));
|
|
102
|
+
print("");
|
|
103
|
+
print("To approve this agent, open:");
|
|
104
|
+
print("");
|
|
105
|
+
print(` ${code.verification_uri_complete}`);
|
|
106
|
+
print("");
|
|
107
|
+
print(`and confirm the code: ${code.user_code}`);
|
|
108
|
+
print("");
|
|
109
|
+
print("You can open the link on any device (phone or laptop) and sign in there.");
|
|
110
|
+
print(`Waiting for approval... (expires in ${expiresMin} min; Ctrl-C to abort)`);
|
|
111
|
+
// ---- 3. Poll until a terminal answer -----------------------------------
|
|
112
|
+
let intervalSeconds = typeof code.interval === "number" && code.interval > 0 ? code.interval : 5;
|
|
113
|
+
const deadline = Date.now() + (code.expires_in ?? 900) * 1000;
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
await sleep(intervalSeconds * 1000);
|
|
116
|
+
// Re-check after sleeping: don't fire a poll we already know is past
|
|
117
|
+
// the code's lifetime (the relay would just answer expired_token).
|
|
118
|
+
if (Date.now() >= deadline)
|
|
119
|
+
break;
|
|
120
|
+
const poll = await postJson(fetchImpl, `${base}/v1/device/token`, { device_code: code.device_code }, opts.cliVersion);
|
|
121
|
+
if (poll.status === 200) {
|
|
122
|
+
const body = poll.body;
|
|
123
|
+
if (typeof body?.agent_key !== "string" ||
|
|
124
|
+
typeof body?.agent_id !== "string") {
|
|
125
|
+
throw new HomespunApiError(200, "invalid_response", "relay returned an unexpected /v1/device/token body");
|
|
126
|
+
}
|
|
127
|
+
print("Approved.");
|
|
128
|
+
return {
|
|
129
|
+
supported: true,
|
|
130
|
+
agent_id: body.agent_id,
|
|
131
|
+
agent_key: body.agent_key,
|
|
132
|
+
name: typeof body.name === "string" ? body.name : opts.name,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const rfc = rfcErrorCode(poll.body);
|
|
136
|
+
if (poll.status === 400 && rfc !== null) {
|
|
137
|
+
switch (rfc) {
|
|
138
|
+
case "authorization_pending":
|
|
139
|
+
continue;
|
|
140
|
+
case "slow_down":
|
|
141
|
+
// RFC 8628 §3.5: add 5 seconds to the interval and keep going.
|
|
142
|
+
intervalSeconds += 5;
|
|
143
|
+
continue;
|
|
144
|
+
case "access_denied":
|
|
145
|
+
throw new HomespunApiError(400, "device_flow_denied", "the approval request was denied in the browser");
|
|
146
|
+
case "expired_token":
|
|
147
|
+
throw new HomespunApiError(400, "device_flow_expired", "the device code expired before it was approved - run 'homespun agent register' again");
|
|
148
|
+
default:
|
|
149
|
+
throw new HomespunApiError(400, rfc, `relay rejected the poll (${rfc})`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (poll.status === 429) {
|
|
153
|
+
// Transient general rate limit - back off like a slow_down and retry.
|
|
154
|
+
intervalSeconds += 5;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// 426 cli_upgrade_required and anything else: surface the envelope.
|
|
158
|
+
const env = envelopeError(poll.body);
|
|
159
|
+
throw new HomespunApiError(poll.status, env?.code ?? "relay_error", env?.message ?? `relay returned ${poll.status} for /v1/device/token`, poll.body?.error?.details);
|
|
160
|
+
}
|
|
161
|
+
throw new HomespunApiError(400, "device_flow_expired", "the device code expired before it was approved - run 'homespun agent register' again");
|
|
162
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -110,6 +110,9 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
110
110
|
"plain",
|
|
111
111
|
// `homespun deploy --force` / `homespun apps ... --force`: override a compat gate.
|
|
112
112
|
"force",
|
|
113
|
+
// `homespun agent register --no-device`: skip the browser device-authorization
|
|
114
|
+
// flow and register directly (unowned agent), the pre-device-flow behavior.
|
|
115
|
+
"no-device",
|
|
113
116
|
]);
|
|
114
117
|
async function main() {
|
|
115
118
|
const rawArgv = process.argv.slice(2);
|
package/dist/version.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// Single source of truth for the CLI version string.
|
|
2
2
|
//
|
|
3
3
|
// - `homespun --version` prints this verbatim.
|
|
4
|
-
// - Every HomespunClient construction passes it as `cliVersion`, which
|
|
5
|
-
// as the `x-homespun-cli-version` header on every relay request
|
|
4
|
+
// - Every HomespunClient construction passes it as `cliVersion`, which rides
|
|
5
|
+
// as the `x-homespun-cli-version` header on every relay request, driving the
|
|
6
6
|
// relay's version-skew check (HTTP 426 `cli_upgrade_required`).
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
8
|
+
// The value is read at runtime from THIS package's own `package.json`
|
|
9
|
+
// `version` field rather than hardcoded, so it can never drift from what npm
|
|
10
|
+
// published. Both the built `dist/version.js` and the source `src/version.ts`
|
|
11
|
+
// sit exactly one directory below the package root, so `../package.json`
|
|
12
|
+
// resolves the same in either location.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
function readVersion() {
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const pkgPath = resolve(here, "..", "package.json");
|
|
19
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
20
|
+
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
|
21
|
+
throw new Error(`homespun CLI: missing version in ${pkgPath}`);
|
|
22
|
+
}
|
|
23
|
+
return pkg.version;
|
|
24
|
+
}
|
|
25
|
+
export const VERSION = readVersion();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homespunapps/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Command-line client for the Homespun relay: create apps, inspect state, send and watch events.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,14 +11,9 @@
|
|
|
11
11
|
"relay",
|
|
12
12
|
"human-in-the-loop"
|
|
13
13
|
],
|
|
14
|
-
"homepage": "https://
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/aerolalit/homespun.git",
|
|
18
|
-
"directory": "packages/cli"
|
|
19
|
-
},
|
|
14
|
+
"homepage": "https://homespun.dev",
|
|
20
15
|
"bugs": {
|
|
21
|
-
"url": "
|
|
16
|
+
"url": "mailto:support@homespun.dev"
|
|
22
17
|
},
|
|
23
18
|
"engines": {
|
|
24
19
|
"node": ">=20"
|