@homespunapps/cli 1.0.1 → 1.4.3
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 +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 +2 -49
- package/dist/commands/set-key.js +2 -32
- package/dist/commands/skill.js +3 -39
- package/dist/commands/taste.js +4 -49
- package/dist/help-catalog.js +1087 -0
- package/dist/index.js +28 -103
- package/package.json +11 -6
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// `homespun grants` (M5) grant-link management for a v2 app: mint a
|
|
2
|
+
// capability URL that confers a DECLARED custom role on a stable, per-holder
|
|
3
|
+
// anonymous identity, list an app's grant links, and revoke one. Every verb
|
|
4
|
+
// targets an app via a required `--app <idOrSlug>` flag, resolved the same way
|
|
5
|
+
// `homespun members`/`homespun data` do (resolveAppId).
|
|
6
|
+
//
|
|
7
|
+
// Auth on the relay side is owner-or-agent; this CLI always authenticates as
|
|
8
|
+
// the owning agent, so any verb works for an app the calling agent's owning
|
|
9
|
+
// human owns.
|
|
10
|
+
//
|
|
11
|
+
// A grant link NEVER escalates: --role must be a DECLARED custom role
|
|
12
|
+
// (x-homespun-manifest.roles), never a built-in role (owner/member/agent). The
|
|
13
|
+
// minted grant_url carries the raw token in its #g= fragment and is printed
|
|
14
|
+
// ONCE (it is never recoverable afterward).
|
|
15
|
+
import { assertKnownFlags } from "../argv.js";
|
|
16
|
+
import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
|
|
17
|
+
import { makeClient } from "../config.js";
|
|
18
|
+
import { fail, failFromError, printJson } from "../output.js";
|
|
19
|
+
import { resolveAppId } from "../resolve-app.js";
|
|
20
|
+
export async function runGrant(args) {
|
|
21
|
+
const verb = args.positionals[0];
|
|
22
|
+
if ((verb === undefined || verb === "help") && args.bools.has("help")) {
|
|
23
|
+
process.stdout.write(renderNounHelp(nounSpec("grants")) + "\n");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (verb === undefined) {
|
|
27
|
+
fail("missing verb: homespun grants <mint|list|revoke>", "invalid_args");
|
|
28
|
+
}
|
|
29
|
+
const sub = {
|
|
30
|
+
positionals: args.positionals.slice(1),
|
|
31
|
+
flags: args.flags,
|
|
32
|
+
bools: args.bools,
|
|
33
|
+
...(args.danglingValueFlags !== undefined
|
|
34
|
+
? { danglingValueFlags: args.danglingValueFlags }
|
|
35
|
+
: {}),
|
|
36
|
+
};
|
|
37
|
+
switch (verb) {
|
|
38
|
+
case "mint":
|
|
39
|
+
return runMint(sub);
|
|
40
|
+
case "list":
|
|
41
|
+
return runList(sub);
|
|
42
|
+
case "revoke":
|
|
43
|
+
return runRevoke(sub);
|
|
44
|
+
default:
|
|
45
|
+
fail(`unknown verb '${verb}' (homespun grants <mint|list|revoke>)`, "invalid_args");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function parsePositiveInt(raw, flag) {
|
|
49
|
+
const n = Number(raw);
|
|
50
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
51
|
+
fail(`${flag} must be a positive integer`, "invalid_args");
|
|
52
|
+
}
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// mint
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
async function runMint(args) {
|
|
59
|
+
assertKnownFlags(args, ...specFor("grants", "mint"));
|
|
60
|
+
const appArg = args.flags.get("app");
|
|
61
|
+
if (!appArg) {
|
|
62
|
+
fail("usage: homespun grants mint --app <idOrSlug> --role <customRole>", "invalid_args");
|
|
63
|
+
}
|
|
64
|
+
const role = args.flags.get("role");
|
|
65
|
+
if (!role) {
|
|
66
|
+
fail("--role is required", "invalid_args");
|
|
67
|
+
}
|
|
68
|
+
const mode = args.flags.get("mode");
|
|
69
|
+
if (mode !== undefined && mode !== "once" && mode !== "multi") {
|
|
70
|
+
fail('--mode must be "once" or "multi"', "invalid_args");
|
|
71
|
+
}
|
|
72
|
+
const pinRow = args.flags.get("pin-row");
|
|
73
|
+
const pinWhereRaw = args.flags.get("pin-where");
|
|
74
|
+
if (pinRow !== undefined && pinWhereRaw !== undefined) {
|
|
75
|
+
fail("--pin-row and --pin-where are mutually exclusive", "invalid_args");
|
|
76
|
+
}
|
|
77
|
+
let pin;
|
|
78
|
+
if (pinRow !== undefined) {
|
|
79
|
+
pin = { rowKey: pinRow };
|
|
80
|
+
}
|
|
81
|
+
else if (pinWhereRaw !== undefined) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(pinWhereRaw);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
fail("--pin-where must be a JSON array of conditions", "invalid_args");
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(parsed)) {
|
|
90
|
+
fail("--pin-where must be a JSON array of conditions", "invalid_args");
|
|
91
|
+
}
|
|
92
|
+
pin = { where: parsed };
|
|
93
|
+
}
|
|
94
|
+
const maxUsesRaw = args.flags.get("max-uses");
|
|
95
|
+
const ttlRaw = args.flags.get("ttl");
|
|
96
|
+
const client = makeClient(args);
|
|
97
|
+
const appId = await resolveAppId(client, appArg);
|
|
98
|
+
try {
|
|
99
|
+
printJson(await client.mintAppGrant(appId, {
|
|
100
|
+
role: role,
|
|
101
|
+
...(mode !== undefined ? { mode: mode } : {}),
|
|
102
|
+
...(maxUsesRaw !== undefined
|
|
103
|
+
? { maxUses: parsePositiveInt(maxUsesRaw, "--max-uses") }
|
|
104
|
+
: {}),
|
|
105
|
+
...(args.flags.get("label") !== undefined
|
|
106
|
+
? { label: args.flags.get("label") }
|
|
107
|
+
: {}),
|
|
108
|
+
...(ttlRaw !== undefined
|
|
109
|
+
? { ttlSeconds: parsePositiveInt(ttlRaw, "--ttl") }
|
|
110
|
+
: {}),
|
|
111
|
+
...(pin !== undefined ? { pin } : {}),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
failFromError(e);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// list
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
async function runList(args) {
|
|
122
|
+
assertKnownFlags(args, ...specFor("grants", "list"));
|
|
123
|
+
const appArg = args.flags.get("app");
|
|
124
|
+
if (!appArg) {
|
|
125
|
+
fail("usage: homespun grants list --app <idOrSlug>", "invalid_args");
|
|
126
|
+
}
|
|
127
|
+
const client = makeClient(args);
|
|
128
|
+
const appId = await resolveAppId(client, appArg);
|
|
129
|
+
try {
|
|
130
|
+
printJson(await client.listAppGrants(appId));
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
failFromError(e);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// revoke
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
async function runRevoke(args) {
|
|
140
|
+
assertKnownFlags(args, ...specFor("grants", "revoke"));
|
|
141
|
+
const appArg = args.flags.get("app");
|
|
142
|
+
if (!appArg) {
|
|
143
|
+
fail("usage: homespun grants revoke --app <idOrSlug> --grant <grantId>", "invalid_args");
|
|
144
|
+
}
|
|
145
|
+
const grantId = args.flags.get("grant");
|
|
146
|
+
if (!grantId) {
|
|
147
|
+
fail("--grant is required", "invalid_args");
|
|
148
|
+
}
|
|
149
|
+
const client = makeClient(args);
|
|
150
|
+
const appId = await resolveAppId(client, appArg);
|
|
151
|
+
try {
|
|
152
|
+
await client.revokeAppGrant(appId, grantId);
|
|
153
|
+
printJson({ revoked: true, app_id: appId, grant_id: grantId });
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
failFromError(e);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// `homespun ingest`: inbound catch-hook read surface for an app (inbound-webhooks
|
|
2
|
+
// PR 3). List the app's declared hooks with their full secret URL, and rotate a
|
|
3
|
+
// hook's secret. Every verb targets an app via a required `--app <idOrSlug>`,
|
|
4
|
+
// resolved the same way `homespun members`/`homespun data` do (resolveAppId).
|
|
5
|
+
//
|
|
6
|
+
// This is the smallest surface an agent needs during app setup: after deploying
|
|
7
|
+
// a manifest that declares an `ingest` hook, the agent runs `homespun ingest list`
|
|
8
|
+
// to read back the exact URL and tells its owner to paste it into Stripe/Zapier/
|
|
9
|
+
// Home Assistant/etc. Hooks are manifest-declared, so there is no create/delete
|
|
10
|
+
// here; `rotate` re-keys a leaked URL without a redeploy.
|
|
11
|
+
//
|
|
12
|
+
// Auth on the relay side is owner-or-agent (the owning agent's API key OR the
|
|
13
|
+
// owner human's login cookie); this CLI always authenticates as the agent, so
|
|
14
|
+
// both verbs work for an app the calling agent's owning human owns.
|
|
15
|
+
import { assertKnownFlags } from "../argv.js";
|
|
16
|
+
import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
|
|
17
|
+
import { makeClient } from "../config.js";
|
|
18
|
+
import { fail, failFromError, printJson } from "../output.js";
|
|
19
|
+
import { resolveAppId } from "../resolve-app.js";
|
|
20
|
+
export async function runIngest(args) {
|
|
21
|
+
const verb = args.positionals[0];
|
|
22
|
+
if ((verb === undefined || verb === "help") && args.bools.has("help")) {
|
|
23
|
+
process.stdout.write(renderNounHelp(nounSpec("ingest")) + "\n");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (verb === undefined) {
|
|
27
|
+
fail("missing verb (homespun ingest <list|rotate>)", "invalid_args");
|
|
28
|
+
}
|
|
29
|
+
const sub = {
|
|
30
|
+
positionals: args.positionals.slice(1),
|
|
31
|
+
flags: args.flags,
|
|
32
|
+
bools: args.bools,
|
|
33
|
+
...(args.danglingValueFlags !== undefined
|
|
34
|
+
? { danglingValueFlags: args.danglingValueFlags }
|
|
35
|
+
: {}),
|
|
36
|
+
};
|
|
37
|
+
switch (verb) {
|
|
38
|
+
case "list":
|
|
39
|
+
return runList(sub);
|
|
40
|
+
case "rotate":
|
|
41
|
+
return runRotate(sub);
|
|
42
|
+
default:
|
|
43
|
+
fail(`unknown verb '${verb}' (homespun ingest <list|rotate>)`, "invalid_args");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// list
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
async function runList(args) {
|
|
50
|
+
assertKnownFlags(args, ...specFor("ingest", "list"));
|
|
51
|
+
const appArg = args.flags.get("app");
|
|
52
|
+
if (!appArg) {
|
|
53
|
+
fail("usage: homespun ingest list --app <idOrSlug>", "invalid_args");
|
|
54
|
+
}
|
|
55
|
+
const client = makeClient(args);
|
|
56
|
+
const appId = await resolveAppId(client, appArg);
|
|
57
|
+
try {
|
|
58
|
+
printJson(await client.listIngestHooks(appId));
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
failFromError(e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// rotate
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
async function runRotate(args) {
|
|
68
|
+
assertKnownFlags(args, ...specFor("ingest", "rotate"));
|
|
69
|
+
const appArg = args.flags.get("app");
|
|
70
|
+
if (!appArg) {
|
|
71
|
+
fail("usage: homespun ingest rotate --app <idOrSlug> --name <hookName>", "invalid_args");
|
|
72
|
+
}
|
|
73
|
+
const name = args.flags.get("name");
|
|
74
|
+
if (!name) {
|
|
75
|
+
fail("--name is required", "invalid_args");
|
|
76
|
+
}
|
|
77
|
+
const client = makeClient(args);
|
|
78
|
+
const appId = await resolveAppId(client, appArg);
|
|
79
|
+
try {
|
|
80
|
+
printJson(await client.rotateIngestHook(appId, name));
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
failFromError(e);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/commands/key.js
CHANGED
|
@@ -5,37 +5,11 @@
|
|
|
5
5
|
// authenticated agent, so there is exactly one key — the caller's own. Both
|
|
6
6
|
// verbs therefore act ONLY on the caller's own key.
|
|
7
7
|
import { assertKnownFlags } from "../argv.js";
|
|
8
|
+
import { specFor } from "../help-catalog.js";
|
|
8
9
|
import { makeClient } from "../config.js";
|
|
9
10
|
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
11
|
async function runKeyList(args) {
|
|
38
|
-
assertKnownFlags(args,
|
|
12
|
+
assertKnownFlags(args, ...specFor("key", "list"));
|
|
39
13
|
const client = makeClient(args);
|
|
40
14
|
try {
|
|
41
15
|
const info = await client.listKeys();
|
|
@@ -45,8 +19,20 @@ async function runKeyList(args) {
|
|
|
45
19
|
failFromError(e);
|
|
46
20
|
}
|
|
47
21
|
}
|
|
22
|
+
async function runKeyMint(args) {
|
|
23
|
+
assertKnownFlags(args, ...specFor("key", "mint"));
|
|
24
|
+
const client = makeClient(args);
|
|
25
|
+
try {
|
|
26
|
+
// The raw key is in this response ONCE and never again, so print it verbatim.
|
|
27
|
+
const minted = await client.mintKey();
|
|
28
|
+
printJson(minted);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
failFromError(e);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
48
34
|
async function runKeyRevoke(args) {
|
|
49
|
-
assertKnownFlags(args,
|
|
35
|
+
assertKnownFlags(args, ...specFor("key", "revoke"));
|
|
50
36
|
if (!args.bools.has("yes")) {
|
|
51
37
|
fail("'homespun key revoke' revokes YOUR OWN API key — it stops working " +
|
|
52
38
|
"immediately and is irreversible. Pass --yes to confirm.", "confirmation_required");
|
|
@@ -70,13 +56,16 @@ export async function runKey(args) {
|
|
|
70
56
|
case "list":
|
|
71
57
|
await runKeyList(args);
|
|
72
58
|
break;
|
|
59
|
+
case "mint":
|
|
60
|
+
await runKeyMint(args);
|
|
61
|
+
break;
|
|
73
62
|
case "revoke":
|
|
74
63
|
await runKeyRevoke(args);
|
|
75
64
|
break;
|
|
76
65
|
case undefined:
|
|
77
|
-
fail("missing verb
|
|
66
|
+
fail("missing verb; usage: homespun key <list|mint|revoke> (run 'homespun key --help')", "invalid_args");
|
|
78
67
|
break;
|
|
79
68
|
default:
|
|
80
|
-
fail(`unknown key verb '${sub}'
|
|
69
|
+
fail(`unknown key verb '${sub}', expected list|mint|revoke (run 'homespun key --help')`, "invalid_args");
|
|
81
70
|
}
|
|
82
71
|
}
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,36 +1,10 @@
|
|
|
1
1
|
// `homespun agent logout` — clear one (or all) saved profile(s).
|
|
2
2
|
import { assertKnownFlags } from "../argv.js";
|
|
3
|
+
import { specFor } from "../help-catalog.js";
|
|
3
4
|
import { clearStore, readStore, removeProfile, resolveProfile, } from "../store.js";
|
|
4
5
|
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
6
|
export async function runLogout(args) {
|
|
33
|
-
assertKnownFlags(args,
|
|
7
|
+
assertKnownFlags(args, ...specFor("agent", "logout"));
|
|
34
8
|
if (args.bools.has("all")) {
|
|
35
9
|
// Nuke everything — file gone, both legacy and new shape covered.
|
|
36
10
|
const path = clearStore();
|
package/dist/commands/members.js
CHANGED
|
@@ -14,43 +14,18 @@
|
|
|
14
14
|
// authorizable — was removed in PR 2c-1 along with the rest of the v1
|
|
15
15
|
// Template subsystem.)
|
|
16
16
|
import { assertKnownFlags } from "../argv.js";
|
|
17
|
+
import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
|
|
17
18
|
import { makeClient } from "../config.js";
|
|
18
19
|
import { fail, failFromError, printJson } from "../output.js";
|
|
19
20
|
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
21
|
export async function runMembers(args) {
|
|
47
22
|
const verb = args.positionals[0];
|
|
48
23
|
if ((verb === undefined || verb === "help") && args.bools.has("help")) {
|
|
49
|
-
process.stdout.write(
|
|
24
|
+
process.stdout.write(renderNounHelp(nounSpec("members")) + "\n");
|
|
50
25
|
return;
|
|
51
26
|
}
|
|
52
27
|
if (verb === undefined) {
|
|
53
|
-
fail("missing verb — homespun members <add|list|remove>", "invalid_args");
|
|
28
|
+
fail("missing verb — homespun members <add|list|set-role|remove|roles>", "invalid_args");
|
|
54
29
|
}
|
|
55
30
|
const sub = {
|
|
56
31
|
positionals: args.positionals.slice(1),
|
|
@@ -65,17 +40,21 @@ export async function runMembers(args) {
|
|
|
65
40
|
return runAdd(sub);
|
|
66
41
|
case "list":
|
|
67
42
|
return runList(sub);
|
|
43
|
+
case "set-role":
|
|
44
|
+
return runSetRole(sub);
|
|
68
45
|
case "remove":
|
|
69
46
|
return runRemove(sub);
|
|
47
|
+
case "roles":
|
|
48
|
+
return runRoles(sub);
|
|
70
49
|
default:
|
|
71
|
-
fail(`unknown verb '${verb}' — homespun members <add|list|remove>`, "invalid_args");
|
|
50
|
+
fail(`unknown verb '${verb}' — homespun members <add|list|set-role|remove|roles>`, "invalid_args");
|
|
72
51
|
}
|
|
73
52
|
}
|
|
74
53
|
// ---------------------------------------------------------------------------
|
|
75
54
|
// add
|
|
76
55
|
// ---------------------------------------------------------------------------
|
|
77
56
|
async function runAdd(args) {
|
|
78
|
-
assertKnownFlags(args,
|
|
57
|
+
assertKnownFlags(args, ...specFor("members", "add"));
|
|
79
58
|
const appArg = args.flags.get("app");
|
|
80
59
|
if (!appArg) {
|
|
81
60
|
fail("usage: homespun members add --app <idOrSlug> --email <email> [--role member]", "invalid_args");
|
|
@@ -104,7 +83,7 @@ async function runAdd(args) {
|
|
|
104
83
|
// list
|
|
105
84
|
// ---------------------------------------------------------------------------
|
|
106
85
|
async function runList(args) {
|
|
107
|
-
assertKnownFlags(args,
|
|
86
|
+
assertKnownFlags(args, ...specFor("members", "list"));
|
|
108
87
|
const appArg = args.flags.get("app");
|
|
109
88
|
if (!appArg) {
|
|
110
89
|
fail("usage: homespun members list --app <idOrSlug>", "invalid_args");
|
|
@@ -119,10 +98,45 @@ async function runList(args) {
|
|
|
119
98
|
}
|
|
120
99
|
}
|
|
121
100
|
// ---------------------------------------------------------------------------
|
|
101
|
+
// set-role
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
async function runSetRole(args) {
|
|
104
|
+
assertKnownFlags(args, ...specFor("members", "set-role"));
|
|
105
|
+
const appArg = args.flags.get("app");
|
|
106
|
+
if (!appArg) {
|
|
107
|
+
fail("usage: homespun members set-role --app <idOrSlug> --human <humanId> (--custom-role <name> | --clear-role)", "invalid_args");
|
|
108
|
+
}
|
|
109
|
+
const humanId = args.flags.get("human");
|
|
110
|
+
if (!humanId) {
|
|
111
|
+
fail("--human is required", "invalid_args");
|
|
112
|
+
}
|
|
113
|
+
// Clearing a role is a real instruction, so it gets its own explicit flag
|
|
114
|
+
// rather than being spelled as an omitted or empty --custom-role: an omitted
|
|
115
|
+
// value must never silently wipe someone's role.
|
|
116
|
+
const customRole = args.flags.get("custom-role");
|
|
117
|
+
const clear = args.bools.has("clear-role");
|
|
118
|
+
if (clear && customRole !== undefined) {
|
|
119
|
+
fail("--custom-role and --clear-role are mutually exclusive", "invalid_args");
|
|
120
|
+
}
|
|
121
|
+
if (!clear && customRole === undefined) {
|
|
122
|
+
fail("one of --custom-role <name> or --clear-role is required", "invalid_args");
|
|
123
|
+
}
|
|
124
|
+
const client = makeClient(args);
|
|
125
|
+
const appId = await resolveAppId(client, appArg);
|
|
126
|
+
try {
|
|
127
|
+
printJson(await client.setAppMemberRole(appId, humanId, {
|
|
128
|
+
customRole: clear ? null : customRole,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
failFromError(e);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
122
136
|
// remove
|
|
123
137
|
// ---------------------------------------------------------------------------
|
|
124
138
|
async function runRemove(args) {
|
|
125
|
-
assertKnownFlags(args,
|
|
139
|
+
assertKnownFlags(args, ...specFor("members", "remove"));
|
|
126
140
|
const appArg = args.flags.get("app");
|
|
127
141
|
if (!appArg) {
|
|
128
142
|
fail("usage: homespun members remove --app <idOrSlug> --human <humanId>", "invalid_args");
|
|
@@ -141,3 +155,21 @@ async function runRemove(args) {
|
|
|
141
155
|
failFromError(e);
|
|
142
156
|
}
|
|
143
157
|
}
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// roles
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
async function runRoles(args) {
|
|
162
|
+
assertKnownFlags(args, ...specFor("members", "roles"));
|
|
163
|
+
const appArg = args.flags.get("app");
|
|
164
|
+
if (!appArg) {
|
|
165
|
+
fail("usage: homespun members roles --app <idOrSlug>", "invalid_args");
|
|
166
|
+
}
|
|
167
|
+
const client = makeClient(args);
|
|
168
|
+
const appId = await resolveAppId(client, appArg);
|
|
169
|
+
try {
|
|
170
|
+
printJson(await client.listAppRoles(appId));
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
failFromError(e);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -20,59 +20,12 @@
|
|
|
20
20
|
import { hostname } from "node:os";
|
|
21
21
|
import { registerAgent, HomespunApiError } from "@homespunapps/core";
|
|
22
22
|
import { assertKnownFlags } from "../argv.js";
|
|
23
|
+
import { specFor } from "../help-catalog.js";
|
|
23
24
|
import { DEFAULT_RELAY_URL } from "../config.js";
|
|
24
25
|
import { runDeviceFlow } from "../device-flow.js";
|
|
25
26
|
import { printJson, fail, failUpgradeRequired } from "../output.js";
|
|
26
27
|
import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
|
|
27
28
|
import { VERSION } from "../version.js";
|
|
28
|
-
const KNOWN_FLAGS = ["name", "secret"];
|
|
29
|
-
const KNOWN_BOOLS = ["print-key", "no-device"];
|
|
30
|
-
export const registerHelp = `homespun agent register - register this agent with the relay and save the key locally
|
|
31
|
-
|
|
32
|
-
Usage:
|
|
33
|
-
homespun agent register [options]
|
|
34
|
-
|
|
35
|
-
By default this runs the browser device-authorization flow: it prints a link
|
|
36
|
-
and a short code, you (or the account owner) open the link on any device,
|
|
37
|
-
sign in, and approve - and the agent comes out already linked to that
|
|
38
|
-
account. Older relays without the flow fall back to plain POST /v1/register
|
|
39
|
-
automatically (such agents need a separate 'homespun agent claim' afterwards).
|
|
40
|
-
|
|
41
|
-
The returned API key (and relay URL) are saved under a named profile in the
|
|
42
|
-
CLI config file - so afterwards every other command works with only HOMESPUN_URL
|
|
43
|
-
set (no HOMESPUN_API_KEY needed).
|
|
44
|
-
|
|
45
|
-
If --profile is omitted, the registered key goes under the currently-active
|
|
46
|
-
profile (or 'default' for a fresh install). Pass --profile <name> to keep
|
|
47
|
-
multiple environments (dev/staging/prod) side by side; switch between them
|
|
48
|
-
with 'homespun config use <name>' or '--profile <name>' / HOMESPUN_PROFILE.
|
|
49
|
-
|
|
50
|
-
Options:
|
|
51
|
-
--name <n> Agent display name on the relay (shown on the approval
|
|
52
|
-
screen). Defaults to cli-<hostname> for the device flow;
|
|
53
|
-
the relay defaults it for the direct path.
|
|
54
|
-
--profile <name> Local profile name to save under. Defaults to the active
|
|
55
|
-
profile, or 'default' on a fresh install. Letters,
|
|
56
|
-
digits, _ and -, up to 32 chars.
|
|
57
|
-
--url <url> Relay base URL. Falls back to HOMESPUN_URL, then the active
|
|
58
|
-
profile, then the hosted Homespun relay. Self-hosters set
|
|
59
|
-
this.
|
|
60
|
-
--secret <s> Registration secret, sent as a Bearer token. Only needed
|
|
61
|
-
when the relay uses REGISTRATION_MODE=secret. Implies the
|
|
62
|
-
direct path (no browser approval). Falls back to the
|
|
63
|
-
HOMESPUN_REGISTER_SECRET env var.
|
|
64
|
-
--no-device Skip the browser approval and register directly
|
|
65
|
-
(POST /v1/register). The agent is unowned until claimed.
|
|
66
|
-
--print-key Also echo the full api_key in the output. By default the
|
|
67
|
-
key is only persisted to the config file, never printed.
|
|
68
|
-
-h, --help Show this help.
|
|
69
|
-
|
|
70
|
-
Output (stdout, JSON):
|
|
71
|
-
{ agent_id, key_prefix, profile, saved_to, registered_via }
|
|
72
|
-
(+ api_key when --print-key)
|
|
73
|
-
|
|
74
|
-
The API key is saved to the CLI config file (mode 0600); it is not printed
|
|
75
|
-
unless --print-key is passed.`;
|
|
76
29
|
/**
|
|
77
30
|
* Default agent name for the device flow: the consent screen must name what
|
|
78
31
|
* the human is approving, so an unnamed agent gets "cli-<hostname>" instead
|
|
@@ -95,7 +48,7 @@ function apiKeyPrefix(key) {
|
|
|
95
48
|
return key.startsWith("hs_") ? key.slice(0, 9) : key.slice(0, 8);
|
|
96
49
|
}
|
|
97
50
|
export async function runRegister(args) {
|
|
98
|
-
assertKnownFlags(args,
|
|
51
|
+
assertKnownFlags(args, ...specFor("agent", "register"));
|
|
99
52
|
// Profile selection for the WRITE side: --profile flag → HOMESPUN_PROFILE env
|
|
100
53
|
// → the store's current profile → DEFAULT_PROFILE_NAME ('default') for
|
|
101
54
|
// a fresh install. We deliberately don't fall through to "no profile, use
|
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");
|