@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 +43 -5
- package/dist/commands/connection.js +59 -4
- package/dist/commands/data.js +0 -3
- package/dist/commands/deploy.js +277 -12
- package/dist/help-catalog.js +53 -2
- package/dist/index.js +2 -2
- package/package.json +2 -2
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,
|
|
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,
|
|
4
|
-
// that completes an oauth2 connection's owner consent
|
|
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) {
|
package/dist/commands/data.js
CHANGED
|
@@ -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 {
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
|
296
|
-
// leaving assets uploaded by another
|
|
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
|
-
...(
|
|
565
|
+
...(finalAssets !== undefined ? { assets: finalAssets } : {}),
|
|
301
566
|
force,
|
|
302
567
|
});
|
|
303
568
|
const app = await client.getApp(id);
|
package/dist/help-catalog.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
39
|
+
"@homespunapps/core": "^1.6.65",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|