create-op-node 0.3.0 → 0.5.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
@@ -536,10 +536,23 @@ var initCommand = new Command("init").description(
536
536
  "--use-existing-repo",
537
537
  "Continue using a previously-created node repo (don't fail on 'already exists')"
538
538
  ).default(false)
539
- ).addOption(new Option("--skip-wait", "Don't poll for Terraform apply; exit after PR open").default(false)).addOption(new Option("-y, --yes", "Skip the final confirmation").default(false)).action(async (opts) => {
539
+ ).addOption(new Option("--skip-wait", "Don't poll for Terraform apply; exit after PR open").default(false)).addOption(
540
+ new Option(
541
+ "--local-only",
542
+ "Skip the Cloudflare/TFC/PR/tunnel phases. Creates the region repo from template + stores a pgsodium key \u2014 enough for `bootstrap --local-only` to work."
543
+ ).default(false)
544
+ ).addOption(new Option("-y, --yes", "Skip the final confirmation").default(false)).action(async (opts) => {
540
545
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node init ")));
541
546
  p3.note(
542
- [
547
+ opts.localOnly ? [
548
+ "Local-only setup \u2014 creates the region repo from template + a pgsodium key.",
549
+ "No Cloudflare, no Terraform Cloud, no public exposure.",
550
+ "",
551
+ pc2.dim("Phase 1: laptop side (now) \u2014 GitHub repo from template + Keychain."),
552
+ pc2.dim("Phase 2: Studio side \u2014 `create-op-node bootstrap --local-only`."),
553
+ "",
554
+ pc2.dim("When you're ready to expose publicly, re-run `init` without --local-only.")
555
+ ].join("\n") : [
543
556
  "This walks you from a sealed Mac Studio + a Cloudflare account to a",
544
557
  "live federation node serving traffic at api.<your-domain>.",
545
558
  "",
@@ -555,62 +568,25 @@ var initCommand = new Command("init").description(
555
568
  validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
556
569
  })
557
570
  );
558
- const domain = opts.domain ? opts.domain : unwrap(
559
- await p3.text({
560
- message: "Domain registered in Cloudflare?",
561
- placeholder: "example.org",
562
- validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Missing a TLD?"
563
- })
564
- );
565
571
  const owner = opts.owner ?? "OpusPopuli";
566
572
  const newRepoName = `opuspopuli-node-${region}`;
567
573
  const newRepoFull = `${owner}/${newRepoName}`;
568
- const cfToken = await collectSecret(
569
- opts.cfToken,
570
- "Cloudflare Account API token (input hidden)",
571
- "cloudflare"
572
- );
573
- const cfAccount = await collectId(opts.cfAccount, "Cloudflare account ID");
574
- const cfZone = await collectId(opts.cfZone, `Cloudflare zone ID for ${domain}`);
575
- const cfSpin = p3.spinner();
576
- cfSpin.start("Verifying Cloudflare token + 5 scopes\u2026");
577
- const cfProbe = await probeCloudflareToken({
578
- token: cfToken,
579
- accountId: cfAccount,
580
- zoneId: cfZone
581
- });
582
- if (!cfProbe.ok) {
583
- cfSpin.stop(pc2.red("\u2717 Cloudflare token check failed."));
584
- for (const issue of cfProbe.issues) console.error(` - ${issue}`);
585
- p3.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
586
- process.exit(1);
587
- }
588
- cfSpin.stop(pc2.green("\u2713 Cloudflare token valid, 5 scopes present."));
589
- const tfToken = await collectSecret(
590
- opts.tfToken,
591
- "Terraform Cloud user/team API token",
592
- "tfc"
593
- );
594
- const tfOrg = opts.tfOrg ? opts.tfOrg : unwrap(
595
- await p3.text({
596
- message: "Terraform Cloud organization name?",
597
- placeholder: "op-region-ca"
598
- })
599
- );
600
- const tfSpin = p3.spinner();
601
- tfSpin.start("Verifying Terraform Cloud token + organization\u2026");
602
- const tfProbe = await probeTfcToken({ token: tfToken, organization: tfOrg });
603
- if (!tfProbe.ok) {
604
- tfSpin.stop(pc2.red("\u2717 Terraform Cloud check failed."));
605
- for (const issue of tfProbe.issues) console.error(` - ${issue}`);
606
- p3.cancel("Fix the token / org, then re-run.");
607
- process.exit(1);
574
+ if (opts.localOnly) {
575
+ const ignored = listIgnoredLocalOnlyFlags(opts);
576
+ if (ignored.length > 0) {
577
+ p3.note(
578
+ pc2.yellow(
579
+ `\u26A0 The following ${ignored.length === 1 ? "flag is" : "flags are"} ignored with --local-only:
580
+ ${ignored.join(", ")}
581
+ ` + pc2.dim(
582
+ "These configure Cloudflare / TFC / PR-merge flow, which --local-only skips."
583
+ )
584
+ ),
585
+ "Notice"
586
+ );
587
+ }
608
588
  }
609
- tfSpin.stop(
610
- pc2.green(
611
- `\u2713 TFC token valid${tfProbe.userName ? ` (as ${tfProbe.userName})` : ""}, org "${tfOrg}" reachable.`
612
- )
613
- );
589
+ const publicConfig = opts.localOnly ? null : await collectPublicConfig(opts);
614
590
  let ghToken = opts.ghToken;
615
591
  if (!ghToken) {
616
592
  const fromCli = await ghTokenFromCli();
@@ -636,12 +612,23 @@ var initCommand = new Command("init").description(
636
612
  ` + 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
613
  p3.note(kcNote, "Secret store");
638
614
  p3.note(
639
- [
615
+ opts.localOnly ? [
616
+ `Region label: ${pc2.cyan(region)}`,
617
+ `New node repo: ${pc2.cyan(newRepoFull)}`,
618
+ `Template: ${pc2.dim(opts.template ?? "OpusPopuli/opuspopuli-node")}`,
619
+ `GH org write access: ${pc2.dim(`required for ${owner}`)}`,
620
+ ``,
621
+ `Will create the repo from template (private, no Cloudflare),`,
622
+ `and store a fresh pgsodium key in Keychain. That's it \u2014 no PR,`,
623
+ `no terraform, no tunnel token. Bootstrap on the Studio with`,
624
+ `\`bootstrap --local-only --region ${region}\` next.`
625
+ ].join("\n") : [
640
626
  `Region label: ${pc2.cyan(region)}`,
641
- `Domain: ${pc2.cyan(domain)}`,
627
+ `Domain: ${pc2.cyan(publicConfig?.domain ?? "")}`,
642
628
  `New node repo: ${pc2.cyan(newRepoFull)}`,
643
629
  `Template: ${pc2.dim(opts.template ?? "OpusPopuli/opuspopuli-node")}`,
644
- `TFC organization: ${pc2.cyan(tfOrg)}`,
630
+ `TFC organization: ${pc2.cyan(publicConfig?.tfOrg ?? "")}`,
631
+ `GH org write access: ${pc2.dim(`required for ${owner}`)}`,
645
632
  ``,
646
633
  `Will create the repo, seed 5 secrets, write prod.tfvars on a branch,`,
647
634
  `open a PR, generate a fresh pgsodium key, and (after you merge) wait`,
@@ -697,90 +684,94 @@ var initCommand = new Command("init").description(
697
684
  defaultBranch: "main"
698
685
  };
699
686
  }
700
- const secrets = [
701
- { name: "CLOUDFLARE_API_TOKEN", value: cfToken },
702
- { name: "CLOUDFLARE_ACCOUNT_ID", value: cfAccount },
703
- { name: "CLOUDFLARE_ZONE_ID", value: cfZone },
704
- { name: "TF_API_TOKEN", value: tfToken },
705
- { name: "TF_CLOUD_ORGANIZATION", value: tfOrg }
706
- ];
707
- const secSpin = p3.spinner();
708
- secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
709
- const seeded = [];
710
- for (const s of secrets) {
711
- const r = await setRepoSecret({ repo: newRepoFull, name: s.name, value: s.value });
712
- if (!r.written) {
713
- secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
714
- const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
715
- p3.cancel(
716
- [
717
- seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
718
- `Still pending: ${remaining.join(", ")}.`,
687
+ if (publicConfig) {
688
+ const secrets = [
689
+ { name: "CLOUDFLARE_API_TOKEN", value: publicConfig.cfToken },
690
+ { name: "CLOUDFLARE_ACCOUNT_ID", value: publicConfig.cfAccount },
691
+ { name: "CLOUDFLARE_ZONE_ID", value: publicConfig.cfZone },
692
+ { name: "TF_API_TOKEN", value: publicConfig.tfToken },
693
+ { name: "TF_CLOUD_ORGANIZATION", value: publicConfig.tfOrg }
694
+ ];
695
+ const secSpin = p3.spinner();
696
+ secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
697
+ const seeded = [];
698
+ for (const s of secrets) {
699
+ const r = await setRepoSecret({ repo: newRepoFull, name: s.name, value: s.value });
700
+ if (!r.written) {
701
+ secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
702
+ const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
703
+ p3.cancel(
704
+ [
705
+ seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
706
+ `Still pending: ${remaining.join(", ")}.`,
707
+ "",
708
+ `Make sure \`gh\` is installed and signed in (\`gh auth login\`).`,
709
+ `Re-run with --use-existing-repo to retry (GitHub will overwrite the already-set secrets idempotently).`
710
+ ].join("\n")
711
+ );
712
+ process.exit(1);
713
+ }
714
+ seeded.push(s.name);
715
+ }
716
+ secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
717
+ }
718
+ let pr = null;
719
+ if (publicConfig) {
720
+ const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
721
+ const setupSpin = p3.spinner();
722
+ setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
723
+ try {
724
+ await createBranch({
725
+ token: ghToken,
726
+ repo: newRepoFull,
727
+ branch,
728
+ fromBranch: created.defaultBranch
729
+ });
730
+ const tfvars = renderProdTfvars({
731
+ project: opts.project ?? "opuspopuli",
732
+ domain: publicConfig.domain
733
+ });
734
+ await commitFile({
735
+ token: ghToken,
736
+ repo: newRepoFull,
737
+ branch,
738
+ path: "infra/cloudflare/environments/prod.tfvars",
739
+ content: tfvars,
740
+ message: `init: prod.tfvars for ${region}`
741
+ });
742
+ } catch (err) {
743
+ setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
744
+ p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
745
+ process.exit(1);
746
+ }
747
+ setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
748
+ const prSpin = p3.spinner();
749
+ prSpin.start("Opening pull request\u2026");
750
+ try {
751
+ pr = await openPullRequest({
752
+ token: ghToken,
753
+ repo: newRepoFull,
754
+ head: branch,
755
+ base: created.defaultBranch,
756
+ title: `init: bring up region ${region}`,
757
+ body: [
758
+ `Initial region deployment for **${region}** (${publicConfig.domain}).`,
759
+ "",
760
+ "Adds `infra/cloudflare/environments/prod.tfvars`. Merging this PR triggers",
761
+ "`cloudflare-infra.yml` which runs `terraform apply` against the",
762
+ `\`${publicConfig.tfOrg}\` Terraform Cloud organization, provisioning the Cloudflare`,
763
+ "Tunnel, DNS records, R2 buckets, and Pages project.",
719
764
  "",
720
- `Make sure \`gh\` is installed and signed in (\`gh auth login\`).`,
721
- `Re-run with --use-existing-repo to retry (GitHub will overwrite the already-set secrets idempotently).`
765
+ `Generated by \`create-op-node init\`.`
722
766
  ].join("\n")
723
- );
767
+ });
768
+ } catch (err) {
769
+ prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
770
+ p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
724
771
  process.exit(1);
725
772
  }
726
- seeded.push(s.name);
727
- }
728
- secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
729
- const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
730
- const setupSpin = p3.spinner();
731
- setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
732
- try {
733
- await createBranch({
734
- token: ghToken,
735
- repo: newRepoFull,
736
- branch,
737
- fromBranch: created.defaultBranch
738
- });
739
- const tfvars = renderProdTfvars({
740
- project: opts.project ?? "opuspopuli",
741
- domain
742
- });
743
- await commitFile({
744
- token: ghToken,
745
- repo: newRepoFull,
746
- branch,
747
- path: "infra/cloudflare/environments/prod.tfvars",
748
- content: tfvars,
749
- message: `init: prod.tfvars for ${region}`
750
- });
751
- } catch (err) {
752
- setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
753
- p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
754
- process.exit(1);
755
- }
756
- setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
757
- const prSpin = p3.spinner();
758
- prSpin.start("Opening pull request\u2026");
759
- let pr;
760
- try {
761
- pr = await openPullRequest({
762
- token: ghToken,
763
- repo: newRepoFull,
764
- head: branch,
765
- base: created.defaultBranch,
766
- title: `init: bring up region ${region}`,
767
- body: [
768
- `Initial region deployment for **${region}** (${domain}).`,
769
- "",
770
- "Adds `infra/cloudflare/environments/prod.tfvars`. Merging this PR triggers",
771
- "`cloudflare-infra.yml` which runs `terraform apply` against the",
772
- `\`${tfOrg}\` Terraform Cloud organization, provisioning the Cloudflare`,
773
- "Tunnel, DNS records, R2 buckets, and Pages project.",
774
- "",
775
- `Generated by \`create-op-node init\`.`
776
- ].join("\n")
777
- });
778
- } catch (err) {
779
- prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
780
- p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
781
- process.exit(1);
773
+ prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
782
774
  }
783
- prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
784
775
  const pgsodiumCoords = { region, account: "pgsodium-root-key" };
785
776
  const keyLabel = `org.opuspopuli.${region}/pgsodium-root-key`;
786
777
  if (keychain.available) {
@@ -806,6 +797,28 @@ var initCommand = new Command("init").description(
806
797
  const fresh = generatePgsodiumRootKey();
807
798
  await printKeyForManualSave(fresh, keyLabel);
808
799
  }
800
+ if (!publicConfig) {
801
+ p3.outro(
802
+ pc2.cyan(
803
+ [
804
+ `Region ${region} provisioned for local-only mode.`,
805
+ `Repo: https://github.com/${newRepoFull}`,
806
+ ``,
807
+ `Next: on the Studio (fresh or otherwise \u2014 bootstrap installs`,
808
+ `brew packages it needs), run:`,
809
+ ` npx create-op-node bootstrap --region ${region} --local-only`,
810
+ ``,
811
+ `When you're ready to expose publicly, re-run BOTH \`init\` and`,
812
+ `\`bootstrap\` without --local-only. Same region repo + pgsodium key`,
813
+ `promote cleanly to the production-shaped deploy.`
814
+ ].join("\n")
815
+ )
816
+ );
817
+ return;
818
+ }
819
+ if (!pr) {
820
+ throw new Error("init: PR creation step did not run despite publicConfig present");
821
+ }
809
822
  if (opts.skipWait) {
810
823
  p3.outro(
811
824
  pc2.cyan(
@@ -833,19 +846,19 @@ var initCommand = new Command("init").description(
833
846
  return;
834
847
  }
835
848
  const ws = await findWorkspace({
836
- token: tfToken,
837
- organization: tfOrg,
849
+ token: publicConfig.tfToken,
850
+ organization: publicConfig.tfOrg,
838
851
  tags: ["opuspopuli", "cloudflare"]
839
852
  });
840
853
  if (!ws) {
841
854
  p3.cancel(
842
- `Couldn't find a TFC workspace tagged opuspopuli + cloudflare in ${tfOrg}. Check the workflow's run log; the workspace is created on first apply.`
855
+ `Couldn't find a TFC workspace tagged opuspopuli + cloudflare in ${publicConfig.tfOrg}. Check the workflow's run log; the workspace is created on first apply.`
843
856
  );
844
857
  process.exit(1);
845
858
  }
846
859
  const tunnelToken = await waitForApplyAndFetchTunnelToken({
847
- token: tfToken,
848
- organization: tfOrg,
860
+ token: publicConfig.tfToken,
861
+ organization: publicConfig.tfOrg,
849
862
  workspaceId: ws.id,
850
863
  runId: ws.currentRunId
851
864
  });
@@ -882,6 +895,73 @@ var MIN_TOKEN_LENGTH = {
882
895
  github: 40,
883
896
  generic: 20
884
897
  };
898
+ function listIgnoredLocalOnlyFlags(opts) {
899
+ const ignored = [];
900
+ if (opts.domain) ignored.push("--domain");
901
+ if (opts.cfToken) ignored.push("--cf-token");
902
+ if (opts.cfAccount) ignored.push("--cf-account");
903
+ if (opts.cfZone) ignored.push("--cf-zone");
904
+ if (opts.tfToken) ignored.push("--tf-token");
905
+ if (opts.tfOrg) ignored.push("--tf-org");
906
+ if (opts.skipWait) ignored.push("--skip-wait");
907
+ return ignored;
908
+ }
909
+ async function collectPublicConfig(opts) {
910
+ const domain = opts.domain ? opts.domain : unwrap(
911
+ await p3.text({
912
+ message: "Domain registered in Cloudflare?",
913
+ placeholder: "example.org",
914
+ validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Missing a TLD?"
915
+ })
916
+ );
917
+ const cfToken = await collectSecret(
918
+ opts.cfToken,
919
+ "Cloudflare Account API token (input hidden)",
920
+ "cloudflare"
921
+ );
922
+ const cfAccount = await collectId(opts.cfAccount, "Cloudflare account ID");
923
+ const cfZone = await collectId(opts.cfZone, `Cloudflare zone ID for ${domain}`);
924
+ const cfSpin = p3.spinner();
925
+ cfSpin.start("Verifying Cloudflare token + 5 scopes\u2026");
926
+ const cfProbe = await probeCloudflareToken({
927
+ token: cfToken,
928
+ accountId: cfAccount,
929
+ zoneId: cfZone
930
+ });
931
+ if (!cfProbe.ok) {
932
+ cfSpin.stop(pc2.red("\u2717 Cloudflare token check failed."));
933
+ for (const issue of cfProbe.issues) console.error(` - ${issue}`);
934
+ p3.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
935
+ process.exit(1);
936
+ }
937
+ cfSpin.stop(pc2.green("\u2713 Cloudflare token valid, 5 scopes present."));
938
+ const tfToken = await collectSecret(
939
+ opts.tfToken,
940
+ "Terraform Cloud user/team API token",
941
+ "tfc"
942
+ );
943
+ const tfOrg = opts.tfOrg ? opts.tfOrg : unwrap(
944
+ await p3.text({
945
+ message: "Terraform Cloud organization name?",
946
+ placeholder: "op-region-ca"
947
+ })
948
+ );
949
+ const tfSpin = p3.spinner();
950
+ tfSpin.start("Verifying Terraform Cloud token + organization\u2026");
951
+ const tfProbe = await probeTfcToken({ token: tfToken, organization: tfOrg });
952
+ if (!tfProbe.ok) {
953
+ tfSpin.stop(pc2.red("\u2717 Terraform Cloud check failed."));
954
+ for (const issue of tfProbe.issues) console.error(` - ${issue}`);
955
+ p3.cancel("Fix the token / org, then re-run.");
956
+ process.exit(1);
957
+ }
958
+ tfSpin.stop(
959
+ pc2.green(
960
+ `\u2713 TFC token valid${tfProbe.userName ? ` (as ${tfProbe.userName})` : ""}, org "${tfOrg}" reachable.`
961
+ )
962
+ );
963
+ return { domain, cfToken, cfAccount, cfZone, tfToken, tfOrg };
964
+ }
885
965
  async function collectSecret(preset, message, kind = "generic") {
886
966
  if (preset) return preset;
887
967
  const min = MIN_TOKEN_LENGTH[kind];
@@ -1110,7 +1190,7 @@ async function writePgsodiumKeyFile(key, keyFile) {
1110
1190
  }
1111
1191
  }
1112
1192
  function renderLaunchAgentPlist(input) {
1113
- if (!TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1193
+ if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1114
1194
  throw new Error("Tunnel token contains characters outside the expected base64-url set");
1115
1195
  }
1116
1196
  if (!SAFE_PATH_RE.test(input.keyFilePath)) {
@@ -1118,7 +1198,11 @@ function renderLaunchAgentPlist(input) {
1118
1198
  `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
1119
1199
  );
1120
1200
  }
1121
- const command = `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"; launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`;
1201
+ const setenvLines = [
1202
+ `launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
1203
+ ...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : []
1204
+ ];
1205
+ const command = setenvLines.join("; ");
1122
1206
  return [
1123
1207
  '<?xml version="1.0" encoding="UTF-8"?>',
1124
1208
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -1171,7 +1255,7 @@ async function setupLaunchAgent(input) {
1171
1255
  try {
1172
1256
  plistContent = renderLaunchAgentPlist({
1173
1257
  keyFilePath: paths.keyFile,
1174
- tunnelToken: input.tunnelToken
1258
+ ...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {}
1175
1259
  });
1176
1260
  } catch (err) {
1177
1261
  return { ok: false, paths, step: "plist", reason: err.message };
@@ -1254,7 +1338,8 @@ async function loginToGhcr() {
1254
1338
  function composeArgs(opts, sub) {
1255
1339
  const files = opts.files.flatMap((f) => ["-f", f]);
1256
1340
  const env = opts.envFile ? ["--env-file", opts.envFile] : [];
1257
- return ["compose", ...files, ...env, ...sub];
1341
+ const profiles = (opts.profiles ?? []).flatMap((pr) => ["--profile", pr]);
1342
+ return ["compose", ...files, ...env, ...profiles, ...sub];
1258
1343
  }
1259
1344
  async function composePull(opts) {
1260
1345
  const res = await safeExeca("docker", composeArgs(opts, ["pull"]));
@@ -1264,6 +1349,10 @@ async function composeUp(opts) {
1264
1349
  const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
1265
1350
  return result(res, "compose up");
1266
1351
  }
1352
+ async function composeRemoveService(opts, service) {
1353
+ const res = await safeExeca("docker", composeArgs(opts, ["rm", "-sfv", service]));
1354
+ return result(res, `compose rm ${service}`);
1355
+ }
1267
1356
  async function composeDown(opts) {
1268
1357
  const flags = ["down"];
1269
1358
  if (opts.wipeVolumes) flags.push("-v");
@@ -1543,19 +1632,26 @@ async function locateOrCloneRepo(input) {
1543
1632
  }
1544
1633
 
1545
1634
  // src/commands/bootstrap.ts
1635
+ var PUBLIC_PROFILES = ["public"];
1636
+ var LOCAL_PROFILES = [];
1546
1637
  var bootstrapCommand = new Command("bootstrap").description(
1547
1638
  "Configure the Mac Studio and bring the stack up. Run this on the Studio itself, after `init` has finished on your laptop."
1548
1639
  ).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(
1549
1640
  new Option(
1550
1641
  "--compose-file <path>",
1551
- "Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml + docker-compose-backup.yml"
1552
- ).default(["docker-compose-prod.yml", "docker-compose-backup.yml"])
1642
+ "Repeatable. Compose file relative to repo root. Default: prod + backup (production), prod only (--local-only)."
1643
+ )
1553
1644
  ).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(
1554
1645
  new Option(
1555
1646
  "--skip-launch-agent",
1556
1647
  "Skip the LaunchAgent setup (assumes one is already in place)"
1557
1648
  ).default(false)
1558
- ).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) => {
1649
+ ).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(
1650
+ new Option(
1651
+ "--local-only",
1652
+ "Run for local dev / testing: no Tunnel token required, cloudflared stays down. Auto-generates the pgsodium key if not in Keychain (init unnecessary)."
1653
+ ).default(false)
1654
+ ).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
1559
1655
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
1560
1656
  const region = opts.region ? opts.region : unwrap(
1561
1657
  await p3.text({
@@ -1566,6 +1662,8 @@ var bootstrapCommand = new Command("bootstrap").description(
1566
1662
  );
1567
1663
  const owner = opts.owner ?? "OpusPopuli";
1568
1664
  const repoName = `opuspopuli-node-${region}`;
1665
+ const composeFileDefault = opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
1666
+ const composeFile = opts.composeFile ?? composeFileDefault;
1569
1667
  const sysSpin = p3.spinner();
1570
1668
  sysSpin.start("Inspecting macOS\u2026");
1571
1669
  const snap = await inspectSystem();
@@ -1682,14 +1780,14 @@ var bootstrapCommand = new Command("bootstrap").description(
1682
1780
  );
1683
1781
  process.exit(1);
1684
1782
  }
1685
- const pgsodiumKey = await loadOrPromptSecret({
1783
+ const pgsodiumKey = await loadSecret({
1686
1784
  region,
1687
1785
  account: "pgsodium-root-key",
1688
1786
  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"
1787
+ validate: (v) => PGSODIUM_KEY_RE.test(v) ? void 0 : "must be exactly 64 lowercase hex characters",
1788
+ ...opts.localOnly ? { generate: generatePgsodiumRootKey } : {}
1691
1789
  });
1692
- const tunnelToken = await loadOrPromptSecret({
1790
+ const tunnelToken = opts.localOnly ? void 0 : await loadSecret({
1693
1791
  region,
1694
1792
  account: "tunnel-token",
1695
1793
  label: "Cloudflare Tunnel token",
@@ -1699,13 +1797,20 @@ var bootstrapCommand = new Command("bootstrap").description(
1699
1797
  if (!opts.skipLaunchAgent) {
1700
1798
  const laSpin = p3.spinner();
1701
1799
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
1702
- const la = await setupLaunchAgent({ pgsodiumKey, tunnelToken });
1800
+ const la = await setupLaunchAgent({
1801
+ pgsodiumKey,
1802
+ ...tunnelToken !== void 0 ? { tunnelToken } : {}
1803
+ });
1703
1804
  if (!la.ok) {
1704
1805
  laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
1705
1806
  p3.cancel(la.reason ?? "LaunchAgent setup failed.");
1706
1807
  process.exit(1);
1707
1808
  }
1708
- laSpin.stop(pc2.green(`\u2713 LaunchAgent loaded (${la.paths.plistFile}).`));
1809
+ laSpin.stop(
1810
+ pc2.green(
1811
+ `\u2713 LaunchAgent loaded (${la.paths.plistFile})${opts.localOnly ? " \u2014 local-only mode, no TUNNEL_TOKEN set" : ""}.`
1812
+ )
1813
+ );
1709
1814
  }
1710
1815
  const ghcrSpin = p3.spinner();
1711
1816
  ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
@@ -1756,19 +1861,32 @@ var bootstrapCommand = new Command("bootstrap").description(
1756
1861
  }
1757
1862
  }
1758
1863
  if (opts.skipStack) {
1864
+ const profileFlag = opts.localOnly ? "" : "--profile public ";
1759
1865
  p3.outro(
1760
1866
  pc2.cyan(
1761
- `Stack-up skipped. Run \`docker compose -f ${(opts.composeFile ?? ["docker-compose-prod.yml"]).join(" -f ")} pull && up -d\` from ${repoPath} when ready.`
1867
+ `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.`
1762
1868
  )
1763
1869
  );
1764
1870
  return;
1765
1871
  }
1766
- const composeFiles = resolveComposeFiles(repoPath, opts.composeFile);
1872
+ const composeFiles = resolveComposeFiles(repoPath, composeFile);
1767
1873
  const composeOpts = {
1768
1874
  files: composeFiles,
1769
1875
  cwd: repoPath,
1770
- ...opts.envFile ? { envFile: opts.envFile } : {}
1876
+ ...opts.envFile ? { envFile: opts.envFile } : {},
1877
+ profiles: opts.localOnly ? LOCAL_PROFILES : PUBLIC_PROFILES
1771
1878
  };
1879
+ if (opts.localOnly) {
1880
+ const evict = p3.spinner();
1881
+ evict.start("Evicting any cloudflared from a prior public bootstrap\u2026");
1882
+ const rm2 = await composeRemoveService(
1883
+ { ...composeOpts, profiles: PUBLIC_PROFILES },
1884
+ "cloudflared"
1885
+ );
1886
+ evict.stop(
1887
+ rm2.ok ? pc2.green("\u2713 cloudflared not present (or removed).") : pc2.yellow(`\u26A0 cloudflared eviction reported: ${rm2.reason ?? "unknown"} \u2014 continuing.`)
1888
+ );
1889
+ }
1772
1890
  const pullSpin = p3.spinner();
1773
1891
  pullSpin.start("Pulling images from ghcr.io\u2026");
1774
1892
  const pull = await composePull(composeOpts);
@@ -1799,11 +1917,23 @@ var bootstrapCommand = new Command("bootstrap").description(
1799
1917
  switch (outcome.kind) {
1800
1918
  case "healthy":
1801
1919
  healthSpin.stop(pc2.green(`\u2713 All ${outcome.snapshots.length} containers healthy.`));
1802
- p3.outro(
1803
- pc2.cyan(
1804
- `Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
1805
- )
1806
- );
1920
+ if (opts.localOnly) {
1921
+ p3.outro(
1922
+ pc2.cyan(
1923
+ [
1924
+ `Region ${region} is up locally (no public Cloudflare Tunnel \u2014 cloudflared stays down).`,
1925
+ `Access from your laptop over Tailscale at this Studio's tailnet IP.`,
1926
+ `When you're ready to expose publicly, re-run \`bootstrap\` without --local-only.`
1927
+ ].join("\n")
1928
+ )
1929
+ );
1930
+ } else {
1931
+ p3.outro(
1932
+ pc2.cyan(
1933
+ `Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
1934
+ )
1935
+ );
1936
+ }
1807
1937
  return;
1808
1938
  case "unhealthy":
1809
1939
  healthSpin.stop(pc2.red(`\u2717 ${outcome.problem}`));
@@ -1930,7 +2060,7 @@ async function promptTailscaleSignin() {
1930
2060
  );
1931
2061
  unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
1932
2062
  }
1933
- async function loadOrPromptSecret(input) {
2063
+ async function loadSecret(input) {
1934
2064
  const coords = { region: input.region, account: input.account };
1935
2065
  const spin = p3.spinner();
1936
2066
  spin.start(`Reading ${input.label} from Keychain\u2026`);
@@ -1940,8 +2070,20 @@ async function loadOrPromptSecret(input) {
1940
2070
  return existing;
1941
2071
  }
1942
2072
  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.`)
2073
+ existing ? pc2.yellow(`\u26A0 ${input.label} in Keychain failed format validation \u2014 will replace.`) : pc2.dim(`\xB7 ${input.label} not in Keychain.`)
1944
2074
  );
2075
+ if (input.generate) {
2076
+ const fresh = input.generate();
2077
+ const save2 = await saveSecret(coords, fresh);
2078
+ if (!save2.written) {
2079
+ p3.cancel(
2080
+ `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.`
2081
+ );
2082
+ process.exit(1);
2083
+ }
2084
+ p3.note(`${pc2.green("\u2713")} Generated fresh ${input.label} and stored in Keychain.`, "Keychain");
2085
+ return fresh;
2086
+ }
1945
2087
  const pasted = unwrap(
1946
2088
  await p3.password({
1947
2089
  message: `Paste the ${input.label} for region ${input.region}:`,
@@ -1950,7 +2092,10 @@ async function loadOrPromptSecret(input) {
1950
2092
  );
1951
2093
  const save = await saveSecret(coords, pasted);
1952
2094
  if (!save.written) {
1953
- p3.note(`${pc2.yellow("\u26A0")} Couldn't persist to Keychain: ${save.reason ?? "unknown"}. Continuing anyway.`, "Keychain");
2095
+ p3.note(
2096
+ `${pc2.yellow("\u26A0")} Couldn't persist to Keychain: ${save.reason ?? "unknown"}. You'll need to paste it again next run.`,
2097
+ "Keychain"
2098
+ );
1954
2099
  } else {
1955
2100
  p3.note(`${pc2.green("\u2713")} Stored ${input.label} in Keychain for re-runs.`, "Keychain");
1956
2101
  }
@@ -3663,7 +3808,7 @@ async function looksLikeRegionsRepo(dir) {
3663
3808
  }
3664
3809
 
3665
3810
  // src/cli.ts
3666
- var VERSION = "0.3.0";
3811
+ var VERSION = "0.5.0";
3667
3812
  var program = new Command();
3668
3813
  program.name("create-op-node").description(
3669
3814
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."