@homespunapps/cli 1.6.34 → 1.6.36
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 +6 -0
- package/dist/commands/deploy.js +30 -11
- package/dist/commands/register.js +156 -1
- package/dist/device-flow.js +89 -46
- package/dist/help-catalog.js +12 -2
- package/dist/pending-device.js +101 -0
- package/package.json +2 -2
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",
|
package/dist/commands/deploy.js
CHANGED
|
@@ -9,7 +9,18 @@ import { specFor } from "../help-catalog.js";
|
|
|
9
9
|
import { fail, failFromError, printJson } from "../output.js";
|
|
10
10
|
import { resolveJson } from "../input.js";
|
|
11
11
|
import { resolveAppId } from "../resolve-app.js";
|
|
12
|
-
function readBundle(source, manifestFlag) {
|
|
12
|
+
function readBundle(source, manifestFlag, isRedeploy) {
|
|
13
|
+
// No source at all: only a redeploy can do this, and only to change the
|
|
14
|
+
// manifest alone (the live document is inherited).
|
|
15
|
+
if (source === undefined) {
|
|
16
|
+
if (!isRedeploy) {
|
|
17
|
+
fail("usage: homespun deploy <dir|file> [--app <id>] ...", "invalid_args");
|
|
18
|
+
}
|
|
19
|
+
if (manifestFlag === undefined) {
|
|
20
|
+
fail("nothing to deploy: pass a directory or file, or --manifest <path|json> for a manifest-only redeploy", "invalid_args");
|
|
21
|
+
}
|
|
22
|
+
return { manifest: resolveJson(manifestFlag, "--manifest") };
|
|
23
|
+
}
|
|
13
24
|
if (!existsSync(source)) {
|
|
14
25
|
fail(`no such file or directory: ${source}`, "invalid_args");
|
|
15
26
|
}
|
|
@@ -33,9 +44,13 @@ function readBundle(source, manifestFlag) {
|
|
|
33
44
|
manifest: JSON.parse(readFileSync(manifestPath, "utf8")),
|
|
34
45
|
};
|
|
35
46
|
}
|
|
36
|
-
// Single-file escape hatch.
|
|
47
|
+
// Single-file escape hatch. A create must pair it with --manifest; a
|
|
48
|
+
// redeploy may omit --manifest, which keeps the live manifest.
|
|
37
49
|
if (manifestFlag === undefined) {
|
|
38
|
-
|
|
50
|
+
if (!isRedeploy) {
|
|
51
|
+
fail("single-file deploy requires --manifest <path|json>", "invalid_args");
|
|
52
|
+
}
|
|
53
|
+
return { html: readFileSync(source, "utf8") };
|
|
39
54
|
}
|
|
40
55
|
return {
|
|
41
56
|
html: readFileSync(source, "utf8"),
|
|
@@ -45,9 +60,6 @@ function readBundle(source, manifestFlag) {
|
|
|
45
60
|
export async function runDeploy(args) {
|
|
46
61
|
assertKnownFlags(args, ...specFor("deploy"));
|
|
47
62
|
const source = args.positionals[0];
|
|
48
|
-
if (!source) {
|
|
49
|
-
fail("usage: homespun deploy <dir|file> [--app <id>] ...", "invalid_args");
|
|
50
|
-
}
|
|
51
63
|
const appId = args.flags.get("app");
|
|
52
64
|
const slug = args.flags.get("slug");
|
|
53
65
|
const visibility = args.flags.get("visibility");
|
|
@@ -57,7 +69,7 @@ export async function runDeploy(args) {
|
|
|
57
69
|
}
|
|
58
70
|
const force = args.bools.has("force");
|
|
59
71
|
const check = args.bools.has("check");
|
|
60
|
-
const bundle = readBundle(source, args.flags.get("manifest"));
|
|
72
|
+
const bundle = readBundle(source, args.flags.get("manifest"), appId !== undefined);
|
|
61
73
|
const client = makeClient(args);
|
|
62
74
|
// Dry run (--check): validate + report what a real deploy would do, persist
|
|
63
75
|
// NOTHING. Runs for both create (no --app) and redeploy (--app), the latter
|
|
@@ -67,8 +79,8 @@ export async function runDeploy(args) {
|
|
|
67
79
|
const id = appId !== undefined ? await resolveAppId(client, appId) : undefined;
|
|
68
80
|
const result = await client.checkDeploy({
|
|
69
81
|
...(id !== undefined ? { app_id: id } : {}),
|
|
70
|
-
html: bundle.html,
|
|
71
|
-
manifest: bundle.manifest,
|
|
82
|
+
...(bundle.html !== undefined ? { html: bundle.html } : {}),
|
|
83
|
+
...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
|
|
72
84
|
...(force ? { force } : {}),
|
|
73
85
|
});
|
|
74
86
|
printJson(result);
|
|
@@ -85,6 +97,9 @@ export async function runDeploy(args) {
|
|
|
85
97
|
fail("a caller-supplied --slug is not allowed with visibility 'link' (link slugs are always server-generated); drop --visibility link, or omit --slug", "invalid_args");
|
|
86
98
|
}
|
|
87
99
|
try {
|
|
100
|
+
// readBundle guarantees both halves on the create path (a create can
|
|
101
|
+
// inherit nothing), so the non-null assertions are the type system
|
|
102
|
+
// catching up with a check that already ran.
|
|
88
103
|
const out = await client.deployApp({
|
|
89
104
|
html: bundle.html,
|
|
90
105
|
manifest: bundle.manifest,
|
|
@@ -107,9 +122,13 @@ export async function runDeploy(args) {
|
|
|
107
122
|
}
|
|
108
123
|
const id = await resolveAppId(client, appId);
|
|
109
124
|
try {
|
|
125
|
+
// Only what this invocation actually read is sent: an omitted html or
|
|
126
|
+
// manifest keeps what is live, so `homespun deploy ./index.html --app <id>`
|
|
127
|
+
// ships the document alone and `--manifest` with no file ships the
|
|
128
|
+
// manifest alone.
|
|
110
129
|
const redeployed = await client.redeployApp(id, {
|
|
111
|
-
html: bundle.html,
|
|
112
|
-
manifest: bundle.manifest,
|
|
130
|
+
...(bundle.html !== undefined ? { html: bundle.html } : {}),
|
|
131
|
+
...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
|
|
113
132
|
force,
|
|
114
133
|
});
|
|
115
134
|
const app = await client.getApp(id);
|
|
@@ -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).
|
package/dist/device-flow.js
CHANGED
|
@@ -67,20 +67,17 @@ function rfcErrorCode(body) {
|
|
|
67
67
|
return typeof err === "string" ? err : null;
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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(`
|
|
111
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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:
|
|
131
|
-
agent_key:
|
|
132
|
-
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
|
-
|
|
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
|
}
|
package/dist/help-catalog.js
CHANGED
|
@@ -777,7 +777,7 @@ const DEPLOY = {
|
|
|
777
777
|
verbs: [
|
|
778
778
|
{
|
|
779
779
|
verb: "",
|
|
780
|
-
positionals: "
|
|
780
|
+
positionals: "[dir|file]",
|
|
781
781
|
summary: "Creates a new app, or redeploys an existing one when --app is given.",
|
|
782
782
|
flags: [
|
|
783
783
|
{
|
|
@@ -813,7 +813,8 @@ const DEPLOY = {
|
|
|
813
813
|
notes: [
|
|
814
814
|
"Packaging has one canonical shape and one escape hatch. A directory deploy (homespun deploy ./my-app) reads ./my-app/index.html and ./my-app/manifest.json: fixed filenames, no discovery heuristics, and both files are required. The single-file escape hatch (homespun deploy ./index.html --manifest ./manifest.json) takes the manifest from --manifest, which accepts a file path or inline JSON.",
|
|
815
815
|
"Create versus redeploy is decided by the presence of --app, not by two verbs. With no --app this creates an app (POST /v1/apps); new apps default to private (owner plus invited members, sign-in gated), --slug is accepted with private or public visibility including the default, and an explicit --visibility link always gets a server-generated slug and rejects --slug. With --app <id> this redeploys (POST /v1/apps/:id/versions), where --slug and --visibility are rejected because the slug is immutable and visibility changes go through 'homespun apps update'.",
|
|
816
|
-
"
|
|
816
|
+
"On redeploy, what you do not send is kept. 'homespun deploy ./index.html --app <id>' ships the document alone and keeps the live manifest; 'homespun deploy --app <id> --manifest ./manifest.json' ships the manifest alone and keeps the live document, with no file argument at all; a directory deploy still ships both. The live asset set is carried forward either way. A create can inherit nothing, so it still needs both halves.",
|
|
817
|
+
"--check is a dry run. It runs the full manifest and asset validation (shape and MIME), the redeploy compat gate (with --app), and the schedule-timezone advisory, then prints { ok, warnings, compat, breaks } without creating a version or mutating anything. An invalid manifest fails the same way a real deploy would, and a redeploy the compat gate would refuse reports the break instead of applying it. It resolves omitted fields exactly as a real redeploy would, so it reports on the deploy that would actually run.",
|
|
817
818
|
],
|
|
818
819
|
outputNote: 'Output is JSON: { app_id, slug, url, version, visibility, created, share_url, compat, breaks, warnings }. share_url is present only when creating a link-visibility app: it carries the app share token in its #k= fragment and is shown ONCE, it is not recoverable later, and it can be rotated with \'homespun apps share-link rotate <app>\'. warnings flags non-fatal issues, for example an app that declares schedules with no timezone set (reminders fire at 08:00 UTC until one is set). Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
|
|
819
820
|
};
|
|
@@ -956,6 +957,14 @@ const AGENT = {
|
|
|
956
957
|
name: "no-device",
|
|
957
958
|
description: "Skip the browser approval and register directly via POST /v1/register",
|
|
958
959
|
},
|
|
960
|
+
{
|
|
961
|
+
name: "start",
|
|
962
|
+
description: "Print the approval link and code, then exit immediately instead of waiting",
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
name: "resume",
|
|
966
|
+
description: "Collect the key for a link started with --start, once the human has approved it",
|
|
967
|
+
},
|
|
959
968
|
],
|
|
960
969
|
},
|
|
961
970
|
{
|
|
@@ -981,6 +990,7 @@ const AGENT = {
|
|
|
981
990
|
],
|
|
982
991
|
notes: [
|
|
983
992
|
"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.",
|
|
993
|
+
"--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
994
|
"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
995
|
"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
996
|
"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.
|
|
3
|
+
"version": "1.6.36",
|
|
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.
|
|
39
|
+
"@homespunapps/core": "^1.6.36",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|