@homespunapps/cli 1.6.63 → 1.6.65

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
@@ -76,14 +76,28 @@ export const BOOLEAN_FLAGS = new Set([
76
76
  // member directory in its boot/hello payloads.
77
77
  "members",
78
78
  ]);
79
+ /**
80
+ * Flags that may be repeated, accumulating one value per occurrence instead
81
+ * of throwing ArgvError on the second one. Everything not listed here keeps
82
+ * the default last-flag-wins-is-a-bug behavior (see the "throws on a
83
+ * repeated value-flag" tests): repetition is opt-in per flag, not a global
84
+ * relaxation, so a typo'd repeat of an ordinary flag still fails loudly.
85
+ *
86
+ * `asset`: `homespun deploy --asset <local>=<app-path>`, once per file
87
+ * (issue #1028).
88
+ */
89
+ export const REPEATABLE_FLAGS = new Set(["asset"]);
79
90
  /**
80
91
  * Parse argv tokens. `booleanFlags` lists flags that never consume a value
81
92
  * (e.g. --json, --once, --help); everything else with a `--name` form
82
- * consumes the next token unless written as `--name=value`.
93
+ * consumes the next token unless written as `--name=value`. `repeatableFlags`
94
+ * (default: none) lists flags that accumulate into `repeated` instead of
95
+ * throwing on a second occurrence.
83
96
  *
84
97
  * Bails with ArgvError on the first duplicate (`--foo x --foo y` or
85
98
  * `--once --once`) so a typo'd repeat doesn't silently overwrite the first
86
- * value the way a plain `Map.set` would.
99
+ * value the way a plain `Map.set` would, UNLESS the flag is in
100
+ * `repeatableFlags`, in which case every occurrence is kept, in order.
87
101
  *
88
102
  * Does NOT throw on a value-flag with no following value. Instead it
89
103
  * records the name in `danglingValueFlags` so `assertKnownFlags` can
@@ -93,11 +107,21 @@ export const BOOLEAN_FLAGS = new Set([
93
107
  * a value" while `--bogus something` said "unknown flag(s)" — same root
94
108
  * cause, two messages).
95
109
  */
96
- export function parseArgs(tokens, booleanFlags) {
110
+ export function parseArgs(tokens, booleanFlags, repeatableFlags = new Set()) {
97
111
  const positionals = [];
98
112
  const flags = new Map();
99
113
  const bools = new Set();
100
114
  const danglingValueFlags = new Set();
115
+ const repeated = new Map();
116
+ const pushRepeated = (key, value) => {
117
+ const arr = repeated.get(key);
118
+ if (arr) {
119
+ arr.push(value);
120
+ }
121
+ else {
122
+ repeated.set(key, [value]);
123
+ }
124
+ };
101
125
  for (let i = 0; i < tokens.length; i++) {
102
126
  const tok = tokens[i];
103
127
  if (tok === "-h" || tok === "--help") {
@@ -109,10 +133,15 @@ export function parseArgs(tokens, booleanFlags) {
109
133
  const eq = body.indexOf("=");
110
134
  if (eq !== -1) {
111
135
  const key = body.slice(0, eq);
136
+ const value = body.slice(eq + 1);
137
+ if (repeatableFlags.has(key)) {
138
+ pushRepeated(key, value);
139
+ continue;
140
+ }
112
141
  if (flags.has(key)) {
113
142
  throw new ArgvError(`duplicate flag: --${key}`);
114
143
  }
115
- flags.set(key, body.slice(eq + 1));
144
+ flags.set(key, value);
116
145
  continue;
117
146
  }
118
147
  if (booleanFlags.has(body)) {
@@ -131,6 +160,11 @@ export function parseArgs(tokens, booleanFlags) {
131
160
  danglingValueFlags.add(body);
132
161
  continue;
133
162
  }
163
+ if (repeatableFlags.has(body)) {
164
+ pushRepeated(body, next);
165
+ i++;
166
+ continue;
167
+ }
134
168
  if (flags.has(body)) {
135
169
  throw new ArgvError(`duplicate flag: --${body}`);
136
170
  }
@@ -140,7 +174,7 @@ export function parseArgs(tokens, booleanFlags) {
140
174
  }
141
175
  positionals.push(tok);
142
176
  }
143
- return { positionals, flags, bools, danglingValueFlags };
177
+ return { positionals, flags, bools, danglingValueFlags, repeated };
144
178
  }
145
179
  /**
146
180
  * Flags every command accepts. Kept here (not in each command's allow-list)
@@ -192,6 +226,10 @@ export function assertKnownFlags(args, knownFlags, knownBools, helpCommand) {
192
226
  if (!flagSet.has(k) && !boolSet.has(k))
193
227
  unknown.push(`--${k}`);
194
228
  }
229
+ for (const k of args.repeated?.keys() ?? []) {
230
+ if (!flagSet.has(k) && !boolSet.has(k))
231
+ unknown.push(`--${k}`);
232
+ }
195
233
  if (unknown.length > 0) {
196
234
  throw new ArgvError(`unknown flag(s): ${unknown.join(", ")}`, `run \`${helpCommand} --help\` for the supported flags`);
197
235
  }
@@ -1,7 +1,9 @@
1
1
  // `homespun connections` (#1363) webhook-connection management for a v2 app:
2
2
  // create a static or oauth2 connection, list an app's connections (metadata
3
- // plus a fingerprint, never a secret), delete one, and print the browser URL
4
- // that completes an oauth2 connection's owner consent. Every verb targets an
3
+ // plus a fingerprint, never a secret), delete one, print the browser URL
4
+ // that completes an oauth2 connection's owner consent, and read or replay the
5
+ // outbound delivery journal (#1720) so an agent can debug its own webhooks
6
+ // without hand-rolling HTTP. Every verb targets an
5
7
  // app via a required `--app <idOrSlug>` flag, resolved the same way
6
8
  // `homespun grants`/`homespun members`/`homespun data` do (resolveAppId).
7
9
  //
@@ -26,7 +28,7 @@ export async function runConnection(args) {
26
28
  return;
27
29
  }
28
30
  if (verb === undefined) {
29
- fail("missing verb: homespun connections <create|list|delete|authorize-url>", "invalid_args");
31
+ fail("missing verb: homespun connections <create|list|delete|authorize-url|deliveries|replay>", "invalid_args");
30
32
  }
31
33
  const sub = {
32
34
  positionals: args.positionals.slice(1),
@@ -45,8 +47,12 @@ export async function runConnection(args) {
45
47
  return runDelete(sub);
46
48
  case "authorize-url":
47
49
  return runAuthorizeUrl(sub);
50
+ case "deliveries":
51
+ return runDeliveries(sub);
52
+ case "replay":
53
+ return runReplay(sub);
48
54
  default:
49
- fail(`unknown verb '${verb}' (homespun connections <create|list|delete|authorize-url>)`, "invalid_args");
55
+ fail(`unknown verb '${verb}' (homespun connections <create|list|delete|authorize-url|deliveries|replay>)`, "invalid_args");
50
56
  }
51
57
  }
52
58
  // Parse a "key=value" list, one per --param flag repetition is not supported
@@ -188,6 +194,55 @@ async function runDelete(args) {
188
194
  }
189
195
  }
190
196
  // ---------------------------------------------------------------------------
197
+ // deliveries
198
+ // ---------------------------------------------------------------------------
199
+ async function runDeliveries(args) {
200
+ assertKnownFlags(args, ...specFor("connections", "deliveries"));
201
+ const appArg = args.flags.get("app");
202
+ if (!appArg) {
203
+ fail("usage: homespun connections deliveries --app <idOrSlug> [--status <pending|delivered|failed>] [--collection <name>] [--limit <n>]", "invalid_args");
204
+ }
205
+ const limitRaw = args.flags.get("limit");
206
+ // Rejected here rather than passed through, so a typo'd --limit is an
207
+ // argument error instead of a silent fallback to the server's default.
208
+ if (limitRaw !== undefined && !/^[0-9]+$/.test(limitRaw)) {
209
+ fail("--limit must be a whole number", "invalid_args");
210
+ }
211
+ const client = makeClient(args);
212
+ const appId = await resolveAppId(client, appArg);
213
+ const status = args.flags.get("status");
214
+ const collection = args.flags.get("collection");
215
+ try {
216
+ printJson(await client.listWebhookDeliveries(appId, {
217
+ ...(status !== undefined ? { status } : {}),
218
+ ...(collection !== undefined ? { collection } : {}),
219
+ ...(limitRaw !== undefined ? { limit: Number(limitRaw) } : {}),
220
+ }));
221
+ }
222
+ catch (e) {
223
+ failFromError(e);
224
+ }
225
+ }
226
+ // ---------------------------------------------------------------------------
227
+ // replay
228
+ // ---------------------------------------------------------------------------
229
+ async function runReplay(args) {
230
+ assertKnownFlags(args, ...specFor("connections", "replay"));
231
+ const appArg = args.flags.get("app");
232
+ const deliveryId = args.flags.get("delivery");
233
+ if (!appArg || !deliveryId) {
234
+ fail("usage: homespun connections replay --app <idOrSlug> --delivery <deliveryId>", "invalid_args");
235
+ }
236
+ const client = makeClient(args);
237
+ const appId = await resolveAppId(client, appArg);
238
+ try {
239
+ printJson(await client.replayWebhookDelivery(appId, deliveryId));
240
+ }
241
+ catch (e) {
242
+ failFromError(e);
243
+ }
244
+ }
245
+ // ---------------------------------------------------------------------------
191
246
  // authorize-url
192
247
  // ---------------------------------------------------------------------------
193
248
  async function runAuthorizeUrl(args) {
@@ -76,9 +76,6 @@ async function runList(appArg, collection, args) {
76
76
  const limit = parseIntFlag(args, "limit", undefined, { min: 1, max: 1000 });
77
77
  const where = parseJsonArrayFlag(args, "where");
78
78
  const sort = parseJsonArrayFlag(args, "sort");
79
- if (since !== undefined && sort !== undefined) {
80
- fail("--since (cursor pagination) cannot be combined with --sort", "invalid_args");
81
- }
82
79
  const client = makeClient(args);
83
80
  const appId = await resolveAppId(client, appArg);
84
81
  try {
@@ -3,6 +3,8 @@
3
3
  // `--app <id>` redeploys an existing one (compat-gated unless --force).
4
4
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
5
  import { join } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import { HomespunApiError, putPresigned, } from "@homespunapps/core";
6
8
  import { makeClient } from "../config.js";
7
9
  import { assertKnownFlags } from "../argv.js";
8
10
  import { specFor } from "../help-catalog.js";
@@ -80,6 +82,24 @@ function extensionOf(path) {
80
82
  const dot = base.lastIndexOf(".");
81
83
  return dot <= 0 ? "" : base.slice(dot).toLowerCase();
82
84
  }
85
+ /**
86
+ * Path-shape checks shared by every source of a relay asset path: the
87
+ * relay's charset (ASSET_PATH_CHARSET) and the non-servable-extension
88
+ * refusal. `readAssets` calls this for a directory-convention path; the
89
+ * explicit `--asset` flag below calls the SAME function on its app-path half,
90
+ * so `--asset` cannot smuggle a path shape the directory-convention bundle
91
+ * would already reject (issue #1028). One definition rather than two that
92
+ * could quietly drift apart.
93
+ */
94
+ function checkAssetPathShape(path) {
95
+ if (!ASSET_PATH_CHARSET.test(path)) {
96
+ fail(`cannot ship ${path} as an asset: an asset path may only contain A-Za-z0-9._/- , so rename the file (spaces and accented characters are the usual cause)`, "invalid_args");
97
+ }
98
+ const ext = extensionOf(path);
99
+ if (NON_SERVABLE_EXTENSIONS.has(ext)) {
100
+ fail(`cannot ship ${path} as an asset: the relay serves ${ext} files as an inert download (Content-Disposition: attachment, X-Content-Type-Options: nosniff), so a browser would refuse to execute or apply it. Inline scripts and styles in index.html instead: the app CSP allows them.`, "invalid_args");
101
+ }
102
+ }
83
103
  /**
84
104
  * Every file under `<dir>/assets/`, as paths relative to that directory,
85
105
  * depth-first and sorted so a deploy is byte-identical across machines.
@@ -132,13 +152,8 @@ function readAssets(source) {
132
152
  // a filename is the common case here, and both are ordinary on disk, so
133
153
  // catching it locally turns a server round-trip into an immediate message
134
154
  // naming the file. The relay re-validates and stays authoritative.
135
- if (!ASSET_PATH_CHARSET.test(`${ASSET_DIR}/${rel}`)) {
136
- fail(`cannot ship ${ASSET_DIR}/${rel} as an asset: an asset path may only contain A-Za-z0-9._/- , so rename the file (spaces and accented characters are the usual cause)`, "invalid_args");
137
- }
155
+ checkAssetPathShape(`${ASSET_DIR}/${rel}`);
138
156
  const ext = extensionOf(rel);
139
- if (NON_SERVABLE_EXTENSIONS.has(ext)) {
140
- fail(`cannot ship ${ASSET_DIR}/${rel} as an asset: the relay serves ${ext} files as an inert download (Content-Disposition: attachment, X-Content-Type-Options: nosniff), so a browser would refuse to execute or apply it. Inline scripts and styles in index.html instead: the app CSP allows them.`, "invalid_args");
141
- }
142
157
  const bytes = readFileSync(join(assetRoot, rel));
143
158
  if (bytes.byteLength > MAX_ASSET_BYTES) {
144
159
  fail(`asset ${ASSET_DIR}/${rel} is ${bytes.byteLength} bytes, over the ${MAX_ASSET_BYTES}-byte per-file limit`, "invalid_args");
@@ -151,6 +166,206 @@ function readAssets(source) {
151
166
  };
152
167
  });
153
168
  }
169
+ // -----------------------------------------------------------------------
170
+ // The explicit `--asset <local>=<app-path>` flag (issue #1028): for a file
171
+ // that does not live under `assets/`, or that is too big to inline.
172
+ //
173
+ // - small (under ASSET_PRESIGN_THRESHOLD_BYTES): inlined exactly like a
174
+ // directory-convention asset, base64 in the deploy body.
175
+ // - large (at or over ASSET_PRESIGN_THRESHOLD_BYTES): shipped by
176
+ // reference, via presignBlob, a PUT of the bytes straight to storage,
177
+ // then confirmBlob, so the deploy body never carries them. Falls back to
178
+ // inlining when the relay's presign route answers not_implemented (the
179
+ // filesystem backend doesn't ship it), as long as the bytes still fit
180
+ // the MAX_ASSET_BYTES inline cap; a file over that cap with no working
181
+ // presign route simply cannot ship, and fails naming it rather than
182
+ // silently doing nothing.
183
+ //
184
+ // 1 MB is HomespunClient.uploadBlob's own documented cutoff for reaching for
185
+ // presignBlob() + confirmBlob() over the multipart fallback (see the doc
186
+ // comment above uploadBlob in packages/core/src/client.ts). Reusing that
187
+ // number here keeps the CLI's two "when is presign worth it" answers in
188
+ // sync, rather than inventing a second threshold that could drift from it.
189
+ const ASSET_PRESIGN_THRESHOLD_BYTES = 1_000_000;
190
+ /**
191
+ * Split one `--asset` value into its local and app-path halves. The expected
192
+ * shape is `<local>=<app-path>`; anything else (no '=', or an empty half)
193
+ * fails with a message showing the expected form, not a generic parse error.
194
+ */
195
+ function parseAssetFlagValue(raw) {
196
+ const eq = raw.indexOf("=");
197
+ if (eq <= 0 || eq === raw.length - 1) {
198
+ fail(`malformed --asset value ${JSON.stringify(raw)}: expected the form <local>=<app-path>, for example --asset ./logo.png=logo.png`, "invalid_args");
199
+ }
200
+ return { local: raw.slice(0, eq), appPath: raw.slice(eq + 1) };
201
+ }
202
+ /**
203
+ * Read one `--asset`'s local file. Every failure names the path: the relay
204
+ * cannot help diagnose this half of a deploy, since it never sees the local
205
+ * filesystem, so a missing or unreadable file must not surface as an opaque
206
+ * stack trace or a wasted round trip.
207
+ */
208
+ function readExplicitAssetBytes(localPath) {
209
+ if (!existsSync(localPath)) {
210
+ fail(`--asset local file not found: ${localPath}`, "invalid_args");
211
+ }
212
+ let isFile;
213
+ try {
214
+ isFile = statSync(localPath).isFile();
215
+ }
216
+ catch (e) {
217
+ fail(`--asset local file is not readable: ${localPath} (${e.message})`, "invalid_args");
218
+ }
219
+ if (!isFile) {
220
+ fail(`--asset local path is not a regular file: ${localPath}`, "invalid_args");
221
+ }
222
+ try {
223
+ return readFileSync(localPath);
224
+ }
225
+ catch (e) {
226
+ fail(`--asset local file is not readable: ${localPath} (${e.message})`, "invalid_args");
227
+ }
228
+ }
229
+ /**
230
+ * Parse and validate every `--asset` flag into its final relay path plus
231
+ * bytes.
232
+ *
233
+ * A leading '/' on the app-path half is stripped: `--asset ./logo.png=/logo.png`
234
+ * and `--asset ./logo.png=logo.png` mean the same app-root file. Every
235
+ * relay-side asset path is relative (core/app-assets.ts's validateAssetPath
236
+ * rejects a leading '/' outright), so stripping it here is the only way the
237
+ * form a caller reaches for first (a URL-shaped root path) doesn't round-trip
238
+ * to a relay error. What remains is checked with checkAssetPathShape, the
239
+ * SAME function readAssets uses, so `--asset` cannot smuggle a path shape the
240
+ * directory-convention bundle would already reject.
241
+ *
242
+ * A repeated app-path keeps the LAST occurrence, matching how the merge into
243
+ * directory assets below treats an explicit path as authoritative.
244
+ */
245
+ function collectExplicitAssets(rawValues) {
246
+ const byPath = new Map();
247
+ for (const raw of rawValues) {
248
+ const { local, appPath } = parseAssetFlagValue(raw);
249
+ const path = appPath.startsWith("/") ? appPath.slice(1) : appPath;
250
+ checkAssetPathShape(path);
251
+ const bytes = readExplicitAssetBytes(local);
252
+ byPath.set(path, { path, localPath: local, bytes });
253
+ }
254
+ return [...byPath.values()];
255
+ }
256
+ /** The relay's declared mime for a path's extension, or undefined to let the relay sniff it. */
257
+ function declaredMimeFor(path) {
258
+ return DECLARED_MIME_BY_EXTENSION.get(extensionOf(path));
259
+ }
260
+ /**
261
+ * Inline an explicit asset exactly like a directory-convention one: base64
262
+ * in the deploy body. Enforces the same MAX_ASSET_BYTES cap readAssets does,
263
+ * naming the LOCAL path (what the caller typed) in the error rather than the
264
+ * relay path.
265
+ */
266
+ function inlineExplicitAsset(asset) {
267
+ if (asset.bytes.byteLength > MAX_ASSET_BYTES) {
268
+ fail(`--asset ${asset.localPath} is ${asset.bytes.byteLength} bytes, over the ${MAX_ASSET_BYTES}-byte inline limit, and this relay has no working presign route to ship it by reference instead`, "invalid_args");
269
+ }
270
+ const mime = declaredMimeFor(asset.path);
271
+ return {
272
+ path: asset.path,
273
+ content_base64: asset.bytes.toString("base64"),
274
+ ...(mime !== undefined ? { mime } : {}),
275
+ };
276
+ }
277
+ /** Split explicit assets by the presign threshold. A pure size check, no I/O. */
278
+ function partitionExplicitAssets(assets) {
279
+ const small = [];
280
+ const large = [];
281
+ for (const asset of assets) {
282
+ (asset.bytes.byteLength < ASSET_PRESIGN_THRESHOLD_BYTES
283
+ ? small
284
+ : large).push(asset);
285
+ }
286
+ return { small, large };
287
+ }
288
+ /**
289
+ * Resolve one "large" explicit asset to a by-reference AppAssetRef: presign
290
+ * against `appId`, PUT the bytes straight to storage, then confirm. `appId`
291
+ * must already be real; see the two-round-trip note on runDeploy for why a
292
+ * brand-new app cannot presign on its FIRST deploy call, since the id does
293
+ * not exist yet.
294
+ *
295
+ * Falls back to inlining the asset when the relay answers not_implemented
296
+ * (the filesystem backend has no presign route), provided the bytes still
297
+ * fit the inline cap; a genuinely large file with no working presign route
298
+ * fails naming it, rather than silently shrinking the deploy's asset set.
299
+ * Any other failure (a network error, a rejected PUT, a failed confirm) is
300
+ * rethrown for the caller's own error handling.
301
+ */
302
+ async function resolveLargeAsset(client, appId, asset) {
303
+ const mime = declaredMimeFor(asset.path) ?? "application/octet-stream";
304
+ const sha256 = createHash("sha256").update(asset.bytes).digest("hex");
305
+ try {
306
+ const presign = await client.presignBlob({
307
+ mime,
308
+ size: asset.bytes.byteLength,
309
+ sha256,
310
+ scope: "app",
311
+ appId,
312
+ filename: asset.path.split("/").pop(),
313
+ });
314
+ await putPresigned(presign.upload_url, asset.bytes, mime);
315
+ await client.confirmBlob(presign.attachment_id);
316
+ return { path: asset.path, attachment_id: presign.attachment_id };
317
+ }
318
+ catch (e) {
319
+ if (e instanceof HomespunApiError && e.code === "not_implemented") {
320
+ if (asset.bytes.byteLength > MAX_ASSET_BYTES) {
321
+ fail(`cannot ship --asset ${asset.localPath} (${asset.bytes.byteLength} bytes): it is over the ${MAX_ASSET_BYTES}-byte inline cap, and this relay's presign route is not implemented (${e.message})`, "invalid_args");
322
+ }
323
+ warn(`presigned upload is not available on this relay; shipping --asset ${asset.localPath} (${asset.bytes.byteLength} bytes) inline instead`);
324
+ return inlineExplicitAsset(asset);
325
+ }
326
+ throw e;
327
+ }
328
+ }
329
+ /** Resolve every "large" explicit asset against `appId`, in order. */
330
+ async function resolveLargeAssets(client, appId, assets) {
331
+ const resolved = [];
332
+ for (const asset of assets) {
333
+ resolved.push(await resolveLargeAsset(client, appId, asset));
334
+ }
335
+ return resolved;
336
+ }
337
+ /**
338
+ * Merge an explicit `--asset` list into a base asset array, the explicit
339
+ * list winning on a path collision. A colliding path keeps its ORIGINAL
340
+ * position in the merged array, so a directory-convention asset overridden
341
+ * by `--asset` does not jump to the end; a new path is appended in
342
+ * `--asset` order.
343
+ */
344
+ function mergeAssets(base, overrides) {
345
+ const merged = [...base];
346
+ const indexByPath = new Map(merged.map((a, i) => [a.path, i]));
347
+ for (const asset of overrides) {
348
+ const idx = indexByPath.get(asset.path);
349
+ if (idx !== undefined) {
350
+ merged[idx] = asset;
351
+ }
352
+ else {
353
+ indexByPath.set(asset.path, merged.length);
354
+ merged.push(asset);
355
+ }
356
+ }
357
+ return merged;
358
+ }
359
+ /**
360
+ * Merge, but preserve "no assets/ directory and no --asset flags" as
361
+ * `undefined`, the redeploy "keep the live set" signal (issue #1272),
362
+ * rather than turning it into an empty array.
363
+ */
364
+ function mergeAssetsMaybe(base, overrides) {
365
+ if (overrides.length === 0)
366
+ return base;
367
+ return mergeAssets(base ?? [], overrides);
368
+ }
154
369
  /**
155
370
  * Warn about files sitting next to `index.html` that this deploy is NOT
156
371
  * shipping. Silently dropping them is the failure mode issue #1225 is about:
@@ -234,17 +449,26 @@ export async function runDeploy(args) {
234
449
  const check = args.bools.has("check");
235
450
  const bundle = readBundle(source, args.flags.get("manifest"), appId !== undefined);
236
451
  const client = makeClient(args);
452
+ const explicitAssets = collectExplicitAssets(args.repeated?.get("asset") ?? []);
237
453
  // Dry run (--check): validate + report what a real deploy would do, persist
238
454
  // NOTHING. Runs for both create (no --app) and redeploy (--app), the latter
239
455
  // reporting the compat gate. slug/visibility are not part of a dry run.
456
+ //
457
+ // Every explicit asset is inlined here regardless of size: presigning is a
458
+ // real upload (presignBlob reserves storage, PUT writes bytes, confirmBlob
459
+ // finalises it), and --check's contract is to persist nothing. A file too
460
+ // big to inline fails the same way inlineExplicitAsset always fails one,
461
+ // naming it, rather than pretending a dry run validated a transport it
462
+ // never exercised.
240
463
  if (check) {
464
+ const checkAssets = mergeAssetsMaybe(bundle.assets, explicitAssets.map(inlineExplicitAsset));
241
465
  try {
242
466
  const id = appId !== undefined ? await resolveAppId(client, appId) : undefined;
243
467
  const result = await client.checkDeploy({
244
468
  ...(id !== undefined ? { app_id: id } : {}),
245
469
  ...(bundle.html !== undefined ? { html: bundle.html } : {}),
246
470
  ...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
247
- ...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
471
+ ...(checkAssets !== undefined ? { assets: checkAssets } : {}),
248
472
  ...(force ? { force } : {}),
249
473
  });
250
474
  printJson(result);
@@ -254,12 +478,23 @@ export async function runDeploy(args) {
254
478
  }
255
479
  return;
256
480
  }
481
+ const { small, large } = partitionExplicitAssets(explicitAssets);
257
482
  if (appId === undefined) {
258
483
  // Create. Client-side mirror of the relay's slug_not_allowed_for_link —
259
484
  // fail fast rather than round-trip a request that will 400 (spec-cli §3.1).
260
485
  if (slug !== undefined && visibility === "link") {
261
486
  fail("a caller-supplied --slug is not allowed with visibility 'link' (link slugs are always server-generated); drop --visibility link, or omit --slug", "invalid_args");
262
487
  }
488
+ // Any "large" explicit asset needs an app id to presign against (scope:
489
+ // "app" is bound to a specific app at presign time, and there is no
490
+ // rescope endpoint), and a brand-new app has no id until the FIRST
491
+ // deploy call returns one. So a create carrying a large asset is a
492
+ // two-round-trip: deploy without it, presign + PUT + confirm against the
493
+ // real id the relay just minted, then redeploy carrying the reference.
494
+ // An existing app (--app given, below) already has an id, so it stays a
495
+ // single pass. Adding a rescope endpoint to collapse this to one round
496
+ // trip was considered and rejected as disproportionate to the problem.
497
+ const firstPassAssets = mergeAssetsMaybe(bundle.assets, small.map(inlineExplicitAsset));
263
498
  try {
264
499
  // readBundle guarantees both halves on the create path (a create can
265
500
  // inherit nothing), so the non-null assertions are the type system
@@ -269,9 +504,31 @@ export async function runDeploy(args) {
269
504
  manifest: bundle.manifest,
270
505
  visibility,
271
506
  slug,
272
- ...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
507
+ ...(firstPassAssets !== undefined ? { assets: firstPassAssets } : {}),
508
+ });
509
+ if (large.length === 0) {
510
+ printJson(out);
511
+ return;
512
+ }
513
+ const largeRefs = await resolveLargeAssets(client, out.app_id, large);
514
+ const finalAssets = mergeAssets(firstPassAssets ?? [], largeRefs);
515
+ const redeployed = await client.redeployApp(out.app_id, {
516
+ assets: finalAssets,
517
+ force: false,
518
+ });
519
+ const app = await client.getApp(out.app_id);
520
+ printJson({
521
+ app_id: out.app_id,
522
+ slug: app.slug,
523
+ url: app.url,
524
+ version: redeployed.version,
525
+ visibility: app.visibility,
526
+ created: true,
527
+ ...(out.share_url !== undefined ? { share_url: out.share_url } : {}),
528
+ compat: redeployed.compat,
529
+ ...(redeployed.breaks ? { breaks: redeployed.breaks } : {}),
530
+ ...(redeployed.warnings ? { warnings: redeployed.warnings } : {}),
273
531
  });
274
- printJson(out);
275
532
  }
276
533
  catch (e) {
277
534
  failFromError(e);
@@ -287,17 +544,25 @@ export async function runDeploy(args) {
287
544
  }
288
545
  const id = await resolveAppId(client, appId);
289
546
  try {
547
+ // The app id already exists, so presigning any "large" explicit asset is
548
+ // a single pass: resolve every explicit asset up front, merge into the
549
+ // directory-convention bundle, then redeploy once.
550
+ const smallInline = small.map(inlineExplicitAsset);
551
+ const largeRefs = large.length > 0 ? await resolveLargeAssets(client, id, large) : [];
552
+ const explicitResolved = [...smallInline, ...largeRefs];
290
553
  // Only what this invocation actually read is sent: an omitted html,
291
554
  // manifest or asset set keeps what is live, so
292
555
  // `homespun deploy ./index.html --app <id>` ships the document alone and
293
556
  // `--manifest` with no file ships the manifest alone. A directory WITH an
294
557
  // `assets/` folder always sends the full computed set, so deleting a file
295
- // on disk removes it from the app; a directory WITHOUT one sends nothing,
296
- // leaving assets uploaded by another path (MCP `deploy_app`) untouched.
558
+ // on disk removes it from the app; a directory WITHOUT one, and no
559
+ // `--asset` flags, sends nothing, leaving assets uploaded by another
560
+ // path (MCP `deploy_app`) untouched.
561
+ const finalAssets = mergeAssetsMaybe(bundle.assets, explicitResolved);
297
562
  const redeployed = await client.redeployApp(id, {
298
563
  ...(bundle.html !== undefined ? { html: bundle.html } : {}),
299
564
  ...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
300
- ...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
565
+ ...(finalAssets !== undefined ? { assets: finalAssets } : {}),
301
566
  force,
302
567
  });
303
568
  const app = await client.getApp(id);
@@ -334,7 +334,7 @@ const DATA = {
334
334
  ],
335
335
  notes: [
336
336
  "<app> accepts either the app_id or its slug. upsert is the ONLY create-shaped verb: omit --key to add a new row (the server generates the key); pass --key to ensure a row exists at that key (returns the existing row with deduped:true on a collision). A collision on a row the collection's read list does not reach for you is row_not_found (404) instead of the row, the same answer a get on that key gives, so upsert cannot read past read. Pass --on <field> to upsert on a manifest-declared UNIQUE field instead of the key: the row whose <field> value matches is updated in place (idempotent re-import), else created.",
337
- "list --where takes a JSON array of {field, op, value} conditions (ANDed), op one of eq, neq, in, notIn, gt, lt, gte, lte (in and notIn take an array value). --sort takes a JSON array of {field, dir} (dir asc or desc). Filtering is applied AFTER the read permission and author scoping, so a filtered list is always a subset of what you could already read. Comparisons are same-type only (no coercion); dates compare as ISO-8601 strings. A custom --sort cannot be combined with --since.",
337
+ "list --where takes a JSON array of {field, op, value} conditions (ANDed), op one of eq, neq, in, notIn, gt, lt, gte, lte (in and notIn take an array value). --sort takes a JSON array of {field, dir} (dir asc or desc). Filtering is applied AFTER the read permission and author scoping, so a filtered list is always a subset of what you could already read. Comparisons are same-type only (no coercion); dates compare as ISO-8601 strings. --since paginates a custom --sort too: pass back the same --sort with the next_cursor a page returned, since a cursor is only valid for the exact sort it came from.",
338
338
  "delete is RECOVERABLE: it tombstones the row, `deleted` lists what can still be brought back, and `restore` brings one back for 30 days. purge is the permanent one: it removes ONE row by --key even in an append-only collection, scrubs its contents immediately, and cannot be restored. Both are owner and agent only (never members or anyone); purge bypasses append-only and the collection delete list on purpose, and both write an audited delete feed entry.",
339
339
  "import reads NDJSON (one JSON object per line) OR a JSON array from --file and bulk-writes it in chunks via the batch API, in ONE process. Each object is a row's data. Pass --key-field to derive the row key from a field: an existing row at that key is LEFT UNCHANGED, so this is create-or-skip-by-id, not overwrite, and re-importing changed data for a known key does not update it. A skipped row is reported as a per-row row_not_found rather than an ok when the collection's read list does not reach that row for you (the row is still left unchanged); list 'agent' in read if you want the skip reported as a success. Import DEFAULTS TO SILENT (it suppresses notify and webhooks, since a bulk import is a migration); pass --emit-effects to fire them. A per-row failure is listed in the summary WITHOUT aborting the import.",
340
340
  "retention is an OWNER control: the author declares default retention in the manifest, and this tightens or loosens it per collection at runtime WITHOUT a redeploy. Effective retention is per-axis override-or-author-default: --max-rows/--max-age-days set an axis override, --clear-rows/--clear-age revert an axis to the author default, and with no flag (or --show) it just reads. The response reports the effective bounds, the author default, the override, and wouldPrune (how many live rows the effective bound would prune on the next sweep). The override survives redeploys and effective maxRows is capped at MAX_ROWS_PER_APP.",
@@ -765,7 +765,7 @@ const CONNECTIONS = {
765
765
  noun: "connections",
766
766
  tagline: "webhook connection management",
767
767
  group: "app",
768
- rootSummary: "App webhook-connection management: create, list, delete, authorize-url. Store the credential a webhook rule authenticates its target with.",
768
+ rootSummary: "App webhook-connection management: create, list, delete, authorize-url, deliveries, replay. Store the credential a webhook rule authenticates its target with, and inspect or re-send what went out.",
769
769
  verbs: [
770
770
  {
771
771
  verb: "create",
@@ -885,6 +885,48 @@ const CONNECTIONS = {
885
885
  },
886
886
  ],
887
887
  },
888
+ {
889
+ verb: "deliveries",
890
+ summary: "Lists the app's outbound webhook deliveries, newest first, with the request body that was sent and the target's response.",
891
+ flags: [
892
+ {
893
+ name: "app",
894
+ value: "<idOrSlug>",
895
+ description: "App to read the delivery journal of (required)",
896
+ },
897
+ {
898
+ name: "status",
899
+ value: "<status>",
900
+ description: "Only deliveries in this state: pending, delivered or failed",
901
+ },
902
+ {
903
+ name: "collection",
904
+ value: "<name>",
905
+ description: "Only deliveries triggered by rows in this collection",
906
+ },
907
+ {
908
+ name: "limit",
909
+ value: "<n>",
910
+ description: "How many to return (default 25, capped at 100)",
911
+ },
912
+ ],
913
+ },
914
+ {
915
+ verb: "replay",
916
+ summary: "Re-sends one stored delivery's own rule, url and body as a fresh delivery, now, without waiting out its backoff.",
917
+ flags: [
918
+ {
919
+ name: "app",
920
+ value: "<idOrSlug>",
921
+ description: "App the delivery belongs to (required)",
922
+ },
923
+ {
924
+ name: "delivery",
925
+ value: "<deliveryId>",
926
+ description: "Delivery to re-send, from `deliveries` (required)",
927
+ },
928
+ ],
929
+ },
888
930
  {
889
931
  verb: "authorize-url",
890
932
  summary: "Prints the browser URL that completes an oauth2 connection's owner consent. Never fetched by this command.",
@@ -908,6 +950,9 @@ const CONNECTIONS = {
908
950
  "Every stored secret (a static header value, or an oauth2 client secret and its tokens) is encrypted at rest and never returned by any call; list returns metadata plus a non-reversible fingerprint only.",
909
951
  "OAuth2 consent is inherently a human-in-a-browser step: the relay refuses an agent-key caller at the authorize endpoint. authorize-url never makes a network call, it builds the URL locally so you can hand it to the signed-in app owner to open. A newly created oauth2 connection starts in pending_auth until the owner completes it.",
910
952
  "--header-value and --client-secret on create take the value straight from argv where it is visible in shell history and to other local users via ps for the life of the process. Prefer '--header-value -' / '--client-secret -' to read the value from stdin, or set HOMESPUN_CONNECTION_HEADER_VALUE / HOMESPUN_CONNECTION_CLIENT_SECRET, both of which never touch argv.",
953
+ "deliveries returns { deliveries: [{ id, collection, rowKey, op, url, status, attempts, responseStatus, responseBody, payload, payloadTruncated, error, replayOfId, createdAt, deliveredAt, lastAttemptAt }] }. payload is the request body as it was rendered and sent, truncated by the relay; payloadTruncated says whether you are looking at all of it. url is host and path only, never the query string, which can carry a token. The same rows are on the app detail page in the console.",
954
+ "The journal is a ROLLING window, not the app's whole history: the relay hard-deletes delivered and failed rows past its retention window, and caps how many one app may keep. A delivery you cannot find is more likely aged out than never sent.",
955
+ "replay re-sends a stored delivery's own rule, url and body verbatim, as a fresh row the normal worker picks up (signing and connection auth apply exactly as they would to any other delivery). Nothing you pass enters the new row. It is NOT idempotent in effect: the target receives the same request a second time, so a target without its own dedupe ends up with a duplicate record. It is refused when the rule that produced the original is no longer in the app's current manifest.",
911
956
  ],
912
957
  };
913
958
  const KEY = {
@@ -1139,6 +1184,11 @@ const DEPLOY = {
1139
1184
  value: "<private|link|public>",
1140
1185
  description: "Who can open the new app, on create only",
1141
1186
  },
1187
+ {
1188
+ name: "asset",
1189
+ value: "<local>=<app-path>",
1190
+ description: "Ship a file that is not under assets/, repeatable, wins over a directory-convention asset at the same app-path",
1191
+ },
1142
1192
  ],
1143
1193
  bools: [
1144
1194
  { name: "force", description: "Override the redeploy compat gate" },
@@ -1155,6 +1205,7 @@ const DEPLOY = {
1155
1205
  "Create versus redeploy is decided by the presence of --app, not by two verbs. With no --app this creates an app (POST /v1/apps); new apps default to private (owner plus invited members, sign-in gated), --slug is accepted with private or public visibility including the default, and an explicit --visibility link always gets a server-generated slug and rejects --slug. With --app <id> this redeploys (POST /v1/apps/:id/versions), where --slug and --visibility are rejected because the slug is immutable and visibility changes go through 'homespun apps update'.",
1156
1206
  "On redeploy, what you do not send is kept. 'homespun deploy ./index.html --app <id>' ships the document alone and keeps the live manifest; 'homespun deploy --app <id> --manifest ./manifest.json' ships the manifest alone and keeps the live document, with no file argument at all; a directory deploy still ships both. Assets follow the same rule, decided by whether the directory has an assets/ folder: with one, the full set on disk is sent, so deleting a file there removes it from the app; with none, nothing is sent and the live asset set is carried forward untouched. A create can inherit nothing, so it still needs both halves.",
1157
1207
  "--check is a dry run. It runs the full manifest and asset validation (shape and MIME), the redeploy compat gate (with --app), and the schedule-timezone advisory, then prints { ok, warnings, compat, breaks } without creating a version or mutating anything. An invalid manifest fails the same way a real deploy would, and a redeploy the compat gate would refuse reports the break instead of applying it. It resolves omitted fields exactly as a real redeploy would, so it reports on the deploy that would actually run.",
1208
+ "--asset <local>=<app-path> ships one file that is not under assets/, or that assets/ cannot hold because it is too big to inline. Repeatable; the app-path side is validated the same way a directory-convention path is (charset, no .js/.css/.svg/.html), and a leading '/' on it is stripped since every relay-side path is relative. Files under 1 MB are inlined exactly like a directory asset; at or above 1 MB the CLI presigns instead (presignBlob, PUT the bytes straight to storage, confirmBlob), so the deploy body never carries them, up to the 50 MB media ceiling. On a brand-new app (no --app) that needs presigning, this is two round trips: the app does not exist yet to presign against, and scope is fixed at presign time with no rescope endpoint, so the CLI deploys first, presigns against the id the relay just minted, then redeploys carrying the reference. With --app <id> the id already exists, so it is one pass. If the relay's presign route is not implemented (some backends), a file under the 5 MB inline cap is shipped inline instead with a warning on stderr; a file over that cap fails naming it, since there is then no way to ship it. An --asset wins over a directory-convention asset at the same app-path.",
1158
1209
  ],
1159
1210
  outputNote: 'Output is JSON: { app_id, slug, url, version, visibility, created, share_url, compat, breaks, warnings }. share_url is present only when creating a link-visibility app: it carries the app share token in its #k= fragment and is shown ONCE, it is not recoverable later, and it can be rotated with \'homespun apps share-link rotate <app>\'. warnings flags non-fatal issues, for example an app that declares schedules with no timezone set (reminders fire at 08:00 UTC until one is set). Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
1160
1211
  };
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // Multiple environments live as named profiles in
10
10
  // $XDG_CONFIG_HOME/homespun/config.json; pick one with --profile or HOMESPUN_PROFILE.
11
11
  // Output is JSON by default. Every noun self-documents via --help.
12
- import { parseArgs, ArgvError, BOOLEAN_FLAGS } from "./argv.js";
12
+ import { parseArgs, ArgvError, BOOLEAN_FLAGS, REPEATABLE_FLAGS, } from "./argv.js";
13
13
  import { helpTextFor, nounSpec, renderNounHelp, renderRootHelp, } from "./help-catalog.js";
14
14
  /**
15
15
  * Translate an ArgvError into the canonical `invalid_args` envelope and exit
@@ -69,7 +69,7 @@ async function main() {
69
69
  }
70
70
  let args;
71
71
  try {
72
- args = parseArgs(rest, BOOLEAN_FLAGS);
72
+ args = parseArgs(rest, BOOLEAN_FLAGS, REPEATABLE_FLAGS);
73
73
  }
74
74
  catch (e) {
75
75
  if (e instanceof ArgvError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.63",
3
+ "version": "1.6.65",
4
4
  "description": "Command-line client for the Homespun relay: deploy a real multi-user web app from your agent, then keep reading and writing its data.",
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.63",
39
+ "@homespunapps/core": "^1.6.65",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {