@homespunapps/cli 1.6.78 → 1.6.80

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
@@ -75,6 +75,21 @@ export const BOOLEAN_FLAGS = new Set([
75
75
  // `homespun credentials mint --members`: opt the credential into the app's
76
76
  // member directory in its boot/hello payloads.
77
77
  "members",
78
+ // `homespun apps transfer <app> --show`: print the app's pending ownership
79
+ // transfer instead of starting or withdrawing one. Named "show" rather than
80
+ // "status", which "apps list --status <value>" and "connections deliveries
81
+ // --status <value>" already own as a value flag under the same name.
82
+ "show",
83
+ // `homespun apps transfer <app> --cancel`: withdraw the app's pending
84
+ // ownership transfer.
85
+ "cancel",
86
+ // `homespun apps transfer <app> --to <email> --keep-as-member`: the
87
+ // recipient becomes the owner, the current owner stays on as a plain
88
+ // member. Default when neither this nor --remove-me is given.
89
+ "keep-as-member",
90
+ // `homespun apps transfer <app> --to <email> --remove-me`: the current
91
+ // owner is removed the moment the recipient accepts the transfer.
92
+ "remove-me",
78
93
  ]);
79
94
  /**
80
95
  * Flags that may be repeated, accumulating one value per occurrence instead
@@ -26,7 +26,7 @@ export async function runApps(args) {
26
26
  return;
27
27
  }
28
28
  if (verb === undefined) {
29
- fail("missing verb: homespun apps <list|show|audit|update|share-link|delete|wake|watch|domain>", "invalid_args");
29
+ fail("missing verb: homespun apps <list|show|audit|update|share-link|delete|wake|watch|domain|transfer>", "invalid_args");
30
30
  }
31
31
  const sub = {
32
32
  positionals: args.positionals.slice(1),
@@ -61,8 +61,10 @@ export async function runApps(args) {
61
61
  return runWatch(sub);
62
62
  case "domain":
63
63
  return runDomain(sub);
64
+ case "transfer":
65
+ return runTransfer(sub);
64
66
  default:
65
- fail(`unknown verb '${verb}': homespun apps <list|show|audit|update|share-link|delete|deleted|restore|purge|wake|watch|domain>`, "invalid_args");
67
+ fail(`unknown verb '${verb}': homespun apps <list|show|audit|update|share-link|delete|deleted|restore|purge|wake|watch|domain|transfer>`, "invalid_args");
66
68
  }
67
69
  }
68
70
  // ---------------------------------------------------------------------------
@@ -560,3 +562,98 @@ async function runDomain(args) {
560
562
  failFromError(e);
561
563
  }
562
564
  }
565
+ // ---------------------------------------------------------------------------
566
+ // `homespun apps transfer <app> --to <email> [--keep-as-member|--remove-me]`
567
+ // `<app> --show` | `<app> --cancel` (issue #1847)
568
+ //
569
+ // Transferring an app is a two-party job, same shape as `domain` above: this
570
+ // side only ever mints or withdraws a PENDING transfer. Ownership does not
571
+ // move until the recipient accepts it by email, so the initiating path's
572
+ // output has to say that in plain words: a caller reading only a JSON blob
573
+ // back has no way to tell "the app moved" from "an invite went out".
574
+ //
575
+ // Flag naming note: the inspection mode is `--show`, not `--status`. `apps
576
+ // list --status <value>` and `connections deliveries --status <value>`
577
+ // already own `status` as a VALUE flag, and the argv parser has one global
578
+ // boolean-flags set shared by every noun/verb (see BOOLEAN_FLAGS in
579
+ // argv.ts), so there is no per-command scoping that would let `--status` be
580
+ // a bare boolean here and a value flag there. `--show` says the same thing
581
+ // without the collision.
582
+ // ---------------------------------------------------------------------------
583
+ const TRANSFER_USAGE = "usage: homespun apps transfer <app> --to <email> [--keep-as-member|--remove-me] | homespun apps transfer <app> --show | homespun apps transfer <app> --cancel";
584
+ /** Render a pending transfer for the human-readable (non `--json`) path. */
585
+ function describeTransfer(transfer) {
586
+ const stay = transfer.keepAsMember
587
+ ? "you will stay on as a member once they accept"
588
+ : "you will be removed from the app once they accept";
589
+ return (`transfer pending to ${transfer.toEmail}, sent ${transfer.createdAt}, ` +
590
+ `expires ${transfer.expiresAt}: ${stay}. ` +
591
+ "Nothing has moved yet: the recipient has to accept it by email first.");
592
+ }
593
+ async function runTransfer(args) {
594
+ assertKnownFlags(args, ...specFor("apps", "transfer"));
595
+ const appArg = args.positionals[0];
596
+ if (!appArg)
597
+ fail(TRANSFER_USAGE, "invalid_args");
598
+ const to = args.flags.get("to");
599
+ const show = args.bools.has("show");
600
+ const cancel = args.bools.has("cancel");
601
+ const keepAsMember = args.bools.has("keep-as-member");
602
+ const removeMe = args.bools.has("remove-me");
603
+ const modeCount = [to !== undefined, show, cancel].filter(Boolean).length;
604
+ if (modeCount === 0)
605
+ fail(TRANSFER_USAGE, "invalid_args");
606
+ if (modeCount > 1) {
607
+ fail("--to, --show and --cancel are mutually exclusive", "invalid_args");
608
+ }
609
+ if (keepAsMember && removeMe) {
610
+ fail("--keep-as-member and --remove-me are mutually exclusive", "invalid_args");
611
+ }
612
+ if ((keepAsMember || removeMe) && to === undefined) {
613
+ fail("--keep-as-member and --remove-me only apply when initiating a transfer with --to", "invalid_args");
614
+ }
615
+ const client = makeClient(args);
616
+ const id = await resolveAppId(client, appArg);
617
+ const json = args.bools.has("json");
618
+ try {
619
+ if (show) {
620
+ const { transfer } = await client.getAppTransfer(id);
621
+ if (json) {
622
+ printJson({ transfer });
623
+ }
624
+ else if (!transfer) {
625
+ process.stdout.write("no ownership transfer is pending for this app\n");
626
+ }
627
+ else {
628
+ process.stdout.write(describeTransfer(transfer) + "\n");
629
+ }
630
+ return;
631
+ }
632
+ if (cancel) {
633
+ await client.cancelAppTransfer(id);
634
+ if (json) {
635
+ printJson({ cancelled: true, app_id: id });
636
+ }
637
+ else {
638
+ process.stdout.write("the pending transfer, if any, has been withdrawn\n");
639
+ }
640
+ return;
641
+ }
642
+ // initiate: keepAsMember defaults to true (--remove-me is the only way
643
+ // to opt out), matching the relay's own default.
644
+ const { transfer } = await client.createAppTransfer(id, {
645
+ email: to,
646
+ keepAsMember: !removeMe,
647
+ });
648
+ if (json) {
649
+ printJson({ transfer });
650
+ }
651
+ else {
652
+ process.stdout.write(describeTransfer(transfer) + "\n");
653
+ process.stdout.write(`Check its status with 'homespun apps transfer ${appArg} --show', or withdraw it with 'homespun apps transfer ${appArg} --cancel'.\n`);
654
+ }
655
+ }
656
+ catch (e) {
657
+ failFromError(e);
658
+ }
659
+ }
@@ -31,7 +31,7 @@ const APPS = {
31
31
  noun: "apps",
32
32
  tagline: "app lifecycle management",
33
33
  group: "app",
34
- rootSummary: "App lifecycle: list, show, audit (security review of your apps' collection permissions), update, delete, deleted (what is in the trash), restore, purge (destroy a deleted app for good), wake, domain (custom domains), watch (stream the app's change feed as JSON-lines).",
34
+ rootSummary: "App lifecycle: list, show, audit (security review of your apps' collection permissions), update, delete, deleted (what is in the trash), restore, purge (destroy a deleted app for good), wake, domain (custom domains), watch (stream the app's change feed as JSON-lines), transfer (hand ownership to another human).",
35
35
  verbs: [
36
36
  {
37
37
  verb: "list",
@@ -152,12 +152,43 @@ const APPS = {
152
152
  },
153
153
  ],
154
154
  },
155
+ {
156
+ verb: "transfer",
157
+ positionals: "<app>",
158
+ summary: "Hands app ownership to another human. Nothing moves until they accept the invite by email; e.g. `homespun apps transfer shop --to new-owner@example.com`, then check it with `homespun apps transfer shop --show` or pull it back with `homespun apps transfer shop --cancel`.",
159
+ flags: [
160
+ {
161
+ name: "to",
162
+ value: "<email>",
163
+ description: "Invite this email to take ownership",
164
+ },
165
+ ],
166
+ bools: [
167
+ {
168
+ name: "keep-as-member",
169
+ description: "Stay on as a member once accepted (default when neither this nor --remove-me is given)",
170
+ },
171
+ {
172
+ name: "remove-me",
173
+ description: "Be removed from the app once accepted",
174
+ },
175
+ {
176
+ name: "show",
177
+ description: "Show the pending transfer instead of starting one",
178
+ },
179
+ {
180
+ name: "cancel",
181
+ description: "Withdraw the pending transfer",
182
+ },
183
+ ],
184
+ },
155
185
  ],
156
186
  notes: [
157
187
  "<app> accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
158
188
  'watch streams the app\'s change feed as JSON-lines on stdout, one compact SerializedFeedEntry object per line, identical whether served over the live WebSocket (primary) or the long-poll fallback (used automatically when the WS upgrade fails, for example a locked-down network blocks outbound WS). A dormancy transition mid-watch emits a single {"type":"_dormant"} line and exits 0.',
189
+ "transfer prints a plain-language explanation by default (what will happen, that the recipient must accept by email, and that ownership has not moved yet), not just a JSON object; pass --json for the raw { transfer } record instead. audit does the same: a human report by default, --json for the machine one.",
159
190
  ],
160
- outputNote: 'Output is JSON, and JSON-lines for watch. Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
191
+ outputNote: 'Output is JSON, JSON-lines for watch, and a human-readable report by default for audit and transfer (pass --json for the raw object on those two). Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
161
192
  };
162
193
  const DATA = {
163
194
  noun: "data",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.78",
3
+ "version": "1.6.80",
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.78",
39
+ "@homespunapps/core": "^1.6.80",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {