create-op-node 0.2.0 → 0.4.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/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}`;
250
232
  }
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) {
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}`;
236
+ }
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
287
256
  ]);
288
- if (create === null || create.exitCode !== 0) {
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
273
+ ]);
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
  );
@@ -1128,7 +1110,7 @@ async function writePgsodiumKeyFile(key, keyFile) {
1128
1110
  }
1129
1111
  }
1130
1112
  function renderLaunchAgentPlist(input) {
1131
- if (!TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1113
+ if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1132
1114
  throw new Error("Tunnel token contains characters outside the expected base64-url set");
1133
1115
  }
1134
1116
  if (!SAFE_PATH_RE.test(input.keyFilePath)) {
@@ -1136,7 +1118,11 @@ function renderLaunchAgentPlist(input) {
1136
1118
  `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
1137
1119
  );
1138
1120
  }
1139
- const command = `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"; launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`;
1121
+ const setenvLines = [
1122
+ `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
1123
+ ...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : []
1124
+ ];
1125
+ const command = setenvLines.join("; ");
1140
1126
  return [
1141
1127
  '<?xml version="1.0" encoding="UTF-8"?>',
1142
1128
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -1189,7 +1175,7 @@ async function setupLaunchAgent(input) {
1189
1175
  try {
1190
1176
  plistContent = renderLaunchAgentPlist({
1191
1177
  keyFilePath: paths.keyFile,
1192
- tunnelToken: input.tunnelToken
1178
+ ...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {}
1193
1179
  });
1194
1180
  } catch (err) {
1195
1181
  return { ok: false, paths, step: "plist", reason: err.message };
@@ -1272,7 +1258,8 @@ async function loginToGhcr() {
1272
1258
  function composeArgs(opts, sub) {
1273
1259
  const files = opts.files.flatMap((f) => ["-f", f]);
1274
1260
  const env = opts.envFile ? ["--env-file", opts.envFile] : [];
1275
- return ["compose", ...files, ...env, ...sub];
1261
+ const profiles = (opts.profiles ?? []).flatMap((pr) => ["--profile", pr]);
1262
+ return ["compose", ...files, ...env, ...profiles, ...sub];
1276
1263
  }
1277
1264
  async function composePull(opts) {
1278
1265
  const res = await safeExeca("docker", composeArgs(opts, ["pull"]));
@@ -1282,6 +1269,10 @@ async function composeUp(opts) {
1282
1269
  const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
1283
1270
  return result(res, "compose up");
1284
1271
  }
1272
+ async function composeRemoveService(opts, service) {
1273
+ const res = await safeExeca("docker", composeArgs(opts, ["rm", "-sfv", service]));
1274
+ return result(res, `compose rm ${service}`);
1275
+ }
1285
1276
  async function composeDown(opts) {
1286
1277
  const flags = ["down"];
1287
1278
  if (opts.wipeVolumes) flags.push("-v");
@@ -1561,19 +1552,26 @@ async function locateOrCloneRepo(input) {
1561
1552
  }
1562
1553
 
1563
1554
  // src/commands/bootstrap.ts
1555
+ var PUBLIC_PROFILES = ["public"];
1556
+ var LOCAL_PROFILES = [];
1564
1557
  var bootstrapCommand = new Command("bootstrap").description(
1565
1558
  "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(
1559
+ ).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
1560
  new Option(
1568
1561
  "--compose-file <path>",
1569
- "Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml + docker-compose-backup.yml"
1570
- ).default(["docker-compose-prod.yml", "docker-compose-backup.yml"])
1562
+ "Repeatable. Compose file relative to repo root. Default: prod + backup (production), prod only (--local-only)."
1563
+ )
1571
1564
  ).addOption(new Option("--env-file <path>", "Compose --env-file. Default: .env.production")).addOption(new Option("--skip-brew", "Skip the Homebrew package install pass").default(false)).addOption(
1572
1565
  new Option(
1573
1566
  "--skip-launch-agent",
1574
1567
  "Skip the LaunchAgent setup (assumes one is already in place)"
1575
1568
  ).default(false)
1576
- ).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1569
+ ).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
1570
+ new Option(
1571
+ "--local-only",
1572
+ "Run for local dev / testing: no Tunnel token required, cloudflared stays down. Auto-generates the pgsodium key if not in Keychain (init unnecessary)."
1573
+ ).default(false)
1574
+ ).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1577
1575
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
1578
1576
  const region = opts.region ? opts.region : unwrap(
1579
1577
  await p3.text({
@@ -1584,6 +1582,8 @@ var bootstrapCommand = new Command("bootstrap").description(
1584
1582
  );
1585
1583
  const owner = opts.owner ?? "OpusPopuli";
1586
1584
  const repoName = `opuspopuli-node-${region}`;
1585
+ const composeFileDefault = opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
1586
+ const composeFile = opts.composeFile ?? composeFileDefault;
1587
1587
  const sysSpin = p3.spinner();
1588
1588
  sysSpin.start("Inspecting macOS\u2026");
1589
1589
  const snap = await inspectSystem();
@@ -1693,47 +1693,44 @@ var bootstrapCommand = new Command("bootstrap").description(
1693
1693
  repoSpin.stop(
1694
1694
  located.kind === "cloned" ? pc2.green(`\u2713 Cloned ${owner}/${repoName} to ${repoPath}`) : pc2.green(`\u2713 Found region repo at ${repoPath}`)
1695
1695
  );
1696
- const op = await detectOp();
1697
- if (!op.installed || !op.signedIn) {
1698
- 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.)"
1700
- );
1701
- process.exit(1);
1702
- }
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);
1696
+ const keychain = await detectKeychain();
1697
+ if (!keychain.available) {
1716
1698
  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.`
1699
+ `${keychain.reason ?? "Keychain unavailable"}. Bootstrap requires the macOS Keychain.`
1718
1700
  );
1719
1701
  process.exit(1);
1720
1702
  }
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."));
1703
+ const pgsodiumKey = await loadSecret({
1704
+ region,
1705
+ account: "pgsodium-root-key",
1706
+ label: "pgsodium master key",
1707
+ validate: (v) => PGSODIUM_KEY_RE.test(v) ? void 0 : "must be exactly 64 lowercase hex characters",
1708
+ ...opts.localOnly ? { generate: generatePgsodiumRootKey } : {}
1709
+ });
1710
+ const tunnelToken = opts.localOnly ? void 0 : await loadSecret({
1711
+ region,
1712
+ account: "tunnel-token",
1713
+ label: "Cloudflare Tunnel token",
1714
+ placeholder: "JWT-style base64url string from `terraform output tunnel_token`",
1715
+ validate: (v) => TUNNEL_TOKEN_RE.test(v) ? void 0 : "tunnel token must be a base64-url JWT"
1716
+ });
1727
1717
  if (!opts.skipLaunchAgent) {
1728
1718
  const laSpin = p3.spinner();
1729
1719
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
1730
- const la = await setupLaunchAgent({ pgsodiumKey, tunnelToken });
1720
+ const la = await setupLaunchAgent({
1721
+ pgsodiumKey,
1722
+ ...tunnelToken !== void 0 ? { tunnelToken } : {}
1723
+ });
1731
1724
  if (!la.ok) {
1732
1725
  laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
1733
1726
  p3.cancel(la.reason ?? "LaunchAgent setup failed.");
1734
1727
  process.exit(1);
1735
1728
  }
1736
- laSpin.stop(pc2.green(`\u2713 LaunchAgent loaded (${la.paths.plistFile}).`));
1729
+ laSpin.stop(
1730
+ pc2.green(
1731
+ `\u2713 LaunchAgent loaded (${la.paths.plistFile})${opts.localOnly ? " \u2014 local-only mode, no TUNNEL_TOKEN set" : ""}.`
1732
+ )
1733
+ );
1737
1734
  }
1738
1735
  const ghcrSpin = p3.spinner();
1739
1736
  ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
@@ -1784,19 +1781,32 @@ var bootstrapCommand = new Command("bootstrap").description(
1784
1781
  }
1785
1782
  }
1786
1783
  if (opts.skipStack) {
1784
+ const profileFlag = opts.localOnly ? "" : "--profile public ";
1787
1785
  p3.outro(
1788
1786
  pc2.cyan(
1789
- `Stack-up skipped. Run \`docker compose -f ${(opts.composeFile ?? ["docker-compose-prod.yml"]).join(" -f ")} pull && up -d\` from ${repoPath} when ready.`
1787
+ `Stack-up skipped. Run \`docker compose -f ${composeFile.join(" -f ")} ${profileFlag}pull && docker compose -f ${composeFile.join(" -f ")} ${profileFlag}up -d\` from ${repoPath} when ready.`
1790
1788
  )
1791
1789
  );
1792
1790
  return;
1793
1791
  }
1794
- const composeFiles = resolveComposeFiles(repoPath, opts.composeFile);
1792
+ const composeFiles = resolveComposeFiles(repoPath, composeFile);
1795
1793
  const composeOpts = {
1796
1794
  files: composeFiles,
1797
1795
  cwd: repoPath,
1798
- ...opts.envFile ? { envFile: opts.envFile } : {}
1796
+ ...opts.envFile ? { envFile: opts.envFile } : {},
1797
+ profiles: opts.localOnly ? LOCAL_PROFILES : PUBLIC_PROFILES
1799
1798
  };
1799
+ if (opts.localOnly) {
1800
+ const evict = p3.spinner();
1801
+ evict.start("Evicting any cloudflared from a prior public bootstrap\u2026");
1802
+ const rm2 = await composeRemoveService(
1803
+ { ...composeOpts, profiles: PUBLIC_PROFILES },
1804
+ "cloudflared"
1805
+ );
1806
+ evict.stop(
1807
+ rm2.ok ? pc2.green("\u2713 cloudflared not present (or removed).") : pc2.yellow(`\u26A0 cloudflared eviction reported: ${rm2.reason ?? "unknown"} \u2014 continuing.`)
1808
+ );
1809
+ }
1800
1810
  const pullSpin = p3.spinner();
1801
1811
  pullSpin.start("Pulling images from ghcr.io\u2026");
1802
1812
  const pull = await composePull(composeOpts);
@@ -1827,11 +1837,23 @@ var bootstrapCommand = new Command("bootstrap").description(
1827
1837
  switch (outcome.kind) {
1828
1838
  case "healthy":
1829
1839
  healthSpin.stop(pc2.green(`\u2713 All ${outcome.snapshots.length} containers healthy.`));
1830
- p3.outro(
1831
- pc2.cyan(
1832
- `Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
1833
- )
1834
- );
1840
+ if (opts.localOnly) {
1841
+ p3.outro(
1842
+ pc2.cyan(
1843
+ [
1844
+ `Region ${region} is up locally (no public Cloudflare Tunnel \u2014 cloudflared stays down).`,
1845
+ `Access from your laptop over Tailscale at this Studio's tailnet IP.`,
1846
+ `When you're ready to expose publicly, re-run \`bootstrap\` without --local-only.`
1847
+ ].join("\n")
1848
+ )
1849
+ );
1850
+ } else {
1851
+ p3.outro(
1852
+ pc2.cyan(
1853
+ `Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
1854
+ )
1855
+ );
1856
+ }
1835
1857
  return;
1836
1858
  case "unhealthy":
1837
1859
  healthSpin.stop(pc2.red(`\u2717 ${outcome.problem}`));
@@ -1958,6 +1980,47 @@ async function promptTailscaleSignin() {
1958
1980
  );
1959
1981
  unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
1960
1982
  }
1983
+ async function loadSecret(input) {
1984
+ const coords = { region: input.region, account: input.account };
1985
+ const spin = p3.spinner();
1986
+ spin.start(`Reading ${input.label} from Keychain\u2026`);
1987
+ const existing = await readSecret(coords);
1988
+ if (existing && input.validate(existing) === void 0) {
1989
+ spin.stop(pc2.green(`\u2713 ${input.label} read from Keychain.`));
1990
+ return existing;
1991
+ }
1992
+ spin.stop(
1993
+ existing ? pc2.yellow(`\u26A0 ${input.label} in Keychain failed format validation \u2014 will replace.`) : pc2.dim(`\xB7 ${input.label} not in Keychain.`)
1994
+ );
1995
+ if (input.generate) {
1996
+ const fresh = input.generate();
1997
+ const save2 = await saveSecret(coords, fresh);
1998
+ if (!save2.written) {
1999
+ p3.cancel(
2000
+ `Generated a fresh ${input.label} but couldn't persist it to Keychain: ${save2.reason ?? "unknown"}. Continuing would risk silent key rotation on the next re-run. Resolve the Keychain access (re-grant via Keychain Access.app or check the security CLI) and re-run.`
2001
+ );
2002
+ process.exit(1);
2003
+ }
2004
+ p3.note(`${pc2.green("\u2713")} Generated fresh ${input.label} and stored in Keychain.`, "Keychain");
2005
+ return fresh;
2006
+ }
2007
+ const pasted = unwrap(
2008
+ await p3.password({
2009
+ message: `Paste the ${input.label} for region ${input.region}:`,
2010
+ validate: (v) => input.validate(v ?? "")
2011
+ })
2012
+ );
2013
+ const save = await saveSecret(coords, pasted);
2014
+ if (!save.written) {
2015
+ p3.note(
2016
+ `${pc2.yellow("\u26A0")} Couldn't persist to Keychain: ${save.reason ?? "unknown"}. You'll need to paste it again next run.`,
2017
+ "Keychain"
2018
+ );
2019
+ } else {
2020
+ p3.note(`${pc2.green("\u2713")} Stored ${input.label} in Keychain for re-runs.`, "Keychain");
2021
+ }
2022
+ return pasted;
2023
+ }
1961
2024
  var REGION_RE = /^[a-z0-9-]{2,32}$/;
1962
2025
  var RESET_PHASES = {
1963
2026
  STOP_STACK: "Stop stack",
@@ -3665,7 +3728,7 @@ async function looksLikeRegionsRepo(dir) {
3665
3728
  }
3666
3729
 
3667
3730
  // src/cli.ts
3668
- var VERSION = "0.2.0";
3731
+ var VERSION = "0.4.0";
3669
3732
  var program = new Command();
3670
3733
  program.name("create-op-node").description(
3671
3734
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."