@homespunapps/cli 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/argv.js CHANGED
@@ -54,6 +54,11 @@ export const BOOLEAN_FLAGS = new Set([
54
54
  "all",
55
55
  // `homespun members set-role --clear-role`: drop a custom role back to plain member.
56
56
  "clear-role",
57
+ // `homespun template publish --attest-example-only`: attest the captured template
58
+ // and its seed rows carry no real personal data (marketplace PR 10).
59
+ "attest-example-only",
60
+ // `homespun review respond --clear`: clear a publisher response (sends null).
61
+ "clear",
57
62
  ]);
58
63
  /**
59
64
  * Parse argv tokens. `booleanFlags` lists flags that never consume a value
@@ -1,7 +1,9 @@
1
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).
2
+ // PR 3). List the app's declared hooks with their full secret URL, rotate a
3
+ // hook's secret, manage its opt-in signing secret, and backfill historical
4
+ // payloads through a hook's mapping (issue #966). Every verb targets an app via a
5
+ // required `--app <idOrSlug>`, resolved the same way `homespun members`/`homespun
6
+ // data` do (resolveAppId).
5
7
  //
6
8
  // This is the smallest surface an agent needs during app setup: after deploying
7
9
  // a manifest that declares an `ingest` hook, the agent runs `homespun ingest list`
@@ -12,6 +14,7 @@
12
14
  // Auth on the relay side is owner-or-agent (the owning agent's API key OR the
13
15
  // owner human's login cookie); this CLI always authenticates as the agent, so
14
16
  // both verbs work for an app the calling agent's owning human owns.
17
+ import { readFileSync } from "node:fs";
15
18
  import { assertKnownFlags } from "../argv.js";
16
19
  import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
17
20
  import { makeClient } from "../config.js";
@@ -24,7 +27,7 @@ export async function runIngest(args) {
24
27
  return;
25
28
  }
26
29
  if (verb === undefined) {
27
- fail("missing verb (homespun ingest <list|rotate|signing-secret>)", "invalid_args");
30
+ fail("missing verb (homespun ingest <list|rotate|signing-secret|backfill>)", "invalid_args");
28
31
  }
29
32
  const sub = {
30
33
  positionals: args.positionals.slice(1),
@@ -41,8 +44,10 @@ export async function runIngest(args) {
41
44
  return runRotate(sub);
42
45
  case "signing-secret":
43
46
  return runSigningSecret(sub);
47
+ case "backfill":
48
+ return runBackfill(sub);
44
49
  default:
45
- fail(`unknown verb '${verb}' (homespun ingest <list|rotate|signing-secret>)`, "invalid_args");
50
+ fail(`unknown verb '${verb}' (homespun ingest <list|rotate|signing-secret|backfill>)`, "invalid_args");
46
51
  }
47
52
  }
48
53
  // ---------------------------------------------------------------------------
@@ -161,3 +166,118 @@ async function runSigningSecretClear(args) {
161
166
  failFromError(e);
162
167
  }
163
168
  }
169
+ // ---------------------------------------------------------------------------
170
+ // backfill
171
+ // ---------------------------------------------------------------------------
172
+ //
173
+ // Bulk-load historical raw provider bodies through a hook's mapping (issue
174
+ // #966), so a seeded app's rows are byte-identical to live deliveries. Reads a
175
+ // JSON-array OR NDJSON file of raw payloads (each a whole provider body, any JSON
176
+ // value, not necessarily an object), chunks it into <= --chunk bodies per call,
177
+ // and prints aggregate { accepted, dropped_duplicate, failed } counts. Re-running
178
+ // the same file is idempotent for a body-path dedupeKey (the relay dedupes).
179
+ // The relay's INGEST_BACKFILL_MAX_BODIES default; a larger --chunk is rejected
180
+ // server-side (400), so the CLI keeps the conservative default and lets the
181
+ // chunker split the whole file across as many calls as needed.
182
+ const DEFAULT_BACKFILL_CHUNK = 500;
183
+ /**
184
+ * Parse a backfill file into an ordered list of raw provider bodies. Accepts
185
+ * either a single JSON array (the whole file parses as an array) or NDJSON (one
186
+ * JSON value per non-blank line). Unlike a row import, an entry may be ANY JSON
187
+ * value (object, array, string, number): it is a raw provider payload, mapped
188
+ * server-side. A malformed line reports its 1-based line number.
189
+ */
190
+ function parseBackfillBodies(raw) {
191
+ const trimmed = raw.trim();
192
+ if (trimmed.length === 0)
193
+ return [];
194
+ // JSON array form: the entire file is one array literal.
195
+ if (trimmed.startsWith("[")) {
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(trimmed);
199
+ }
200
+ catch (e) {
201
+ fail(`--file is not valid JSON (${e instanceof Error ? e.message : String(e)})`, "invalid_args");
202
+ }
203
+ if (!Array.isArray(parsed)) {
204
+ fail("--file top-level JSON must be an array of payloads", "invalid_args");
205
+ }
206
+ return parsed;
207
+ }
208
+ // NDJSON form: one JSON value per non-blank line.
209
+ const out = [];
210
+ const lines = raw.split("\n");
211
+ for (let i = 0; i < lines.length; i++) {
212
+ const line = lines[i].trim();
213
+ if (line.length === 0)
214
+ continue;
215
+ try {
216
+ out.push(JSON.parse(line));
217
+ }
218
+ catch (e) {
219
+ fail(`--file line ${i + 1} is not valid JSON (${e instanceof Error ? e.message : String(e)})`, "invalid_args");
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+ async function runBackfill(args) {
225
+ assertKnownFlags(args, ...specFor("ingest", "backfill"));
226
+ const appArg = args.flags.get("app");
227
+ const name = args.flags.get("name");
228
+ const file = args.flags.get("file");
229
+ if (!appArg || !name || !file) {
230
+ fail("usage: homespun ingest backfill --app <idOrSlug> --name <hookName> --file <path> [--chunk <n>]", "invalid_args");
231
+ }
232
+ let raw;
233
+ try {
234
+ raw = readFileSync(file, "utf8");
235
+ }
236
+ catch (e) {
237
+ fail(`cannot read --file '${file}': ${e instanceof Error ? e.message : String(e)}`, "invalid_args");
238
+ }
239
+ const bodies = parseBackfillBodies(raw);
240
+ if (bodies.length === 0) {
241
+ fail("--file contained no payloads to backfill", "invalid_args");
242
+ }
243
+ let chunkSize = DEFAULT_BACKFILL_CHUNK;
244
+ const chunkRaw = args.flags.get("chunk");
245
+ if (chunkRaw !== undefined) {
246
+ chunkSize = Number(chunkRaw);
247
+ if (!Number.isInteger(chunkSize) || chunkSize < 1) {
248
+ fail("--chunk must be a positive integer", "invalid_args");
249
+ }
250
+ }
251
+ const client = makeClient(args);
252
+ // resolveAppId ONCE for the whole backfill (not per chunk): the app id is
253
+ // stable for the process, so one lookup drives every call.
254
+ const appId = await resolveAppId(client, appArg);
255
+ const total = bodies.length;
256
+ const chunkCount = Math.ceil(total / chunkSize);
257
+ let accepted = 0;
258
+ let droppedDuplicate = 0;
259
+ let failed = 0;
260
+ try {
261
+ for (let start = 0, chunkNo = 1; start < total; start += chunkSize, chunkNo++) {
262
+ const chunk = bodies.slice(start, start + chunkSize);
263
+ const res = await client.backfillIngestHook(appId, name, chunk);
264
+ accepted += res.accepted;
265
+ droppedDuplicate += res.dropped_duplicate;
266
+ failed += res.failed;
267
+ // Human-readable progress on stderr so stdout stays a single JSON summary.
268
+ process.stderr.write(`backfilled ${accepted + droppedDuplicate + failed}/${total} (chunk ${chunkNo}/${chunkCount}, ${accepted} accepted, ${droppedDuplicate} dropped_duplicate, ${failed} failed)\n`);
269
+ }
270
+ }
271
+ catch (e) {
272
+ failFromError(e);
273
+ }
274
+ printJson({
275
+ app: appId,
276
+ hook: name,
277
+ total,
278
+ accepted,
279
+ dropped_duplicate: droppedDuplicate,
280
+ failed,
281
+ chunks: chunkCount,
282
+ });
283
+ }
@@ -0,0 +1,115 @@
1
+ // `homespun publisher` (issue #890) community publisher identity: claim the
2
+ // one permanent handle, show the caller's own profile, update the mutable
3
+ // profile fields, and (operator) set another publisher's trust level. Every
4
+ // verb acts AS the calling agent's owning human via the relay /v1/publisher
5
+ // routes; set-trust is operator-gated server-side.
6
+ import { assertKnownFlags } from "../argv.js";
7
+ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
8
+ import { makeClient } from "../config.js";
9
+ import { fail, failFromError, printJson } from "../output.js";
10
+ export async function runPublisher(args) {
11
+ const verb = args.positionals[0];
12
+ if ((verb === undefined || verb === "help") && args.bools.has("help")) {
13
+ process.stdout.write(renderNounHelp(nounSpec("publisher")) + "\n");
14
+ return;
15
+ }
16
+ if (verb === undefined) {
17
+ fail("missing verb: homespun publisher <claim|show|update|set-trust>", "invalid_args");
18
+ }
19
+ const sub = {
20
+ positionals: args.positionals.slice(1),
21
+ flags: args.flags,
22
+ bools: args.bools,
23
+ ...(args.danglingValueFlags !== undefined
24
+ ? { danglingValueFlags: args.danglingValueFlags }
25
+ : {}),
26
+ };
27
+ switch (verb) {
28
+ case "claim":
29
+ return runClaim(sub);
30
+ case "show":
31
+ return runShow(sub);
32
+ case "update":
33
+ return runUpdate(sub);
34
+ case "set-trust":
35
+ return runSetTrust(sub);
36
+ default:
37
+ fail(`unknown verb '${verb}' (homespun publisher <claim|show|update|set-trust>)`, "invalid_args");
38
+ }
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // claim
42
+ // ---------------------------------------------------------------------------
43
+ async function runClaim(args) {
44
+ assertKnownFlags(args, ...specFor("publisher", "claim"));
45
+ const handle = args.positionals[0];
46
+ if (!handle) {
47
+ fail("usage: homespun publisher claim <handle>", "invalid_args");
48
+ }
49
+ const client = makeClient(args);
50
+ try {
51
+ printJson(await client.claimPublisherHandle(handle));
52
+ }
53
+ catch (e) {
54
+ failFromError(e);
55
+ }
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // show
59
+ // ---------------------------------------------------------------------------
60
+ async function runShow(args) {
61
+ assertKnownFlags(args, ...specFor("publisher", "show"));
62
+ const client = makeClient(args);
63
+ try {
64
+ printJson(await client.getPublisher());
65
+ }
66
+ catch (e) {
67
+ failFromError(e);
68
+ }
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // update
72
+ // ---------------------------------------------------------------------------
73
+ async function runUpdate(args) {
74
+ assertKnownFlags(args, ...specFor("publisher", "update"));
75
+ const displayName = args.flags.get("display-name");
76
+ const bio = args.flags.get("bio");
77
+ // The SDK field is `url`, but the CLI flag is --website: --url is the global
78
+ // relay-target override, so the publisher's profile url gets its own name.
79
+ const website = args.flags.get("website");
80
+ if (displayName === undefined && bio === undefined && website === undefined) {
81
+ fail("at least one of --display-name, --bio, or --website is required", "invalid_args");
82
+ }
83
+ const client = makeClient(args);
84
+ try {
85
+ printJson(await client.updatePublisher({
86
+ ...(displayName !== undefined ? { displayName } : {}),
87
+ ...(bio !== undefined ? { bio } : {}),
88
+ ...(website !== undefined ? { url: website } : {}),
89
+ }));
90
+ }
91
+ catch (e) {
92
+ failFromError(e);
93
+ }
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // set-trust
97
+ // ---------------------------------------------------------------------------
98
+ async function runSetTrust(args) {
99
+ assertKnownFlags(args, ...specFor("publisher", "set-trust"));
100
+ const handle = args.positionals[0];
101
+ const level = args.positionals[1];
102
+ if (!handle || !level) {
103
+ fail("usage: homespun publisher set-trust <handle> <new|established>", "invalid_args");
104
+ }
105
+ if (level !== "new" && level !== "established") {
106
+ fail('trust level must be "new" or "established"', "invalid_args");
107
+ }
108
+ const client = makeClient(args);
109
+ try {
110
+ printJson(await client.setPublisherTrustLevel(handle, level));
111
+ }
112
+ catch (e) {
113
+ failFromError(e);
114
+ }
115
+ }
@@ -0,0 +1,155 @@
1
+ // `homespun review` (issue #890) community marketplace reviews: create a star
2
+ // review for a template the caller installed, respond to a review as the
3
+ // publisher, report a review for operator attention, and (operator) remove or
4
+ // unhold a review. create/respond/report act AS the calling agent's owning
5
+ // human; remove/unhold are operator-gated server-side. A template <ref> is a
6
+ // namespaced <handle>/<slug>, passed straight to the SDK.
7
+ import { assertKnownFlags } from "../argv.js";
8
+ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
9
+ import { makeClient } from "../config.js";
10
+ import { fail, failFromError, printJson } from "../output.js";
11
+ export async function runReview(args) {
12
+ const verb = args.positionals[0];
13
+ if ((verb === undefined || verb === "help") && args.bools.has("help")) {
14
+ process.stdout.write(renderNounHelp(nounSpec("review")) + "\n");
15
+ return;
16
+ }
17
+ if (verb === undefined) {
18
+ fail("missing verb: homespun review <create|respond|report|remove|unhold>", "invalid_args");
19
+ }
20
+ const sub = {
21
+ positionals: args.positionals.slice(1),
22
+ flags: args.flags,
23
+ bools: args.bools,
24
+ ...(args.danglingValueFlags !== undefined
25
+ ? { danglingValueFlags: args.danglingValueFlags }
26
+ : {}),
27
+ };
28
+ switch (verb) {
29
+ case "create":
30
+ return runCreate(sub);
31
+ case "respond":
32
+ return runRespond(sub);
33
+ case "report":
34
+ return runReport(sub);
35
+ case "remove":
36
+ return runRemove(sub);
37
+ case "unhold":
38
+ return runUnhold(sub);
39
+ default:
40
+ fail(`unknown verb '${verb}' (homespun review <create|respond|report|remove|unhold>)`, "invalid_args");
41
+ }
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // create
45
+ // ---------------------------------------------------------------------------
46
+ async function runCreate(args) {
47
+ assertKnownFlags(args, ...specFor("review", "create"));
48
+ const ref = args.positionals[0];
49
+ if (!ref) {
50
+ fail("usage: homespun review create <ref> --stars <1-5> [--body <text>]", "invalid_args");
51
+ }
52
+ const starsRaw = args.flags.get("stars");
53
+ if (starsRaw === undefined) {
54
+ fail("--stars is required", "invalid_args");
55
+ }
56
+ const stars = Number(starsRaw);
57
+ if (!Number.isInteger(stars) || stars < 1 || stars > 5) {
58
+ fail("--stars must be an integer from 1 to 5", "invalid_args");
59
+ }
60
+ const body = args.flags.get("body");
61
+ const client = makeClient(args);
62
+ try {
63
+ printJson(await client.createReview({
64
+ template: ref,
65
+ stars,
66
+ ...(body !== undefined ? { body } : {}),
67
+ }));
68
+ }
69
+ catch (e) {
70
+ failFromError(e);
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // respond
75
+ // ---------------------------------------------------------------------------
76
+ async function runRespond(args) {
77
+ assertKnownFlags(args, ...specFor("review", "respond"));
78
+ const reviewId = args.positionals[0];
79
+ if (!reviewId) {
80
+ fail("usage: homespun review respond <review-id> (--response <text> | --clear)", "invalid_args");
81
+ }
82
+ // Clearing a response is a real instruction, so it gets its own explicit flag
83
+ // rather than being spelled as an omitted or empty --response: an omitted
84
+ // value must never silently wipe a published response.
85
+ const response = args.flags.get("response");
86
+ const clear = args.bools.has("clear");
87
+ if (clear && response !== undefined) {
88
+ fail("--response and --clear are mutually exclusive", "invalid_args");
89
+ }
90
+ if (!clear && response === undefined) {
91
+ fail("one of --response <text> or --clear is required", "invalid_args");
92
+ }
93
+ const client = makeClient(args);
94
+ try {
95
+ printJson(await client.respondToReview(reviewId, clear ? null : response));
96
+ }
97
+ catch (e) {
98
+ failFromError(e);
99
+ }
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // report
103
+ // ---------------------------------------------------------------------------
104
+ async function runReport(args) {
105
+ assertKnownFlags(args, ...specFor("review", "report"));
106
+ const reviewId = args.positionals[0];
107
+ if (!reviewId) {
108
+ fail("usage: homespun review report <review-id> --reason <reason>", "invalid_args");
109
+ }
110
+ const reason = args.flags.get("reason");
111
+ if (!reason) {
112
+ fail("--reason is required", "invalid_args");
113
+ }
114
+ const client = makeClient(args);
115
+ try {
116
+ printJson(await client.reportReview(reviewId, reason));
117
+ }
118
+ catch (e) {
119
+ failFromError(e);
120
+ }
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // remove
124
+ // ---------------------------------------------------------------------------
125
+ async function runRemove(args) {
126
+ assertKnownFlags(args, ...specFor("review", "remove"));
127
+ const reviewId = args.positionals[0];
128
+ if (!reviewId) {
129
+ fail("usage: homespun review remove <review-id>", "invalid_args");
130
+ }
131
+ const client = makeClient(args);
132
+ try {
133
+ printJson(await client.removeReview(reviewId));
134
+ }
135
+ catch (e) {
136
+ failFromError(e);
137
+ }
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // unhold
141
+ // ---------------------------------------------------------------------------
142
+ async function runUnhold(args) {
143
+ assertKnownFlags(args, ...specFor("review", "unhold"));
144
+ const reviewId = args.positionals[0];
145
+ if (!reviewId) {
146
+ fail("usage: homespun review unhold <review-id>", "invalid_args");
147
+ }
148
+ const client = makeClient(args);
149
+ try {
150
+ printJson(await client.unholdReview(reviewId));
151
+ }
152
+ catch (e) {
153
+ failFromError(e);
154
+ }
155
+ }
@@ -0,0 +1,226 @@
1
+ // `homespun template` (issue #890) community marketplace templates: publish an
2
+ // owned app as a pending template, read a template's install-time config
3
+ // contract, install a template for the caller, and (operator) list/show/approve/
4
+ // reject pending submissions. Publish/config-contract/install act AS the calling
5
+ // agent's owning human; list-pending/show/approve/reject are operator-gated
6
+ // server-side. A template <ref> is a namespaced <handle>/<slug> or a snapshot id,
7
+ // passed straight to the SDK.
8
+ import { assertKnownFlags } from "../argv.js";
9
+ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
10
+ import { makeClient } from "../config.js";
11
+ import { fail, failFromError, printJson } from "../output.js";
12
+ import { resolveJson } from "../input.js";
13
+ import { resolveAppId } from "../resolve-app.js";
14
+ export async function runTemplate(args) {
15
+ const verb = args.positionals[0];
16
+ if ((verb === undefined || verb === "help") && args.bools.has("help")) {
17
+ process.stdout.write(renderNounHelp(nounSpec("template")) + "\n");
18
+ return;
19
+ }
20
+ if (verb === undefined) {
21
+ fail("missing verb: homespun template <publish|config-contract|install|list-pending|show|approve|reject>", "invalid_args");
22
+ }
23
+ const sub = {
24
+ positionals: args.positionals.slice(1),
25
+ flags: args.flags,
26
+ bools: args.bools,
27
+ ...(args.danglingValueFlags !== undefined
28
+ ? { danglingValueFlags: args.danglingValueFlags }
29
+ : {}),
30
+ };
31
+ switch (verb) {
32
+ case "publish":
33
+ return runPublish(sub);
34
+ case "config-contract":
35
+ return runConfigContract(sub);
36
+ case "install":
37
+ return runInstall(sub);
38
+ case "list-pending":
39
+ return runListPending(sub);
40
+ case "show":
41
+ return runShow(sub);
42
+ case "approve":
43
+ return runApprove(sub);
44
+ case "reject":
45
+ return runReject(sub);
46
+ default:
47
+ fail(`unknown verb '${verb}' (homespun template <publish|config-contract|install|list-pending|show|approve|reject>)`, "invalid_args");
48
+ }
49
+ }
50
+ function parseIntFlag(args, name, bounds = {}) {
51
+ const raw = args.flags.get(name);
52
+ if (raw === undefined)
53
+ return undefined;
54
+ const n = Number(raw);
55
+ if (!Number.isInteger(n))
56
+ fail(`--${name} must be an integer`, "invalid_args");
57
+ if (bounds.min !== undefined && n < bounds.min) {
58
+ fail(`--${name} must be >= ${bounds.min}`, "invalid_args");
59
+ }
60
+ if (bounds.max !== undefined && n > bounds.max) {
61
+ fail(`--${name} must be <= ${bounds.max}`, "invalid_args");
62
+ }
63
+ return n;
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // publish
67
+ // ---------------------------------------------------------------------------
68
+ async function runPublish(args) {
69
+ assertKnownFlags(args, ...specFor("template", "publish"));
70
+ const appArg = args.positionals[0];
71
+ if (!appArg) {
72
+ fail("usage: homespun template publish <app>", "invalid_args");
73
+ }
74
+ const title = args.flags.get("title");
75
+ const description = args.flags.get("description");
76
+ const longDescription = args.flags.get("long-description");
77
+ const category = args.flags.get("category");
78
+ const slug = args.flags.get("slug");
79
+ const version = args.flags.get("version");
80
+ const changelogNote = args.flags.get("changelog-note");
81
+ const attestExampleOnly = args.bools.has("attest-example-only");
82
+ const tagsRaw = args.flags.get("tags");
83
+ const tags = tagsRaw !== undefined
84
+ ? resolveJson(tagsRaw, "--tags")
85
+ : undefined;
86
+ const setupStepsRaw = args.flags.get("setup-steps");
87
+ const setupSteps = setupStepsRaw !== undefined
88
+ ? resolveJson(setupStepsRaw, "--setup-steps")
89
+ : undefined;
90
+ const client = makeClient(args);
91
+ const appId = await resolveAppId(client, appArg);
92
+ try {
93
+ printJson(await client.publishCommunityTemplate({
94
+ appId,
95
+ ...(title !== undefined ? { title } : {}),
96
+ ...(description !== undefined ? { description } : {}),
97
+ ...(longDescription !== undefined ? { longDescription } : {}),
98
+ ...(category !== undefined ? { category } : {}),
99
+ ...(tags !== undefined ? { tags } : {}),
100
+ ...(slug !== undefined ? { slug } : {}),
101
+ ...(version !== undefined ? { version } : {}),
102
+ ...(changelogNote !== undefined ? { changelogNote } : {}),
103
+ ...(setupSteps !== undefined ? { setupSteps } : {}),
104
+ ...(attestExampleOnly ? { attestExampleOnly: true } : {}),
105
+ }));
106
+ }
107
+ catch (e) {
108
+ failFromError(e);
109
+ }
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // config-contract
113
+ // ---------------------------------------------------------------------------
114
+ async function runConfigContract(args) {
115
+ assertKnownFlags(args, ...specFor("template", "config-contract"));
116
+ const ref = args.positionals[0];
117
+ if (!ref) {
118
+ fail("usage: homespun template config-contract <ref>", "invalid_args");
119
+ }
120
+ const client = makeClient(args);
121
+ try {
122
+ printJson(await client.getCommunityConfigContract(ref));
123
+ }
124
+ catch (e) {
125
+ failFromError(e);
126
+ }
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // install
130
+ // ---------------------------------------------------------------------------
131
+ async function runInstall(args) {
132
+ assertKnownFlags(args, ...specFor("template", "install"));
133
+ const ref = args.positionals[0];
134
+ if (!ref) {
135
+ fail("usage: homespun template install <ref>", "invalid_args");
136
+ }
137
+ const configRaw = args.flags.get("config");
138
+ const config = configRaw !== undefined
139
+ ? resolveJson(configRaw, "--config")
140
+ : undefined;
141
+ const client = makeClient(args);
142
+ try {
143
+ printJson(await client.installCommunityTemplate(ref, config));
144
+ }
145
+ catch (e) {
146
+ failFromError(e);
147
+ }
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // list-pending
151
+ // ---------------------------------------------------------------------------
152
+ async function runListPending(args) {
153
+ assertKnownFlags(args, ...specFor("template", "list-pending"));
154
+ const limit = parseIntFlag(args, "limit", { min: 1 });
155
+ const cursor = args.flags.get("cursor");
156
+ const client = makeClient(args);
157
+ try {
158
+ printJson(await client.listCommunitySubmissions({
159
+ ...(limit !== undefined ? { limit } : {}),
160
+ ...(cursor !== undefined ? { cursor } : {}),
161
+ }));
162
+ }
163
+ catch (e) {
164
+ failFromError(e);
165
+ }
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // show
169
+ // ---------------------------------------------------------------------------
170
+ async function runShow(args) {
171
+ assertKnownFlags(args, ...specFor("template", "show"));
172
+ const snapshotId = args.positionals[0];
173
+ if (!snapshotId) {
174
+ fail("usage: homespun template show <snapshot-id>", "invalid_args");
175
+ }
176
+ const client = makeClient(args);
177
+ try {
178
+ printJson(await client.getCommunitySubmission(snapshotId));
179
+ }
180
+ catch (e) {
181
+ failFromError(e);
182
+ }
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // approve
186
+ // ---------------------------------------------------------------------------
187
+ async function runApprove(args) {
188
+ assertKnownFlags(args, ...specFor("template", "approve"));
189
+ const snapshotId = args.positionals[0];
190
+ if (!snapshotId) {
191
+ fail("usage: homespun template approve <snapshot-id>", "invalid_args");
192
+ }
193
+ const client = makeClient(args);
194
+ try {
195
+ printJson(await client.reviewCommunitySubmission(snapshotId, {
196
+ decision: "approve",
197
+ }));
198
+ }
199
+ catch (e) {
200
+ failFromError(e);
201
+ }
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // reject
205
+ // ---------------------------------------------------------------------------
206
+ async function runReject(args) {
207
+ assertKnownFlags(args, ...specFor("template", "reject"));
208
+ const snapshotId = args.positionals[0];
209
+ if (!snapshotId) {
210
+ fail("usage: homespun template reject <snapshot-id> --note <note>", "invalid_args");
211
+ }
212
+ const note = args.flags.get("note");
213
+ if (!note) {
214
+ fail("--note is required", "invalid_args");
215
+ }
216
+ const client = makeClient(args);
217
+ try {
218
+ printJson(await client.reviewCommunitySubmission(snapshotId, {
219
+ decision: "reject",
220
+ note: note,
221
+ }));
222
+ }
223
+ catch (e) {
224
+ failFromError(e);
225
+ }
226
+ }
@@ -403,7 +403,7 @@ const INGEST = {
403
403
  noun: "ingest",
404
404
  tagline: "inbound catch-hook read surface",
405
405
  group: "app",
406
- 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).",
406
+ rootSummary: "Inbound catch-hook management: list, rotate, signing-secret, backfill. 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, manage a hook's opt-in signing secret, or bulk-load historical payloads through a hook's mapping. Hooks themselves are declared in the app manifest (x-homespun-manifest.ingest).",
407
407
  verbs: [
408
408
  {
409
409
  verb: "list",
@@ -459,6 +459,32 @@ const INGEST = {
459
459
  },
460
460
  ],
461
461
  },
462
+ {
463
+ verb: "backfill",
464
+ summary: "Bulk-loads historical raw provider bodies through a hook's mapping, writing rows identical to live deliveries.",
465
+ flags: [
466
+ {
467
+ name: "app",
468
+ value: "<idOrSlug>",
469
+ description: "App the hook belongs to (required)",
470
+ },
471
+ {
472
+ name: "name",
473
+ value: "<hookName>",
474
+ description: "Name of the manifest ingest hook to backfill into (required)",
475
+ },
476
+ {
477
+ name: "file",
478
+ value: "<path>",
479
+ description: "Path to a JSON-array or NDJSON file of raw provider payloads, one whole body per entry (required)",
480
+ },
481
+ {
482
+ name: "chunk",
483
+ value: "<n>",
484
+ description: "Bodies per relay call (default 500); the file is split across as many calls as needed",
485
+ },
486
+ ],
487
+ },
462
488
  ],
463
489
  notes: [
464
490
  "--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
@@ -466,6 +492,7 @@ const INGEST = {
466
492
  "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.",
467
493
  "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.",
468
494
  "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.",
495
+ "backfill seeds a hook's collection with historical data: it POSTs an array of raw provider bodies to the OWNER endpoint (POST /v1/apps/:id/ingest-hooks/:name/backfill) and runs each through the SAME mapping the live public URL uses, so a backfilled row is byte-identical to a live delivery. It reads a JSON-array or NDJSON --file (each entry is a whole provider payload, any JSON value, not necessarily an object) and chunks it into --chunk bodies per call (default 500). It reuses the receive pipeline, so map/dedupeKey/upsertOn/row-schema validation and the collection quota all apply, but it SKIPS the public-URL brakes (the per-IP rate limit and the per-app hourly cap) and never verifies a signature (you are the authenticated owner). Wake is suppressed, so a large historical load never wakes a dormant app. Dedupe is ON: re-running the same file is idempotent for a body-path dedupeKey; a header:<name> dedupeKey cannot resolve here (no request headers), so it does not dedupe. Prints aggregate { total, accepted, dropped_duplicate, failed } counts.",
469
496
  ],
470
497
  };
471
498
  const GRANTS = {
@@ -955,6 +982,238 @@ const AGENT = {
955
982
  "logout clears the active profile only, leaving the other profiles on disk and unsetting current_profile, while --all deletes the whole config file. It is idempotent, and it touches only LOCAL config: it does NOT revoke the key on the relay, which keeps working until 'homespun key revoke' retires it.",
956
983
  ],
957
984
  };
985
+ const PUBLISHER = {
986
+ noun: "publisher",
987
+ tagline: "community publisher identity",
988
+ group: "other",
989
+ rootSummary: "Your community publisher identity: claim (set the permanent handle), show, update (display name, bio, website), and set-trust (operator only).",
990
+ verbs: [
991
+ {
992
+ verb: "claim",
993
+ positionals: "<handle>",
994
+ summary: "Claims your one permanent publisher handle.",
995
+ },
996
+ {
997
+ verb: "show",
998
+ summary: "Shows your own publisher profile.",
999
+ },
1000
+ {
1001
+ verb: "update",
1002
+ summary: "Updates your publisher display name, bio, or website.",
1003
+ flags: [
1004
+ {
1005
+ name: "display-name",
1006
+ value: "<name>",
1007
+ description: "Public display name",
1008
+ },
1009
+ { name: "bio", value: "<text>", description: "Short publisher bio" },
1010
+ {
1011
+ name: "website",
1012
+ value: "<url>",
1013
+ description: "Public website or profile link",
1014
+ },
1015
+ ],
1016
+ },
1017
+ {
1018
+ verb: "set-trust",
1019
+ positionals: "<handle> <new|established>",
1020
+ summary: "Sets a publisher's trust level (operator only).",
1021
+ },
1022
+ ],
1023
+ notes: [
1024
+ "All verbs act as the calling agent's owning human. The handle is set ONCE by claim and is permanent afterward; claim and update additionally need a verified email server-side.",
1025
+ "update requires at least one of --display-name, --bio, or --website. The website field maps to the profile url; it is spelled --website here because --url is the global relay-target override.",
1026
+ "set-trust promotes a publisher to established (the review fast-track) or back to new. It is operator-gated server-side and rejects a caller who is not the relay operator.",
1027
+ ],
1028
+ };
1029
+ const TEMPLATE = {
1030
+ noun: "template",
1031
+ tagline: "community marketplace templates",
1032
+ group: "other",
1033
+ rootSummary: "Community marketplace templates: publish an owned app, read a template's config-contract, install one, and the operator review queue (list-pending, show, approve, reject).",
1034
+ verbs: [
1035
+ {
1036
+ verb: "publish",
1037
+ positionals: "<app>",
1038
+ summary: "Publishes an owned app as a pending community template.",
1039
+ flags: [
1040
+ {
1041
+ name: "title",
1042
+ value: "<text>",
1043
+ description: "Listing title, defaults to the manifest name",
1044
+ },
1045
+ {
1046
+ name: "description",
1047
+ value: "<text>",
1048
+ description: "Short listing blurb, defaults to the manifest one",
1049
+ },
1050
+ {
1051
+ name: "long-description",
1052
+ value: "<text>",
1053
+ description: "Long-form description shown on the detail page",
1054
+ },
1055
+ {
1056
+ name: "category",
1057
+ value: "<name>",
1058
+ description: "Listing category, validated server-side",
1059
+ },
1060
+ {
1061
+ name: "tags",
1062
+ value: "<path|json>",
1063
+ description: "Curation tags as inline JSON or a path to a JSON file",
1064
+ },
1065
+ {
1066
+ name: "slug",
1067
+ value: "<slug>",
1068
+ description: "Per-publisher slug for a namespaced, versioned line",
1069
+ },
1070
+ {
1071
+ name: "version",
1072
+ value: "<semver>",
1073
+ description: "Semver version, defaults to 1.0.0",
1074
+ },
1075
+ {
1076
+ name: "changelog-note",
1077
+ value: "<text>",
1078
+ description: "Note recorded in this version's changelog",
1079
+ },
1080
+ {
1081
+ name: "setup-steps",
1082
+ value: "<path|json>",
1083
+ description: "Typed setup steps as inline JSON or a JSON file path",
1084
+ },
1085
+ ],
1086
+ bools: [
1087
+ {
1088
+ name: "attest-example-only",
1089
+ description: "Attest the template and seed rows carry no real data",
1090
+ },
1091
+ ],
1092
+ },
1093
+ {
1094
+ verb: "config-contract",
1095
+ positionals: "<ref>",
1096
+ summary: "Shows a template's install-time config contract.",
1097
+ },
1098
+ {
1099
+ verb: "install",
1100
+ positionals: "<ref>",
1101
+ summary: "Installs a community template for your owning human.",
1102
+ flags: [
1103
+ {
1104
+ name: "config",
1105
+ value: "<path|json>",
1106
+ description: "Install config as inline JSON or a path to a JSON file",
1107
+ },
1108
+ ],
1109
+ },
1110
+ {
1111
+ verb: "list-pending",
1112
+ summary: "Lists pending submissions in the review queue (operator only).",
1113
+ flags: [
1114
+ { name: "limit", value: "<n>", description: "Page size" },
1115
+ { name: "cursor", value: "<cursor>", description: "Page cursor" },
1116
+ ],
1117
+ },
1118
+ {
1119
+ verb: "show",
1120
+ positionals: "<snapshot-id>",
1121
+ summary: "Shows one submission's full content (operator only).",
1122
+ },
1123
+ {
1124
+ verb: "approve",
1125
+ positionals: "<snapshot-id>",
1126
+ summary: "Approves a pending submission (operator only).",
1127
+ },
1128
+ {
1129
+ verb: "reject",
1130
+ positionals: "<snapshot-id>",
1131
+ summary: "Rejects a pending submission with a note (operator only).",
1132
+ flags: [
1133
+ {
1134
+ name: "note",
1135
+ value: "<note>",
1136
+ description: "Reason sent to the publisher's app feed (required)",
1137
+ },
1138
+ ],
1139
+ },
1140
+ ],
1141
+ notes: [
1142
+ "A template <ref> is a namespaced <handle>/<slug> or a community snapshot id, passed straight to the relay. publish resolves <app> by id or slug, the same way apps and data do.",
1143
+ "publish, config-contract, and install act as the calling agent's owning human. Install always creates a new owned app and returns its id, slug, and url; use config-contract first to discover what config an install needs.",
1144
+ "list-pending, show, approve, and reject drive the operator review queue and are operator-gated server-side. reject requires --note, which is delivered to the publisher's app feed.",
1145
+ ],
1146
+ };
1147
+ const REVIEW = {
1148
+ noun: "review",
1149
+ tagline: "community template reviews",
1150
+ group: "other",
1151
+ rootSummary: "Community template reviews: create a star review for a template you installed, respond as the publisher, report a review, and remove or unhold one (operator only).",
1152
+ verbs: [
1153
+ {
1154
+ verb: "create",
1155
+ positionals: "<ref>",
1156
+ summary: "Creates a star review for a template you installed.",
1157
+ flags: [
1158
+ {
1159
+ name: "stars",
1160
+ value: "<1-5>",
1161
+ description: "Star rating, an integer from 1 to 5 (required)",
1162
+ },
1163
+ {
1164
+ name: "body",
1165
+ value: "<text>",
1166
+ description: "Optional written review body",
1167
+ },
1168
+ ],
1169
+ },
1170
+ {
1171
+ verb: "respond",
1172
+ positionals: "<review-id>",
1173
+ summary: "Responds to a review as the publisher.",
1174
+ flags: [
1175
+ {
1176
+ name: "response",
1177
+ value: "<text>",
1178
+ description: "Publisher response text",
1179
+ },
1180
+ ],
1181
+ bools: [
1182
+ {
1183
+ name: "clear",
1184
+ description: "Clear the existing publisher response",
1185
+ },
1186
+ ],
1187
+ },
1188
+ {
1189
+ verb: "report",
1190
+ positionals: "<review-id>",
1191
+ summary: "Reports a review for operator attention.",
1192
+ flags: [
1193
+ {
1194
+ name: "reason",
1195
+ value: "<reason>",
1196
+ description: "Why the review is being reported (required)",
1197
+ },
1198
+ ],
1199
+ },
1200
+ {
1201
+ verb: "remove",
1202
+ positionals: "<review-id>",
1203
+ summary: "Removes a review (operator only).",
1204
+ },
1205
+ {
1206
+ verb: "unhold",
1207
+ positionals: "<review-id>",
1208
+ summary: "Publishes a held review (operator only).",
1209
+ },
1210
+ ],
1211
+ notes: [
1212
+ "create identifies the template by its namespaced <handle>/<slug> ref, and you must have installed it. --stars is an integer 1 to 5; --body is optional. A body containing a link or email may land held for moderation.",
1213
+ "respond acts as the publisher on your own template's review: exactly one of --response <text> or --clear is required, where --clear removes the existing response by sending null.",
1214
+ "report flags a review for the operator with a --reason. remove and unhold are operator-gated moderation actions: remove takes a review down, unhold publishes a held review.",
1215
+ ],
1216
+ };
958
1217
  // Order here is the order in `homespun --help` and in the generated reference
959
1218
  // page: app commands first, then the rest.
960
1219
  const NOUNS = [
@@ -964,6 +1223,9 @@ const NOUNS = [
964
1223
  MEMBERS,
965
1224
  GRANTS,
966
1225
  INGEST,
1226
+ PUBLISHER,
1227
+ TEMPLATE,
1228
+ REVIEW,
967
1229
  KEY,
968
1230
  TASTE,
969
1231
  FEEDBACK,
package/dist/index.js CHANGED
@@ -39,6 +39,9 @@ import { runData } from "./commands/data.js";
39
39
  import { runMembers } from "./commands/members.js";
40
40
  import { runGrant } from "./commands/grant.js";
41
41
  import { runIngest } from "./commands/ingest.js";
42
+ import { runPublisher } from "./commands/publisher.js";
43
+ import { runTemplate } from "./commands/template.js";
44
+ import { runReview } from "./commands/review.js";
42
45
  import { VERSION } from "./version.js";
43
46
  import { HomespunApiError } from "@homespunapps/core";
44
47
  import { failUpgradeRequired } from "./output.js";
@@ -129,6 +132,15 @@ async function main() {
129
132
  case "ingest":
130
133
  await runIngest(args);
131
134
  break;
135
+ case "publisher":
136
+ await runPublisher(args);
137
+ break;
138
+ case "template":
139
+ await runTemplate(args);
140
+ break;
141
+ case "review":
142
+ await runReview(args);
143
+ break;
132
144
  }
133
145
  }
134
146
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
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.6.0",
39
+ "@homespunapps/core": "^1.6.2",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {