create-op-node 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -13,8 +13,8 @@ That's it. The wizard walks you through:
13
13
  2. **GitHub** — creates your region's node repo from the [`OpusPopuli/opuspopuli-node`](https://github.com/OpusPopuli/opuspopuli-node) template via the GitHub API. Seeds the 5 required GitHub Secrets (Cloudflare token, account ID, zone ID, Terraform Cloud token, TFC org) for you.
14
14
  3. **Terraform Cloud** — verifies your TFC token, prepares the workspace.
15
15
  4. **First PR** — writes `environments/prod.tfvars` from your answers, commits, opens the first PR. The node repo's `cloudflare-infra.yml` workflow runs `terraform plan` against the PR; on merge to `main` it applies — Tunnel, DNS, R2 buckets, and Pages project come up automatically.
16
- 5. **pgsodium master key** — generates a fresh 64-hex root key, stores it in 1Password (via the `op` CLI if available, otherwise prompts you to paste it once).
17
- 6. **Tunnel token retrieval** — after `terraform apply` lands, fetches the Tunnel token from Terraform Cloud outputs and stores it in 1Password alongside the pgsodium key.
16
+ 5. **pgsodium master key** — generates a fresh 64-hex root key, stores it in your **macOS login Keychain** as `org.opuspopuli.<region>/pgsodium-root-key`. No third-party password manager required.
17
+ 6. **Tunnel token retrieval** — after `terraform apply` lands, fetches the Tunnel token from Terraform Cloud outputs and stores it alongside the pgsodium key in your Keychain.
18
18
 
19
19
  Then on the Mac Studio itself:
20
20
 
@@ -22,7 +22,17 @@ Then on the Mac Studio itself:
22
22
  npx create-op-node bootstrap
23
23
  ```
24
24
 
25
- Configures macOS power settings, installs Homebrew + the CLI tool list, sets up Docker Desktop + Tailscale + Ollama, clones the node repo you created, materializes the pgsodium key from 1Password, writes the LaunchAgent plist, logs into ghcr.io, pulls + warms the LLM model, and finally `docker compose pull && up -d` brings the whole stack online. Health-check loop waits until all 10 containers are `(healthy)`.
25
+ Configures macOS power settings, installs Homebrew + the CLI tool list, sets up Docker Desktop + Tailscale + Ollama, clones the node repo you created, reads the pgsodium key + Tunnel token from the Studio's Keychain (or prompts you to paste them once, then persists for re-runs), writes the LaunchAgent plist, logs into ghcr.io, pulls + warms the LLM model, and finally `docker compose pull && up -d` brings the whole stack online. Health-check loop waits until all 10 containers are `(healthy)`.
26
+
27
+ > **Secret transport between laptop and Studio**
28
+ >
29
+ > The macOS `security` CLI writes to the local login keychain — items
30
+ > don't sync to iCloud Keychain automatically. On the Studio's first
31
+ > bootstrap, the operator pastes the pgsodium key + Tunnel token once
32
+ > (from the laptop's Keychain Access, or wherever you copied them); the
33
+ > Studio bootstrap validates the format and persists locally so re-runs
34
+ > read straight through. Use AirDrop / `security find-generic-password`
35
+ > output / Tailscale `scp` to ferry the values.
26
36
 
27
37
  ## Resetting the Studio
28
38
 
@@ -52,7 +62,10 @@ with `bootstrap` without losing data.
52
62
  those need separate cleanup.
53
63
 
54
64
  Reset does **not** touch cloud-side state: the Cloudflare resources,
55
- the GitHub repo, the TFC workspace, and the 1Password items remain.
65
+ the GitHub repo, and the TFC workspace remain. Keychain items on the
66
+ Studio are also left in place — `security delete-generic-password -s
67
+ org.opuspopuli.<region> -a pgsodium-root-key` etc. if you want them
68
+ gone.
56
69
  `init` is idempotent against existing cloud setup, so re-running it
57
70
  won't duplicate anything.
58
71
 
@@ -280,13 +293,63 @@ rather than in the regions repo itself:
280
293
  green run here, as the real signal. If the two ever disagree, the schema
281
294
  wins and this command needs updating.
282
295
 
296
+ ## What lands where
297
+
298
+ `create-op-node` touches several secret stores. Here's the full map of
299
+ what we own (the two macOS Keychain items) vs. what we just route to its
300
+ destination.
301
+
302
+ ### Stored by `create-op-node` in macOS Keychain
303
+
304
+ Two items per region, both generic-password class
305
+ (`kSecClassGenericPassword`). Visible in **Keychain Access.app**
306
+ (`/System/Applications/Utilities/Keychain Access.app`) — **not** in the
307
+ new Passwords.app, which is filtered to website-login items only.
308
+
309
+ | # | Service | Account | Label (GUI display) | Value format | Written by | Read by |
310
+ |---|---|---|---|---|---|---|
311
+ | 1 | `org.opuspopuli.<region>` | `pgsodium-root-key` | `Opus Populi (<region>) — pgsodium root key` | 64 lowercase hex chars | `init` on laptop | `bootstrap` on Studio |
312
+ | 2 | `org.opuspopuli.<region>` | `tunnel-token` | `Opus Populi (<region>) — Cloudflare Tunnel token` | JWT-style base64url string | `init` on laptop (after TFC apply) | `bootstrap` on Studio |
313
+
314
+ Both items also carry `-D 'Opus Populi secret'` (the "Kind" column in
315
+ Keychain Access) so you can filter for them at a glance.
316
+
317
+ Inspect from a shell:
318
+
319
+ ```bash
320
+ # Metadata only (safe to share output):
321
+ security find-generic-password -s org.opuspopuli.us-ca -a pgsodium-root-key
322
+
323
+ # Reveal the value (you'll be prompted to allow access on first call):
324
+ security find-generic-password -s org.opuspopuli.us-ca -a pgsodium-root-key -w
325
+ ```
326
+
327
+ ### Stored elsewhere (we don't put these in Keychain)
328
+
329
+ Everything else flows through transiently or lives in its destination
330
+ system's own credential store.
331
+
332
+ | Secret | Where it lives | Why not in Keychain |
333
+ |---|---|---|
334
+ | Cloudflare API token | Pasted into `init` prompt → forwarded to **GitHub Secrets** + **Terraform Cloud** vars | One-shot during init. Re-runs prompt again. We could store it; adds risk vs benefit. |
335
+ | Cloudflare account ID, zone ID | Same as above | Not really a "secret" but flow alongside the token |
336
+ | Terraform Cloud token | Pasted, used to verify + poll runs | Same one-shot pattern |
337
+ | GitHub PAT | Read from `gh auth token` if available, else pasted | `gh` already manages it |
338
+ | pgsodium key (Studio runtime form) | `~/.config/opuspopuli/pgsodium_root_key` (mode `0400`) | LaunchAgent reads it at every login → interpolates into the `PGSODIUM_ROOT_KEY` env var. Same value as in Keychain; file is runtime form. |
339
+ | Cloudflare Tunnel token (Studio runtime form) | Baked into `~/Library/LaunchAgents/org.opuspopuli.envloader.plist` (mode `0600`) | launchd's `launchctl setenv TUNNEL_TOKEN` injects it into the session at every boot. Same value as in Keychain; plist is runtime form. |
340
+ | ghcr.io credentials | `~/.docker/config.json` or `docker-credential-osxkeychain` | Docker manages its own credential store — it actually saves the ghcr token to a separate Keychain item under service `ghcr.io`. We just call `docker login`. |
341
+
342
+ Per region, the **only** persistent secrets `create-op-node` owns are
343
+ the two Keychain items above. Everything else is either transient
344
+ (prompted, used, forgotten) or lives in its destination system.
345
+
283
346
  ## Why this exists
284
347
 
285
348
  Each Opus Populi region is operated independently by a local maintainer — its own Cloudflare account, its own Mac Studio, its own domain. The full bootstrap is a few hours of manual steps across Cloudflare, GitHub, Terraform Cloud, macOS Setup Assistant, Docker Desktop, Tailscale, Ollama, and the node's own Docker Compose stack. Doable from the runbook, but error-prone.
286
349
 
287
350
  This CLI exists to make that bootstrap **foolproof** — every prompt validates immediately, every secret is retrieved from a secure source (never echoed, never written to disk in plaintext), and every step has an explicit "what happens next" message. The goal is zero documentation reading required to get a node running.
288
351
 
289
- The CLI itself never holds any credentials beyond the scope of a single command — secrets flow from your 1Password vault → through the CLI → directly into the destination (GitHub Secrets, Terraform Cloud workspace variables, Mac Studio LaunchAgent). Nothing persists in this process.
352
+ The CLI itself never holds any credentials beyond the scope of a single command — secrets flow from your macOS Keychain → through the CLI → directly into the destination (GitHub Secrets, Terraform Cloud workspace variables, Mac Studio LaunchAgent). Nothing persists in this process.
290
353
 
291
354
  ## Architecture
292
355
 
@@ -322,9 +385,9 @@ The CLI itself never holds any credentials beyond the scope of a single command
322
385
 
323
386
  ## Status
324
387
 
325
- **`init` — fully wired.** Full Phase 1 of the runbook: prompts → Cloudflare 5-scope probe → Terraform Cloud verify → GitHub template clone → 5 repo secrets seeded → branch + prod.tfvars committed → PR opened → pgsodium key generated → (after operator merges PR) Terraform apply polled → Tunnel token retrieved + saved to 1Password.
388
+ **`init` — fully wired.** Full Phase 1 of the runbook: prompts → Cloudflare 5-scope probe → Terraform Cloud verify → GitHub template clone → 5 repo secrets seeded → branch + prod.tfvars committed → PR opened → pgsodium key generated → (after operator merges PR) Terraform apply polled → Tunnel token retrieved + saved to the macOS Keychain.
326
389
 
327
- **`bootstrap` — fully wired.** Phase 2 on the Mac Studio: macOS sanity (auto-restart, disk sleep), Homebrew + tool installs (gh, pnpm, jq, cloudflared, rclone, ollama, docker, tailscale), GitHub + Tailscale signin prompts, pgsodium key + Tunnel token read from 1Password, LaunchAgent written + loaded, ghcr.io login, Ollama models pulled + warmed, region repo located or cloned, `docker compose pull && up -d`, health-check loop until everything reports `(healthy)`.
390
+ **`bootstrap` — fully wired.** Phase 2 on the Mac Studio: macOS sanity (auto-restart, disk sleep), Homebrew + tool installs (gh, pnpm, jq, cloudflared, rclone, ollama, docker, tailscale), GitHub + Tailscale signin prompts, pgsodium key + Tunnel token read from the Studio's Keychain (or pasted in once if first run on that machine, then persisted), LaunchAgent written + loaded, ghcr.io login, Ollama models pulled + warmed, region repo located or cloned, `docker compose pull && up -d`, health-check loop until everything reports `(healthy)`.
328
391
 
329
392
  **`verify` — scaffold stub.** Type-safe argument parsing only; prints a roadmap-style message and exits.
330
393
 
package/dist/cli.js CHANGED
@@ -225,81 +225,75 @@ async function safeExeca(cmd, args, options) {
225
225
  }
226
226
  }
227
227
 
228
- // src/lib/onepassword.ts
229
- async function detectOp() {
230
- const version = await safeExeca("op", ["--version"]);
231
- if (version === null) {
232
- return { installed: false, signedIn: false };
233
- }
234
- const installed = true;
235
- const whoami = await safeExeca("op", ["whoami", "--format=json"]);
236
- if (whoami === null || whoami.exitCode !== 0) {
237
- return { installed, signedIn: false };
238
- }
239
- let email;
240
- try {
241
- const parsed = JSON.parse(whoami.stdout);
242
- email = parsed.email;
243
- } catch {
244
- }
245
- return {
246
- installed,
247
- signedIn: true,
248
- ...email ? { email } : {}
249
- };
228
+ // src/lib/keychain.ts
229
+ var SERVICE_PREFIX = "org.opuspopuli";
230
+ function serviceFor(region) {
231
+ return `${SERVICE_PREFIX}.${region}`;
232
+ }
233
+ function labelFor(coords) {
234
+ const friendly = coords.account === "pgsodium-root-key" ? "pgsodium root key" : "Cloudflare Tunnel token";
235
+ return `Opus Populi (${coords.region}) \u2014 ${friendly}`;
250
236
  }
251
- async function saveSecretToOp(input) {
252
- const vaultArg = input.vault ? ["--vault", input.vault] : [];
253
- const existing = await safeExeca("op", ["item", "get", input.title, ...vaultArg, "--format=json"]);
254
- if (existing === null) {
237
+ async function detectKeychain() {
238
+ const res = await safeExeca("security", ["-h"]);
239
+ if (res === null) {
255
240
  return {
256
- written: false,
257
- alreadyExisted: false,
258
- reason: "`op` CLI not installed"
241
+ available: false,
242
+ reason: "`security` CLI not on PATH (Keychain requires macOS)"
259
243
  };
260
244
  }
261
- if (existing.exitCode === 0) {
262
- if (!input.overwrite) {
263
- return { written: false, alreadyExisted: true };
264
- }
265
- const edit = await safeExeca(
266
- "op",
267
- ["item", "edit", input.title, ...vaultArg, `notesPlain=${input.value}`]
268
- );
269
- if (edit === null || edit.exitCode !== 0) {
270
- return {
271
- written: false,
272
- alreadyExisted: true,
273
- reason: `op item edit failed: ${edit?.stderr || edit?.stdout || "`op` not installed"}`
274
- };
275
- }
276
- return { written: true, alreadyExisted: true };
277
- }
278
- const create = await safeExeca("op", [
279
- "item",
280
- "create",
281
- "--category",
282
- "Secure Note",
283
- "--title",
284
- input.title,
285
- ...vaultArg,
286
- `notesPlain=${input.value}`
245
+ return { available: true };
246
+ }
247
+ async function saveSecret(coords, value) {
248
+ const service = serviceFor(coords.region);
249
+ const label = labelFor(coords);
250
+ const existing = await safeExeca("security", [
251
+ "find-generic-password",
252
+ "-s",
253
+ service,
254
+ "-a",
255
+ coords.account
256
+ ]);
257
+ const updated = existing !== null && existing.exitCode === 0;
258
+ const res = await safeExeca("security", [
259
+ "add-generic-password",
260
+ "-U",
261
+ // upsert
262
+ "-s",
263
+ service,
264
+ "-a",
265
+ coords.account,
266
+ "-l",
267
+ label,
268
+ "-D",
269
+ "Opus Populi secret",
270
+ "-w",
271
+ value
272
+ // argv-exposure caveat, see file header
287
273
  ]);
288
- if (create === null || create.exitCode !== 0) {
274
+ if (res === null) {
275
+ return { written: false, updated: false, reason: "`security` CLI not on PATH" };
276
+ }
277
+ if (res.exitCode !== 0) {
289
278
  return {
290
279
  written: false,
291
- alreadyExisted: false,
292
- reason: `op item create failed: ${create?.stderr || create?.stdout || "`op` not installed"}`
280
+ updated,
281
+ reason: `security add-generic-password failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
293
282
  };
294
283
  }
295
- return { written: true, alreadyExisted: false };
296
- }
297
- async function readSecretFromOp(input) {
298
- const vaultArg = input.vault ? ["--vault", input.vault] : [];
299
- const res = await safeExeca(
300
- "op",
301
- ["item", "get", input.title, ...vaultArg, "--fields", "notesPlain", "--reveal"]
302
- );
284
+ return { written: true, updated };
285
+ }
286
+ async function readSecret(coords) {
287
+ const service = serviceFor(coords.region);
288
+ const res = await safeExeca("security", [
289
+ "find-generic-password",
290
+ "-s",
291
+ service,
292
+ "-a",
293
+ coords.account,
294
+ "-w"
295
+ // print the password to stdout (final flag = read mode)
296
+ ]);
303
297
  if (res === null || res.exitCode !== 0) return null;
304
298
  const value = res.stdout.trim();
305
299
  return value.length > 0 ? value : null;
@@ -532,10 +526,10 @@ var initCommand = new Command("init").description(
532
526
  new Option("--template <owner/repo>", "Template repo to clone from").default(
533
527
  "OpusPopuli/opuspopuli-node"
534
528
  )
535
- ).addOption(new Option("--project <name>", "tfvars project prefix").default("opuspopuli")).addOption(new Option("--cf-token <token>", "Cloudflare Account API token").env("CF_TOKEN")).addOption(new Option("--cf-account <id>", "Cloudflare account ID").env("CF_ACCOUNT")).addOption(new Option("--cf-zone <id>", "Cloudflare zone ID for your domain").env("CF_ZONE")).addOption(new Option("--tf-token <token>", "Terraform Cloud user/team token").env("TF_API_TOKEN")).addOption(new Option("--tf-org <org>", "Terraform Cloud organization").env("TF_CLOUD_ORGANIZATION")).addOption(new Option("--gh-token <token>", "GitHub Personal Access Token (else gh CLI)").env("GH_TOKEN")).addOption(new Option("--vault <vault>", "1Password vault for secrets").default("Private")).addOption(
529
+ ).addOption(new Option("--project <name>", "tfvars project prefix").default("opuspopuli")).addOption(new Option("--cf-token <token>", "Cloudflare Account API token").env("CF_TOKEN")).addOption(new Option("--cf-account <id>", "Cloudflare account ID").env("CF_ACCOUNT")).addOption(new Option("--cf-zone <id>", "Cloudflare zone ID for your domain").env("CF_ZONE")).addOption(new Option("--tf-token <token>", "Terraform Cloud user/team token").env("TF_API_TOKEN")).addOption(new Option("--tf-org <org>", "Terraform Cloud organization").env("TF_CLOUD_ORGANIZATION")).addOption(new Option("--gh-token <token>", "GitHub Personal Access Token (else gh CLI)").env("GH_TOKEN")).addOption(
536
530
  new Option(
537
531
  "--overwrite",
538
- "Overwrite existing 1Password items (pgsodium key, Tunnel token) on a re-run"
532
+ "Overwrite existing Keychain items (pgsodium key, Tunnel token) on a re-run"
539
533
  ).default(false)
540
534
  ).addOption(
541
535
  new Option(
@@ -556,7 +550,7 @@ var initCommand = new Command("init").description(
556
550
  );
557
551
  const region = opts.region ? opts.region : unwrap(
558
552
  await p3.text({
559
- message: "Short region label (used as a prefix in 1Password + R2)?",
553
+ message: "Short region label (used as the Keychain service suffix + R2 prefix)?",
560
554
  placeholder: "us-ca",
561
555
  validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
562
556
  })
@@ -637,9 +631,10 @@ var initCommand = new Command("init").description(
637
631
  "github"
638
632
  );
639
633
  }
640
- const op = await detectOp();
641
- const opNote = op.installed ? op.signedIn ? `\u2713 1Password CLI signed in${op.email ? ` (${op.email})` : ""} \u2014 pgsodium key + Tunnel token will be saved automatically.` : `\u26A0 1Password CLI installed but not signed in. Run \`op signin\` in another shell, or you'll be prompted to paste secrets.` : `\u26A0 1Password CLI not installed. Secrets will be printed for you to paste into 1Password by hand.`;
642
- p3.note(opNote, "1Password");
634
+ const keychain = await detectKeychain();
635
+ const kcNote = keychain.available ? `\u2713 macOS Keychain available \u2014 pgsodium key + Tunnel token will be saved to your login keychain.
636
+ ` + pc2.dim("Note: items don't sync to iCloud Keychain via the security CLI.\n") + pc2.dim("On the Studio, bootstrap will read locally and prompt you to paste if missing.") : `\u26A0 ${keychain.reason ?? "Keychain unavailable"}. Secrets will be printed for manual stash.`;
637
+ p3.note(kcNote, "Secret store");
643
638
  p3.note(
644
639
  [
645
640
  `Region label: ${pc2.cyan(region)}`,
@@ -786,40 +781,30 @@ var initCommand = new Command("init").description(
786
781
  process.exit(1);
787
782
  }
788
783
  prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
789
- const keyTitle = `opuspopuli-${region}-pgsodium-root-key`;
790
- const vaultArg = opts.vault ? { vault: opts.vault } : {};
791
- if (op.installed && op.signedIn) {
792
- const existing = opts.overwrite ? null : await readSecretFromOp({ title: keyTitle, ...vaultArg });
784
+ const pgsodiumCoords = { region, account: "pgsodium-root-key" };
785
+ const keyLabel = `org.opuspopuli.${region}/pgsodium-root-key`;
786
+ if (keychain.available) {
787
+ const existing = opts.overwrite ? null : await readSecret(pgsodiumCoords);
793
788
  if (existing && /^[a-f0-9]{64}$/.test(existing)) {
794
789
  p3.note(
795
- `${pc2.green("\u2713")} Re-using existing pgsodium master key from 1Password (${pc2.cyan(keyTitle)}). Pass --overwrite to rotate.`,
790
+ `${pc2.green("\u2713")} Re-using existing pgsodium master key from Keychain (${pc2.cyan(keyLabel)}). Pass --overwrite to rotate.`,
796
791
  "Secret"
797
792
  );
798
793
  } else {
799
794
  const fresh = generatePgsodiumRootKey();
800
- const r = await saveSecretToOp({
801
- title: keyTitle,
802
- value: fresh,
803
- overwrite: opts.overwrite ?? false,
804
- ...vaultArg
805
- });
795
+ const r = await saveSecret(pgsodiumCoords, fresh);
806
796
  if (r.written) {
807
797
  p3.note(
808
- `${pc2.green("\u2713")} pgsodium master key${r.alreadyExisted ? " rotated" : " saved"} to 1Password as ${pc2.cyan(keyTitle)}.`,
809
- "Secret"
810
- );
811
- } else if (r.alreadyExisted) {
812
- p3.note(
813
- `${pc2.yellow("!")} 1Password has an item titled ${pc2.cyan(keyTitle)} but its value is NOT a 64-hex pgsodium key. Inspect it in 1Password; re-run with --overwrite to replace.`,
798
+ `${pc2.green("\u2713")} pgsodium master key${r.updated ? " rotated" : " saved"} to Keychain as ${pc2.cyan(keyLabel)}.`,
814
799
  "Secret"
815
800
  );
816
801
  } else {
817
- await printKeyForManualSave(fresh, keyTitle, r.reason);
802
+ await printKeyForManualSave(fresh, keyLabel, r.reason);
818
803
  }
819
804
  }
820
805
  } else {
821
806
  const fresh = generatePgsodiumRootKey();
822
- await printKeyForManualSave(fresh, keyTitle);
807
+ await printKeyForManualSave(fresh, keyLabel);
823
808
  }
824
809
  if (opts.skipWait) {
825
810
  p3.outro(
@@ -870,24 +855,20 @@ var initCommand = new Command("init").description(
870
855
  );
871
856
  process.exit(1);
872
857
  }
873
- const tunnelTitle = `opuspopuli-${region}-tunnel-token`;
874
- if (op.installed && op.signedIn) {
875
- const r = await saveSecretToOp({
876
- title: tunnelTitle,
877
- value: tunnelToken,
878
- ...opts.vault ? { vault: opts.vault } : {},
879
- overwrite: true
880
- });
858
+ const tunnelCoords = { region, account: "tunnel-token" };
859
+ const tunnelLabel = `org.opuspopuli.${region}/tunnel-token`;
860
+ if (keychain.available) {
861
+ const r = await saveSecret(tunnelCoords, tunnelToken);
881
862
  if (r.written) {
882
863
  p3.note(
883
- `${pc2.green("\u2713")} Tunnel token saved to 1Password as ${pc2.cyan(tunnelTitle)}.`,
864
+ `${pc2.green("\u2713")} Tunnel token saved to Keychain as ${pc2.cyan(tunnelLabel)}.`,
884
865
  "Secret"
885
866
  );
886
867
  } else {
887
- await printKeyForManualSave(tunnelToken, tunnelTitle, r.reason);
868
+ await printKeyForManualSave(tunnelToken, tunnelLabel, r.reason);
888
869
  }
889
870
  } else {
890
- await printKeyForManualSave(tunnelToken, tunnelTitle);
871
+ await printKeyForManualSave(tunnelToken, tunnelLabel);
891
872
  }
892
873
  p3.outro(
893
874
  pc2.cyan(
@@ -923,21 +904,22 @@ async function collectId(preset, message) {
923
904
  );
924
905
  return v;
925
906
  }
926
- async function printKeyForManualSave(value, title, reason) {
907
+ async function printKeyForManualSave(value, label, reason) {
927
908
  p3.note(
928
909
  [
929
910
  reason ? `${pc2.red("\u2717")} ${reason}` : "",
930
- `Save this value to 1Password as ${pc2.cyan(title)}:`,
911
+ `Save this value to your secret store as ${pc2.cyan(label)}:`,
931
912
  "",
932
913
  pc2.yellow(value),
933
914
  "",
934
- pc2.dim("It will not be shown again.")
915
+ pc2.dim("It will not be shown again."),
916
+ pc2.dim("On the Studio, bootstrap will prompt you to paste it back.")
935
917
  ].filter((line) => line.length > 0).join("\n"),
936
918
  "Manual save"
937
919
  );
938
920
  unwrap(
939
921
  await p3.confirm({
940
- message: "Value stashed in 1Password?",
922
+ message: "Value stashed?",
941
923
  initialValue: true
942
924
  })
943
925
  );
@@ -1563,7 +1545,7 @@ async function locateOrCloneRepo(input) {
1563
1545
  // src/commands/bootstrap.ts
1564
1546
  var bootstrapCommand = new Command("bootstrap").description(
1565
1547
  "Configure the Mac Studio and bring the stack up. Run this on the Studio itself, after `init` has finished on your laptop."
1566
- ).addOption(new Option("--region <slug>", "Region label set during init (e.g. us-ca)")).addOption(new Option("--owner <owner>", "GitHub owner for the node repo").default("OpusPopuli")).addOption(new Option("--vault <vault>", "1Password vault to read secrets from").default("Private")).addOption(new Option("--repo-dir <path>", "Explicit path to a checked-out node repo (overrides cwd + clone)")).addOption(
1548
+ ).addOption(new Option("--region <slug>", "Region label set during init (e.g. us-ca)")).addOption(new Option("--owner <owner>", "GitHub owner for the node repo").default("OpusPopuli")).addOption(new Option("--repo-dir <path>", "Explicit path to a checked-out node repo (overrides cwd + clone)")).addOption(
1567
1549
  new Option(
1568
1550
  "--compose-file <path>",
1569
1551
  "Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml + docker-compose-backup.yml"
@@ -1693,37 +1675,27 @@ var bootstrapCommand = new Command("bootstrap").description(
1693
1675
  repoSpin.stop(
1694
1676
  located.kind === "cloned" ? pc2.green(`\u2713 Cloned ${owner}/${repoName} to ${repoPath}`) : pc2.green(`\u2713 Found region repo at ${repoPath}`)
1695
1677
  );
1696
- const op = await detectOp();
1697
- if (!op.installed || !op.signedIn) {
1678
+ const keychain = await detectKeychain();
1679
+ if (!keychain.available) {
1698
1680
  p3.cancel(
1699
- "1Password CLI not installed or not signed in. Run `op signin` and re-run bootstrap. (Or paste pgsodium key + Tunnel token by hand \u2014 manual path not in v0.1.)"
1681
+ `${keychain.reason ?? "Keychain unavailable"}. Bootstrap requires the macOS Keychain.`
1700
1682
  );
1701
1683
  process.exit(1);
1702
1684
  }
1703
- const keyTitle = `opuspopuli-${region}-pgsodium-root-key`;
1704
- const tunnelTitle = `opuspopuli-${region}-tunnel-token`;
1705
- const vaultArg = opts.vault ? { vault: opts.vault } : {};
1706
- const opSpin = p3.spinner();
1707
- opSpin.start("Reading pgsodium key + Tunnel token from 1Password\u2026");
1708
- const pgsodiumKey = await readSecretFromOp({ title: keyTitle, ...vaultArg });
1709
- const tunnelToken = await readSecretFromOp({ title: tunnelTitle, ...vaultArg });
1710
- if (!pgsodiumKey || !tunnelToken) {
1711
- opSpin.stop(pc2.red("\u2717 Required 1Password items missing."));
1712
- const missing = [
1713
- pgsodiumKey ? null : keyTitle,
1714
- tunnelToken ? null : tunnelTitle
1715
- ].filter(Boolean);
1716
- p3.cancel(
1717
- `Missing in 1Password (vault: ${opts.vault ?? "Private"}): ${missing.join(", ")}. Run \`create-op-node init\` first, or pass --vault if your items live elsewhere.`
1718
- );
1719
- process.exit(1);
1720
- }
1721
- if (!PGSODIUM_KEY_RE.test(pgsodiumKey)) {
1722
- opSpin.stop(pc2.red("\u2717 pgsodium item exists but isn't a 64-hex key."));
1723
- p3.cancel("Inspect the item in 1Password; it should be 64 lowercase hex characters.");
1724
- process.exit(1);
1725
- }
1726
- opSpin.stop(pc2.green("\u2713 Secrets read from 1Password."));
1685
+ const pgsodiumKey = await loadOrPromptSecret({
1686
+ region,
1687
+ account: "pgsodium-root-key",
1688
+ label: "pgsodium master key",
1689
+ placeholder: "64 lowercase hex characters",
1690
+ validate: (v) => PGSODIUM_KEY_RE.test(v) ? void 0 : "must be exactly 64 lowercase hex characters"
1691
+ });
1692
+ const tunnelToken = await loadOrPromptSecret({
1693
+ region,
1694
+ account: "tunnel-token",
1695
+ label: "Cloudflare Tunnel token",
1696
+ placeholder: "JWT-style base64url string from `terraform output tunnel_token`",
1697
+ validate: (v) => TUNNEL_TOKEN_RE.test(v) ? void 0 : "tunnel token must be a base64-url JWT"
1698
+ });
1727
1699
  if (!opts.skipLaunchAgent) {
1728
1700
  const laSpin = p3.spinner();
1729
1701
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
@@ -1958,6 +1930,32 @@ async function promptTailscaleSignin() {
1958
1930
  );
1959
1931
  unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
1960
1932
  }
1933
+ async function loadOrPromptSecret(input) {
1934
+ const coords = { region: input.region, account: input.account };
1935
+ const spin = p3.spinner();
1936
+ spin.start(`Reading ${input.label} from Keychain\u2026`);
1937
+ const existing = await readSecret(coords);
1938
+ if (existing && input.validate(existing) === void 0) {
1939
+ spin.stop(pc2.green(`\u2713 ${input.label} read from Keychain.`));
1940
+ return existing;
1941
+ }
1942
+ spin.stop(
1943
+ existing ? pc2.yellow(`\u26A0 ${input.label} in Keychain failed validation \u2014 will re-prompt.`) : pc2.dim(`\xB7 ${input.label} not in Keychain \u2014 will prompt.`)
1944
+ );
1945
+ const pasted = unwrap(
1946
+ await p3.password({
1947
+ message: `Paste the ${input.label} for region ${input.region}:`,
1948
+ validate: (v) => input.validate(v ?? "")
1949
+ })
1950
+ );
1951
+ const save = await saveSecret(coords, pasted);
1952
+ if (!save.written) {
1953
+ p3.note(`${pc2.yellow("\u26A0")} Couldn't persist to Keychain: ${save.reason ?? "unknown"}. Continuing anyway.`, "Keychain");
1954
+ } else {
1955
+ p3.note(`${pc2.green("\u2713")} Stored ${input.label} in Keychain for re-runs.`, "Keychain");
1956
+ }
1957
+ return pasted;
1958
+ }
1961
1959
  var REGION_RE = /^[a-z0-9-]{2,32}$/;
1962
1960
  var RESET_PHASES = {
1963
1961
  STOP_STACK: "Stop stack",
@@ -3665,7 +3663,7 @@ async function looksLikeRegionsRepo(dir) {
3665
3663
  }
3666
3664
 
3667
3665
  // src/cli.ts
3668
- var VERSION = "0.2.0";
3666
+ var VERSION = "0.3.0";
3669
3667
  var program = new Command();
3670
3668
  program.name("create-op-node").description(
3671
3669
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."