create-op-node 0.4.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/README.md CHANGED
@@ -16,6 +16,10 @@ That's it. The wizard walks you through:
16
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
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
+ > Doing **local dev / testing first** (no public exposure yet)?
20
+ > See [Local-only mode](#local-only-mode-no-cloudflare) — `init --local-only`
21
+ > skips the Cloudflare/TFC/PR phases entirely.
22
+
19
23
  Then on the Mac Studio itself:
20
24
 
21
25
  ```bash
@@ -26,16 +30,31 @@ Configures macOS power settings, installs Homebrew + the CLI tool list, sets up
26
30
 
27
31
  ### Local-only mode (no Cloudflare)
28
32
 
33
+ For local dev / testing — frontend on your laptop, backend on the Studio
34
+ over Tailscale, no public exposure. The flow mirrors production-init →
35
+ production-bootstrap, just with the Cloudflare half cut out on both
36
+ sides.
37
+
38
+ **On the laptop:**
39
+
40
+ ```bash
41
+ npx create-op-node init --region us-ca --local-only
42
+ ```
43
+
44
+ This creates the region repo from the `OpusPopuli/opuspopuli-node`
45
+ template (private, no public exposure), generates the pgsodium master
46
+ key, and saves it to Keychain. No Cloudflare, no Terraform Cloud, no PR
47
+ to merge.
48
+
49
+ **On the Studio:**
50
+
29
51
  ```bash
30
52
  npx create-op-node bootstrap --region us-ca --local-only
31
53
  ```
32
54
 
33
- Brings the Studio up for local dev / testing — frontend on your laptop
34
- hits the Studio over Tailscale, no public exposure. Differences from
35
- the standard run:
55
+ Differences from production bootstrap:
36
56
 
37
- - **No Tunnel token required.** `init` is unnecessary; if the pgsodium
38
- key isn't in Keychain, bootstrap generates one inline and persists it.
57
+ - **No Tunnel token required.** `init --local-only` skipped that phase.
39
58
  - **`cloudflared` stays down.** It's gated behind the `public` compose
40
59
  profile, which `--local-only` doesn't activate. Bootstrap also evicts
41
60
  any leftover cloudflared from a prior public run so it doesn't strand
@@ -47,8 +66,9 @@ the standard run:
47
66
  exported into the launchd session.
48
67
  - **Outro tells you to use Tailscale**, not `npx create-op-node verify`.
49
68
 
50
- When you're ready to go public, re-run `bootstrap` without `--local-only`
51
- and the same Studio promotes to the full production-shaped deploy.
69
+ When you're ready to expose publicly, re-run **both** commands without
70
+ `--local-only`. Same region repo, same pgsodium key promotes
71
+ cleanly to the production-shaped deploy.
52
72
 
53
73
  > **Template version**: this mode depends on the `opuspopuli-node`
54
74
  > template having `profiles: [public]` on its cloudflared service. If
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);
773
+ prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
727
774
  }
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);
782
- }
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];
@@ -3728,7 +3808,7 @@ async function looksLikeRegionsRepo(dir) {
3728
3808
  }
3729
3809
 
3730
3810
  // src/cli.ts
3731
- var VERSION = "0.4.0";
3811
+ var VERSION = "0.5.0";
3732
3812
  var program = new Command();
3733
3813
  program.name("create-op-node").description(
3734
3814
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."