@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.
- package/README.md +15 -5
- package/dist/argv.js +69 -4
- package/dist/commands/agent.js +0 -20
- package/dist/commands/apps.js +54 -37
- package/dist/commands/attachment-delete.js +2 -19
- package/dist/commands/attachment-download.js +2 -22
- package/dist/commands/attachment-list.js +2 -22
- package/dist/commands/attachment-show.js +2 -19
- package/dist/commands/attachment-token.js +4 -44
- package/dist/commands/attachment-upload.js +2 -25
- package/dist/commands/attachment.js +9 -52
- package/dist/commands/claim.js +2 -30
- package/dist/commands/config.js +6 -35
- package/dist/commands/data.js +211 -24
- package/dist/commands/deploy.js +23 -28
- package/dist/commands/feedback.js +3 -44
- package/dist/commands/grant.js +158 -0
- package/dist/commands/ingest.js +85 -0
- package/dist/commands/key.js +20 -31
- package/dist/commands/logout.js +2 -28
- package/dist/commands/members.js +64 -32
- package/dist/commands/register.js +100 -51
- package/dist/commands/set-key.js +2 -32
- package/dist/commands/skill.js +3 -39
- package/dist/commands/taste.js +4 -49
- package/dist/device-flow.js +162 -0
- package/dist/help-catalog.js +1087 -0
- package/dist/index.js +28 -100
- package/dist/version.js +20 -6
- package/package.json +11 -11
|
@@ -1,59 +1,58 @@
|
|
|
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";
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
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"
|
|
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}'
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/commands/set-key.js
CHANGED
|
@@ -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,
|
|
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");
|
package/dist/commands/skill.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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);
|
package/dist/commands/taste.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
+
}
|