@homespunapps/cli 1.6.48 → 1.6.49

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
@@ -63,6 +63,10 @@ export const BOOLEAN_FLAGS = new Set([
63
63
  // `homespun template publish --attest-example-only`: attest the captured template
64
64
  // and its seed rows carry no real personal data (marketplace PR 10).
65
65
  "attest-example-only",
66
+ // `homespun template upgrade --accept-permissions`: accept that the new version
67
+ // of a template asks for more than the installed one (#1502). Required when it
68
+ // does; it never clears an update that would strand rows.
69
+ "accept-permissions",
66
70
  // `homespun review respond --clear`: clear a publisher response (sends null).
67
71
  "clear",
68
72
  // `homespun credentials mint --no-expiry`: the explicit opt-in to NO EXPIRY
@@ -14,6 +14,7 @@ import { specFor } from "../help-catalog.js";
14
14
  import { describeConfig } from "../config.js";
15
15
  import { isValidProfileName, readStore, removeProfile, setCurrentProfile, storePath, upsertProfile, } from "../store.js";
16
16
  import { printJson, fail } from "../output.js";
17
+ import { resolveSecretFlag } from "../input.js";
17
18
  const showHelp = `homespun config show — show the resolved relay config
18
19
 
19
20
  Usage:
@@ -83,7 +84,10 @@ If <profile> already exists, the existing values are overwritten.
83
84
 
84
85
  Options:
85
86
  --url <url> Relay base URL. REQUIRED.
86
- --api-key <key> Agent API key. REQUIRED.
87
+ --api-key <key|-> Agent API key. REQUIRED.
88
+ Pass - to read it from stdin, or set
89
+ HOMESPUN_CONFIG_API_KEY, instead of putting it on
90
+ the command line where ps and shell history can see it.
87
91
  -h, --help Show this help.
88
92
 
89
93
  Output (stdout, JSON):
@@ -158,7 +162,7 @@ async function runConfigAdd(args) {
158
162
  fail(`invalid profile name '${name}' — letters, digits, _ and -, up to 32 chars`, "invalid_args");
159
163
  }
160
164
  const url = args.flags.get("url");
161
- const apiKey = args.flags.get("api-key");
165
+ const apiKey = await resolveSecretFlag(args.flags.get("api-key"), "HOMESPUN_CONFIG_API_KEY", "--api-key");
162
166
  if (!url) {
163
167
  fail("--url is required — usage: homespun config add <profile> --url <url> --api-key <key>", "invalid_args");
164
168
  }
@@ -18,6 +18,7 @@ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
18
18
  import { makeClient } from "../config.js";
19
19
  import { fail, failFromError, printJson } from "../output.js";
20
20
  import { resolveAppId } from "../resolve-app.js";
21
+ import { resolveSecretFlag } from "../input.js";
21
22
  export async function runConnection(args) {
22
23
  const verb = args.positionals[0];
23
24
  if ((verb === undefined || verb === "help") && args.bools.has("help")) {
@@ -88,7 +89,7 @@ async function runCreate(args) {
88
89
  const authorizeUrl = args.flags.get("authorize-url");
89
90
  const tokenEndpoint = args.flags.get("token-url");
90
91
  const clientId = args.flags.get("client-id");
91
- const clientSecret = args.flags.get("client-secret");
92
+ const clientSecret = await resolveSecretFlag(args.flags.get("client-secret"), "HOMESPUN_CONNECTION_CLIENT_SECRET", "--client-secret");
92
93
  if (!authorizeUrl || !tokenEndpoint || !clientId || !clientSecret) {
93
94
  fail("kind=oauth2 requires --authorize-url, --token-url, --client-id and --client-secret", "invalid_args");
94
95
  }
@@ -126,7 +127,7 @@ async function runCreate(args) {
126
127
  }));
127
128
  return;
128
129
  }
129
- const headerValue = args.flags.get("header-value");
130
+ const headerValue = await resolveSecretFlag(args.flags.get("header-value"), "HOMESPUN_CONNECTION_HEADER_VALUE", "--header-value");
130
131
  if (!headerValue) {
131
132
  fail("--header-value is required for a static connection", "invalid_args");
132
133
  }
@@ -20,6 +20,7 @@ import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
20
20
  import { makeClient } from "../config.js";
21
21
  import { fail, failFromError, printJson } from "../output.js";
22
22
  import { resolveAppId } from "../resolve-app.js";
23
+ import { resolveSecretFlag } from "../input.js";
23
24
  export async function runIngest(args) {
24
25
  const verb = args.positionals[0];
25
26
  if ((verb === undefined || verb === "help") && args.bools.has("help")) {
@@ -125,7 +126,7 @@ async function runSigningSecretSet(args) {
125
126
  if (!appArg || !name) {
126
127
  fail("usage: homespun ingest signing-secret set --app <idOrSlug> --name <hookName> [--secret <value>] [--grace-seconds <n>]", "invalid_args");
127
128
  }
128
- const secret = args.flags.get("secret");
129
+ const secret = await resolveSecretFlag(args.flags.get("secret"), "HOMESPUN_INGEST_SIGNING_SECRET", "--secret");
129
130
  const graceRaw = args.flags.get("grace-seconds");
130
131
  let graceSeconds;
131
132
  if (graceRaw !== undefined) {
@@ -19,7 +19,7 @@ export async function runTemplate(args) {
19
19
  return;
20
20
  }
21
21
  if (verb === undefined) {
22
- fail("missing verb: homespun template <publish|unpublish|config-contract|install|list-pending|show|approve|reject>", "invalid_args");
22
+ fail("missing verb: homespun template <publish|unpublish|config-contract|install|upgrade-check|upgrade|revert|list-pending|show|approve|reject>", "invalid_args");
23
23
  }
24
24
  const sub = {
25
25
  positionals: args.positionals.slice(1),
@@ -38,6 +38,12 @@ export async function runTemplate(args) {
38
38
  return runConfigContract(sub);
39
39
  case "install":
40
40
  return runInstall(sub);
41
+ case "upgrade-check":
42
+ return runUpgradeCheck(sub);
43
+ case "upgrade":
44
+ return runUpgrade(sub);
45
+ case "revert":
46
+ return runRevert(sub);
41
47
  case "list-pending":
42
48
  return runListPending(sub);
43
49
  case "show":
@@ -47,7 +53,7 @@ export async function runTemplate(args) {
47
53
  case "reject":
48
54
  return runReject(sub);
49
55
  default:
50
- fail(`unknown verb '${verb}' (homespun template <publish|unpublish|config-contract|install|list-pending|show|approve|reject>)`, "invalid_args");
56
+ fail(`unknown verb '${verb}' (homespun template <publish|unpublish|config-contract|install|upgrade-check|upgrade|revert|list-pending|show|approve|reject>)`, "invalid_args");
51
57
  }
52
58
  }
53
59
  function parseIntFlag(args, name, bounds = {}) {
@@ -244,3 +250,61 @@ async function runReject(args) {
244
250
  failFromError(e);
245
251
  }
246
252
  }
253
+ // ---------------------------------------------------------------------------
254
+ // upgrade-check / upgrade / revert (#1502)
255
+ //
256
+ // These take an APP, not a template ref. An installed template is a fork, so
257
+ // the question is "is there a newer version of the template THIS app came
258
+ // from", which only the app can answer.
259
+ // ---------------------------------------------------------------------------
260
+ async function runUpgradeCheck(args) {
261
+ assertKnownFlags(args, ...specFor("template", "upgrade-check"));
262
+ const appId = args.positionals[0];
263
+ if (!appId) {
264
+ fail("usage: homespun template upgrade-check <app>", "invalid_args");
265
+ }
266
+ const client = makeClient(args);
267
+ try {
268
+ printJson(await client.checkTemplateUpgrade(appId));
269
+ }
270
+ catch (e) {
271
+ failFromError(e);
272
+ }
273
+ }
274
+ async function runUpgrade(args) {
275
+ assertKnownFlags(args, ...specFor("template", "upgrade"));
276
+ const appId = args.positionals[0];
277
+ if (!appId) {
278
+ fail("usage: homespun template upgrade <app>", "invalid_args");
279
+ }
280
+ const client = makeClient(args);
281
+ const expectVersion = args.flags.get("expect-version");
282
+ try {
283
+ printJson(await client.upgradeTemplate(appId, {
284
+ // Only ever true when the flag is present. An omitted flag must not
285
+ // satisfy the consent gate: the point of the gate is that somebody
286
+ // looked at what the new version is asking for.
287
+ ...(args.bools.has("accept-permissions")
288
+ ? { acceptPermissions: true }
289
+ : {}),
290
+ ...(expectVersion !== undefined ? { expectVersion } : {}),
291
+ }));
292
+ }
293
+ catch (e) {
294
+ failFromError(e);
295
+ }
296
+ }
297
+ async function runRevert(args) {
298
+ assertKnownFlags(args, ...specFor("template", "revert"));
299
+ const appId = args.positionals[0];
300
+ if (!appId) {
301
+ fail("usage: homespun template revert <app>", "invalid_args");
302
+ }
303
+ const client = makeClient(args);
304
+ try {
305
+ printJson(await client.revertTemplateUpgrade(appId));
306
+ }
307
+ catch (e) {
308
+ failFromError(e);
309
+ }
310
+ }
@@ -477,8 +477,8 @@ const INGEST = {
477
477
  },
478
478
  {
479
479
  name: "secret",
480
- value: "<value>",
481
- description: "set only: a provider-generated signing secret to store verbatim; omit to have the relay mint one (shown once)",
480
+ value: "<value|->",
481
+ description: "set only: a provider-generated signing secret to store verbatim; omit to have the relay mint one (shown once). Pass - to read it from stdin, or set HOMESPUN_INGEST_SIGNING_SECRET, instead of putting it on the command line where ps and shell history can see it",
482
482
  },
483
483
  {
484
484
  name: "grace-seconds",
@@ -521,6 +521,7 @@ const INGEST = {
521
521
  "signing-secret manages a hook's OPT-IN signing secret, distinct from the URL secret above: it is what a provider (GitHub, Stripe, ...) HMACs the request body with. `set` without --secret mints one and returns { secret, fingerprint, setAt } with the value shown ONCE; `set --secret <value>` stores a provider-generated value verbatim and returns { fingerprint, setAt } without echoing it; `clear` removes it. A rotation keeps the previous secret valid for --grace-seconds so deliveries verify while you update the provider. A hook that declares `verify` in its manifest rule (GitHub scheme in v1) requires a valid signature over the raw body and stays fail-closed (401) until this secret is set; the fingerprint (a plaintext-derived id) lets you confirm which secret is set without the relay ever showing it.",
522
522
  "Hooks are declared in the app manifest (x-homespun-manifest.ingest) and materialized at deploy, so there is no create or delete verb here: add or remove a hook by editing the manifest and redeploying.",
523
523
  "backfill seeds a hook's collection with historical data: it POSTs an array of raw provider bodies to the OWNER endpoint (POST /v1/apps/:id/ingest-hooks/:name/backfill) and runs each through the SAME mapping the live public URL uses, so a backfilled row is byte-identical to a live delivery. It reads a JSON-array or NDJSON --file (each entry is a whole provider payload, any JSON value, not necessarily an object) and chunks it into --chunk bodies per call (default 500). It reuses the receive pipeline, so map/dedupeKey/upsertOn/row-schema validation and the collection quota all apply, but it SKIPS the public-URL brakes (the per-IP rate limit and the per-app hourly cap) and never verifies a signature (you are the authenticated owner). Wake is suppressed, so a large historical load never wakes a dormant app. Dedupe is ON: re-running the same file is idempotent for a body-path dedupeKey; a header:<name> dedupeKey cannot resolve here (no request headers), so it does not dedupe. Prints aggregate { total, accepted, dropped_duplicate, failed } counts.",
524
+ "--secret on signing-secret set takes 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 '--secret -' to read it from stdin, or set HOMESPUN_INGEST_SIGNING_SECRET, both of which never touch argv.",
524
525
  ],
525
526
  };
526
527
  const GRANTS = {
@@ -792,8 +793,8 @@ const CONNECTIONS = {
792
793
  },
793
794
  {
794
795
  name: "header-value",
795
- value: "<value>",
796
- description: 'static only, required. The header value to send, e.g. "Bearer sk_live_..."',
796
+ value: "<value|->",
797
+ description: 'static only, required. The header value to send, e.g. "Bearer sk_live_...". Pass - to read it from stdin, or set HOMESPUN_CONNECTION_HEADER_VALUE, instead of putting it on the command line where ps and shell history can see it',
797
798
  },
798
799
  {
799
800
  name: "authorize-url",
@@ -812,8 +813,8 @@ const CONNECTIONS = {
812
813
  },
813
814
  {
814
815
  name: "client-secret",
815
- value: "<secret>",
816
- description: "oauth2 only, required. Your OAuth2 app's client secret",
816
+ value: "<secret|->",
817
+ description: "oauth2 only, required. Your OAuth2 app's client secret. Pass - to read it from stdin, or set HOMESPUN_CONNECTION_CLIENT_SECRET, instead of putting it on the command line where ps and shell history can see it",
817
818
  },
818
819
  {
819
820
  name: "scopes",
@@ -891,6 +892,7 @@ const CONNECTIONS = {
891
892
  "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
893
  "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
894
  "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.",
895
+ "--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.",
894
896
  ],
895
897
  };
896
898
  const KEY = {
@@ -1035,8 +1037,8 @@ const CONFIG = {
1035
1037
  flags: [
1036
1038
  {
1037
1039
  name: "api-key",
1038
- value: "<key>",
1039
- description: "Agent API key to save in the profile, required",
1040
+ value: "<key|->",
1041
+ description: "Agent API key to save in the profile, required. Pass - to read it from stdin, or set HOMESPUN_CONFIG_API_KEY, instead of putting it on the command line where ps and shell history can see it",
1040
1042
  },
1041
1043
  ],
1042
1044
  },
@@ -1050,6 +1052,7 @@ const CONFIG = {
1050
1052
  "A profile is one url and api_key pair under a short name (dev, staging, prod). Switch via 'homespun config use', --profile <name>, or the HOMESPUN_PROFILE env var. The active profile is what every other command sees unless overridden by --url, --api-key, HOMESPUN_URL or HOMESPUN_API_KEY.",
1051
1053
  "Every verb is purely local: it inspects flags, env, and the saved config file and makes no network call. The full API key is never printed, only a short masked prefix. The config file lives at ${XDG_CONFIG_HOME:-~/.config}/homespun/config.json (mode 0600).",
1052
1054
  "add requires both --url and --api-key, and overwrites the existing values if the profile already exists. Use it when an operator handed you an API key out of band, for example a closed-registration relay; for self-register and secret-mode relays prefer 'homespun agent register --profile <name>'. It does not change current_profile unless it is the first profile added, so run 'homespun config use' afterwards to switch. rm clears current_profile when it removes the active profile, and the next command falls back to env or the default URL until another profile is selected.",
1055
+ "--api-key on add takes 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 '--api-key -' to read it from stdin, or set HOMESPUN_CONFIG_API_KEY, both of which never touch argv.",
1053
1056
  ],
1054
1057
  };
1055
1058
  const SKILL = {
@@ -1367,7 +1370,7 @@ const TEMPLATE = {
1367
1370
  noun: "template",
1368
1371
  tagline: "community marketplace templates",
1369
1372
  group: "other",
1370
- rootSummary: "Community marketplace templates: publish an owned app, unpublish your own listing, read a template's config-contract, install one, and the operator review queue (list-pending, show, approve, reject).",
1373
+ rootSummary: "Community marketplace templates: publish an owned app, unpublish your own listing, read a template's config-contract, install one, keep an installed app current (upgrade-check, upgrade, revert), and the operator review queue (list-pending, show, approve, reject).",
1371
1374
  verbs: [
1372
1375
  {
1373
1376
  verb: "publish",
@@ -1449,6 +1452,39 @@ const TEMPLATE = {
1449
1452
  },
1450
1453
  ],
1451
1454
  },
1455
+ {
1456
+ verb: "upgrade-check",
1457
+ positionals: "<app>",
1458
+ summary: "Reports whether a newer version of the app's source template is available.",
1459
+ flags: [],
1460
+ },
1461
+ {
1462
+ verb: "upgrade",
1463
+ positionals: "<app>",
1464
+ summary: "Updates an app in place to its template's current version, keeping its data.",
1465
+ flags: [
1466
+ {
1467
+ name: "expect-version",
1468
+ value: "<semver>",
1469
+ description: "Refuse unless this is still the version on offer, so a republish mid-flight cannot slip through",
1470
+ },
1471
+ ],
1472
+ // A value-less flag belongs in `bools`, not `flags`: the catalog is what
1473
+ // assertKnownFlags reads, so declaring it as a value flag makes the real
1474
+ // parser reject `--accept-permissions` as missing its value.
1475
+ bools: [
1476
+ {
1477
+ name: "accept-permissions",
1478
+ description: "Accept a version that asks for more than the installed one; required when it does",
1479
+ },
1480
+ ],
1481
+ },
1482
+ {
1483
+ verb: "revert",
1484
+ positionals: "<app>",
1485
+ summary: "Puts an app back on the version it ran before its last template update.",
1486
+ flags: [],
1487
+ },
1452
1488
  {
1453
1489
  verb: "list-pending",
1454
1490
  summary: "Lists pending submissions in the review queue (operator only).",
package/dist/input.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Helpers for reading CLI inputs that may be either a file path or an inline
2
- // literal (JSON, or raw text for an HTML template body).
2
+ // literal (JSON, or raw text for an HTML template body), plus a shared
3
+ // resolver for flags that carry a caller-supplied secret.
3
4
  import { readFileSync, statSync } from "node:fs";
5
+ import { fail, warn } from "./output.js";
4
6
  /**
5
7
  * True if `value` names an existing file. Only a missing path (ENOENT) is
6
8
  * treated as "not a file" — any other fs error (EACCES, ELOOP, …) propagates
@@ -40,3 +42,56 @@ export function resolveJson(value, label) {
40
42
  export function resolveText(value) {
41
43
  return isFilePath(value) ? readFileSync(value, "utf8") : value;
42
44
  }
45
+ /**
46
+ * Drain process.stdin to a utf8 string and trim exactly one trailing
47
+ * newline (or CRLF) — the byte an `echo` or heredoc appends and which is
48
+ * never part of the secret itself. The caller is responsible for gating on
49
+ * `process.stdin.isTTY` first; in a TTY this blocks waiting for ^D.
50
+ */
51
+ async function readStdinSecret() {
52
+ const chunks = [];
53
+ for await (const chunk of process.stdin) {
54
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
55
+ }
56
+ const raw = Buffer.concat(chunks).toString("utf8");
57
+ if (raw.endsWith("\r\n"))
58
+ return raw.slice(0, -2);
59
+ if (raw.endsWith("\n"))
60
+ return raw.slice(0, -1);
61
+ return raw;
62
+ }
63
+ /**
64
+ * Resolve a flag that carries a caller-supplied secret (a third-party
65
+ * client secret, a static connection's header value, a webhook signing
66
+ * secret, an agent API key) — anything an operator types in, as opposed to
67
+ * a token the relay generates and hands back. A value on argv sits in shell
68
+ * history and is readable by any other local user via `ps` or
69
+ * /proc/<pid>/cmdline for the life of the process, so this offers two ways
70
+ * around that, checked in order:
71
+ *
72
+ * 1. the flag given as exactly "-": read the value from stdin, refusing
73
+ * when stdin is a TTY so the command fails fast instead of hanging on
74
+ * ^D. Mirrors `feedback create --message -`.
75
+ * 2. `envVar`, read only when the flag was not given at all.
76
+ *
77
+ * The flag's literal value keeps working (scripts that already pass it
78
+ * verbatim must not break), but that path is the exposed one, so it prints
79
+ * a stderr warning naming the two alternatives above. Mirrors the
80
+ * `--secret` / `HOMESPUN_REGISTER_SECRET` fallback in `agent register`.
81
+ *
82
+ * `flagLabel` (e.g. "--client-secret") appears only in messages.
83
+ */
84
+ export async function resolveSecretFlag(flagValue, envVar, flagLabel) {
85
+ if (flagValue === "-") {
86
+ if (process.stdin.isTTY) {
87
+ fail(`'${flagLabel} -' expects the secret on stdin, but stdin is a TTY`, "invalid_args");
88
+ }
89
+ return readStdinSecret();
90
+ }
91
+ if (flagValue !== undefined) {
92
+ warn(`${flagLabel} was passed on the command line: it is visible in your shell history and, while this command runs, to any other user on this machine via ps or /proc. Use '${flagLabel} -' to read it from stdin instead, or set ${envVar}.`);
93
+ return flagValue;
94
+ }
95
+ const env = process.env[envVar];
96
+ return env !== undefined && env !== "" ? env : undefined;
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.48",
3
+ "version": "1.6.49",
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",
@@ -31,12 +31,12 @@
31
31
  ],
32
32
  "scripts": {
33
33
  "build": "tsc",
34
- "typecheck": "tsc --noEmit",
34
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
35
35
  "test": "vitest run",
36
36
  "test:unit": "vitest run"
37
37
  },
38
38
  "dependencies": {
39
- "@homespunapps/core": "^1.6.48",
39
+ "@homespunapps/core": "^1.6.49",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {