@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.
@@ -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
+ }
@@ -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: "<app> <collection> <key>",
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: "<app> <collection> <key>",
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: "<app> <collection> <key>",
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, or rotate a leaked secret. Hooks themselves are declared in the app manifest (x-homespun-manifest.ingest).",
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
- /** "homespun apps watch <app> [--since <cursor>] [--once]" */
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 (v.verb)
945
- parts.push(v.verb);
946
- if (v.positionals)
947
- parts.push(v.positionals);
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
- out.push(` ${["homespun", noun.noun, v.verb].filter(Boolean).join(" ")}`);
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.4.3",
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.4.3",
39
+ "@homespunapps/core": "^1.5.1",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {