@homespunapps/cli 1.0.0 → 1.4.2

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.
@@ -1,59 +1,58 @@
1
- // `homespun agent register` self-provision an agent API key from the relay.
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. If the relay runs REGISTRATION_MODE=secret, pass the shared
5
- // registration secret via --secret or HOMESPUN_REGISTER_SECRET. On success the key
6
- // (and relay URL) are persisted under a named profile in the CLI config file,
7
- // so every later command works with only HOMESPUN_URL (or nothing) set.
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";
23
+ import { specFor } from "../help-catalog.js";
10
24
  import { DEFAULT_RELAY_URL } from "../config.js";
25
+ import { runDeviceFlow } from "../device-flow.js";
11
26
  import { printJson, fail, failUpgradeRequired } from "../output.js";
12
27
  import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
13
28
  import { VERSION } from "../version.js";
14
- const KNOWN_FLAGS = ["name", "secret"];
15
- const KNOWN_BOOLS = ["print-key"];
16
- export const registerHelp = `homespun agent register register this agent with the relay and save the key locally
17
-
18
- Usage:
19
- homespun agent register [options]
20
-
21
- Calls POST /v1/register, then saves the returned API key (and relay URL) under
22
- a named profile in the CLI config file — so afterwards every other command
23
- works with only HOMESPUN_URL set (no HOMESPUN_API_KEY needed).
24
-
25
- If --profile is omitted, the registered key goes under the currently-active
26
- profile (or 'default' for a fresh install). Pass --profile <name> to keep
27
- multiple environments (dev/staging/prod) side by side; switch between them
28
- with 'homespun config use <name>' or '--profile <name>' / HOMESPUN_PROFILE.
29
-
30
- Options:
31
- --name <n> Agent display name on the relay. The relay defaults it
32
- if omitted.
33
- --profile <name> Local profile name to save under. Defaults to the active
34
- profile, or 'default' on a fresh install. Letters,
35
- digits, _ and -, up to 32 chars.
36
- --url <url> Relay base URL. Falls back to HOMESPUN_URL, then the active
37
- profile, then the hosted Homespun relay. Self-hosters set
38
- this.
39
- --secret <s> Registration secret, sent as a Bearer token. Only needed
40
- when the relay uses REGISTRATION_MODE=secret. Falls back
41
- to the HOMESPUN_REGISTER_SECRET env var.
42
- --print-key Also echo the full api_key in the output. By default the
43
- key is only persisted to the config file, never printed.
44
- -h, --help Show this help.
45
-
46
- Output (stdout, JSON):
47
- { agent_id, key_prefix, profile, saved_to } (+ api_key when --print-key)
48
-
49
- The API key is saved to the CLI config file (mode 0600); it is not printed
50
- unless --print-key is passed.`;
29
+ /**
30
+ * Default agent name for the device flow: the consent screen must name what
31
+ * the human is approving, so an unnamed agent gets "cli-<hostname>" instead
32
+ * of the relay's unhelpful generic default. Control characters are stripped
33
+ * and the result clamped to the relay's 64-char cap.
34
+ */
35
+ export function defaultDeviceAgentName(host = hostname()) {
36
+ let cleaned = "";
37
+ for (const ch of host.trim()) {
38
+ const codePoint = ch.codePointAt(0) ?? 0;
39
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f))
40
+ continue;
41
+ cleaned += ch;
42
+ }
43
+ const name = `cli-${cleaned}`.slice(0, 64).trim();
44
+ return name.length > "cli-".length ? name : "cli-agent";
45
+ }
46
+ /** Compute the display prefix of an API key, mirroring the relay's rule. */
47
+ function apiKeyPrefix(key) {
48
+ return key.startsWith("hs_") ? key.slice(0, 9) : key.slice(0, 8);
49
+ }
51
50
  export async function runRegister(args) {
52
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun agent register");
51
+ assertKnownFlags(args, ...specFor("agent", "register"));
53
52
  // Profile selection for the WRITE side: --profile flag → HOMESPUN_PROFILE env
54
53
  // → the store's current profile → DEFAULT_PROFILE_NAME ('default') for
55
54
  // a fresh install. We deliberately don't fall through to "no profile, use
56
- // a fresh name" the agent needs to end up somewhere callable, and
55
+ // a fresh name" - the agent needs to end up somewhere callable, and
57
56
  // 'default' is a stable, predictable home.
58
57
  const profileFlag = args.flags.get("profile") ?? process.env.HOMESPUN_PROFILE;
59
58
  const store = readStore();
@@ -61,7 +60,7 @@ export async function runRegister(args) {
61
60
  ? profileFlag
62
61
  : (store.currentProfile ?? DEFAULT_PROFILE_NAME);
63
62
  if (!isValidProfileName(profileName)) {
64
- fail(`invalid profile name '${profileName}' letters, digits, _ and -, up to 32 chars`, "invalid_args");
63
+ fail(`invalid profile name '${profileName}' - letters, digits, _ and -, up to 32 chars`, "invalid_args");
65
64
  }
66
65
  // URL precedence for the relay we're registering against:
67
66
  // --url flag > HOMESPUN_URL env > target-profile's existing url > default.
@@ -74,21 +73,70 @@ export async function runRegister(args) {
74
73
  activeUrl = active?.profile.url;
75
74
  }
76
75
  catch {
77
- // Selector didn't resolve fine on register: we're about to create it.
76
+ // Selector didn't resolve - fine on register: we're about to create it.
78
77
  activeUrl = undefined;
79
78
  }
80
- const url = args.flags.get("url") ??
79
+ const url = (args.flags.get("url") ??
81
80
  process.env.HOMESPUN_URL ??
82
81
  activeUrl ??
83
- DEFAULT_RELAY_URL;
82
+ DEFAULT_RELAY_URL).replace(/\/$/, "");
84
83
  const name = args.flags.get("name");
85
84
  const secret = args.flags.get("secret") ??
86
85
  process.env.HOMESPUN_REGISTER_SECRET ??
87
86
  undefined;
87
+ // The device flow is the default. A registration secret implies a
88
+ // REGISTRATION_MODE=secret relay whose operator hands out direct access,
89
+ // and --no-device is the explicit opt-out (CI, headless-with-no-human).
90
+ const wantDevice = !args.bools.has("no-device") && (secret === undefined || secret === "");
91
+ if (wantDevice) {
92
+ try {
93
+ const outcome = await runDeviceFlow({
94
+ url,
95
+ name: name ?? defaultDeviceAgentName(),
96
+ cliVersion: VERSION,
97
+ });
98
+ if (outcome.supported) {
99
+ const savedTo = upsertProfile(profileName, { url, apiKey: outcome.agent_key }, true);
100
+ const out = {
101
+ agent_id: outcome.agent_id,
102
+ key_prefix: apiKeyPrefix(outcome.agent_key),
103
+ profile: profileName,
104
+ saved_to: savedTo,
105
+ registered_via: "device",
106
+ };
107
+ if (args.bools.has("print-key")) {
108
+ out["api_key"] = outcome.agent_key;
109
+ }
110
+ printJson(out);
111
+ return;
112
+ }
113
+ // 404 on /v1/device/code: an older relay. Fall through to the direct
114
+ // path with a note so the behavior change is visible, not silent.
115
+ process.stderr.write("note: this relay does not support browser approval (older relay); " +
116
+ "falling back to direct registration. The agent will need " +
117
+ "'homespun agent claim <code>' to get an owner.\n");
118
+ }
119
+ catch (e) {
120
+ if (e instanceof HomespunApiError) {
121
+ if (e.status === 426 && e.code === "cli_upgrade_required") {
122
+ failUpgradeRequired(e);
123
+ }
124
+ if (e.status === 429) {
125
+ fail("device authorization rate limit exceeded - try again later", "rate_limited", undefined, { hint: e.hint, retryable: true, docs_url: e.docsUrl });
126
+ }
127
+ fail(e.message, e.code, e.details, {
128
+ hint: e.hint,
129
+ retryable: e.retryable,
130
+ docs_url: e.docsUrl,
131
+ });
132
+ }
133
+ fail(e instanceof Error ? e.message : String(e), "internal");
134
+ }
135
+ }
88
136
  let result;
89
137
  try {
90
138
  result = await registerAgent({
91
- url: url.replace(/\/$/, ""),
139
+ url,
92
140
  ...(name !== undefined ? { name } : {}),
93
141
  ...(secret !== undefined && secret !== "" ? { secret } : {}),
94
142
  cliVersion: VERSION,
@@ -103,7 +151,7 @@ export async function runRegister(args) {
103
151
  failUpgradeRequired(e);
104
152
  }
105
153
  if (e.status === 429) {
106
- fail("registration rate limit exceeded try again later", "rate_limited", undefined, { hint: e.hint, retryable: e.retryable, docs_url: e.docsUrl });
154
+ fail("registration rate limit exceeded - try again later", "rate_limited", undefined, { hint: e.hint, retryable: e.retryable, docs_url: e.docsUrl });
107
155
  }
108
156
  fail(e.message, e.code, e.details, {
109
157
  hint: e.hint,
@@ -117,12 +165,13 @@ export async function runRegister(args) {
117
165
  // registered against this relay, so the only sensible follow-up is to
118
166
  // start using it. The previous behaviour (one global URL+key) is exactly
119
167
  // the single-profile case of this.
120
- const savedTo = upsertProfile(profileName, { url: url.replace(/\/$/, ""), apiKey: result.api_key }, true);
168
+ const savedTo = upsertProfile(profileName, { url, apiKey: result.api_key }, true);
121
169
  const out = {
122
170
  agent_id: result.agent_id,
123
171
  key_prefix: result.key_prefix,
124
172
  profile: profileName,
125
173
  saved_to: savedTo,
174
+ registered_via: "direct",
126
175
  };
127
176
  if (args.bools.has("print-key")) {
128
177
  out["api_key"] = result.api_key;
@@ -8,39 +8,9 @@
8
8
  // than guessing here and adding a network hop for what's a local config
9
9
  // write.
10
10
  import { assertKnownFlags } from "../argv.js";
11
+ import { specFor } from "../help-catalog.js";
11
12
  import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
12
13
  import { printJson, fail } from "../output.js";
13
- const KNOWN_FLAGS = ["url"];
14
- const KNOWN_BOOLS = [];
15
- export const setKeyHelp = `homespun agent set-key <api-key> — save a new API key to the local config
16
-
17
- Usage:
18
- homespun agent set-key <api-key> [--url <url>] [--profile <name>]
19
-
20
- After regenerating an agent's API key in the relay's My-agents UI, run
21
- this on the agent's machine to land the new key in the CLI config file
22
- (\${XDG_CONFIG_HOME:-~/.config}/homespun/config.json, mode 0600). Every later
23
- command then works with no HOMESPUN_API_KEY env var.
24
-
25
- The key is saved under the ACTIVE profile (unless --profile picks a different
26
- one). To add a brand-new profile by hand (e.g. for an out-of-band key from a
27
- closed-registration relay), use 'homespun config add'.
28
-
29
- If you'd rather not touch the config file at all, set the new key as the
30
- HOMESPUN_API_KEY env var on the agent process — both work.
31
-
32
- Options:
33
- --url <url> Also update the saved relay URL on the target profile.
34
- Useful when pointing the agent at a different relay
35
- alongside the key swap.
36
- --profile <name> Target this profile instead of the active one. Created
37
- if it doesn't exist.
38
- -h, --help Show this help.
39
-
40
- Output (stdout, JSON):
41
- { saved_to, profile, key_prefix }
42
-
43
- The key is never echoed back. To verify, run \`homespun key list\` afterwards.`;
44
14
  function keyPrefixOf(key) {
45
15
  // Match the relay's keyPrefix() display width for "hs_" + 6 hex chars
46
16
  // (11 total). Falls back to the first 8 chars for any unrecognised shape.
@@ -49,7 +19,7 @@ function keyPrefixOf(key) {
49
19
  return key.slice(0, 8);
50
20
  }
51
21
  export async function runSetKey(args) {
52
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun agent set-key");
22
+ assertKnownFlags(args, ...specFor("agent", "set-key"));
53
23
  const apiKey = args.positionals[0];
54
24
  if (!apiKey) {
55
25
  fail("missing api-key — usage: homespun agent set-key <api-key>", "invalid_args");
@@ -20,46 +20,10 @@
20
20
  // an agent on a too-old CLI must be able to read the upgrade instructions
21
21
  // even before it has registered (or before its key was minted).
22
22
  import { assertKnownFlags } from "../argv.js";
23
+ import { specFor } from "../help-catalog.js";
23
24
  import { resolveRelayUrl } from "../config.js";
24
25
  import { fail } from "../output.js";
25
- const NO_FLAGS = [];
26
- const NO_BOOLS = [];
27
- const VERSION_BOOLS = ["plain"];
28
26
  import { VERSION } from "../version.js";
29
- export const skillHelp = `homespun skill — fetch the relay's SKILL.md (or its version)
30
-
31
- Usage:
32
- homespun skill show Print the full skill to stdout.
33
- homespun skill version [--plain] Print just the relay's skill version.
34
-
35
- The skill is auto-updating: the relay's deployed image owns the version,
36
- so this is always the skill that matches the relay you are talking to.
37
-
38
- Unauthenticated — no API key needed. An agent can call either form
39
- before 'homespun agent register' to bootstrap or refresh its local skill copy.
40
-
41
- Verbs:
42
- show Fetch GET /skills/homespun/SKILL.md and write the raw
43
- markdown to stdout. Pipe to your local skill path:
44
- homespun skill show > ~/.claude/skills/homespun/SKILL.md
45
- version Fetch GET /skills/homespun/SKILL.md/version and print
46
- the relay's skill version. Default output is the
47
- JSON envelope; --plain prints just the version
48
- string so an agent can compare it inline in shell.
49
-
50
- Options:
51
- --plain (with 'version' only) print the bare version
52
- string on stdout, no JSON envelope. Useful inside
53
- a shell pipeline: \`if [ "$(homespun skill version
54
- --plain)" != "$LOCAL" ]; then ...\`.
55
- --url <url> Relay base URL (overrides HOMESPUN_URL).
56
- -h, --help Show this help.
57
-
58
- Output (stdout):
59
- (bare) Raw markdown, as served by the relay.
60
- version { "version": "1.0.0" } — or '1.0.0\\n' with --plain.
61
-
62
- Errors (stderr): { "error": { "code", "message" } } and non-zero exit.`;
63
27
  // Shared fetch with the consistent x-homespun-cli-version header (the skill
64
28
  // routes are exempt from the version-skew middleware, but sending it lets
65
29
  // access logs see which CLI versions are reading the skill).
@@ -84,7 +48,7 @@ async function failOnNon2xx(res, target) {
84
48
  }
85
49
  // `homespun skill show` — print the full skill.
86
50
  async function runSkillFetch(args) {
87
- assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "homespun skill show");
51
+ assertKnownFlags(args, ...specFor("skill", "show"));
88
52
  const url = resolveRelayUrl(args);
89
53
  const target = url + "/skills/homespun/SKILL.md";
90
54
  const res = await fetchOrFail(target);
@@ -99,7 +63,7 @@ async function runSkillFetch(args) {
99
63
  }
100
64
  // `homespun skill version [--plain]` — print just the version.
101
65
  async function runSkillVersion(args) {
102
- assertKnownFlags(args, NO_FLAGS, VERSION_BOOLS, "homespun skill version");
66
+ assertKnownFlags(args, ...specFor("skill", "version"));
103
67
  const url = resolveRelayUrl(args);
104
68
  const target = url + "/skills/homespun/SKILL.md/version";
105
69
  const res = await fetchOrFail(target);
@@ -19,54 +19,9 @@
19
19
  // when app gains first-class humans, this may move to per-human.
20
20
  import { readFileSync } from "node:fs";
21
21
  import { assertKnownFlags } from "../argv.js";
22
+ import { specFor } from "../help-catalog.js";
22
23
  import { makeClient } from "../config.js";
23
24
  import { printJson, fail, failFromError } from "../output.js";
24
- const NO_FLAGS = [];
25
- const NO_BOOLS = [];
26
- const SET_FLAGS = ["file"];
27
- const CLEAR_BOOLS = ["yes"];
28
- export const tasteHelp = `homespun taste — read / write / clear YOUR agent's UI taste notes
29
-
30
- Taste notes are a small markdown attachment storing presentation preferences your
31
- agent has picked up from human feedback ("denser table", "no rounded corners",
32
- "use a dark header"). Read them before generating an app template so prior
33
- feedback shapes the output; rewrite them whenever the human gives new
34
- presentation feedback. Keep entries about UI/presentation taste only — not
35
- project context, todos, or homespun state.
36
-
37
- Usage:
38
- homespun taste <subcommand> [options]
39
-
40
- Subcommands:
41
- get Print the current notes attachment:
42
- { taste: string|null, updated_at: string|null, bytes: number }.
43
- taste is null and bytes is 0 when notes have never been written.
44
-
45
- set Whole-attachment replace. Source the markdown via --file <path>,
46
- --file - (read stdin), or by piping into 'homespun taste set' with
47
- no flag. The relay rejects empty/whitespace-only payloads and
48
- caps the attachment at MAX_TASTE_BYTES (utf8). To clear the notes,
49
- use 'homespun taste clear', not 'set' with an empty body.
50
-
51
- clear Delete the notes. Requires --yes (it is destructive). Prints
52
- { cleared: true }.
53
-
54
- Options:
55
- --file <path|-> Source for 'set' — a file path, or '-' to read stdin
56
- explicitly. Omit to fall back to piped stdin.
57
- --yes Confirm 'clear'.
58
- --url <url> Relay base URL (overrides HOMESPUN_URL).
59
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
60
- -h, --help Show this help.
61
-
62
- Examples:
63
- homespun taste get
64
- homespun taste set --file ./taste.md
65
- homespun taste set --file - # explicit stdin
66
- echo "- denser layout" | homespun taste set
67
- homespun taste clear --yes
68
-
69
- Output: stdout is machine-readable JSON.`;
70
25
  // Drain process.stdin to a utf8 string. The caller is responsible for
71
26
  // deciding that stdin should be read (e.g. an explicit `--file -`, or a
72
27
  // non-TTY stdin where data is actually piped). In a TTY this would block
@@ -79,7 +34,7 @@ async function readStdin() {
79
34
  return Buffer.concat(chunks).toString("utf8");
80
35
  }
81
36
  async function runTasteGet(args) {
82
- assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "homespun taste get");
37
+ assertKnownFlags(args, ...specFor("taste", "get"));
83
38
  const client = makeClient(args);
84
39
  try {
85
40
  const info = await client.getTaste();
@@ -90,7 +45,7 @@ async function runTasteGet(args) {
90
45
  }
91
46
  }
92
47
  async function runTasteSet(args) {
93
- assertKnownFlags(args, SET_FLAGS, NO_BOOLS, "homespun taste set");
48
+ assertKnownFlags(args, ...specFor("taste", "set"));
94
49
  const filePath = args.flags.get("file");
95
50
  // Source the attachment deterministically — no isTTY-flag fusing, because
96
51
  // `!process.stdin.isTTY` is true under every non-interactive caller
@@ -131,7 +86,7 @@ async function runTasteSet(args) {
131
86
  }
132
87
  }
133
88
  async function runTasteClear(args) {
134
- assertKnownFlags(args, NO_FLAGS, CLEAR_BOOLS, "homespun taste clear");
89
+ assertKnownFlags(args, ...specFor("taste", "clear"));
135
90
  if (!args.bools.has("yes")) {
136
91
  fail("'homespun taste clear' deletes YOUR agent's taste notes — it is destructive. Pass --yes to confirm.", "confirmation_required");
137
92
  }
@@ -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
+ }