@homespunapps/cli 1.0.0
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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/argv.js +140 -0
- package/dist/commands/agent.js +62 -0
- package/dist/commands/apps.js +368 -0
- package/dist/commands/attachment-delete.js +37 -0
- package/dist/commands/attachment-download.js +53 -0
- package/dist/commands/attachment-list.js +50 -0
- package/dist/commands/attachment-show.js +37 -0
- package/dist/commands/attachment-token.js +133 -0
- package/dist/commands/attachment-upload.js +65 -0
- package/dist/commands/attachment.js +133 -0
- package/dist/commands/claim.js +68 -0
- package/dist/commands/config.js +262 -0
- package/dist/commands/data.js +168 -0
- package/dist/commands/deploy.js +136 -0
- package/dist/commands/feedback.js +133 -0
- package/dist/commands/key.js +82 -0
- package/dist/commands/logout.js +59 -0
- package/dist/commands/members.js +143 -0
- package/dist/commands/register.js +131 -0
- package/dist/commands/set-key.js +92 -0
- package/dist/commands/skill.js +145 -0
- package/dist/commands/taste.js +165 -0
- package/dist/config.js +165 -0
- package/dist/format.js +133 -0
- package/dist/index.js +230 -0
- package/dist/input.js +42 -0
- package/dist/output.js +77 -0
- package/dist/resolve-app.js +51 -0
- package/dist/store.js +205 -0
- package/dist/upgrade.js +115 -0
- package/dist/version.js +11 -0
- package/package.json +53 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { assertKnownFlags } from "../argv.js";
|
|
2
|
+
import { makeClient } from "../config.js";
|
|
3
|
+
import { printJson, fail, failFromError } from "../output.js";
|
|
4
|
+
const CREATE_FLAGS = ["type", "message", "app-id"];
|
|
5
|
+
const LIST_FLAGS = ["limit", "before"];
|
|
6
|
+
const NO_BOOLS = [];
|
|
7
|
+
export const feedbackHelp = `homespun feedback — submit / list feedback to the relay operator
|
|
8
|
+
|
|
9
|
+
Feedback is a one-shot bug report, feature request, or note from YOUR agent
|
|
10
|
+
to whoever runs the relay. Submissions are stored in the relay DB; the
|
|
11
|
+
operator triages out of band.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
homespun feedback <subcommand> [options]
|
|
15
|
+
|
|
16
|
+
Subcommands:
|
|
17
|
+
create Submit one feedback row. Requires --type and --message.
|
|
18
|
+
Prints { id, type, created_at } — the message is not echoed back.
|
|
19
|
+
|
|
20
|
+
list List YOUR agent's own submissions, newest first. Prints
|
|
21
|
+
{ items: [...], next_before?: <cursor> }. Pass --before <cursor>
|
|
22
|
+
from a previous page to fetch the next page.
|
|
23
|
+
|
|
24
|
+
Options for 'create':
|
|
25
|
+
--type <bug|feature|note> Feedback category. Required.
|
|
26
|
+
--message <text|-> Message body. Pass '-' to read from stdin.
|
|
27
|
+
1..4000 chars after trim.
|
|
28
|
+
--app-id <id> Optional App this feedback relates to;
|
|
29
|
+
must be owned by YOUR agent's human.
|
|
30
|
+
|
|
31
|
+
Options for 'list':
|
|
32
|
+
--limit <N> Page size (default 50, max 100).
|
|
33
|
+
--before <cursor> Opaque cursor from a previous page's next_before.
|
|
34
|
+
|
|
35
|
+
Global:
|
|
36
|
+
--url <url> Relay base URL (overrides HOMESPUN_URL).
|
|
37
|
+
--api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
|
|
38
|
+
-h, --help Show this help.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
homespun feedback create --type bug --message "watch hangs on empty app"
|
|
42
|
+
echo "long-form note..." | homespun feedback create --type note --message -
|
|
43
|
+
homespun feedback list --limit 20
|
|
44
|
+
|
|
45
|
+
Output: stdout is machine-readable JSON.`;
|
|
46
|
+
const FEEDBACK_TYPES = ["bug", "feature", "note"];
|
|
47
|
+
async function readStdin() {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for await (const chunk of process.stdin) {
|
|
50
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
53
|
+
}
|
|
54
|
+
async function runFeedbackCreate(args) {
|
|
55
|
+
assertKnownFlags(args, CREATE_FLAGS, NO_BOOLS, "homespun feedback create");
|
|
56
|
+
const type = args.flags.get("type");
|
|
57
|
+
const rawMessage = args.flags.get("message");
|
|
58
|
+
const appId = args.flags.get("app-id");
|
|
59
|
+
if (type === undefined) {
|
|
60
|
+
fail("'homespun feedback create' requires --type <bug|feature|note>", "invalid_args");
|
|
61
|
+
}
|
|
62
|
+
if (!FEEDBACK_TYPES.includes(type)) {
|
|
63
|
+
fail(`unknown --type '${type}' — expected one of: ${FEEDBACK_TYPES.join(", ")}`, "invalid_args");
|
|
64
|
+
}
|
|
65
|
+
if (rawMessage === undefined) {
|
|
66
|
+
fail("'homespun feedback create' requires --message <text|-> (use '-' to read from stdin)", "invalid_args");
|
|
67
|
+
}
|
|
68
|
+
let message;
|
|
69
|
+
if (rawMessage === "-") {
|
|
70
|
+
if (process.stdin.isTTY) {
|
|
71
|
+
fail("'homespun feedback create --message -' expects feedback on stdin, but stdin is a TTY", "invalid_args");
|
|
72
|
+
}
|
|
73
|
+
message = await readStdin();
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
message = rawMessage;
|
|
77
|
+
}
|
|
78
|
+
if (message.trim().length === 0) {
|
|
79
|
+
fail("feedback message must not be empty or whitespace-only", "invalid_args");
|
|
80
|
+
}
|
|
81
|
+
const client = makeClient(args);
|
|
82
|
+
try {
|
|
83
|
+
const res = await client.submitFeedback({
|
|
84
|
+
type: type,
|
|
85
|
+
message,
|
|
86
|
+
...(appId !== undefined ? { appId } : {}),
|
|
87
|
+
});
|
|
88
|
+
printJson(res);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
failFromError(e);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function runFeedbackList(args) {
|
|
95
|
+
assertKnownFlags(args, LIST_FLAGS, NO_BOOLS, "homespun feedback list");
|
|
96
|
+
const limitRaw = args.flags.get("limit");
|
|
97
|
+
const before = args.flags.get("before");
|
|
98
|
+
let limit;
|
|
99
|
+
if (limitRaw !== undefined) {
|
|
100
|
+
const n = Number(limitRaw);
|
|
101
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
102
|
+
fail(`--limit must be a positive integer, got '${limitRaw}'`, "invalid_args");
|
|
103
|
+
}
|
|
104
|
+
limit = n;
|
|
105
|
+
}
|
|
106
|
+
const client = makeClient(args);
|
|
107
|
+
try {
|
|
108
|
+
const page = await client.listFeedback({
|
|
109
|
+
...(limit !== undefined ? { limit } : {}),
|
|
110
|
+
...(before !== undefined ? { before } : {}),
|
|
111
|
+
});
|
|
112
|
+
printJson(page);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
failFromError(e);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function runFeedback(args) {
|
|
119
|
+
const sub = args.positionals[0];
|
|
120
|
+
switch (sub) {
|
|
121
|
+
case "create":
|
|
122
|
+
await runFeedbackCreate(args);
|
|
123
|
+
break;
|
|
124
|
+
case "list":
|
|
125
|
+
await runFeedbackList(args);
|
|
126
|
+
break;
|
|
127
|
+
case undefined:
|
|
128
|
+
fail("missing subcommand — usage: homespun feedback <create|list> (run 'homespun feedback --help')", "invalid_args");
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
fail(`unknown feedback subcommand '${sub}' — expected create|list (run 'homespun feedback --help')`, "invalid_args");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `homespun key` — inspect or revoke the calling agent's API key.
|
|
2
|
+
//
|
|
3
|
+
// Flat command namespace: `key` is one top-level noun that branches on a
|
|
4
|
+
// positional verb (list / revoke). The relay scopes /v1/keys to the
|
|
5
|
+
// authenticated agent, so there is exactly one key — the caller's own. Both
|
|
6
|
+
// verbs therefore act ONLY on the caller's own key.
|
|
7
|
+
import { assertKnownFlags } from "../argv.js";
|
|
8
|
+
import { makeClient } from "../config.js";
|
|
9
|
+
import { printJson, fail, failFromError } from "../output.js";
|
|
10
|
+
const NO_FLAGS = [];
|
|
11
|
+
const NO_BOOLS = [];
|
|
12
|
+
const REVOKE_BOOLS = ["yes"];
|
|
13
|
+
export const keyHelp = `homespun key — inspect or revoke YOUR agent's API key
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
homespun key <verb> [options]
|
|
17
|
+
|
|
18
|
+
Verbs:
|
|
19
|
+
list Show YOUR agent's key info. The relay scopes keys to the
|
|
20
|
+
authenticated agent — there is exactly one key per agent, your
|
|
21
|
+
own. Prints { agent_id, name, key_prefix, created_at,
|
|
22
|
+
last_used_at, revoked_at }.
|
|
23
|
+
|
|
24
|
+
revoke Revoke YOUR OWN API key — a self-destruct. The key stops working
|
|
25
|
+
IMMEDIATELY; every subsequent command fails until you run
|
|
26
|
+
'homespun agent register' again to provision a new key. The relay only
|
|
27
|
+
allows revoking your own key. Requires --yes to confirm.
|
|
28
|
+
Prints { revoked: true, agent_id }.
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--yes Confirm 'key revoke' (required — it is irreversible).
|
|
32
|
+
--url <url> Relay base URL (overrides HOMESPUN_URL).
|
|
33
|
+
--api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
|
|
34
|
+
-h, --help Show this help.
|
|
35
|
+
|
|
36
|
+
Output: stdout is machine-readable JSON.`;
|
|
37
|
+
async function runKeyList(args) {
|
|
38
|
+
assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "homespun key list");
|
|
39
|
+
const client = makeClient(args);
|
|
40
|
+
try {
|
|
41
|
+
const info = await client.listKeys();
|
|
42
|
+
printJson(info);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
failFromError(e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function runKeyRevoke(args) {
|
|
49
|
+
assertKnownFlags(args, NO_FLAGS, REVOKE_BOOLS, "homespun key revoke");
|
|
50
|
+
if (!args.bools.has("yes")) {
|
|
51
|
+
fail("'homespun key revoke' revokes YOUR OWN API key — it stops working " +
|
|
52
|
+
"immediately and is irreversible. Pass --yes to confirm.", "confirmation_required");
|
|
53
|
+
}
|
|
54
|
+
const client = makeClient(args);
|
|
55
|
+
try {
|
|
56
|
+
// The relay only permits revoking the caller's own key. If a positional id
|
|
57
|
+
// is given, pass it through and let the relay 403 a wrong one; otherwise
|
|
58
|
+
// resolve the caller's own id from GET /v1/keys.
|
|
59
|
+
const id = args.positionals[1] ?? (await client.listKeys()).agent_id;
|
|
60
|
+
await client.revokeKey(id);
|
|
61
|
+
printJson({ revoked: true, agent_id: id });
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
failFromError(e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function runKey(args) {
|
|
68
|
+
const sub = args.positionals[0];
|
|
69
|
+
switch (sub) {
|
|
70
|
+
case "list":
|
|
71
|
+
await runKeyList(args);
|
|
72
|
+
break;
|
|
73
|
+
case "revoke":
|
|
74
|
+
await runKeyRevoke(args);
|
|
75
|
+
break;
|
|
76
|
+
case undefined:
|
|
77
|
+
fail("missing verb — usage: homespun key <list|revoke> (run 'homespun key --help')", "invalid_args");
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
fail(`unknown key verb '${sub}' — expected list|revoke (run 'homespun key --help')`, "invalid_args");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// `homespun agent logout` — clear one (or all) saved profile(s).
|
|
2
|
+
import { assertKnownFlags } from "../argv.js";
|
|
3
|
+
import { clearStore, readStore, removeProfile, resolveProfile, } from "../store.js";
|
|
4
|
+
import { printJson, fail } from "../output.js";
|
|
5
|
+
const NO_FLAGS = [];
|
|
6
|
+
const KNOWN_BOOLS = ["all"];
|
|
7
|
+
export const logoutHelp = `homespun agent logout — clear a saved profile (or all of them)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
homespun agent logout [options]
|
|
11
|
+
|
|
12
|
+
By default this clears the ACTIVE profile only (the one selected by --profile
|
|
13
|
+
/ HOMESPUN_PROFILE / the store's current_profile). The on-disk file keeps the
|
|
14
|
+
other profiles, and 'current_profile' is unset so the next command falls back
|
|
15
|
+
to env / default URL until another profile is selected.
|
|
16
|
+
|
|
17
|
+
Pass --all to delete the whole config file (the pre-profile behaviour) — this
|
|
18
|
+
wipes every profile, not just the active one. Idempotent — no error if there
|
|
19
|
+
is nothing to clear.
|
|
20
|
+
|
|
21
|
+
This only clears the LOCAL config. It does NOT revoke the key on the relay —
|
|
22
|
+
keys keep working until revoked. To revoke a key server-side, use
|
|
23
|
+
'homespun key revoke'.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--profile <name> Target this profile instead of the active one.
|
|
27
|
+
--all Delete every profile (the whole config file).
|
|
28
|
+
-h, --help Show this help.
|
|
29
|
+
|
|
30
|
+
Output (stdout, JSON):
|
|
31
|
+
{ cleared: true, profile, path } (profile=null when --all)`;
|
|
32
|
+
export async function runLogout(args) {
|
|
33
|
+
assertKnownFlags(args, NO_FLAGS, KNOWN_BOOLS, "homespun agent logout");
|
|
34
|
+
if (args.bools.has("all")) {
|
|
35
|
+
// Nuke everything — file gone, both legacy and new shape covered.
|
|
36
|
+
const path = clearStore();
|
|
37
|
+
printJson({ cleared: true, profile: null, path });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const store = readStore();
|
|
41
|
+
const selector = args.flags.get("profile") ?? process.env.HOMESPUN_PROFILE;
|
|
42
|
+
let target;
|
|
43
|
+
try {
|
|
44
|
+
target = resolveProfile(store, selector);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
fail(e instanceof Error ? e.message : String(e), "config_error");
|
|
48
|
+
}
|
|
49
|
+
// Nothing to clear: empty store or legacy file with no migrate yet.
|
|
50
|
+
if (!target) {
|
|
51
|
+
// If there's literally nothing saved, mirror the legacy idempotent
|
|
52
|
+
// behaviour — delete the file (no-op if absent) and report cleared.
|
|
53
|
+
const path = clearStore();
|
|
54
|
+
printJson({ cleared: true, profile: null, path });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const { path } = removeProfile(target.name);
|
|
58
|
+
printJson({ cleared: true, profile: target.name, path });
|
|
59
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// `homespun members` — app membership management for a v2 app (auth spec §6,
|
|
2
|
+
// spec-cli §2.5): invite/attach a member by email, list the app's
|
|
3
|
+
// owner + members, and remove one. Every verb targets an app via a required
|
|
4
|
+
// `--app <idOrSlug>` flag, resolved the same way `homespun apps`/`homespun data` do
|
|
5
|
+
// (resolveAppId).
|
|
6
|
+
//
|
|
7
|
+
// Auth on the relay side is owner-or-agent (the owning agent's API key OR
|
|
8
|
+
// the owner human's login cookie) — this CLI always authenticates as the
|
|
9
|
+
// agent, so any of these verbs works for an app the calling agent's owning
|
|
10
|
+
// human owns.
|
|
11
|
+
//
|
|
12
|
+
// (The v1 Template marketplace's human-login-only install/uninstall route
|
|
13
|
+
// — which was never a sibling verb here, since it isn't agent-key
|
|
14
|
+
// authorizable — was removed in PR 2c-1 along with the rest of the v1
|
|
15
|
+
// Template subsystem.)
|
|
16
|
+
import { assertKnownFlags } from "../argv.js";
|
|
17
|
+
import { makeClient } from "../config.js";
|
|
18
|
+
import { fail, failFromError, printJson } from "../output.js";
|
|
19
|
+
import { resolveAppId } from "../resolve-app.js";
|
|
20
|
+
export const membersHelp = `homespun members — app membership management
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
homespun members add --app <idOrSlug> --email <email> [--role member]
|
|
24
|
+
homespun members list --app <idOrSlug>
|
|
25
|
+
homespun members remove --app <idOrSlug> --human <humanId>
|
|
26
|
+
|
|
27
|
+
--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug=
|
|
28
|
+
when it doesn't look like a cuid).
|
|
29
|
+
|
|
30
|
+
add: if a Human already exists for --email, the member row is attached
|
|
31
|
+
immediately and the response is { member: { humanId, email, role,
|
|
32
|
+
createdAt } }. Otherwise the relay mints a signed invite and emails a magic
|
|
33
|
+
link, responding { ok: true, invited, expires_at }. Only "member" is a valid
|
|
34
|
+
--role (the default); ownership transfer is not available here. Fails with
|
|
35
|
+
a relay error (503 auth_provider_unavailable) if the relay has no email
|
|
36
|
+
provider configured.
|
|
37
|
+
|
|
38
|
+
list: returns { members: [{ humanId, email, role, createdAt }] } — the
|
|
39
|
+
app's owner plus every attached member.
|
|
40
|
+
|
|
41
|
+
remove: idempotent; also revokes the human's live sessions on this app. The
|
|
42
|
+
app owner cannot be removed (the relay refuses with a 409 conflict).
|
|
43
|
+
|
|
44
|
+
Output (JSON). Errors on stderr:
|
|
45
|
+
{"error":{"code","message"}} with non-zero exit.`;
|
|
46
|
+
export async function runMembers(args) {
|
|
47
|
+
const verb = args.positionals[0];
|
|
48
|
+
if ((verb === undefined || verb === "help") && args.bools.has("help")) {
|
|
49
|
+
process.stdout.write(membersHelp + "\n");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (verb === undefined) {
|
|
53
|
+
fail("missing verb — homespun members <add|list|remove>", "invalid_args");
|
|
54
|
+
}
|
|
55
|
+
const sub = {
|
|
56
|
+
positionals: args.positionals.slice(1),
|
|
57
|
+
flags: args.flags,
|
|
58
|
+
bools: args.bools,
|
|
59
|
+
...(args.danglingValueFlags !== undefined
|
|
60
|
+
? { danglingValueFlags: args.danglingValueFlags }
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
switch (verb) {
|
|
64
|
+
case "add":
|
|
65
|
+
return runAdd(sub);
|
|
66
|
+
case "list":
|
|
67
|
+
return runList(sub);
|
|
68
|
+
case "remove":
|
|
69
|
+
return runRemove(sub);
|
|
70
|
+
default:
|
|
71
|
+
fail(`unknown verb '${verb}' — homespun members <add|list|remove>`, "invalid_args");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// add
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
async function runAdd(args) {
|
|
78
|
+
assertKnownFlags(args, ["app", "email", "role", "url", "api-key"], ["help"], "homespun members add");
|
|
79
|
+
const appArg = args.flags.get("app");
|
|
80
|
+
if (!appArg) {
|
|
81
|
+
fail("usage: homespun members add --app <idOrSlug> --email <email> [--role member]", "invalid_args");
|
|
82
|
+
}
|
|
83
|
+
const email = args.flags.get("email");
|
|
84
|
+
if (!email) {
|
|
85
|
+
fail("--email is required", "invalid_args");
|
|
86
|
+
}
|
|
87
|
+
const role = args.flags.get("role");
|
|
88
|
+
if (role !== undefined && role !== "member") {
|
|
89
|
+
fail('--role must be "member"', "invalid_args");
|
|
90
|
+
}
|
|
91
|
+
const client = makeClient(args);
|
|
92
|
+
const appId = await resolveAppId(client, appArg);
|
|
93
|
+
try {
|
|
94
|
+
printJson(await client.addAppMember(appId, {
|
|
95
|
+
email: email,
|
|
96
|
+
...(role !== undefined ? { role: role } : {}),
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
failFromError(e);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// list
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
async function runList(args) {
|
|
107
|
+
assertKnownFlags(args, ["app", "url", "api-key"], ["help"], "homespun members list");
|
|
108
|
+
const appArg = args.flags.get("app");
|
|
109
|
+
if (!appArg) {
|
|
110
|
+
fail("usage: homespun members list --app <idOrSlug>", "invalid_args");
|
|
111
|
+
}
|
|
112
|
+
const client = makeClient(args);
|
|
113
|
+
const appId = await resolveAppId(client, appArg);
|
|
114
|
+
try {
|
|
115
|
+
printJson(await client.listAppMembers(appId));
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
failFromError(e);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// remove
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
async function runRemove(args) {
|
|
125
|
+
assertKnownFlags(args, ["app", "human", "url", "api-key"], ["help"], "homespun members remove");
|
|
126
|
+
const appArg = args.flags.get("app");
|
|
127
|
+
if (!appArg) {
|
|
128
|
+
fail("usage: homespun members remove --app <idOrSlug> --human <humanId>", "invalid_args");
|
|
129
|
+
}
|
|
130
|
+
const humanId = args.flags.get("human");
|
|
131
|
+
if (!humanId) {
|
|
132
|
+
fail("--human is required", "invalid_args");
|
|
133
|
+
}
|
|
134
|
+
const client = makeClient(args);
|
|
135
|
+
const appId = await resolveAppId(client, appArg);
|
|
136
|
+
try {
|
|
137
|
+
await client.removeAppMember(appId, humanId);
|
|
138
|
+
printJson({ removed: true, app_id: appId, human_id: humanId });
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
failFromError(e);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// `homespun agent register` — self-provision an agent API key from the relay.
|
|
2
|
+
//
|
|
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.
|
|
8
|
+
import { registerAgent, HomespunApiError } from "@homespunapps/core";
|
|
9
|
+
import { assertKnownFlags } from "../argv.js";
|
|
10
|
+
import { DEFAULT_RELAY_URL } from "../config.js";
|
|
11
|
+
import { printJson, fail, failUpgradeRequired } from "../output.js";
|
|
12
|
+
import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
|
|
13
|
+
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.`;
|
|
51
|
+
export async function runRegister(args) {
|
|
52
|
+
assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun agent register");
|
|
53
|
+
// Profile selection for the WRITE side: --profile flag → HOMESPUN_PROFILE env
|
|
54
|
+
// → the store's current profile → DEFAULT_PROFILE_NAME ('default') for
|
|
55
|
+
// 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
|
|
57
|
+
// 'default' is a stable, predictable home.
|
|
58
|
+
const profileFlag = args.flags.get("profile") ?? process.env.HOMESPUN_PROFILE;
|
|
59
|
+
const store = readStore();
|
|
60
|
+
const profileName = profileFlag !== undefined && profileFlag !== ""
|
|
61
|
+
? profileFlag
|
|
62
|
+
: (store.currentProfile ?? DEFAULT_PROFILE_NAME);
|
|
63
|
+
if (!isValidProfileName(profileName)) {
|
|
64
|
+
fail(`invalid profile name '${profileName}' — letters, digits, _ and -, up to 32 chars`, "invalid_args");
|
|
65
|
+
}
|
|
66
|
+
// URL precedence for the relay we're registering against:
|
|
67
|
+
// --url flag > HOMESPUN_URL env > target-profile's existing url > default.
|
|
68
|
+
// The "target profile's url" path means re-running `homespun agent register
|
|
69
|
+
// --profile dev` against a profile that already exists keeps hitting the
|
|
70
|
+
// same dev relay without retyping --url.
|
|
71
|
+
let activeUrl;
|
|
72
|
+
try {
|
|
73
|
+
const active = resolveProfile(store, profileFlag);
|
|
74
|
+
activeUrl = active?.profile.url;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Selector didn't resolve — fine on register: we're about to create it.
|
|
78
|
+
activeUrl = undefined;
|
|
79
|
+
}
|
|
80
|
+
const url = args.flags.get("url") ??
|
|
81
|
+
process.env.HOMESPUN_URL ??
|
|
82
|
+
activeUrl ??
|
|
83
|
+
DEFAULT_RELAY_URL;
|
|
84
|
+
const name = args.flags.get("name");
|
|
85
|
+
const secret = args.flags.get("secret") ??
|
|
86
|
+
process.env.HOMESPUN_REGISTER_SECRET ??
|
|
87
|
+
undefined;
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = await registerAgent({
|
|
91
|
+
url: url.replace(/\/$/, ""),
|
|
92
|
+
...(name !== undefined ? { name } : {}),
|
|
93
|
+
...(secret !== undefined && secret !== "" ? { secret } : {}),
|
|
94
|
+
cliVersion: VERSION,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (e instanceof HomespunApiError) {
|
|
99
|
+
// 426 cli_upgrade_required goes through the shared upgrade-message
|
|
100
|
+
// path (stderr block + exit 75) so the SKILL.md's instructions to the
|
|
101
|
+
// agent's harness fire on `homespun agent register` too.
|
|
102
|
+
if (e.status === 426 && e.code === "cli_upgrade_required") {
|
|
103
|
+
failUpgradeRequired(e);
|
|
104
|
+
}
|
|
105
|
+
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 });
|
|
107
|
+
}
|
|
108
|
+
fail(e.message, e.code, e.details, {
|
|
109
|
+
hint: e.hint,
|
|
110
|
+
retryable: e.retryable,
|
|
111
|
+
docs_url: e.docsUrl,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
fail(e instanceof Error ? e.message : String(e), "internal");
|
|
115
|
+
}
|
|
116
|
+
// Save under the chosen profile. We pass setCurrent=true: the user just
|
|
117
|
+
// registered against this relay, so the only sensible follow-up is to
|
|
118
|
+
// start using it. The previous behaviour (one global URL+key) is exactly
|
|
119
|
+
// the single-profile case of this.
|
|
120
|
+
const savedTo = upsertProfile(profileName, { url: url.replace(/\/$/, ""), apiKey: result.api_key }, true);
|
|
121
|
+
const out = {
|
|
122
|
+
agent_id: result.agent_id,
|
|
123
|
+
key_prefix: result.key_prefix,
|
|
124
|
+
profile: profileName,
|
|
125
|
+
saved_to: savedTo,
|
|
126
|
+
};
|
|
127
|
+
if (args.bools.has("print-key")) {
|
|
128
|
+
out["api_key"] = result.api_key;
|
|
129
|
+
}
|
|
130
|
+
printJson(out);
|
|
131
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// `homespun agent set-key <key>` — write a fresh API key into the CLI config
|
|
2
|
+
// file. The companion to the human-side rotation flow on /my-agents: after
|
|
3
|
+
// the human regenerates a key in the browser, this command lands it on
|
|
4
|
+
// the agent's machine without making them hand-edit ~/.config/homespun/config.json.
|
|
5
|
+
//
|
|
6
|
+
// No relay round-trip: we trust the human-supplied key. The relay will
|
|
7
|
+
// reject it on the next call if it's wrong (401 invalid_api_key) — better
|
|
8
|
+
// than guessing here and adding a network hop for what's a local config
|
|
9
|
+
// write.
|
|
10
|
+
import { assertKnownFlags } from "../argv.js";
|
|
11
|
+
import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
|
|
12
|
+
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
|
+
function keyPrefixOf(key) {
|
|
45
|
+
// Match the relay's keyPrefix() display width for "hs_" + 6 hex chars
|
|
46
|
+
// (11 total). Falls back to the first 8 chars for any unrecognised shape.
|
|
47
|
+
if (key.startsWith("hs_") && key.length >= 9)
|
|
48
|
+
return key.slice(0, 9);
|
|
49
|
+
return key.slice(0, 8);
|
|
50
|
+
}
|
|
51
|
+
export async function runSetKey(args) {
|
|
52
|
+
assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun agent set-key");
|
|
53
|
+
const apiKey = args.positionals[0];
|
|
54
|
+
if (!apiKey) {
|
|
55
|
+
fail("missing api-key — usage: homespun agent set-key <api-key>", "invalid_args");
|
|
56
|
+
}
|
|
57
|
+
if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
|
|
58
|
+
fail("api-key must be a non-empty string", "invalid_args");
|
|
59
|
+
}
|
|
60
|
+
// Best-effort shape check. The relay generates `hs_<32 hex>`; we don't
|
|
61
|
+
// reject other shapes outright (a future format change shouldn't strand
|
|
62
|
+
// older CLIs), but we warn on something obviously wrong like leading
|
|
63
|
+
// whitespace.
|
|
64
|
+
const trimmed = apiKey.trim();
|
|
65
|
+
if (trimmed !== apiKey) {
|
|
66
|
+
fail("api-key has surrounding whitespace — copy it without leading/trailing spaces", "invalid_args");
|
|
67
|
+
}
|
|
68
|
+
// Profile selection mirrors `homespun agent register`: --profile flag →
|
|
69
|
+
// HOMESPUN_PROFILE env → store's current_profile → 'default'.
|
|
70
|
+
const profileFlag = args.flags.get("profile") ?? process.env.HOMESPUN_PROFILE;
|
|
71
|
+
const store = readStore();
|
|
72
|
+
const profileName = profileFlag !== undefined && profileFlag !== ""
|
|
73
|
+
? profileFlag
|
|
74
|
+
: (store.currentProfile ?? DEFAULT_PROFILE_NAME);
|
|
75
|
+
if (!isValidProfileName(profileName)) {
|
|
76
|
+
fail(`invalid profile name '${profileName}' — letters, digits, _ and -, up to 32 chars`, "invalid_args");
|
|
77
|
+
}
|
|
78
|
+
const urlFlag = args.flags.get("url");
|
|
79
|
+
const patch = { apiKey };
|
|
80
|
+
if (urlFlag !== undefined)
|
|
81
|
+
patch.url = urlFlag;
|
|
82
|
+
const saved = upsertProfile(profileName, patch);
|
|
83
|
+
// Re-resolve so we report the prefix from the persisted value, not the
|
|
84
|
+
// argument — defensive against future write-side normalisation.
|
|
85
|
+
const after = readStore();
|
|
86
|
+
const reread = resolveProfile(after, profileName);
|
|
87
|
+
printJson({
|
|
88
|
+
saved_to: saved,
|
|
89
|
+
profile: profileName,
|
|
90
|
+
key_prefix: keyPrefixOf(reread?.profile.apiKey ?? apiKey),
|
|
91
|
+
});
|
|
92
|
+
}
|