@homespunapps/cli 1.6.44 → 1.6.45

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
@@ -65,6 +65,12 @@ export const BOOLEAN_FLAGS = new Set([
65
65
  "attest-example-only",
66
66
  // `homespun review respond --clear`: clear a publisher response (sends null).
67
67
  "clear",
68
+ // `homespun credentials mint --no-expiry`: the explicit opt-in to NO EXPIRY
69
+ // (ttl_seconds: null), mutually exclusive with --ttl.
70
+ "no-expiry",
71
+ // `homespun credentials mint --members`: opt the credential into the app's
72
+ // member directory in its boot/hello payloads.
73
+ "members",
68
74
  ]);
69
75
  /**
70
76
  * Parse argv tokens. `booleanFlags` lists flags that never consume a value
@@ -63,8 +63,8 @@ async function runList(args) {
63
63
  assertKnownFlags(args, ...specFor("apps", "list"));
64
64
  const status = args.flags.get("status");
65
65
  if (status !== undefined &&
66
- !["active", "dormant", "archived", "all"].includes(status)) {
67
- fail("--status must be active|dormant|archived|all", "invalid_args");
66
+ !["active", "dormant", "archived", "suspended", "all"].includes(status)) {
67
+ fail("--status must be active|dormant|archived|suspended|all", "invalid_args");
68
68
  }
69
69
  const limitRaw = args.flags.get("limit");
70
70
  const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
@@ -294,6 +294,16 @@ async function runWatch(args) {
294
294
  printJsonLine({ type: "_dormant" });
295
295
  finish(0);
296
296
  };
297
+ // Operator takedown (issue #1041): the WS path gets an explicit
298
+ // `_suspended` frame, same as `_dormant`. There is no long-poll-side
299
+ // equivalent by DESIGN: see `isDormantConflict`'s comment. A suspended
300
+ // app's long-poll GET throws the "gone" 410, not a 409 conflict, which is
301
+ // exactly how `archived` already behaves there (no special case), so
302
+ // `runLongPoll` falls through to `failFromError` unchanged.
303
+ const emitSuspended = () => {
304
+ printJsonLine({ type: "_suspended" });
305
+ finish(0);
306
+ };
297
307
  // Long-poll fallback loop (spec-cli §5) — GET /v1/apps/:id/feed?wait=25.
298
308
  // Uses the SAME printFeedEntryLine as the WS entry handler below, so a
299
309
  // caller piping `homespun apps watch` output can never tell which transport
@@ -339,6 +349,9 @@ async function runWatch(args) {
339
349
  onDormant: () => {
340
350
  emitDormant();
341
351
  },
352
+ onSuspended: () => {
353
+ emitSuspended();
354
+ },
342
355
  onResync: () => {
343
356
  printJsonLine({ type: "resync" });
344
357
  },
@@ -0,0 +1,206 @@
1
+ // `homespun connections` (#1363) webhook-connection management for a v2 app:
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
5
+ // app via a required `--app <idOrSlug>` flag, resolved the same way
6
+ // `homespun grants`/`homespun members`/`homespun data` do (resolveAppId).
7
+ //
8
+ // Auth on the relay side is owner-or-owning-agent; this CLI always
9
+ // authenticates as the owning agent. There is no update verb: change a
10
+ // connection by deleting and recreating it (matching the HTTP API).
11
+ //
12
+ // OAuth2 consent is inherently a human-in-a-browser step: the relay refuses
13
+ // an agent-key caller at `/connections/:name/authorize`. `authorize-url`
14
+ // therefore never makes a network call: it builds the URL locally and hands
15
+ // it back so you can pass it to the signed-in app owner to open.
16
+ import { assertKnownFlags } from "../argv.js";
17
+ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
18
+ import { makeClient } from "../config.js";
19
+ import { fail, failFromError, printJson } from "../output.js";
20
+ import { resolveAppId } from "../resolve-app.js";
21
+ export async function runConnection(args) {
22
+ const verb = args.positionals[0];
23
+ if ((verb === undefined || verb === "help") && args.bools.has("help")) {
24
+ process.stdout.write(renderNounHelp(nounSpec("connections")) + "\n");
25
+ return;
26
+ }
27
+ if (verb === undefined) {
28
+ fail("missing verb: homespun connections <create|list|delete|authorize-url>", "invalid_args");
29
+ }
30
+ const sub = {
31
+ positionals: args.positionals.slice(1),
32
+ flags: args.flags,
33
+ bools: args.bools,
34
+ ...(args.danglingValueFlags !== undefined
35
+ ? { danglingValueFlags: args.danglingValueFlags }
36
+ : {}),
37
+ };
38
+ switch (verb) {
39
+ case "create":
40
+ return runCreate(sub);
41
+ case "list":
42
+ return runList(sub);
43
+ case "delete":
44
+ return runDelete(sub);
45
+ case "authorize-url":
46
+ return runAuthorizeUrl(sub);
47
+ default:
48
+ fail(`unknown verb '${verb}' (homespun connections <create|list|delete|authorize-url>)`, "invalid_args");
49
+ }
50
+ }
51
+ // Parse a "key=value" list, one per --param flag repetition is not supported
52
+ // by this parser (each flag is single-valued), so extra params travel as a
53
+ // JSON object flag, mirroring `homespun grants mint --pin-where <json>`.
54
+ function parseJsonObjectFlag(raw, flag) {
55
+ if (raw === undefined)
56
+ return undefined;
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ }
61
+ catch {
62
+ fail(`${flag} must be a JSON object`, "invalid_args");
63
+ }
64
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
65
+ fail(`${flag} must be a JSON object`, "invalid_args");
66
+ }
67
+ return parsed;
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // create
71
+ // ---------------------------------------------------------------------------
72
+ async function runCreate(args) {
73
+ assertKnownFlags(args, ...specFor("connections", "create"));
74
+ const appArg = args.flags.get("app");
75
+ const name = args.flags.get("name");
76
+ const allowedHost = args.flags.get("allowed-host");
77
+ if (!appArg || !name || !allowedHost) {
78
+ fail("usage: homespun connections create --app <idOrSlug> --name <name> --allowed-host <host> [...]", "invalid_args");
79
+ }
80
+ const kind = args.flags.get("kind");
81
+ if (kind !== undefined && kind !== "static" && kind !== "oauth2") {
82
+ fail('--kind must be "static" or "oauth2"', "invalid_args");
83
+ }
84
+ const client = makeClient(args);
85
+ const appId = await resolveAppId(client, appArg);
86
+ try {
87
+ if ((kind ?? "static") === "oauth2") {
88
+ const authorizeUrl = args.flags.get("authorize-url");
89
+ const tokenEndpoint = args.flags.get("token-url");
90
+ const clientId = args.flags.get("client-id");
91
+ const clientSecret = args.flags.get("client-secret");
92
+ if (!authorizeUrl || !tokenEndpoint || !clientId || !clientSecret) {
93
+ fail("kind=oauth2 requires --authorize-url, --token-url, --client-id and --client-secret", "invalid_args");
94
+ }
95
+ printJson(await client.createConnection(appId, {
96
+ name: name,
97
+ kind: "oauth2",
98
+ allowedHost: allowedHost,
99
+ authorizeUrl: authorizeUrl,
100
+ tokenEndpoint: tokenEndpoint,
101
+ clientId: clientId,
102
+ clientSecret: clientSecret,
103
+ ...(args.flags.get("provider") !== undefined
104
+ ? { provider: args.flags.get("provider") }
105
+ : {}),
106
+ ...(args.flags.get("label") !== undefined
107
+ ? { label: args.flags.get("label") }
108
+ : {}),
109
+ ...(args.flags.get("scopes") !== undefined
110
+ ? { scopes: args.flags.get("scopes") }
111
+ : {}),
112
+ ...(args.flags.get("auth-scheme") !== undefined
113
+ ? { authScheme: args.flags.get("auth-scheme") }
114
+ : {}),
115
+ ...(args.flags.get("instance-field") !== undefined
116
+ ? { instanceField: args.flags.get("instance-field") }
117
+ : {}),
118
+ ...(() => {
119
+ const v = parseJsonObjectFlag(args.flags.get("auth-params"), "--auth-params");
120
+ return v !== undefined ? { authParams: v } : {};
121
+ })(),
122
+ ...(() => {
123
+ const v = parseJsonObjectFlag(args.flags.get("token-params"), "--token-params");
124
+ return v !== undefined ? { tokenParams: v } : {};
125
+ })(),
126
+ }));
127
+ return;
128
+ }
129
+ const headerValue = args.flags.get("header-value");
130
+ if (!headerValue) {
131
+ fail("--header-value is required for a static connection", "invalid_args");
132
+ }
133
+ printJson(await client.createConnection(appId, {
134
+ name: name,
135
+ kind: "static",
136
+ allowedHost: allowedHost,
137
+ headerValue: headerValue,
138
+ headerName: args.flags.get("header-name") ?? "Authorization",
139
+ ...(args.flags.get("provider") !== undefined
140
+ ? { provider: args.flags.get("provider") }
141
+ : {}),
142
+ ...(args.flags.get("label") !== undefined
143
+ ? { label: args.flags.get("label") }
144
+ : {}),
145
+ }));
146
+ }
147
+ catch (e) {
148
+ failFromError(e);
149
+ }
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // list
153
+ // ---------------------------------------------------------------------------
154
+ async function runList(args) {
155
+ assertKnownFlags(args, ...specFor("connections", "list"));
156
+ const appArg = args.flags.get("app");
157
+ if (!appArg) {
158
+ fail("usage: homespun connections list --app <idOrSlug>", "invalid_args");
159
+ }
160
+ const client = makeClient(args);
161
+ const appId = await resolveAppId(client, appArg);
162
+ try {
163
+ printJson(await client.listConnections(appId));
164
+ }
165
+ catch (e) {
166
+ failFromError(e);
167
+ }
168
+ }
169
+ // ---------------------------------------------------------------------------
170
+ // delete
171
+ // ---------------------------------------------------------------------------
172
+ async function runDelete(args) {
173
+ assertKnownFlags(args, ...specFor("connections", "delete"));
174
+ const appArg = args.flags.get("app");
175
+ const name = args.flags.get("name");
176
+ if (!appArg || !name) {
177
+ fail("usage: homespun connections delete --app <idOrSlug> --name <name>", "invalid_args");
178
+ }
179
+ const client = makeClient(args);
180
+ const appId = await resolveAppId(client, appArg);
181
+ try {
182
+ await client.deleteConnection(appId, name);
183
+ printJson({ deleted: true, app_id: appId, name: name });
184
+ }
185
+ catch (e) {
186
+ failFromError(e);
187
+ }
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // authorize-url
191
+ // ---------------------------------------------------------------------------
192
+ async function runAuthorizeUrl(args) {
193
+ assertKnownFlags(args, ...specFor("connections", "authorize-url"));
194
+ const appArg = args.flags.get("app");
195
+ const name = args.flags.get("name");
196
+ if (!appArg || !name) {
197
+ fail("usage: homespun connections authorize-url --app <idOrSlug> --name <name>", "invalid_args");
198
+ }
199
+ const client = makeClient(args);
200
+ const appId = await resolveAppId(client, appArg);
201
+ printJson({
202
+ app_id: appId,
203
+ name: name,
204
+ authorize_url: client.connectionAuthorizeUrl(appId, name),
205
+ });
206
+ }
@@ -0,0 +1,220 @@
1
+ // `homespun credentials` (#1354, #1355, #1363) scoped-service-credential
2
+ // management for a v2 app: mint the bearer token an owner points a backend
3
+ // they host themselves at, list an app's credentials, pause/resume one
4
+ // reversibly, rotate one with an overlap window, and revoke one permanently.
5
+ // Every verb targets an app via a required `--app <idOrSlug>` flag, resolved
6
+ // the same way `homespun grants`/`homespun members`/`homespun data` do
7
+ // (resolveAppId).
8
+ //
9
+ // Auth on the relay side is owner-or-owning-agent; this CLI always
10
+ // authenticates as the owning agent, so any verb works for an app the calling
11
+ // agent's owning human owns. A service credential itself can reach none of
12
+ // these routes (the relay's owner-or-agent gate rejects it at the door), so
13
+ // there is no separate authorization check to add here.
14
+ //
15
+ // mint's raw `token` is printed exactly ONCE (only its sha256 is stored, so
16
+ // it is never recoverable afterward); rotate's new token is printed once the
17
+ // same way.
18
+ import { assertKnownFlags } from "../argv.js";
19
+ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
20
+ import { makeClient } from "../config.js";
21
+ import { fail, failFromError, printJson } from "../output.js";
22
+ import { resolveAppId } from "../resolve-app.js";
23
+ export async function runCredential(args) {
24
+ const verb = args.positionals[0];
25
+ if ((verb === undefined || verb === "help") && args.bools.has("help")) {
26
+ process.stdout.write(renderNounHelp(nounSpec("credentials")) + "\n");
27
+ return;
28
+ }
29
+ if (verb === undefined) {
30
+ fail("missing verb: homespun credentials <mint|list|pause|resume|rotate|revoke>", "invalid_args");
31
+ }
32
+ const sub = {
33
+ positionals: args.positionals.slice(1),
34
+ flags: args.flags,
35
+ bools: args.bools,
36
+ ...(args.danglingValueFlags !== undefined
37
+ ? { danglingValueFlags: args.danglingValueFlags }
38
+ : {}),
39
+ };
40
+ switch (verb) {
41
+ case "mint":
42
+ return runMint(sub);
43
+ case "list":
44
+ return runList(sub);
45
+ case "pause":
46
+ return runPause(sub);
47
+ case "resume":
48
+ return runResume(sub);
49
+ case "rotate":
50
+ return runRotate(sub);
51
+ case "revoke":
52
+ return runRevoke(sub);
53
+ default:
54
+ fail(`unknown verb '${verb}' (homespun credentials <mint|list|pause|resume|rotate|revoke>)`, "invalid_args");
55
+ }
56
+ }
57
+ function parseNonNegativeInt(raw, flag) {
58
+ const n = Number(raw);
59
+ if (!Number.isInteger(n) || n < 0) {
60
+ fail(`${flag} must be a non-negative integer`, "invalid_args");
61
+ }
62
+ return n;
63
+ }
64
+ function parsePositiveInt(raw, flag) {
65
+ const n = Number(raw);
66
+ if (!Number.isInteger(n) || n <= 0) {
67
+ fail(`${flag} must be a positive integer`, "invalid_args");
68
+ }
69
+ return n;
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // mint
73
+ // ---------------------------------------------------------------------------
74
+ async function runMint(args) {
75
+ assertKnownFlags(args, ...specFor("credentials", "mint"));
76
+ const appArg = args.flags.get("app");
77
+ if (!appArg) {
78
+ fail("usage: homespun credentials mint --app <idOrSlug>", "invalid_args");
79
+ }
80
+ const mode = args.flags.get("mode");
81
+ if (mode !== undefined && mode !== "explicit" && mode !== "following") {
82
+ fail('--mode must be "explicit" or "following"', "invalid_args");
83
+ }
84
+ const grantsRaw = args.flags.get("grants");
85
+ let grants;
86
+ if (grantsRaw !== undefined) {
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(grantsRaw);
90
+ }
91
+ catch {
92
+ fail('--grants must be a JSON array, e.g. \'[{"collection":"orders","ops":["read","create"]}]\'', "invalid_args");
93
+ }
94
+ if (!Array.isArray(parsed)) {
95
+ fail("--grants must be a JSON array of allowlist entries", "invalid_args");
96
+ }
97
+ grants = parsed;
98
+ }
99
+ const ttlRaw = args.flags.get("ttl");
100
+ const noExpiry = args.bools.has("no-expiry");
101
+ if (ttlRaw !== undefined && noExpiry) {
102
+ fail("--ttl and --no-expiry are mutually exclusive", "invalid_args");
103
+ }
104
+ const client = makeClient(args);
105
+ const appId = await resolveAppId(client, appArg);
106
+ try {
107
+ printJson(await client.mintAppCredential(appId, {
108
+ ...(mode !== undefined
109
+ ? { mode: mode }
110
+ : {}),
111
+ ...(grants !== undefined ? { grants } : {}),
112
+ ...(args.bools.has("members") ? { members: true } : {}),
113
+ ...(args.flags.get("label") !== undefined
114
+ ? { label: args.flags.get("label") }
115
+ : {}),
116
+ ...(noExpiry
117
+ ? { ttlSeconds: null }
118
+ : ttlRaw !== undefined
119
+ ? { ttlSeconds: parsePositiveInt(ttlRaw, "--ttl") }
120
+ : {}),
121
+ }));
122
+ }
123
+ catch (e) {
124
+ failFromError(e);
125
+ }
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // list
129
+ // ---------------------------------------------------------------------------
130
+ async function runList(args) {
131
+ assertKnownFlags(args, ...specFor("credentials", "list"));
132
+ const appArg = args.flags.get("app");
133
+ if (!appArg) {
134
+ fail("usage: homespun credentials list --app <idOrSlug>", "invalid_args");
135
+ }
136
+ const client = makeClient(args);
137
+ const appId = await resolveAppId(client, appArg);
138
+ try {
139
+ printJson(await client.listAppCredentials(appId));
140
+ }
141
+ catch (e) {
142
+ failFromError(e);
143
+ }
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // pause / resume
147
+ // ---------------------------------------------------------------------------
148
+ function requireCredentialFlags(args, verb) {
149
+ const appArg = args.flags.get("app");
150
+ if (!appArg) {
151
+ fail(`usage: homespun credentials ${verb} --app <idOrSlug> --credential <credentialId>`, "invalid_args");
152
+ }
153
+ const credentialId = args.flags.get("credential");
154
+ if (!credentialId) {
155
+ fail("--credential is required", "invalid_args");
156
+ }
157
+ return { appArg: appArg, credentialId: credentialId };
158
+ }
159
+ async function runPause(args) {
160
+ assertKnownFlags(args, ...specFor("credentials", "pause"));
161
+ const { appArg, credentialId } = requireCredentialFlags(args, "pause");
162
+ const client = makeClient(args);
163
+ const appId = await resolveAppId(client, appArg);
164
+ try {
165
+ await client.pauseAppCredential(appId, credentialId);
166
+ printJson({ paused: true, app_id: appId, credential_id: credentialId });
167
+ }
168
+ catch (e) {
169
+ failFromError(e);
170
+ }
171
+ }
172
+ async function runResume(args) {
173
+ assertKnownFlags(args, ...specFor("credentials", "resume"));
174
+ const { appArg, credentialId } = requireCredentialFlags(args, "resume");
175
+ const client = makeClient(args);
176
+ const appId = await resolveAppId(client, appArg);
177
+ try {
178
+ await client.resumeAppCredential(appId, credentialId);
179
+ printJson({ resumed: true, app_id: appId, credential_id: credentialId });
180
+ }
181
+ catch (e) {
182
+ failFromError(e);
183
+ }
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // rotate
187
+ // ---------------------------------------------------------------------------
188
+ async function runRotate(args) {
189
+ assertKnownFlags(args, ...specFor("credentials", "rotate"));
190
+ const { appArg, credentialId } = requireCredentialFlags(args, "rotate");
191
+ const overlapRaw = args.flags.get("overlap");
192
+ const client = makeClient(args);
193
+ const appId = await resolveAppId(client, appArg);
194
+ try {
195
+ printJson(await client.rotateAppCredential(appId, credentialId, {
196
+ ...(overlapRaw !== undefined
197
+ ? { overlapSeconds: parseNonNegativeInt(overlapRaw, "--overlap") }
198
+ : {}),
199
+ }));
200
+ }
201
+ catch (e) {
202
+ failFromError(e);
203
+ }
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // revoke
207
+ // ---------------------------------------------------------------------------
208
+ async function runRevoke(args) {
209
+ assertKnownFlags(args, ...specFor("credentials", "revoke"));
210
+ const { appArg, credentialId } = requireCredentialFlags(args, "revoke");
211
+ const client = makeClient(args);
212
+ const appId = await resolveAppId(client, appArg);
213
+ try {
214
+ await client.revokeAppCredential(appId, credentialId);
215
+ printJson({ revoked: true, app_id: appId, credential_id: credentialId });
216
+ }
217
+ catch (e) {
218
+ failFromError(e);
219
+ }
220
+ }
@@ -1,14 +1,174 @@
1
1
  // `homespun deploy` — create or redeploy an App (spec-cli §3.1). This is the
2
2
  // create->redeploy loop the v2 vision names: no `--app` creates a new App;
3
3
  // `--app <id>` redeploys an existing one (compat-gated unless --force).
4
- import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { makeClient } from "../config.js";
7
7
  import { assertKnownFlags } from "../argv.js";
8
8
  import { specFor } from "../help-catalog.js";
9
- import { fail, failFromError, printJson } from "../output.js";
9
+ import { fail, failFromError, printJson, warn } from "../output.js";
10
10
  import { resolveJson } from "../input.js";
11
11
  import { resolveAppId } from "../resolve-app.js";
12
+ // The subdirectory a directory deploy ships as the relay's multi-file `assets[]`
13
+ // bundle (issue #1225). Deliberately an EXPLICIT directory rather than "every
14
+ // file next to index.html": a whole-directory walk would sweep up node_modules,
15
+ // .git, package.json, lockfiles and source, burn the 50-asset cap on them, and
16
+ // publish source to an origin the whole world can read. `assets/` cannot be
17
+ // triggered by accident.
18
+ const ASSET_DIR = "assets";
19
+ // The reference path KEEPS the `assets/` prefix, so a file at
20
+ // `<dir>/assets/fonts/inter.woff2` is referenced by the page as
21
+ // `assets/fonts/inter.woff2`. What is on disk is what the HTML writes; no
22
+ // prefix-stripping to reason about.
23
+ // Client-side mirrors of the relay's deploy caps (MAX_APP_ASSETS,
24
+ // MAX_BLOB_BYTES in the relay's config). Same pattern as the slug/visibility
25
+ // checks below: fail fast with a message naming the offending file rather than
26
+ // base64 the whole bundle and round-trip a request that will be rejected. The
27
+ // relay re-checks both and stays authoritative.
28
+ const MAX_ASSETS = 50;
29
+ const MAX_ASSET_BYTES = 5_000_000;
30
+ // The relay's asset-path charset (core/app-assets.ts ASSET_PATH_CHARSET).
31
+ // Excludes ':' , whitespace, '%', '\' and every control byte.
32
+ const ASSET_PATH_CHARSET = /^[A-Za-z0-9._/-]+$/;
33
+ // Extensions the relay cannot serve as executable subresources. These have no
34
+ // magic bytes, so they sniff to `application/octet-stream`, pass the upload
35
+ // allowlist, and are then served `Content-Disposition: attachment` with
36
+ // `X-Content-Type-Options: nosniff`, so the browser downloads them instead of
37
+ // running them, and `<script src>` / `<link rel=stylesheet>` silently does
38
+ // nothing. Uploading them would reproduce exactly the silent failure this
39
+ // change exists to remove, so refuse them here with an explanation.
40
+ const NON_SERVABLE_EXTENSIONS = new Set([
41
+ ".js",
42
+ ".mjs",
43
+ ".cjs",
44
+ ".jsx",
45
+ ".ts",
46
+ ".tsx",
47
+ ".css",
48
+ ".svg",
49
+ ".html",
50
+ ".htm",
51
+ ".xhtml",
52
+ ]);
53
+ // Types the relay CANNOT sniff from magic bytes, where a declared `mime` is the
54
+ // only way the asset is stored as itself rather than as octet-stream. For
55
+ // everything else (images, fonts, audio, video, pdf) the mime is omitted
56
+ // deliberately: the relay sniffs the real type, and a declared type that
57
+ // disagrees with the bytes is rejected.
58
+ const DECLARED_MIME_BY_EXTENSION = new Map([
59
+ [".txt", "text/plain"],
60
+ [".csv", "text/csv"],
61
+ [".md", "text/markdown"],
62
+ [".json", "application/json"],
63
+ [".zip", "application/zip"],
64
+ [
65
+ ".docx",
66
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
67
+ ],
68
+ [
69
+ ".xlsx",
70
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
71
+ ],
72
+ [
73
+ ".pptx",
74
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
75
+ ],
76
+ ]);
77
+ /** Lowercase extension including the dot, or "" when there is none. */
78
+ function extensionOf(path) {
79
+ const base = path.slice(path.lastIndexOf("/") + 1);
80
+ const dot = base.lastIndexOf(".");
81
+ return dot <= 0 ? "" : base.slice(dot).toLowerCase();
82
+ }
83
+ /**
84
+ * Every file under `<dir>/assets/`, as paths relative to that directory,
85
+ * depth-first and sorted so a deploy is byte-identical across machines.
86
+ *
87
+ * Dot-prefixed entries are skipped at every level: `.DS_Store`, `.gitkeep` and
88
+ * editor droppings are never intended as assets, and the relay's path charset
89
+ * would take them anyway, so skipping is the only way they do not silently
90
+ * consume the asset budget.
91
+ */
92
+ function walkAssetDir(assetRoot, rel, out) {
93
+ const entries = readdirSync(rel === "" ? assetRoot : join(assetRoot, rel), {
94
+ withFileTypes: true,
95
+ });
96
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
97
+ if (entry.name.startsWith("."))
98
+ continue;
99
+ const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
100
+ if (entry.isDirectory()) {
101
+ walkAssetDir(assetRoot, childRel, out);
102
+ }
103
+ else if (entry.isFile()) {
104
+ out.push(childRel);
105
+ }
106
+ // Anything else (symlink, socket, fifo) is skipped: not a file we can read
107
+ // bytes from with any confidence about what they are.
108
+ }
109
+ }
110
+ /**
111
+ * Read `<source>/assets/**` into the relay's `assets[]` bundle shape.
112
+ *
113
+ * Returns `undefined` when there is no `assets/` directory at all, which the
114
+ * relay reads as "carry the live asset set forward" on a redeploy. That
115
+ * distinction matters: a directory that never had an `assets/` folder must not
116
+ * wipe assets an agent uploaded through the MCP `deploy_app` path. An `assets/`
117
+ * directory that exists but is empty returns `[]`, the relay's explicit
118
+ * "clear the assets".
119
+ */
120
+ function readAssets(source) {
121
+ const assetRoot = join(source, ASSET_DIR);
122
+ if (!existsSync(assetRoot) || !statSync(assetRoot).isDirectory()) {
123
+ return undefined;
124
+ }
125
+ const relPaths = [];
126
+ walkAssetDir(assetRoot, "", relPaths);
127
+ if (relPaths.length > MAX_ASSETS) {
128
+ fail(`too many assets: ${ASSET_DIR}/ holds ${relPaths.length} files, the limit is ${MAX_ASSETS} per deploy`, "invalid_args");
129
+ }
130
+ return relPaths.map((rel) => {
131
+ // Mirror of the relay's validateAssetPath charset. A space or an accent in
132
+ // a filename is the common case here, and both are ordinary on disk, so
133
+ // catching it locally turns a server round-trip into an immediate message
134
+ // 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
+ }
138
+ 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
+ const bytes = readFileSync(join(assetRoot, rel));
143
+ if (bytes.byteLength > MAX_ASSET_BYTES) {
144
+ fail(`asset ${ASSET_DIR}/${rel} is ${bytes.byteLength} bytes, over the ${MAX_ASSET_BYTES}-byte per-file limit`, "invalid_args");
145
+ }
146
+ const mime = DECLARED_MIME_BY_EXTENSION.get(ext);
147
+ return {
148
+ path: `${ASSET_DIR}/${rel}`,
149
+ content_base64: bytes.toString("base64"),
150
+ ...(mime !== undefined ? { mime } : {}),
151
+ };
152
+ });
153
+ }
154
+ /**
155
+ * Warn about files sitting next to `index.html` that this deploy is NOT
156
+ * shipping. Silently dropping them is the failure mode issue #1225 is about:
157
+ * the deploy succeeds, and the app 404s on a file the author can plainly see in
158
+ * the directory.
159
+ */
160
+ function warnAboutIgnoredFiles(source) {
161
+ const ignored = readdirSync(source, { withFileTypes: true })
162
+ .filter((e) => !e.name.startsWith(".") &&
163
+ e.name !== "index.html" &&
164
+ e.name !== "manifest.json" &&
165
+ !(e.isDirectory() && e.name === ASSET_DIR))
166
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
167
+ .sort();
168
+ if (ignored.length === 0)
169
+ return;
170
+ warn(`not deploying ${ignored.length} entr${ignored.length === 1 ? "y" : "ies"} in ${source}: ${ignored.join(", ")}. A directory deploy ships index.html, manifest.json and everything under ${ASSET_DIR}/. Move files you want served into ${ASSET_DIR}/.`);
171
+ }
12
172
  function readBundle(source, manifestFlag, isRedeploy) {
13
173
  // No source at all: only a redeploy can do this, and only to change the
14
174
  // manifest alone (the live document is inherited).
@@ -39,9 +199,12 @@ function readBundle(source, manifestFlag, isRedeploy) {
39
199
  if (missing.length > 0) {
40
200
  fail(`directory deploy is missing required file(s): ${missing.join(", ")}`, "invalid_args");
41
201
  }
202
+ warnAboutIgnoredFiles(source);
203
+ const assets = readAssets(source);
42
204
  return {
43
205
  html: readFileSync(htmlPath, "utf8"),
44
206
  manifest: JSON.parse(readFileSync(manifestPath, "utf8")),
207
+ ...(assets !== undefined ? { assets } : {}),
45
208
  };
46
209
  }
47
210
  // Single-file escape hatch. A create must pair it with --manifest; a
@@ -81,6 +244,7 @@ export async function runDeploy(args) {
81
244
  ...(id !== undefined ? { app_id: id } : {}),
82
245
  ...(bundle.html !== undefined ? { html: bundle.html } : {}),
83
246
  ...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
247
+ ...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
84
248
  ...(force ? { force } : {}),
85
249
  });
86
250
  printJson(result);
@@ -105,6 +269,7 @@ export async function runDeploy(args) {
105
269
  manifest: bundle.manifest,
106
270
  visibility,
107
271
  slug,
272
+ ...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
108
273
  });
109
274
  printJson(out);
110
275
  }
@@ -122,13 +287,17 @@ export async function runDeploy(args) {
122
287
  }
123
288
  const id = await resolveAppId(client, appId);
124
289
  try {
125
- // Only what this invocation actually read is sent: an omitted html or
126
- // manifest keeps what is live, so `homespun deploy ./index.html --app <id>`
127
- // ships the document alone and `--manifest` with no file ships the
128
- // manifest alone.
290
+ // Only what this invocation actually read is sent: an omitted html,
291
+ // manifest or asset set keeps what is live, so
292
+ // `homespun deploy ./index.html --app <id>` ships the document alone and
293
+ // `--manifest` with no file ships the manifest alone. A directory WITH an
294
+ // `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.
129
297
  const redeployed = await client.redeployApp(id, {
130
298
  ...(bundle.html !== undefined ? { html: bundle.html } : {}),
131
299
  ...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
300
+ ...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
132
301
  force,
133
302
  });
134
303
  const app = await client.getApp(id);
@@ -610,6 +610,289 @@ const GRANTS = {
610
610
  "revoke is idempotent; a revoked link is rejected on every subsequent request.",
611
611
  ],
612
612
  };
613
+ const CREDENTIALS = {
614
+ noun: "credentials",
615
+ tagline: "scoped service credential management",
616
+ group: "app",
617
+ rootSummary: "App service-credential management: mint, list, pause, resume, rotate, revoke. Mint the bearer token an owner points a backend they host themselves at.",
618
+ verbs: [
619
+ {
620
+ verb: "mint",
621
+ summary: "Mints a scoped service credential and prints its raw token once.",
622
+ flags: [
623
+ {
624
+ name: "app",
625
+ value: "<idOrSlug>",
626
+ description: "App to mint the credential for (required)",
627
+ },
628
+ {
629
+ name: "mode",
630
+ value: "<explicit|following>",
631
+ description: "explicit (default): an unnamed collection is denied. following: an unnamed collection falls through to the owner's authority and each grant only narrows",
632
+ },
633
+ {
634
+ name: "grants",
635
+ value: "<json>",
636
+ description: "The allowlist as a JSON array of {collection, ops, scope?} entries (see notes)",
637
+ },
638
+ {
639
+ name: "label",
640
+ value: "<text>",
641
+ description: "Human-readable label for the credential",
642
+ },
643
+ {
644
+ name: "ttl",
645
+ value: "<seconds>",
646
+ description: "Lifetime in seconds, default 365 days and clamped to the server max",
647
+ },
648
+ ],
649
+ bools: [
650
+ {
651
+ name: "members",
652
+ description: "Opt this credential into the app's member directory (default off)",
653
+ },
654
+ {
655
+ name: "no-expiry",
656
+ description: "NO EXPIRY, an explicit opt-in for a long-running backend. Mutually exclusive with --ttl",
657
+ },
658
+ ],
659
+ },
660
+ {
661
+ verb: "list",
662
+ summary: "Lists the app's service credentials.",
663
+ flags: [
664
+ {
665
+ name: "app",
666
+ value: "<idOrSlug>",
667
+ description: "App to list credentials for (required)",
668
+ },
669
+ ],
670
+ },
671
+ {
672
+ verb: "pause",
673
+ summary: "Reversibly stops one credential.",
674
+ flags: [
675
+ {
676
+ name: "app",
677
+ value: "<idOrSlug>",
678
+ description: "App the credential belongs to (required)",
679
+ },
680
+ {
681
+ name: "credential",
682
+ value: "<credentialId>",
683
+ description: "Credential to pause (required)",
684
+ },
685
+ ],
686
+ },
687
+ {
688
+ verb: "resume",
689
+ summary: "Undoes a pause. Never undoes a revoke, which is permanent.",
690
+ flags: [
691
+ {
692
+ name: "app",
693
+ value: "<idOrSlug>",
694
+ description: "App the credential belongs to (required)",
695
+ },
696
+ {
697
+ name: "credential",
698
+ value: "<credentialId>",
699
+ description: "Credential to resume (required)",
700
+ },
701
+ ],
702
+ },
703
+ {
704
+ verb: "rotate",
705
+ summary: "Issues a fresh token and keeps the old one live for an overlap window, printing the new token once.",
706
+ flags: [
707
+ {
708
+ name: "app",
709
+ value: "<idOrSlug>",
710
+ description: "App the credential belongs to (required)",
711
+ },
712
+ {
713
+ name: "credential",
714
+ value: "<credentialId>",
715
+ description: "Credential to rotate (required)",
716
+ },
717
+ {
718
+ name: "overlap",
719
+ value: "<seconds>",
720
+ description: "How long the superseded token keeps working (default 1 day, clamped to a server max); 0 kills it immediately",
721
+ },
722
+ ],
723
+ },
724
+ {
725
+ verb: "revoke",
726
+ summary: "Revokes one credential permanently.",
727
+ flags: [
728
+ {
729
+ name: "app",
730
+ value: "<idOrSlug>",
731
+ description: "App the credential belongs to (required)",
732
+ },
733
+ {
734
+ name: "credential",
735
+ value: "<credentialId>",
736
+ description: "Credential to revoke (required)",
737
+ },
738
+ ],
739
+ },
740
+ ],
741
+ notes: [
742
+ "--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
743
+ "A credential is the bearer token an app owner points a backend they host themselves at. Effective permission is always the intersection of the allowlist and what the app's owner could do, so a credential can only ever narrow, never widen, and it carries no role.",
744
+ "mint's raw token is shown ONCE in the response and is never recoverable afterward (only its sha256 is stored); if it is lost, rotate or mint a new one. --grants names each collection the credential may reach and which of read/create/update/delete it may attempt there; a collection not named is denied under --mode explicit (the default) and falls through to the owner's own authority under --mode following. An entry may set a `scope` of \"own\" to narrow every row-addressed op to rows the credential itself wrote last.",
745
+ "pause is reversible; resume undoes it. revoke is permanent and idempotent, and also kills any token a rotation left inside its overlap window. Every verb here is owner-or-owning-agent only: a service credential itself can reach none of these.",
746
+ ],
747
+ };
748
+ const CONNECTIONS = {
749
+ noun: "connections",
750
+ tagline: "webhook connection management",
751
+ group: "app",
752
+ rootSummary: "App webhook-connection management: create, list, delete, authorize-url. Store the credential a webhook rule authenticates its target with.",
753
+ verbs: [
754
+ {
755
+ verb: "create",
756
+ summary: "Creates a static or oauth2 connection.",
757
+ flags: [
758
+ {
759
+ name: "app",
760
+ value: "<idOrSlug>",
761
+ description: "App to create the connection on (required)",
762
+ },
763
+ {
764
+ name: "name",
765
+ value: "<name>",
766
+ description: "Connection name a manifest webhook rule's `connection` field references (required)",
767
+ },
768
+ {
769
+ name: "allowed-host",
770
+ value: "<host>",
771
+ description: "Host-binding: an exact DNS host or a single leftmost '*.' wildcard (required)",
772
+ },
773
+ {
774
+ name: "kind",
775
+ value: "<static|oauth2>",
776
+ description: "Defaults to static",
777
+ },
778
+ {
779
+ name: "provider",
780
+ value: "<text>",
781
+ description: 'Freeform display label, e.g. "hubspot"',
782
+ },
783
+ {
784
+ name: "label",
785
+ value: "<text>",
786
+ description: "Human-readable label for the connection",
787
+ },
788
+ {
789
+ name: "header-name",
790
+ value: "<name>",
791
+ description: "static only. Defaults to Authorization",
792
+ },
793
+ {
794
+ name: "header-value",
795
+ value: "<value>",
796
+ description: 'static only, required. The header value to send, e.g. "Bearer sk_live_..."',
797
+ },
798
+ {
799
+ name: "authorize-url",
800
+ value: "<url>",
801
+ description: "oauth2 only, required. The provider's authorize endpoint",
802
+ },
803
+ {
804
+ name: "token-url",
805
+ value: "<url>",
806
+ description: "oauth2 only, required. The provider's token endpoint",
807
+ },
808
+ {
809
+ name: "client-id",
810
+ value: "<id>",
811
+ description: "oauth2 only, required. Your OAuth2 app's client id",
812
+ },
813
+ {
814
+ name: "client-secret",
815
+ value: "<secret>",
816
+ description: "oauth2 only, required. Your OAuth2 app's client secret",
817
+ },
818
+ {
819
+ name: "scopes",
820
+ value: "<text>",
821
+ description: "oauth2 only. Space-delimited scopes for the authorize request",
822
+ },
823
+ {
824
+ name: "auth-scheme",
825
+ value: "<text>",
826
+ description: 'oauth2 only. Defaults to "Bearer"',
827
+ },
828
+ {
829
+ name: "instance-field",
830
+ value: "<field>",
831
+ description: 'oauth2 only. Token-response JSON field holding the API base URL, e.g. "instance_url"',
832
+ },
833
+ {
834
+ name: "auth-params",
835
+ value: "<json>",
836
+ description: "oauth2 only. Extra key/values merged into the authorize redirect",
837
+ },
838
+ {
839
+ name: "token-params",
840
+ value: "<json>",
841
+ description: "oauth2 only. Extra key/values merged into the token POST",
842
+ },
843
+ ],
844
+ },
845
+ {
846
+ verb: "list",
847
+ summary: "Lists the app's connections as metadata plus a fingerprint, never a secret.",
848
+ flags: [
849
+ {
850
+ name: "app",
851
+ value: "<idOrSlug>",
852
+ description: "App to list connections for (required)",
853
+ },
854
+ ],
855
+ },
856
+ {
857
+ verb: "delete",
858
+ summary: "Deletes a connection, idempotently.",
859
+ flags: [
860
+ {
861
+ name: "app",
862
+ value: "<idOrSlug>",
863
+ description: "App the connection belongs to (required)",
864
+ },
865
+ {
866
+ name: "name",
867
+ value: "<name>",
868
+ description: "Connection to delete (required)",
869
+ },
870
+ ],
871
+ },
872
+ {
873
+ verb: "authorize-url",
874
+ summary: "Prints the browser URL that completes an oauth2 connection's owner consent. Never fetched by this command.",
875
+ flags: [
876
+ {
877
+ name: "app",
878
+ value: "<idOrSlug>",
879
+ description: "App the connection belongs to (required)",
880
+ },
881
+ {
882
+ name: "name",
883
+ value: "<name>",
884
+ description: "oauth2 connection to build the URL for (required)",
885
+ },
886
+ ],
887
+ },
888
+ ],
889
+ notes: [
890
+ "--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
891
+ "A connection is the stored credential a manifest webhook rule authenticates its delivery target with, bound to a host so it can never be sent to another one. There is no update verb: change a connection by deleting and recreating it.",
892
+ "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.",
893
+ "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.",
894
+ ],
895
+ };
613
896
  const KEY = {
614
897
  noun: "key",
615
898
  tagline: "your agent's API key",
@@ -675,9 +958,9 @@ const TASTE = {
675
958
  };
676
959
  const FEEDBACK = {
677
960
  noun: "feedback",
678
- tagline: "feedback to the relay operator",
961
+ tagline: "report a problem with homespun itself",
679
962
  group: "other",
680
- rootSummary: "One-shot feedback to the relay operator: create, list. Bug reports, feature requests, and notes.",
963
+ rootSummary: "Report a problem with homespun itself to the relay operator: create, list. Bug reports, feature requests, and notes.",
681
964
  verbs: [
682
965
  {
683
966
  verb: "create",
@@ -718,8 +1001,12 @@ const FEEDBACK = {
718
1001
  },
719
1002
  ],
720
1003
  notes: [
721
- "Feedback is a one-shot bug report, feature request, or note from your agent to whoever runs the relay. Submissions are stored in the relay DB and the operator triages them out of band.",
722
- "create does not echo the message back. list prints { items: [...], next_before } so you can pass --before <cursor> from a previous page to fetch the next one.",
1004
+ "Feedback is a one-shot bug report, feature request, or note from your agent to whoever runs the relay. Submissions are stored in the relay DB and the operator triages them out of band. This is the channel that turns a problem you hit into a problem that gets fixed, so file without being asked: nobody will prompt you, and an unreported failure is one the operator cannot see.",
1005
+ "File a report when: a command returns a 5xx or an error code the skill does not describe; documented and observed behaviour disagree; what you need cannot be expressed at all (a missing capability, a self-contradictory schema); a deployed app misbehaves in a way that traces back to the platform rather than to HTML you wrote; or the skill was wrong, ambiguous or silent and you had to guess. An error envelope carrying a `report` field is the CLI telling you the failure was homespun's, not yours.",
1006
+ "Do not file: problems with the human's own task, or bugs in an app you authored; presentation preferences, which belong in 'homespun taste'; the human's own configuration, such as a missing API key or the wrong account; or a 4xx caused by arguments you got wrong, unless the error message itself sent you the wrong way, which is a documentation bug worth --type note.",
1007
+ "Report once, not once per retry. Run 'homespun feedback list' first and skip anything already recorded; one report per distinct failure per session. An agent in a retry loop filing the same row twenty times buries the signal it was trying to send.",
1008
+ "Say enough that it can be fixed without you: the operator sees the row, not your session, so \"deploy failed\" is unactionable. Structure the message as surface (mcp|cli|relay|app-runtime); where (the command or route); versions (cli from 'homespun --version', skill from 'homespun skill version'); expected, one line; observed, one line carrying the exact error code and message; repro, the minimal steps or the arguments you passed. Pipe it in with --message - rather than fighting shell quoting.",
1009
+ "There is no reply channel, so never use feedback for anything you need an answer to. create does not echo the message back. list prints { items: [...], next_before } so you can pass --before <cursor> from a previous page to fetch the next one.",
723
1010
  ],
724
1011
  };
725
1012
  const CONFIG = {
@@ -846,8 +1133,9 @@ const DEPLOY = {
846
1133
  ],
847
1134
  notes: [
848
1135
  "Packaging has one canonical shape and one escape hatch. A directory deploy (homespun deploy ./my-app) reads ./my-app/index.html and ./my-app/manifest.json: fixed filenames, no discovery heuristics, and both files are required. The single-file escape hatch (homespun deploy ./index.html --manifest ./manifest.json) takes the manifest from --manifest, which accepts a file path or inline JSON.",
1136
+ "Files to serve alongside the document go in ./my-app/assets/. Everything under it ships as the deploy's asset bundle and is served on the app's own origin at the same path, so ./my-app/assets/fonts/inter.woff2 is referenced by the page as assets/fonts/inter.woff2. Nested directories are kept, dot-prefixed entries are skipped, and the limits are 50 files at up to 5 MB each. Only assets/ is shipped: anything else sitting next to index.html is reported on stderr and left behind, so a stray node_modules or package.json is never published. Scripts and stylesheets cannot be assets. The relay serves .js, .css and .svg as an inert download, so a browser would refuse to run or apply them; the app CSP allows inline script and style, so put them in index.html.",
849
1137
  "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'.",
850
- "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. The live asset set is carried forward either way. A create can inherit nothing, so it still needs both halves.",
1138
+ "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.",
851
1139
  "--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.",
852
1140
  ],
853
1141
  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.',
@@ -1277,6 +1565,8 @@ const NOUNS = [
1277
1565
  DATA,
1278
1566
  MEMBERS,
1279
1567
  GRANTS,
1568
+ CREDENTIALS,
1569
+ CONNECTIONS,
1280
1570
  INGEST,
1281
1571
  PUBLISHER,
1282
1572
  TEMPLATE,
package/dist/index.js CHANGED
@@ -38,6 +38,8 @@ import { runApps } from "./commands/apps.js";
38
38
  import { runData } from "./commands/data.js";
39
39
  import { runMembers } from "./commands/members.js";
40
40
  import { runGrant } from "./commands/grant.js";
41
+ import { runCredential } from "./commands/credential.js";
42
+ import { runConnection } from "./commands/connection.js";
41
43
  import { runIngest } from "./commands/ingest.js";
42
44
  import { runPublisher } from "./commands/publisher.js";
43
45
  import { runTemplate } from "./commands/template.js";
@@ -129,6 +131,12 @@ async function main() {
129
131
  case "grants":
130
132
  await runGrant(args);
131
133
  break;
134
+ case "credentials":
135
+ await runCredential(args);
136
+ break;
137
+ case "connections":
138
+ await runConnection(args);
139
+ break;
132
140
  case "ingest":
133
141
  await runIngest(args);
134
142
  break;
package/dist/output.js CHANGED
@@ -1,12 +1,20 @@
1
1
  // stdout/stderr helpers. The CLI is JSON-by-default: machine-readable on
2
2
  // stdout, human errors on stderr.
3
- import { HomespunApiError } from "@homespunapps/core";
3
+ import { HomespunApiError, RELAY_FAILURE_REPORT_HINT, } from "@homespunapps/core";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { detectInstallMethod, upgradeCommandFor, formatUpgradeMessage, EXIT_CLI_UPGRADE_REQUIRED, } from "./upgrade.js";
6
6
  /** Print a value as pretty JSON to stdout. */
7
7
  export function printJson(value) {
8
8
  process.stdout.write(JSON.stringify(value, null, 2) + "\n");
9
9
  }
10
+ /**
11
+ * Print a non-fatal advisory to STDERR. Never stdout: stdout carries the JSON
12
+ * result a caller pipes into `jq`, and a warning mixed into it would make that
13
+ * output unparseable.
14
+ */
15
+ export function warn(message) {
16
+ process.stderr.write(`warning: ${message}\n`);
17
+ }
10
18
  /**
11
19
  * Print a single compact JSON line to stdout and flush. Used by `homespun watch`
12
20
  * so a pipe-reader (e.g. Claude Code's Monitor tool) sees each event
@@ -24,6 +32,8 @@ export function fail(message, code = "error", details, extra) {
24
32
  error["retryable"] = extra.retryable;
25
33
  if (extra?.docs_url !== undefined)
26
34
  error["docs_url"] = extra.docs_url;
35
+ if (extra?.report !== undefined)
36
+ error["report"] = extra.report;
27
37
  if (details !== undefined)
28
38
  error["details"] = details;
29
39
  process.stderr.write(JSON.stringify({ error }) + "\n");
@@ -46,6 +56,11 @@ export function failFromError(err) {
46
56
  hint: err.hint,
47
57
  retryable: err.retryable,
48
58
  docs_url: err.docsUrl,
59
+ // 5xx only: the relay failed, so the agent is the only witness and the
60
+ // report is worth prompting. A 4xx is normally the caller's own bad
61
+ // argument, and prompting there would fill the operator's queue with
62
+ // rows they have to triage and close.
63
+ ...(err.status >= 500 ? { report: RELAY_FAILURE_REPORT_HINT } : {}),
49
64
  });
50
65
  }
51
66
  fail(err instanceof Error ? err.message : String(err), "internal");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.44",
3
+ "version": "1.6.45",
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.44",
39
+ "@homespunapps/core": "^1.6.45",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {