@homespunapps/cli 1.6.33 → 1.6.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/argv.js CHANGED
@@ -47,6 +47,12 @@ export const BOOLEAN_FLAGS = new Set([
47
47
  // `homespun agent register --no-device`: skip the browser device-authorization
48
48
  // flow and register directly (unowned agent), the pre-device-flow behavior.
49
49
  "no-device",
50
+ // `homespun agent register --start` / `--resume`: the two halves of the device
51
+ // flow for a caller that cannot hold a blocking command open (an agent whose
52
+ // harness times the call out). --start prints the link and exits; --resume
53
+ // redeems the approval afterwards.
54
+ "start",
55
+ "resume",
50
56
  // `homespun data ... import --emit-effects`: opt a silent bulk import back into
51
57
  // firing notify/webhooks (import defaults to silent).
52
58
  "emit-effects",
@@ -22,7 +22,8 @@ import { registerAgent, HomespunApiError } from "@homespunapps/core";
22
22
  import { assertKnownFlags } from "../argv.js";
23
23
  import { specFor } from "../help-catalog.js";
24
24
  import { DEFAULT_RELAY_URL } from "../config.js";
25
- import { runDeviceFlow } from "../device-flow.js";
25
+ import { runDeviceFlow, startDeviceFlow, pollDeviceToken, } from "../device-flow.js";
26
+ import { readPendingDevice, writePendingDevice, clearPendingDevice, isPendingExpired, } from "../pending-device.js";
26
27
  import { printJson, fail, failUpgradeRequired } from "../output.js";
27
28
  import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
28
29
  import { VERSION } from "../version.js";
@@ -47,6 +48,131 @@ export function defaultDeviceAgentName(host = hostname()) {
47
48
  function apiKeyPrefix(key) {
48
49
  return key.startsWith("hs_") ? key.slice(0, 9) : key.slice(0, 8);
49
50
  }
51
+ /**
52
+ * `--start`: ask the relay for a code pair, park it, and get out of the way.
53
+ *
54
+ * Prints the JSON envelope with the link and code ON STDOUT as well as the
55
+ * human block on stderr, because the caller here is usually an agent relaying
56
+ * the link into a conversation, and stdout is the channel it parses.
57
+ */
58
+ async function runRegisterStart(opts) {
59
+ const agentName = opts.name ?? defaultDeviceAgentName();
60
+ let started;
61
+ try {
62
+ started = await startDeviceFlow({
63
+ url: opts.url,
64
+ name: agentName,
65
+ cliVersion: VERSION,
66
+ });
67
+ }
68
+ catch (e) {
69
+ failFromDeviceError(e, "device authorization");
70
+ }
71
+ if (!started.supported) {
72
+ fail("this relay does not support browser approval (older relay) - run 'homespun agent register --no-device' and claim the agent afterwards", "device_flow_unsupported");
73
+ }
74
+ const code = started.code;
75
+ const expiresAt = new Date(Date.now() + (code.expires_in ?? 900) * 1000).toISOString();
76
+ const savedTo = writePendingDevice({
77
+ device_code: code.device_code,
78
+ user_code: code.user_code,
79
+ verification_uri_complete: code.verification_uri_complete,
80
+ url: opts.url,
81
+ name: agentName,
82
+ profile: opts.profileName,
83
+ expires_at: expiresAt,
84
+ });
85
+ printJson({
86
+ state: "pending_approval",
87
+ verification_uri_complete: code.verification_uri_complete,
88
+ user_code: code.user_code,
89
+ expires_in: code.expires_in ?? 900,
90
+ expires_at: expiresAt,
91
+ name: agentName,
92
+ profile: opts.profileName,
93
+ pending_saved_to: savedTo,
94
+ next: "show the link and code to your human, then run 'homespun agent register --resume' once they say they approved it",
95
+ });
96
+ }
97
+ /**
98
+ * `--resume`: one poll, then a definite answer.
99
+ *
100
+ * Deliberately does NOT loop. The caller is an agent in a conversation: it
101
+ * should ask the human whether they approved and try again, not hold a tool
102
+ * call open, which is the failure this whole flag pair exists to remove.
103
+ */
104
+ async function runRegisterResume(opts) {
105
+ const pending = readPendingDevice();
106
+ if (pending === null) {
107
+ fail("no registration is waiting for approval - run 'homespun agent register --start' first", "no_pending_registration");
108
+ }
109
+ // An explicit --url pointing somewhere else is a mistake worth naming: the
110
+ // device_code is only valid on the relay that issued it, so polling another
111
+ // would answer expired_token and read as "the code died" instead of "you
112
+ // aimed at the wrong relay".
113
+ if (opts.urlFlagGiven && opts.url !== pending.url) {
114
+ fail(`the pending registration belongs to ${pending.url}, not ${opts.url} - drop --url, or run --start against this relay`, "invalid_args");
115
+ }
116
+ if (isPendingExpired(pending)) {
117
+ clearPendingDevice();
118
+ fail("the approval link expired before it was approved - run 'homespun agent register --start' again", "device_flow_expired");
119
+ }
120
+ let outcome;
121
+ try {
122
+ outcome = await pollDeviceToken({
123
+ url: pending.url,
124
+ deviceCode: pending.device_code,
125
+ cliVersion: VERSION,
126
+ });
127
+ }
128
+ catch (e) {
129
+ // A denial or expiry is terminal: drop the parked flow so the next
130
+ // --resume says "nothing to resume" rather than re-reporting a dead code.
131
+ if (e instanceof HomespunApiError &&
132
+ (e.code === "device_flow_denied" || e.code === "device_flow_expired")) {
133
+ clearPendingDevice();
134
+ }
135
+ failFromDeviceError(e, "device authorization");
136
+ }
137
+ if (outcome.state !== "approved") {
138
+ // Not an error in the flow's terms, but a non-zero exit so a script that
139
+ // ignores the payload does not sail on believing it is registered.
140
+ fail(`not approved yet - open ${pending.verification_uri_complete} and confirm the code ${pending.user_code}, then run 'homespun agent register --resume' again`, "not_approved_yet", undefined, { retryable: true });
141
+ }
142
+ const savedTo = upsertProfile(pending.profile, { url: pending.url, apiKey: outcome.agent_key }, true);
143
+ clearPendingDevice();
144
+ const out = {
145
+ agent_id: outcome.agent_id,
146
+ key_prefix: apiKeyPrefix(outcome.agent_key),
147
+ profile: pending.profile,
148
+ saved_to: savedTo,
149
+ registered_via: "device",
150
+ };
151
+ if (opts.printKey)
152
+ out["api_key"] = outcome.agent_key;
153
+ printJson(out);
154
+ }
155
+ /** Shared error mapping for both halves: same codes the blocking path uses. */
156
+ function failFromDeviceError(e, what) {
157
+ if (e instanceof HomespunApiError) {
158
+ if (e.status === 426 && e.code === "cli_upgrade_required") {
159
+ failUpgradeRequired(e);
160
+ }
161
+ if (e.status === 429) {
162
+ fail(`${what} rate limit exceeded - try again later`, "rate_limited", undefined, {
163
+ hint: e.hint,
164
+ retryable: true,
165
+ docs_url: e.docsUrl,
166
+ });
167
+ }
168
+ fail(e.message, e.code, e.details, {
169
+ hint: e.hint,
170
+ retryable: e.retryable,
171
+ docs_url: e.docsUrl,
172
+ });
173
+ }
174
+ fail(e instanceof Error ? e.message : String(e), "internal");
175
+ }
50
176
  export async function runRegister(args) {
51
177
  assertKnownFlags(args, ...specFor("agent", "register"));
52
178
  // Profile selection for the WRITE side: --profile flag → HOMESPUN_PROFILE env
@@ -84,6 +210,35 @@ export async function runRegister(args) {
84
210
  const secret = args.flags.get("secret") ??
85
211
  process.env.HOMESPUN_REGISTER_SECRET ??
86
212
  undefined;
213
+ // ---- Two-phase device flow (--start / --resume) -------------------------
214
+ //
215
+ // For callers that CANNOT hold a blocking command open: a coding agent runs
216
+ // register as one tool call, and the harness kills it long before a human
217
+ // finds their phone. --start prints the link and exits; --resume collects the
218
+ // key afterwards. See pending-device.ts for why this is not merely nicer.
219
+ const wantStart = args.bools.has("start");
220
+ const wantResume = args.bools.has("resume");
221
+ if (wantStart && wantResume) {
222
+ fail("--start and --resume are the two halves of one flow; run --start, get the link approved, then run --resume", "invalid_args");
223
+ }
224
+ if ((wantStart || wantResume) && args.bools.has("no-device")) {
225
+ fail("--no-device registers directly and has no approval step, so there is nothing to --start or --resume", "invalid_args");
226
+ }
227
+ if ((wantStart || wantResume) && secret !== undefined && secret !== "") {
228
+ fail("a registration secret uses the direct path, which has no approval step to --start or --resume", "invalid_args");
229
+ }
230
+ if (wantStart) {
231
+ await runRegisterStart({ url, name, profileName, printKey: false });
232
+ return;
233
+ }
234
+ if (wantResume) {
235
+ await runRegisterResume({
236
+ urlFlagGiven: args.flags.has("url"),
237
+ url,
238
+ printKey: args.bools.has("print-key"),
239
+ });
240
+ return;
241
+ }
87
242
  // The device flow is the default. A registration secret implies a
88
243
  // REGISTRATION_MODE=secret relay whose operator hands out direct access,
89
244
  // and --no-device is the explicit opt-out (CI, headless-with-no-human).
@@ -67,20 +67,17 @@ function rfcErrorCode(body) {
67
67
  return typeof err === "string" ? err : null;
68
68
  }
69
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:
70
+ * Request a device_code + user_code pair and PRINT the human's instructions.
71
+ * Does not poll, so it returns as fast as one HTTP round trip.
74
72
  *
75
- * device_flow_denied the human clicked Deny
76
- * device_flow_expired nobody approved within the code's lifetime
73
+ * Split out of runDeviceFlow so `agent register --start` can hand a coding
74
+ * agent the link and exit, rather than holding the terminal for up to 15
75
+ * minutes while a human finds their phone. See pending-device.ts.
77
76
  */
78
- export async function runDeviceFlow(opts) {
77
+ export async function startDeviceFlow(opts) {
79
78
  const base = opts.url.replace(/\/$/, "");
80
79
  const fetchImpl = opts.fetchImpl ?? fetch;
81
- const sleep = opts.sleepImpl ?? defaultSleep;
82
80
  const print = opts.print ?? defaultPrint;
83
- // ---- 1. Request the code pair -----------------------------------------
84
81
  const start = await postJson(fetchImpl, `${base}/v1/device/code`, { name: opts.name }, opts.cliVersion);
85
82
  if (start.status === 404) {
86
83
  // Older relay without the device flow - signal the caller to fall back.
@@ -97,7 +94,6 @@ export async function runDeviceFlow(opts) {
97
94
  typeof code.verification_uri_complete !== "string") {
98
95
  throw new HomespunApiError(200, "invalid_response", "relay returned an unexpected /v1/device/code body");
99
96
  }
100
- // ---- 2. Hand the human their marching orders ---------------------------
101
97
  const expiresMin = Math.max(1, Math.round((code.expires_in ?? 900) / 60));
102
98
  print("");
103
99
  print("To approve this agent, open:");
@@ -107,8 +103,78 @@ export async function runDeviceFlow(opts) {
107
103
  print(`and confirm the code: ${code.user_code}`);
108
104
  print("");
109
105
  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 -----------------------------------
106
+ print(`The code expires in ${expiresMin} min.`);
107
+ return { supported: true, code };
108
+ }
109
+ /**
110
+ * Poll /v1/device/token ONCE.
111
+ *
112
+ * Returns rather than loops, so the caller decides whether to wait: the
113
+ * blocking `runDeviceFlow` loops on it, and `agent register --resume` calls it
114
+ * exactly once and reports back to the agent. Throws HomespunApiError with an
115
+ * actionable code on every terminal failure:
116
+ *
117
+ * device_flow_denied the human clicked Deny
118
+ * device_flow_expired the code died before anyone approved it
119
+ */
120
+ export async function pollDeviceToken(opts) {
121
+ const base = opts.url.replace(/\/$/, "");
122
+ const fetchImpl = opts.fetchImpl ?? fetch;
123
+ const poll = await postJson(fetchImpl, `${base}/v1/device/token`, { device_code: opts.deviceCode }, opts.cliVersion);
124
+ if (poll.status === 200) {
125
+ const body = poll.body;
126
+ if (typeof body?.agent_key !== "string" ||
127
+ typeof body?.agent_id !== "string") {
128
+ throw new HomespunApiError(200, "invalid_response", "relay returned an unexpected /v1/device/token body");
129
+ }
130
+ return {
131
+ state: "approved",
132
+ agent_id: body.agent_id,
133
+ agent_key: body.agent_key,
134
+ name: typeof body.name === "string" ? body.name : "",
135
+ };
136
+ }
137
+ const rfc = rfcErrorCode(poll.body);
138
+ if (poll.status === 400 && rfc !== null) {
139
+ switch (rfc) {
140
+ case "authorization_pending":
141
+ return { state: "pending" };
142
+ case "slow_down":
143
+ return { state: "slow_down" };
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
+ return { state: "slow_down" };
155
+ }
156
+ // 426 cli_upgrade_required and anything else: surface the envelope.
157
+ const env = envelopeError(poll.body);
158
+ throw new HomespunApiError(poll.status, env?.code ?? "relay_error", env?.message ?? `relay returned ${poll.status} for /v1/device/token`, poll.body?.error?.details);
159
+ }
160
+ /**
161
+ * Run the device-authorization flow end to end, blocking until the human
162
+ * answers. Returns `supported: false` when the relay 404s the code request (an
163
+ * older relay - the caller falls back to plain POST /v1/register).
164
+ *
165
+ * This is the path a HUMAN at their own terminal wants: they can see the link
166
+ * appear and approve it without running a second command. An agent should use
167
+ * startDeviceFlow + pollDeviceToken instead, because a blocking call cannot
168
+ * hand the link to anyone until it returns.
169
+ */
170
+ export async function runDeviceFlow(opts) {
171
+ const sleep = opts.sleepImpl ?? defaultSleep;
172
+ const print = opts.print ?? defaultPrint;
173
+ const started = await startDeviceFlow(opts);
174
+ if (!started.supported)
175
+ return { supported: false };
176
+ const code = started.code;
177
+ print(`Waiting for approval... (Ctrl-C to abort)`);
112
178
  let intervalSeconds = typeof code.interval === "number" && code.interval > 0 ? code.interval : 5;
113
179
  const deadline = Date.now() + (code.expires_in ?? 900) * 1000;
114
180
  while (Date.now() < deadline) {
@@ -117,46 +183,23 @@ export async function runDeviceFlow(opts) {
117
183
  // the code's lifetime (the relay would just answer expired_token).
118
184
  if (Date.now() >= deadline)
119
185
  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
- }
186
+ const outcome = await pollDeviceToken({
187
+ url: opts.url,
188
+ deviceCode: code.device_code,
189
+ ...(opts.cliVersion !== undefined ? { cliVersion: opts.cliVersion } : {}),
190
+ ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
191
+ });
192
+ if (outcome.state === "approved") {
127
193
  print("Approved.");
128
194
  return {
129
195
  supported: true,
130
- agent_id: body.agent_id,
131
- agent_key: body.agent_key,
132
- name: typeof body.name === "string" ? body.name : opts.name,
196
+ agent_id: outcome.agent_id,
197
+ agent_key: outcome.agent_key,
198
+ name: outcome.name !== "" ? outcome.name : opts.name,
133
199
  };
134
200
  }
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.
201
+ if (outcome.state === "slow_down")
154
202
  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
203
  }
161
204
  throw new HomespunApiError(400, "device_flow_expired", "the device code expired before it was approved - run 'homespun agent register' again");
162
205
  }
@@ -956,6 +956,14 @@ const AGENT = {
956
956
  name: "no-device",
957
957
  description: "Skip the browser approval and register directly via POST /v1/register",
958
958
  },
959
+ {
960
+ name: "start",
961
+ description: "Print the approval link and code, then exit immediately instead of waiting",
962
+ },
963
+ {
964
+ name: "resume",
965
+ description: "Collect the key for a link started with --start, once the human has approved it",
966
+ },
959
967
  ],
960
968
  },
961
969
  {
@@ -981,6 +989,7 @@ const AGENT = {
981
989
  ],
982
990
  notes: [
983
991
  "register runs the browser device-authorization flow by default: it prints a link and a short code, the account owner opens the link on any device, signs in and approves, and the agent comes out already linked to that account. Older relays without the flow fall back to plain POST /v1/register automatically, as do --no-device and a supplied registration secret; agents registered that way are unowned until 'homespun agent claim' runs.",
992
+ "--start and --resume split that wait in two, for a caller that cannot hold a command open. An agent runs register as one blocking tool call, and its harness kills the call long before a human finds their phone; the relay issues the key only to the poller that consumes the approved flow, so the human approves, sees success, and no key is ever written. --start prints the link and code and exits at once, parking the device code in pending-device.json beside the config file (mode 0600, since until it is redeemed that code is what collects the key). --resume polls once and either saves the key or exits not_approved_yet, so the agent can ask its human and try again. The approval waits on the relay for the code's full lifetime, so any gap between the two is fine.",
984
993
  "The API key and relay URL are saved under a named profile in the CLI config file (mode 0600), so later commands work with only HOMESPUN_URL set, or with nothing set. The key is never printed unless --print-key is passed. Without --profile the key goes under the currently active profile, or under default on a fresh install; use --profile <name> to keep several environments side by side and switch with 'homespun config use <name>'.",
985
994
  "claim is one-way. The human generates a one-shot code (it begins with cc_) in their settings UI, hands it to the agent out of band, and the relay binds the agent to that human and migrates app ownership. There is no unclaim in v1: to rotate the owner, revoke the agent with 'homespun key revoke' and register a new one.",
986
995
  "set-key makes no relay round-trip. It is the companion to regenerating a key in the relay's my-agents UI: paste the new key here so later commands authenticate as the same agent. Setting HOMESPUN_API_KEY on the agent process instead works just as well.",
@@ -0,0 +1,101 @@
1
+ // The half-finished device-authorization flow, parked on disk between
2
+ // `homespun agent register --start` and `homespun agent register --resume`.
3
+ //
4
+ // WHY THIS FILE EXISTS. The blocking `homespun agent register` holds the
5
+ // device_code in memory and polls until the human approves. That is right for
6
+ // a human at their own terminal and wrong for a coding agent, which runs the
7
+ // command as one blocking tool call: the harness kills it on a timeout (Claude
8
+ // Code's default is 2 minutes against a 15-minute code lifetime), and the relay
9
+ // issues the agent key ONLY to the poller that consumes the approved flow. So
10
+ // the human approves, sees success in the browser, and no key is ever written.
11
+ //
12
+ // Parking the device_code here breaks that coupling. `--start` returns as soon
13
+ // as it has the link, the agent shows it to the human, and `--resume` collects
14
+ // the key whenever the human says they are done. The relay needs no change: an
15
+ // approved flow waits, unconsumed, for its full TTL.
16
+ //
17
+ // MODE 0600, and that is not decoration. Until it is consumed, the device_code
18
+ // is a bearer credential: whoever holds it collects the key the human just
19
+ // approved. It lives beside config.json (which holds API keys under the same
20
+ // mode) rather than in /tmp, where a world-readable default would hand the
21
+ // approval to any other account on the machine.
22
+ //
23
+ // One pending flow at a time. A second --start overwrites the first, which is
24
+ // what someone re-running it after a mistake means; the abandoned flow expires
25
+ // on the relay by itself.
26
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync, } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { storePath } from "./store.js";
29
+ /** Absolute path to the pending-flow file, beside the config file. */
30
+ export function pendingDevicePath() {
31
+ return join(dirname(storePath()), "pending-device.json");
32
+ }
33
+ /** Persist the in-flight flow. Creates the config dir if this is a fresh
34
+ * install, and forces 0600 even when the file already existed looser. */
35
+ export function writePendingDevice(pending) {
36
+ const path = pendingDevicePath();
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ writeFileSync(path, JSON.stringify(pending, null, 2) + "\n", { mode: 0o600 });
39
+ chmodSync(path, 0o600);
40
+ return path;
41
+ }
42
+ /**
43
+ * Read the parked flow, or null when there is none.
44
+ *
45
+ * Returns null rather than throwing for a missing, unreadable, unparseable or
46
+ * structurally wrong file: every one of those means the same thing to the
47
+ * caller ("nothing to resume, run --start"), and a JSON parse error is a
48
+ * worse way to say it.
49
+ */
50
+ export function readPendingDevice() {
51
+ let text;
52
+ try {
53
+ text = readFileSync(pendingDevicePath(), "utf8");
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(text);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ if (parsed === null || typeof parsed !== "object")
66
+ return null;
67
+ const p = parsed;
68
+ if (typeof p.device_code !== "string" ||
69
+ typeof p.url !== "string" ||
70
+ typeof p.profile !== "string") {
71
+ return null;
72
+ }
73
+ return {
74
+ device_code: p.device_code,
75
+ user_code: typeof p.user_code === "string" ? p.user_code : "",
76
+ verification_uri_complete: typeof p.verification_uri_complete === "string"
77
+ ? p.verification_uri_complete
78
+ : "",
79
+ url: p.url,
80
+ name: typeof p.name === "string" ? p.name : "",
81
+ profile: p.profile,
82
+ expires_at: typeof p.expires_at === "string" ? p.expires_at : "",
83
+ };
84
+ }
85
+ /** Whether the parked flow is past its expiry. An unparseable or absent
86
+ * timestamp counts as NOT expired: let the relay be the judge rather than
87
+ * refusing to poll a flow that might still be good. */
88
+ export function isPendingExpired(pending, now = Date.now()) {
89
+ const at = Date.parse(pending.expires_at);
90
+ return Number.isFinite(at) && at <= now;
91
+ }
92
+ /** Delete the parked flow. Idempotent: a missing file is success, since the
93
+ * post-condition ("no pending flow on disk") already holds. */
94
+ export function clearPendingDevice() {
95
+ try {
96
+ unlinkSync(pendingDevicePath());
97
+ }
98
+ catch {
99
+ // Already gone.
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.33",
3
+ "version": "1.6.35",
4
4
  "description": "Command-line client for the Homespun relay: deploy a real multi-user web app from your agent, then keep reading and writing its data.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "test:unit": "vitest run"
37
37
  },
38
38
  "dependencies": {
39
- "@homespunapps/core": "^1.6.33",
39
+ "@homespunapps/core": "^1.6.35",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {