@homespunapps/cli 1.4.3 → 1.5.1
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/commands/ingest.js +80 -2
- package/dist/help-catalog.js +67 -14
- package/package.json +2 -2
package/dist/commands/ingest.js
CHANGED
|
@@ -24,7 +24,7 @@ export async function runIngest(args) {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
if (verb === undefined) {
|
|
27
|
-
fail("missing verb (homespun ingest <list|rotate>)", "invalid_args");
|
|
27
|
+
fail("missing verb (homespun ingest <list|rotate|signing-secret>)", "invalid_args");
|
|
28
28
|
}
|
|
29
29
|
const sub = {
|
|
30
30
|
positionals: args.positionals.slice(1),
|
|
@@ -39,8 +39,10 @@ export async function runIngest(args) {
|
|
|
39
39
|
return runList(sub);
|
|
40
40
|
case "rotate":
|
|
41
41
|
return runRotate(sub);
|
|
42
|
+
case "signing-secret":
|
|
43
|
+
return runSigningSecret(sub);
|
|
42
44
|
default:
|
|
43
|
-
fail(`unknown verb '${verb}' (homespun ingest <list|rotate>)`, "invalid_args");
|
|
45
|
+
fail(`unknown verb '${verb}' (homespun ingest <list|rotate|signing-secret>)`, "invalid_args");
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
// ---------------------------------------------------------------------------
|
|
@@ -83,3 +85,79 @@ async function runRotate(args) {
|
|
|
83
85
|
failFromError(e);
|
|
84
86
|
}
|
|
85
87
|
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// signing-secret set|clear
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
//
|
|
92
|
+
// The SIGNING secret is separate from the URL secret above (opt-in webhook
|
|
93
|
+
// signature verification, issue #935, shipped dark: nothing verifies a
|
|
94
|
+
// signature yet). `set` without --secret MINTS one and prints it ONCE (the
|
|
95
|
+
// GitHub path); `set --secret <val>` stores a provider-generated value verbatim
|
|
96
|
+
// (the Stripe path) and never echoes it; `clear` removes it.
|
|
97
|
+
async function runSigningSecret(args) {
|
|
98
|
+
const action = args.positionals[0];
|
|
99
|
+
const sub = {
|
|
100
|
+
positionals: args.positionals.slice(1),
|
|
101
|
+
flags: args.flags,
|
|
102
|
+
bools: args.bools,
|
|
103
|
+
...(args.danglingValueFlags !== undefined
|
|
104
|
+
? { danglingValueFlags: args.danglingValueFlags }
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
107
|
+
switch (action) {
|
|
108
|
+
case "set":
|
|
109
|
+
return runSigningSecretSet(sub);
|
|
110
|
+
case "clear":
|
|
111
|
+
return runSigningSecretClear(sub);
|
|
112
|
+
default:
|
|
113
|
+
fail("usage: homespun ingest signing-secret <set|clear> --app <idOrSlug> --name <hookName>", "invalid_args");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function runSigningSecretSet(args) {
|
|
117
|
+
assertKnownFlags(args, ...specFor("ingest", "signing-secret"));
|
|
118
|
+
const appArg = args.flags.get("app");
|
|
119
|
+
const name = args.flags.get("name");
|
|
120
|
+
if (!appArg || !name) {
|
|
121
|
+
fail("usage: homespun ingest signing-secret set --app <idOrSlug> --name <hookName> [--secret <value>] [--grace-seconds <n>]", "invalid_args");
|
|
122
|
+
}
|
|
123
|
+
const secret = args.flags.get("secret");
|
|
124
|
+
const graceRaw = args.flags.get("grace-seconds");
|
|
125
|
+
let graceSeconds;
|
|
126
|
+
if (graceRaw !== undefined) {
|
|
127
|
+
graceSeconds = Number(graceRaw);
|
|
128
|
+
if (!Number.isFinite(graceSeconds)) {
|
|
129
|
+
fail("--grace-seconds must be a number", "invalid_args");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const client = makeClient(args);
|
|
133
|
+
const appId = await resolveAppId(client, appArg);
|
|
134
|
+
try {
|
|
135
|
+
const res = await client.setIngestSigningSecret(appId, name, {
|
|
136
|
+
...(secret !== undefined ? { secret } : {}),
|
|
137
|
+
...(graceSeconds !== undefined ? { graceSeconds } : {}),
|
|
138
|
+
});
|
|
139
|
+
printJson(res);
|
|
140
|
+
if (res.secret !== undefined) {
|
|
141
|
+
process.stderr.write("Store this signing secret now: it will not be shown again. Paste it into the provider's webhook settings.\n");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
failFromError(e);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function runSigningSecretClear(args) {
|
|
149
|
+
assertKnownFlags(args, ...specFor("ingest", "signing-secret"));
|
|
150
|
+
const appArg = args.flags.get("app");
|
|
151
|
+
const name = args.flags.get("name");
|
|
152
|
+
if (!appArg || !name) {
|
|
153
|
+
fail("usage: homespun ingest signing-secret clear --app <idOrSlug> --name <hookName>", "invalid_args");
|
|
154
|
+
}
|
|
155
|
+
const client = makeClient(args);
|
|
156
|
+
const appId = await resolveAppId(client, appArg);
|
|
157
|
+
try {
|
|
158
|
+
printJson(await client.clearIngestSigningSecret(appId, name));
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
failFromError(e);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/help-catalog.js
CHANGED
|
@@ -129,10 +129,15 @@ const DATA = {
|
|
|
129
129
|
tagline: "collection row CRUD for an app",
|
|
130
130
|
group: "app",
|
|
131
131
|
rootSummary: "Collection row CRUD for an app: list, get, upsert, update, delete, purge, import.",
|
|
132
|
+
// The data parser is verb-LAST: `homespun data <app> <collection> <verb>`
|
|
133
|
+
// (see commands/data.ts, which reads the verb from positionals[2]). Every
|
|
134
|
+
// other noun is verb-first. Each verb below carries only the positionals that
|
|
135
|
+
// FOLLOW the verb; <app> <collection> come before it, via commonPositionals.
|
|
136
|
+
verbLast: true,
|
|
137
|
+
commonPositionals: "<app> <collection>",
|
|
132
138
|
verbs: [
|
|
133
139
|
{
|
|
134
140
|
verb: "list",
|
|
135
|
-
positionals: "<app> <collection>",
|
|
136
141
|
summary: "Lists rows in a collection.",
|
|
137
142
|
flags: [
|
|
138
143
|
{
|
|
@@ -155,12 +160,11 @@ const DATA = {
|
|
|
155
160
|
},
|
|
156
161
|
{
|
|
157
162
|
verb: "get",
|
|
158
|
-
positionals: "<
|
|
163
|
+
positionals: "<key>",
|
|
159
164
|
summary: "Shows one row by key.",
|
|
160
165
|
},
|
|
161
166
|
{
|
|
162
167
|
verb: "upsert",
|
|
163
|
-
positionals: "<app> <collection>",
|
|
164
168
|
summary: "Creates a row, or ensures one exists at a key or unique field.",
|
|
165
169
|
flags: [
|
|
166
170
|
{
|
|
@@ -182,7 +186,7 @@ const DATA = {
|
|
|
182
186
|
},
|
|
183
187
|
{
|
|
184
188
|
verb: "update",
|
|
185
|
-
positionals: "<
|
|
189
|
+
positionals: "<key>",
|
|
186
190
|
summary: "Replaces an existing row's data.",
|
|
187
191
|
flags: [
|
|
188
192
|
{
|
|
@@ -199,7 +203,7 @@ const DATA = {
|
|
|
199
203
|
},
|
|
200
204
|
{
|
|
201
205
|
verb: "delete",
|
|
202
|
-
positionals: "<
|
|
206
|
+
positionals: "<key>",
|
|
203
207
|
summary: "Deletes one row by key.",
|
|
204
208
|
flags: [
|
|
205
209
|
{
|
|
@@ -212,7 +216,6 @@ const DATA = {
|
|
|
212
216
|
},
|
|
213
217
|
{
|
|
214
218
|
verb: "purge",
|
|
215
|
-
positionals: "<app> <collection>",
|
|
216
219
|
summary: "Removes one row even from an append-only collection.",
|
|
217
220
|
flags: [
|
|
218
221
|
{
|
|
@@ -225,7 +228,6 @@ const DATA = {
|
|
|
225
228
|
},
|
|
226
229
|
{
|
|
227
230
|
verb: "import",
|
|
228
|
-
positionals: "<app> <collection>",
|
|
229
231
|
summary: "Bulk-writes rows from a file in chunks via the batch API.",
|
|
230
232
|
flags: [
|
|
231
233
|
{
|
|
@@ -370,7 +372,7 @@ const INGEST = {
|
|
|
370
372
|
noun: "ingest",
|
|
371
373
|
tagline: "inbound catch-hook read surface",
|
|
372
374
|
group: "app",
|
|
373
|
-
rootSummary: "Inbound catch-hook management: list, rotate. Read back an app's declared inbound hooks with their full secret URL so you can tell the owner where an external system posts,
|
|
375
|
+
rootSummary: "Inbound catch-hook management: list, rotate, signing-secret. Read back an app's declared inbound hooks with their full secret URL so you can tell the owner where an external system posts, rotate a leaked URL secret, or manage a hook's opt-in signing secret. Hooks themselves are declared in the app manifest (x-homespun-manifest.ingest).",
|
|
374
376
|
verbs: [
|
|
375
377
|
{
|
|
376
378
|
verb: "list",
|
|
@@ -399,11 +401,39 @@ const INGEST = {
|
|
|
399
401
|
},
|
|
400
402
|
],
|
|
401
403
|
},
|
|
404
|
+
{
|
|
405
|
+
verb: "signing-secret",
|
|
406
|
+
positionals: "<set|clear>",
|
|
407
|
+
summary: "Sets, rotates, or clears a hook's opt-in signing secret (webhook signature verification).",
|
|
408
|
+
flags: [
|
|
409
|
+
{
|
|
410
|
+
name: "app",
|
|
411
|
+
value: "<idOrSlug>",
|
|
412
|
+
description: "App the hook belongs to (required)",
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
name: "name",
|
|
416
|
+
value: "<hookName>",
|
|
417
|
+
description: "Name of the manifest ingest hook (required)",
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
name: "secret",
|
|
421
|
+
value: "<value>",
|
|
422
|
+
description: "set only: a provider-generated signing secret to store verbatim; omit to have the relay mint one (shown once)",
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "grace-seconds",
|
|
426
|
+
value: "<n>",
|
|
427
|
+
description: "set only: how long the previous secret stays valid on a rotation (default 3600, max 86400)",
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
},
|
|
402
431
|
],
|
|
403
432
|
notes: [
|
|
404
433
|
"--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
|
|
405
434
|
"list returns { hooks: [{ name, url, collection, mode, wake, handshake, disabledAt, createdAt, deliveries: { accepted, failed, dropped_duplicate } }] }. The url is the full secret POST URL an external system posts JSON to; hand it to the app owner to paste into Stripe, Zapier, Make, Home Assistant, or any system that can POST a webhook. A hook whose rule left the manifest has disabledAt set and null rule fields.",
|
|
406
435
|
"rotate mints a fresh secret for the named hook and returns { hook: { name, url } } with the NEW url once. The old url stops working immediately; no redeploy is needed. Use it when a url leaks.",
|
|
436
|
+
"signing-secret manages a hook's OPT-IN signing secret, distinct from the URL secret above: it is what a provider (GitHub, Stripe, ...) HMACs the request body with. `set` without --secret mints one and returns { secret, fingerprint, setAt } with the value shown ONCE; `set --secret <value>` stores a provider-generated value verbatim and returns { fingerprint, setAt } without echoing it; `clear` removes it. A rotation keeps the previous secret valid for --grace-seconds so deliveries verify while you update the provider. A hook that declares `verify` in its manifest rule (GitHub scheme in v1) requires a valid signature over the raw body and stays fail-closed (401) until this secret is set; the fingerprint (a plaintext-derived id) lets you confirm which secret is set without the relay ever showing it.",
|
|
407
437
|
"Hooks are declared in the app manifest (x-homespun-manifest.ingest) and materialized at deploy, so there is no create or delete verb here: add or remove a hook by editing the manifest and redeploying.",
|
|
408
438
|
],
|
|
409
439
|
};
|
|
@@ -938,13 +968,28 @@ export function specFor(noun, verb = "") {
|
|
|
938
968
|
["homespun", noun, verb].filter(Boolean).join(" "),
|
|
939
969
|
];
|
|
940
970
|
}
|
|
941
|
-
/**
|
|
971
|
+
/**
|
|
972
|
+
* "homespun apps watch <app> [--since <cursor>] [--once]", and for a verbLast
|
|
973
|
+
* noun "homespun data <app> <collection> get <key>".
|
|
974
|
+
*/
|
|
942
975
|
export function usageLine(noun, v) {
|
|
943
976
|
const parts = ["homespun", noun.noun];
|
|
944
|
-
if (
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
977
|
+
if (noun.verbLast) {
|
|
978
|
+
// Common positionals precede the verb, per the parser's grammar; the verb's
|
|
979
|
+
// own positionals (if any) follow it.
|
|
980
|
+
if (noun.commonPositionals)
|
|
981
|
+
parts.push(noun.commonPositionals);
|
|
982
|
+
if (v.verb)
|
|
983
|
+
parts.push(v.verb);
|
|
984
|
+
if (v.positionals)
|
|
985
|
+
parts.push(v.positionals);
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
if (v.verb)
|
|
989
|
+
parts.push(v.verb);
|
|
990
|
+
if (v.positionals)
|
|
991
|
+
parts.push(v.positionals);
|
|
992
|
+
}
|
|
948
993
|
for (const f of v.flags ?? []) {
|
|
949
994
|
parts.push(f.value ? `[--${f.name} ${f.value}]` : `[--${f.name}]`);
|
|
950
995
|
}
|
|
@@ -1070,7 +1115,15 @@ export function renderNounHelp(noun) {
|
|
|
1070
1115
|
]);
|
|
1071
1116
|
const col = Math.min(34, all.reduce((w, f) => Math.max(w, spelled(f).length), 0) + 2);
|
|
1072
1117
|
for (const v of flagged) {
|
|
1073
|
-
|
|
1118
|
+
// Verb-last nouns (data) must show <app> <collection> before the verb
|
|
1119
|
+
// here too, matching the Usage block and the parser. A bare
|
|
1120
|
+
// "homespun data list" header is the exact verb-first shape #907 fixed.
|
|
1121
|
+
const header = noun.verbLast
|
|
1122
|
+
? ["homespun", noun.noun, noun.commonPositionals, v.verb]
|
|
1123
|
+
.filter(Boolean)
|
|
1124
|
+
.join(" ")
|
|
1125
|
+
: ["homespun", noun.noun, v.verb].filter(Boolean).join(" ");
|
|
1126
|
+
out.push(` ${header}`);
|
|
1074
1127
|
for (const f of [...(v.flags ?? []), ...(v.bools ?? [])]) {
|
|
1075
1128
|
const left = ` ${spelled(f)}`;
|
|
1076
1129
|
const pad = Math.max(1, col + 4 - left.length);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homespunapps/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Command-line client for the Homespun relay: create apps, inspect state, send and watch events.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"test:unit": "vitest run"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@homespunapps/core": "^1.
|
|
39
|
+
"@homespunapps/core": "^1.5.1",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|