@homespunapps/cli 1.0.1 → 1.4.3

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.
@@ -14,39 +14,11 @@
14
14
  // keeps working.
15
15
  import { HomespunClient, HomespunApiError } from "@homespunapps/core";
16
16
  import { assertKnownFlags } from "../argv.js";
17
+ import { specFor } from "../help-catalog.js";
17
18
  import { resolveConfig } from "../config.js";
18
19
  import { printJson, fail } from "../output.js";
19
- const KNOWN_FLAGS = ["url", "api-key"];
20
- const KNOWN_BOOLS = [];
21
- export const claimHelp = `homespun agent claim — claim this agent for a human
22
-
23
- Usage:
24
- homespun agent claim <code>
25
-
26
- Binds the calling agent to the human whose one-shot claim code is provided.
27
- The human generates the code in their settings UI (or via the relay's
28
- POST /v1/self/claim-codes endpoint) and hands it to the agent out-of-band.
29
-
30
- Arguments:
31
- <code> The one-shot claim code (begins with cc_). Required.
32
-
33
- Options:
34
- --url <url> Relay base URL. Falls back to HOMESPUN_URL / config file.
35
- --api-key <key> Agent API key. Falls back to HOMESPUN_API_KEY / config file.
36
- -h, --help Show this help.
37
-
38
- Output (stdout, JSON):
39
- { ok: true, owner_human_id, claimed_at }
40
-
41
- Errors:
42
- invalid_code code is unknown, expired, or already consumed
43
- agent_already_claimed this agent already has an owning human
44
-
45
- Notes:
46
- This is a one-way operation. To rotate the owner, revoke this agent
47
- (\`homespun key revoke\`) and register a new one.`;
48
20
  export async function runClaim(args) {
49
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun agent claim");
21
+ assertKnownFlags(args, ...specFor("agent", "claim"));
50
22
  const code = args.positionals[0];
51
23
  if (!code) {
52
24
  fail("missing required argument: <code> — run 'homespun agent claim --help'", "invalid_args");
@@ -10,39 +10,10 @@
10
10
  // $XDG_CONFIG_HOME/homespun/config.json. See store.ts for the layout. They make
11
11
  // NO network calls — purely local config management.
12
12
  import { assertKnownFlags } from "../argv.js";
13
+ import { specFor } from "../help-catalog.js";
13
14
  import { describeConfig } from "../config.js";
14
15
  import { isValidProfileName, readStore, removeProfile, setCurrentProfile, storePath, upsertProfile, } from "../store.js";
15
16
  import { printJson, fail } from "../output.js";
16
- const SHOW_FLAGS = [];
17
- const SHOW_BOOLS = [];
18
- const LIST_FLAGS = [];
19
- const LIST_BOOLS = [];
20
- const USE_FLAGS = [];
21
- const USE_BOOLS = [];
22
- const ADD_FLAGS = ["api-key"];
23
- const ADD_BOOLS = [];
24
- const RM_FLAGS = [];
25
- const RM_BOOLS = [];
26
- export const configHelp = `homespun config — show and manage the CLI config (multi-profile)
27
-
28
- Usage:
29
- homespun config show Show the resolved relay config
30
- homespun config list List saved profiles
31
- homespun config use <profile> Switch the active profile
32
- homespun config add <profile> --url <u> --api-key <k>
33
- Add a profile manually
34
- homespun config rm <profile> Delete a profile
35
-
36
- A profile is one (url, api_key) pair under a short name (dev, staging, prod).
37
- Switch via 'homespun config use', '--profile <name>', or the HOMESPUN_PROFILE env var.
38
- The active profile's (url, api_key) is what every other command sees unless
39
- overridden by --url / --api-key or HOMESPUN_URL / HOMESPUN_API_KEY.
40
-
41
- Run \`homespun config <verb> --help\` for verb-specific help. The full API key is
42
- never printed; only a short masked prefix.
43
-
44
- The config file lives at \${XDG_CONFIG_HOME:-~/.config}/homespun/config.json
45
- (mode 0600).`;
46
17
  const showHelp = `homespun config show — show the resolved relay config
47
18
 
48
19
  Usage:
@@ -135,7 +106,7 @@ Options:
135
106
  Output (stdout, JSON):
136
107
  { profile, was_current, path }`;
137
108
  async function runConfigShow(args) {
138
- assertKnownFlags(args, SHOW_FLAGS, SHOW_BOOLS, "homespun config show");
109
+ assertKnownFlags(args, ...specFor("config", "show"));
139
110
  printJson(describeConfig(args));
140
111
  }
141
112
  function maskKey(key) {
@@ -146,7 +117,7 @@ function maskKey(key) {
146
117
  return key.slice(0, 8) + "…";
147
118
  }
148
119
  async function runConfigList(args) {
149
- assertKnownFlags(args, LIST_FLAGS, LIST_BOOLS, "homespun config list");
120
+ assertKnownFlags(args, ...specFor("config", "list"));
150
121
  const store = readStore();
151
122
  const profiles = Object.entries(store.profiles)
152
123
  .map(([name, p]) => ({
@@ -163,7 +134,7 @@ async function runConfigList(args) {
163
134
  });
164
135
  }
165
136
  async function runConfigUse(args) {
166
- assertKnownFlags(args, USE_FLAGS, USE_BOOLS, "homespun config use");
137
+ assertKnownFlags(args, ...specFor("config", "use"));
167
138
  const name = args.positionals[1];
168
139
  if (!name) {
169
140
  fail("missing profile name — usage: homespun config use <profile>", "invalid_args");
@@ -178,7 +149,7 @@ async function runConfigUse(args) {
178
149
  printJson({ profile: name, saved_to: savedTo });
179
150
  }
180
151
  async function runConfigAdd(args) {
181
- assertKnownFlags(args, ADD_FLAGS, ADD_BOOLS, "homespun config add");
152
+ assertKnownFlags(args, ...specFor("config", "add"));
182
153
  const name = args.positionals[1];
183
154
  if (!name) {
184
155
  fail("missing profile name — usage: homespun config add <profile> --url <url> --api-key <key>", "invalid_args");
@@ -202,7 +173,7 @@ async function runConfigAdd(args) {
202
173
  printJson({ profile: name, saved_to: savedTo });
203
174
  }
204
175
  async function runConfigRm(args) {
205
- assertKnownFlags(args, RM_FLAGS, RM_BOOLS, "homespun config rm");
176
+ assertKnownFlags(args, ...specFor("config", "rm"));
206
177
  const name = args.positionals[1];
207
178
  if (!name) {
208
179
  fail("missing profile name — usage: homespun config rm <profile>", "invalid_args");
@@ -2,37 +2,23 @@
2
2
  // `homespun records` (v1 app collections) but against `/v1/apps/:id/collections`
3
3
  // and with a dedicated per-row GET (no client-side scan — spec-cli §8 ruling
4
4
  // 3 confirms the relay route is real).
5
+ import { readFileSync } from "node:fs";
5
6
  import { assertKnownFlags } from "../argv.js";
7
+ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
6
8
  import { makeClient } from "../config.js";
7
9
  import { fail, failFromError, printJson } from "../output.js";
8
10
  import { resolveJson } from "../input.js";
9
11
  import { resolveAppId } from "../resolve-app.js";
10
- export const dataHelp = `homespun data — collection row CRUD for a v2 app
11
-
12
- Usage:
13
- homespun data <app> <collection> list [--since <cursor>] [--limit <n>]
14
- homespun data <app> <collection> get <key>
15
- homespun data <app> <collection> upsert --data <path|json> [--key <key>]
16
- homespun data <app> <collection> update <key> --data <path|json> [--if-match <version>]
17
- homespun data <app> <collection> delete <key> [--if-match <version>] [--yes]
18
-
19
- <app> accepts either the app_id or its slug. upsert is the ONLY create-shaped
20
- verb (spec-cli §8 ruling 4): omit --key to add a new row (server-generates
21
- the key); pass --key to ensure a row exists at that key (returns the
22
- existing row with deduped:true on a collision, never errors).
23
-
24
- Output (stdout, single JSON). Errors on stderr:
25
- {"error":{"code","message"}} with non-zero exit.`;
26
12
  export async function runData(args) {
27
13
  const appArg = args.positionals[0];
28
14
  const collection = args.positionals[1];
29
15
  const verb = args.positionals[2];
30
16
  if ((appArg === undefined || verb === undefined) && args.bools.has("help")) {
31
- process.stdout.write(dataHelp + "\n");
17
+ process.stdout.write(renderNounHelp(nounSpec("data")) + "\n");
32
18
  return;
33
19
  }
34
20
  if (!appArg || !collection || !verb) {
35
- fail("usage: homespun data <app> <collection> <list|get|upsert|update|delete>", "invalid_args");
21
+ fail("usage: homespun data <app> <collection> <list|get|upsert|update|delete|purge|import>", "invalid_args");
36
22
  }
37
23
  const sub = {
38
24
  positionals: args.positionals.slice(3),
@@ -53,8 +39,12 @@ export async function runData(args) {
53
39
  return runUpdate(appArg, collection, sub);
54
40
  case "delete":
55
41
  return runDelete(appArg, collection, sub);
42
+ case "purge":
43
+ return runPurge(appArg, collection, sub);
44
+ case "import":
45
+ return runImport(appArg, collection, sub);
56
46
  default:
57
- fail(`unknown verb '${verb}' homespun data <app> <collection> <list|get|upsert|update|delete>`, "invalid_args");
47
+ fail(`unknown verb '${verb}': homespun data <app> <collection> <list|get|upsert|update|delete|purge|import>`, "invalid_args");
58
48
  }
59
49
  }
60
50
  function parseIntFlag(args, name, defaultValue, bounds = {}) {
@@ -73,23 +63,50 @@ function parseIntFlag(args, name, defaultValue, bounds = {}) {
73
63
  return n;
74
64
  }
75
65
  async function runList(appArg, collection, args) {
76
- assertKnownFlags(args, ["since", "limit", "url", "api-key"], ["help"], "homespun data list");
66
+ assertKnownFlags(args, ...specFor("data", "list"));
77
67
  const since = args.flags.get("since");
78
68
  const limit = parseIntFlag(args, "limit", undefined, { min: 1, max: 1000 });
69
+ const where = parseJsonArrayFlag(args, "where");
70
+ const sort = parseJsonArrayFlag(args, "sort");
71
+ if (since !== undefined && sort !== undefined) {
72
+ fail("--since (cursor pagination) cannot be combined with --sort", "invalid_args");
73
+ }
79
74
  const client = makeClient(args);
80
75
  const appId = await resolveAppId(client, appArg);
81
76
  try {
82
77
  printJson(await client.listAppRows(appId, collection, {
83
78
  since,
84
79
  ...(limit !== undefined ? { limit } : {}),
80
+ ...(where !== undefined ? { where } : {}),
81
+ ...(sort !== undefined ? { sort } : {}),
85
82
  }));
86
83
  }
87
84
  catch (e) {
88
85
  failFromError(e);
89
86
  }
90
87
  }
88
+ // Parse a `--<name> <json>` flag whose value must be a JSON array. Returns
89
+ // undefined when the flag is absent; fails cleanly (invalid_args) when present
90
+ // but not a JSON array. Element-level validation is left to the relay, which
91
+ // returns a precise 400 the CLI surfaces verbatim.
92
+ function parseJsonArrayFlag(args, name) {
93
+ const raw = args.flags.get(name);
94
+ if (raw === undefined)
95
+ return undefined;
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(raw);
99
+ }
100
+ catch (e) {
101
+ fail(`--${name} must be valid JSON (${e instanceof Error ? e.message : String(e)})`, "invalid_args");
102
+ }
103
+ if (!Array.isArray(parsed)) {
104
+ fail(`--${name} must be a JSON array`, "invalid_args");
105
+ }
106
+ return parsed;
107
+ }
91
108
  async function runGet(appArg, collection, args) {
92
- assertKnownFlags(args, ["url", "api-key"], ["help"], "homespun data get");
109
+ assertKnownFlags(args, ...specFor("data", "get"));
93
110
  const key = args.positionals[0];
94
111
  if (!key) {
95
112
  fail("usage: homespun data <app> <collection> get <key>", "invalid_args");
@@ -104,19 +121,25 @@ async function runGet(appArg, collection, args) {
104
121
  }
105
122
  }
106
123
  async function runUpsert(appArg, collection, args) {
107
- assertKnownFlags(args, ["data", "key", "url", "api-key"], ["help"], "homespun data upsert");
124
+ assertKnownFlags(args, ...specFor("data", "upsert"));
108
125
  const dataRaw = args.flags.get("data");
109
126
  if (dataRaw === undefined) {
110
127
  fail("--data is required (path to JSON file, or inline JSON)", "invalid_args");
111
128
  }
112
129
  const data = resolveJson(dataRaw, "--data");
113
130
  const key = args.flags.get("key");
131
+ const on = args.flags.get("on");
132
+ if (key !== undefined && on !== undefined) {
133
+ fail("--key and --on are mutually exclusive (upsert by key, or by a unique field, not both)", "invalid_args");
134
+ }
114
135
  const client = makeClient(args);
115
136
  const appId = await resolveAppId(client, appArg);
116
137
  try {
117
138
  const body = { data };
118
139
  if (key !== undefined)
119
140
  body.key = key;
141
+ if (on !== undefined)
142
+ body.on = on;
120
143
  printJson(await client.upsertAppRow(appId, collection, body));
121
144
  }
122
145
  catch (e) {
@@ -124,7 +147,7 @@ async function runUpsert(appArg, collection, args) {
124
147
  }
125
148
  }
126
149
  async function runUpdate(appArg, collection, args) {
127
- assertKnownFlags(args, ["data", "if-match", "url", "api-key"], ["help"], "homespun data update");
150
+ assertKnownFlags(args, ...specFor("data", "update"));
128
151
  const key = args.positionals[0];
129
152
  if (!key) {
130
153
  fail("usage: homespun data <app> <collection> update <key> --data <path|json>", "invalid_args");
@@ -148,7 +171,7 @@ async function runUpdate(appArg, collection, args) {
148
171
  }
149
172
  }
150
173
  async function runDelete(appArg, collection, args) {
151
- assertKnownFlags(args, ["if-match", "url", "api-key"], ["yes", "help"], "homespun data delete");
174
+ assertKnownFlags(args, ...specFor("data", "delete"));
152
175
  const key = args.positionals[0];
153
176
  if (!key) {
154
177
  fail("usage: homespun data <app> <collection> delete <key>", "invalid_args");
@@ -166,3 +189,167 @@ async function runDelete(appArg, collection, args) {
166
189
  failFromError(e);
167
190
  }
168
191
  }
192
+ // `homespun data <app> <coll> purge --key <key>`, owner/agent-only removal that
193
+ // bypasses an append-only collection (Wave C1). The row key comes from --key
194
+ // (not a positional) so a purge reads deliberately, harder to fire by accident.
195
+ async function runPurge(appArg, collection, args) {
196
+ assertKnownFlags(args, ...specFor("data", "purge"));
197
+ const key = args.flags.get("key");
198
+ if (!key) {
199
+ fail("usage: homespun data <app> <collection> purge --key <key>", "invalid_args");
200
+ }
201
+ const client = makeClient(args);
202
+ const appId = await resolveAppId(client, appArg);
203
+ try {
204
+ await client.purgeAppRow(appId, collection, key);
205
+ printJson({ purged: true, key });
206
+ }
207
+ catch (e) {
208
+ failFromError(e);
209
+ }
210
+ }
211
+ // Default per-batch chunk size (matches the relay's BATCH_MAX_ROWS default). A
212
+ // larger --chunk is capped server-side (a batch over the cap 400s), so the CLI
213
+ // keeps a conservative default and lets an operator raise it if their tier does.
214
+ const DEFAULT_IMPORT_CHUNK = 100;
215
+ /**
216
+ * Parse an import file into an ordered list of raw row objects. Accepts either a
217
+ * single JSON array (the whole file parses as an array) or NDJSON (one JSON
218
+ * object per non-blank line). A malformed line reports its 1-based line number.
219
+ */
220
+ function parseImportRows(raw) {
221
+ const trimmed = raw.trim();
222
+ if (trimmed.length === 0)
223
+ return [];
224
+ // JSON array form: the entire file is one array literal.
225
+ if (trimmed.startsWith("[")) {
226
+ let parsed;
227
+ try {
228
+ parsed = JSON.parse(trimmed);
229
+ }
230
+ catch (e) {
231
+ fail(`--file is not valid JSON (${e instanceof Error ? e.message : String(e)})`, "invalid_args");
232
+ }
233
+ if (!Array.isArray(parsed)) {
234
+ fail("--file top-level JSON must be an array of objects", "invalid_args");
235
+ }
236
+ return parsed.map((el, i) => asRowObject(el, i + 1));
237
+ }
238
+ // NDJSON form: one JSON object per non-blank line.
239
+ const out = [];
240
+ const lines = raw.split("\n");
241
+ for (let i = 0; i < lines.length; i++) {
242
+ const line = lines[i].trim();
243
+ if (line.length === 0)
244
+ continue;
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(line);
248
+ }
249
+ catch (e) {
250
+ fail(`--file line ${i + 1} is not valid JSON (${e instanceof Error ? e.message : String(e)})`, "invalid_args");
251
+ }
252
+ out.push(asRowObject(parsed, i + 1));
253
+ }
254
+ return out;
255
+ }
256
+ function asRowObject(value, where) {
257
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
258
+ fail(`--file entry ${where} must be a JSON object (got ${Array.isArray(value) ? "array" : typeof value})`, "invalid_args");
259
+ }
260
+ return value;
261
+ }
262
+ async function runImport(appArg, collection, args) {
263
+ assertKnownFlags(args, ...specFor("data", "import"));
264
+ const file = args.flags.get("file");
265
+ if (file === undefined) {
266
+ fail("--file is required (path to an NDJSON or JSON-array file)", "invalid_args");
267
+ }
268
+ let raw;
269
+ try {
270
+ raw = readFileSync(file, "utf8");
271
+ }
272
+ catch (e) {
273
+ fail(`cannot read --file '${file}': ${e instanceof Error ? e.message : String(e)}`, "invalid_args");
274
+ }
275
+ const objects = parseImportRows(raw);
276
+ if (objects.length === 0) {
277
+ fail("--file contained no rows to import", "invalid_args");
278
+ }
279
+ const chunkSize = parseIntFlag(args, "chunk", DEFAULT_IMPORT_CHUNK, {
280
+ min: 1,
281
+ max: 1000,
282
+ });
283
+ const keyField = args.flags.get("key-field");
284
+ const on = args.flags.get("on");
285
+ const emitEffects = args.bools.has("emit-effects");
286
+ if (keyField !== undefined && on !== undefined) {
287
+ fail("--key-field and --on are mutually exclusive (derive the row key from a field, or upsert on a unique field, not both)", "invalid_args");
288
+ }
289
+ // Build the batch inputs once: each object is a row's `data`; --key-field pulls
290
+ // the row key from a field (create-or-skip-by-id on re-import), while --on
291
+ // upserts on a declared-unique field (update-in-place on re-import).
292
+ const rows = objects.map((obj) => {
293
+ if (keyField === undefined)
294
+ return { data: obj };
295
+ const kv = obj[keyField];
296
+ if (kv === undefined ||
297
+ kv === null ||
298
+ (typeof kv !== "string" && typeof kv !== "number")) {
299
+ fail(`--key-field '${keyField}' missing or not a string/number in an input row`, "invalid_args");
300
+ }
301
+ const key = String(kv);
302
+ if (key.length === 0) {
303
+ fail(`--key-field '${keyField}' is empty in an input row`, "invalid_args");
304
+ }
305
+ return { key, data: obj };
306
+ });
307
+ const client = makeClient(args);
308
+ // resolveAppId ONCE for the whole import (not per chunk / per row): the app id
309
+ // is stable for the process, so one lookup drives every batch call.
310
+ const appId = await resolveAppId(client, appArg);
311
+ const total = rows.length;
312
+ const chunkCount = Math.ceil(total / chunkSize);
313
+ let imported = 0;
314
+ let failed = 0;
315
+ const failures = [];
316
+ try {
317
+ for (let start = 0, chunkNo = 1; start < total; start += chunkSize, chunkNo++) {
318
+ const chunk = rows.slice(start, start + chunkSize);
319
+ const res = await client.batchRows(appId, collection, chunk, {
320
+ ...(emitEffects ? { emitEffects: true } : {}),
321
+ ...(on !== undefined ? { on } : {}),
322
+ });
323
+ for (const r of res.results) {
324
+ // Translate the per-chunk index back to the GLOBAL row index.
325
+ const globalIndex = start + r.index;
326
+ if (r.ok) {
327
+ imported += 1;
328
+ }
329
+ else {
330
+ failed += 1;
331
+ failures.push({
332
+ index: globalIndex,
333
+ ...(r.key !== undefined ? { key: r.key } : {}),
334
+ error: r.error,
335
+ });
336
+ }
337
+ }
338
+ // Human-readable progress on stderr so stdout stays a single JSON summary.
339
+ process.stderr.write(`imported ${imported}/${total} (chunk ${chunkNo}/${chunkCount}, ${failed} failed)\n`);
340
+ }
341
+ }
342
+ catch (e) {
343
+ failFromError(e);
344
+ }
345
+ printJson({
346
+ app: appId,
347
+ collection,
348
+ total,
349
+ imported,
350
+ failed,
351
+ chunks: chunkCount,
352
+ silent: !emitEffects,
353
+ failures,
354
+ });
355
+ }
@@ -5,36 +5,10 @@ import { existsSync, readFileSync, 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
+ import { specFor } from "../help-catalog.js";
8
9
  import { fail, failFromError, printJson } from "../output.js";
9
10
  import { resolveJson } from "../input.js";
10
11
  import { resolveAppId } from "../resolve-app.js";
11
- const KNOWN_FLAGS = ["app", "manifest", "slug", "visibility"];
12
- const KNOWN_BOOLS = ["force"];
13
- export const deployHelp = `homespun deploy — create or redeploy an app
14
-
15
- Usage:
16
- homespun deploy <dir|file> [--app <id>] [--manifest <path|json>]
17
- [--slug <slug>] [--visibility private|link|public] [--force]
18
-
19
- Packaging (one canonical shape, one escape hatch):
20
- Directory (canonical): homespun deploy ./my-app
21
- Reads ./my-app/index.html and ./my-app/manifest.json — fixed filenames,
22
- no discovery heuristics. Both files are required.
23
- Single file (escape hatch): homespun deploy ./index.html --manifest ./manifest.json
24
- --manifest accepts a file path OR inline JSON.
25
-
26
- Create vs. redeploy — decided by --app's presence, not two verbs:
27
- (no --app) Create (POST /v1/apps). New apps default to private
28
- (owner plus invited members, sign-in gated). --slug is
29
- accepted with private or public visibility, including the
30
- default; an explicit --visibility link always gets a
31
- server-generated slug and rejects --slug.
32
- --app <id> Redeploy (POST /v1/apps/:id/versions). --slug/--visibility
33
- are rejected here (slug is immutable; change visibility via
34
- 'homespun apps update'). --force overrides the compat gate.
35
-
36
- Output: { app_id, slug, url, version, visibility, created, compat?, breaks? }
37
- Errors on stderr: {"error":{"code","message"}} with non-zero exit.`;
38
12
  function readBundle(source, manifestFlag) {
39
13
  if (!existsSync(source)) {
40
14
  fail(`no such file or directory: ${source}`, "invalid_args");
@@ -69,7 +43,7 @@ function readBundle(source, manifestFlag) {
69
43
  };
70
44
  }
71
45
  export async function runDeploy(args) {
72
- assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun deploy");
46
+ assertKnownFlags(args, ...specFor("deploy"));
73
47
  const source = args.positionals[0];
74
48
  if (!source) {
75
49
  fail("usage: homespun deploy <dir|file> [--app <id>] ...", "invalid_args");
@@ -82,8 +56,28 @@ export async function runDeploy(args) {
82
56
  fail("--visibility must be private|link|public", "invalid_args");
83
57
  }
84
58
  const force = args.bools.has("force");
59
+ const check = args.bools.has("check");
85
60
  const bundle = readBundle(source, args.flags.get("manifest"));
86
61
  const client = makeClient(args);
62
+ // Dry run (--check): validate + report what a real deploy would do, persist
63
+ // NOTHING. Runs for both create (no --app) and redeploy (--app), the latter
64
+ // reporting the compat gate. slug/visibility are not part of a dry run.
65
+ if (check) {
66
+ try {
67
+ const id = appId !== undefined ? await resolveAppId(client, appId) : undefined;
68
+ const result = await client.checkDeploy({
69
+ ...(id !== undefined ? { app_id: id } : {}),
70
+ html: bundle.html,
71
+ manifest: bundle.manifest,
72
+ ...(force ? { force } : {}),
73
+ });
74
+ printJson(result);
75
+ }
76
+ catch (e) {
77
+ failFromError(e);
78
+ }
79
+ return;
80
+ }
87
81
  if (appId === undefined) {
88
82
  // Create. Client-side mirror of the relay's slug_not_allowed_for_link —
89
83
  // fail fast rather than round-trip a request that will 400 (spec-cli §3.1).
@@ -128,6 +122,7 @@ export async function runDeploy(args) {
128
122
  created: false,
129
123
  compat: redeployed.compat,
130
124
  ...(redeployed.breaks ? { breaks: redeployed.breaks } : {}),
125
+ ...(redeployed.warnings ? { warnings: redeployed.warnings } : {}),
131
126
  });
132
127
  }
133
128
  catch (e) {
@@ -1,48 +1,7 @@
1
1
  import { assertKnownFlags } from "../argv.js";
2
+ import { specFor } from "../help-catalog.js";
2
3
  import { makeClient } from "../config.js";
3
4
  import { printJson, fail, failFromError } from "../output.js";
4
- const CREATE_FLAGS = ["type", "message", "app-id"];
5
- const LIST_FLAGS = ["limit", "before"];
6
- const NO_BOOLS = [];
7
- export const feedbackHelp = `homespun feedback — submit / list feedback to the relay operator
8
-
9
- Feedback is a one-shot bug report, feature request, or note from YOUR agent
10
- to whoever runs the relay. Submissions are stored in the relay DB; the
11
- operator triages out of band.
12
-
13
- Usage:
14
- homespun feedback <subcommand> [options]
15
-
16
- Subcommands:
17
- create Submit one feedback row. Requires --type and --message.
18
- Prints { id, type, created_at } — the message is not echoed back.
19
-
20
- list List YOUR agent's own submissions, newest first. Prints
21
- { items: [...], next_before?: <cursor> }. Pass --before <cursor>
22
- from a previous page to fetch the next page.
23
-
24
- Options for 'create':
25
- --type <bug|feature|note> Feedback category. Required.
26
- --message <text|-> Message body. Pass '-' to read from stdin.
27
- 1..4000 chars after trim.
28
- --app-id <id> Optional App this feedback relates to;
29
- must be owned by YOUR agent's human.
30
-
31
- Options for 'list':
32
- --limit <N> Page size (default 50, max 100).
33
- --before <cursor> Opaque cursor from a previous page's next_before.
34
-
35
- Global:
36
- --url <url> Relay base URL (overrides HOMESPUN_URL).
37
- --api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
38
- -h, --help Show this help.
39
-
40
- Examples:
41
- homespun feedback create --type bug --message "watch hangs on empty app"
42
- echo "long-form note..." | homespun feedback create --type note --message -
43
- homespun feedback list --limit 20
44
-
45
- Output: stdout is machine-readable JSON.`;
46
5
  const FEEDBACK_TYPES = ["bug", "feature", "note"];
47
6
  async function readStdin() {
48
7
  const chunks = [];
@@ -52,7 +11,7 @@ async function readStdin() {
52
11
  return Buffer.concat(chunks).toString("utf8");
53
12
  }
54
13
  async function runFeedbackCreate(args) {
55
- assertKnownFlags(args, CREATE_FLAGS, NO_BOOLS, "homespun feedback create");
14
+ assertKnownFlags(args, ...specFor("feedback", "create"));
56
15
  const type = args.flags.get("type");
57
16
  const rawMessage = args.flags.get("message");
58
17
  const appId = args.flags.get("app-id");
@@ -92,7 +51,7 @@ async function runFeedbackCreate(args) {
92
51
  }
93
52
  }
94
53
  async function runFeedbackList(args) {
95
- assertKnownFlags(args, LIST_FLAGS, NO_BOOLS, "homespun feedback list");
54
+ assertKnownFlags(args, ...specFor("feedback", "list"));
96
55
  const limitRaw = args.flags.get("limit");
97
56
  const before = args.flags.get("before");
98
57
  let limit;