create-op-node 0.10.6 → 0.10.8

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
@@ -645,6 +645,37 @@ var initCommand = new Command("init").description(
645
645
  ).default(false)
646
646
  ).addOption(new Option("-y, --yes", "Skip the final confirmation").default(false)).action(async (opts) => {
647
647
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node init ")));
648
+ printWelcomeNote(opts);
649
+ const region = await resolveRegion(opts);
650
+ const owner = opts.owner ?? "OpusPopuli";
651
+ const newRepoName = `opuspopuli-node-${region}`;
652
+ const newRepoFull = `${owner}/${newRepoName}`;
653
+ warnIgnoredLocalOnlyFlags(opts);
654
+ const publicConfig = opts.localOnly ? null : await collectPublicConfig(opts);
655
+ const ghToken = await resolveGhToken(opts);
656
+ const keychain = await detectKeychain();
657
+ printSecretStoreNote(keychain);
658
+ await confirmPlan({ opts, region, owner, newRepoFull, publicConfig });
659
+ const created = await createRegionRepo({ opts, ghToken, owner, newRepoName, newRepoFull, region });
660
+ if (publicConfig) {
661
+ await seedRepoSecrets({ publicConfig, newRepoFull });
662
+ }
663
+ const pr = publicConfig ? await openInfraPr({ opts, ghToken, region, newRepoFull, publicConfig, created }) : null;
664
+ await persistPgsodiumKey({ keychain, opts, region });
665
+ if (!publicConfig) {
666
+ printLocalOnlyOutro({ region, newRepoFull });
667
+ return;
668
+ }
669
+ if (!pr) {
670
+ throw new Error("init: PR creation step did not run despite publicConfig present");
671
+ }
672
+ const proceed = await pauseForMerge({ pr, opts });
673
+ if (!proceed) return;
674
+ const tunnelToken = await fetchTunnelToken({ publicConfig });
675
+ await persistTunnelToken({ keychain, region, tunnelToken });
676
+ printDoneOutro(region);
677
+ });
678
+ function printWelcomeNote(opts) {
648
679
  p3.note(
649
680
  opts.localOnly ? [
650
681
  "Local-only setup \u2014 creates the region repo from template + a pgsodium key.",
@@ -663,32 +694,30 @@ var initCommand = new Command("init").description(
663
694
  ].join("\n"),
664
695
  "Welcome"
665
696
  );
666
- const region = opts.region ? opts.region : unwrap(
697
+ }
698
+ async function resolveRegion(opts) {
699
+ return opts.region ? opts.region : unwrap(
667
700
  await p3.text({
668
701
  message: "Short region label (used as the Keychain service suffix + R2 prefix)?",
669
702
  placeholder: "us-ca",
670
703
  validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
671
704
  })
672
705
  );
673
- const owner = opts.owner ?? "OpusPopuli";
674
- const newRepoName = `opuspopuli-node-${region}`;
675
- const newRepoFull = `${owner}/${newRepoName}`;
676
- if (opts.localOnly) {
677
- const ignored = listIgnoredLocalOnlyFlags(opts);
678
- if (ignored.length > 0) {
679
- p3.note(
680
- pc2.yellow(
681
- `\u26A0 The following ${ignored.length === 1 ? "flag is" : "flags are"} ignored with --local-only:
706
+ }
707
+ function warnIgnoredLocalOnlyFlags(opts) {
708
+ if (!opts.localOnly) return;
709
+ const ignored = listIgnoredLocalOnlyFlags(opts);
710
+ if (ignored.length === 0) return;
711
+ p3.note(
712
+ pc2.yellow(
713
+ `\u26A0 The following ${ignored.length === 1 ? "flag is" : "flags are"} ignored with --local-only:
682
714
  ${ignored.join(", ")}
683
- ` + pc2.dim(
684
- "These configure Cloudflare / TFC / PR-merge flow, which --local-only skips."
685
- )
686
- ),
687
- "Notice"
688
- );
689
- }
690
- }
691
- const publicConfig = opts.localOnly ? null : await collectPublicConfig(opts);
715
+ ` + pc2.dim("These configure Cloudflare / TFC / PR-merge flow, which --local-only skips.")
716
+ ),
717
+ "Notice"
718
+ );
719
+ }
720
+ async function resolveGhToken(opts) {
692
721
  let ghToken = opts.ghToken;
693
722
  if (!ghToken) {
694
723
  const fromCli = await ghTokenFromCli();
@@ -709,10 +738,15 @@ var initCommand = new Command("init").description(
709
738
  "github"
710
739
  );
711
740
  }
712
- const keychain = await detectKeychain();
741
+ return ghToken;
742
+ }
743
+ function printSecretStoreNote(keychain) {
713
744
  const kcNote = keychain.available ? `\u2713 macOS Keychain available \u2014 pgsodium key + Tunnel token will be saved to your login keychain.
714
745
  ` + 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.`;
715
746
  p3.note(kcNote, "Secret store");
747
+ }
748
+ async function confirmPlan(args) {
749
+ const { opts, region, owner, newRepoFull, publicConfig } = args;
716
750
  const orgWriteAccess = pc2.dim(`required for ${owner}`);
717
751
  p3.note(
718
752
  opts.localOnly ? [
@@ -739,20 +773,19 @@ var initCommand = new Command("init").description(
739
773
  ].join("\n"),
740
774
  "Plan"
741
775
  );
742
- if (!opts.yes) {
743
- const go = unwrap(
744
- await p3.confirm({ message: "Proceed?", initialValue: true })
745
- );
746
- if (!go) {
747
- p3.cancel("Cancelled.");
748
- process.exit(0);
749
- }
776
+ if (opts.yes) return;
777
+ const go = unwrap(await p3.confirm({ message: "Proceed?", initialValue: true }));
778
+ if (!go) {
779
+ p3.cancel("Cancelled.");
780
+ process.exit(0);
750
781
  }
782
+ }
783
+ async function createRegionRepo(args) {
784
+ const { opts, ghToken, owner, newRepoName, newRepoFull, region } = args;
751
785
  const repoSpin = p3.spinner();
752
786
  repoSpin.start(`Creating ${newRepoFull} from ${opts.template}\u2026`);
753
- let created;
754
787
  try {
755
- created = await createRepoFromTemplate({
788
+ const created = await createRepoFromTemplate({
756
789
  token: ghToken,
757
790
  template: opts.template ?? "OpusPopuli/opuspopuli-node",
758
791
  owner,
@@ -760,6 +793,7 @@ var initCommand = new Command("init").description(
760
793
  description: `Opus Populi node deployment for ${region}`
761
794
  });
762
795
  repoSpin.stop(pc2.green(`\u2713 Created ${created.fullName}`));
796
+ return { fullName: created.fullName, defaultBranch: created.defaultBranch };
763
797
  } catch (err) {
764
798
  const status = typeof err === "object" && err !== null && "status" in err ? err.status : 0;
765
799
  const message = err.message ?? "unknown error";
@@ -781,154 +815,155 @@ var initCommand = new Command("init").description(
781
815
  );
782
816
  process.exit(0);
783
817
  }
784
- created = {
785
- fullName: newRepoFull,
786
- htmlUrl: `https://github.com/${newRepoFull}`,
787
- defaultBranch: "main"
788
- };
789
- }
790
- if (publicConfig) {
791
- const secrets = [
792
- { name: "CLOUDFLARE_API_TOKEN", value: publicConfig.cfToken },
793
- { name: "CLOUDFLARE_ACCOUNT_ID", value: publicConfig.cfAccount },
794
- { name: "CLOUDFLARE_ZONE_ID", value: publicConfig.cfZone },
795
- { name: "TF_API_TOKEN", value: publicConfig.tfToken },
796
- { name: "TF_CLOUD_ORGANIZATION", value: publicConfig.tfOrg }
797
- ];
798
- const secSpin = p3.spinner();
799
- secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
800
- const seeded = [];
801
- for (const s of secrets) {
802
- const r = await setRepoSecret({ repo: newRepoFull, name: s.name, value: s.value });
803
- if (!r.written) {
804
- secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
805
- const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
806
- p3.cancel(
807
- [
808
- seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
809
- `Still pending: ${remaining.join(", ")}.`,
810
- "",
811
- `Make sure \`gh\` is installed and signed in (\`gh auth login\`).`,
812
- `Re-run with --use-existing-repo to retry (GitHub will overwrite the already-set secrets idempotently).`
813
- ].join("\n")
814
- );
815
- process.exit(1);
816
- }
817
- seeded.push(s.name);
818
- }
819
- secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
818
+ return { fullName: newRepoFull, defaultBranch: "main" };
820
819
  }
821
- let pr = null;
822
- if (publicConfig) {
823
- const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
824
- const setupSpin = p3.spinner();
825
- setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
826
- try {
827
- await createBranch({
828
- token: ghToken,
829
- repo: newRepoFull,
830
- branch,
831
- fromBranch: created.defaultBranch
832
- });
833
- const tfvars = renderProdTfvars({
834
- project: opts.project ?? "opuspopuli",
835
- domain: publicConfig.domain
836
- });
837
- await commitFile({
838
- token: ghToken,
839
- repo: newRepoFull,
840
- branch,
841
- path: "infra/cloudflare/environments/prod.tfvars",
842
- content: tfvars,
843
- message: `init: prod.tfvars for ${region}`
844
- });
845
- } catch (err) {
846
- setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
847
- p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
848
- process.exit(1);
849
- }
850
- setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
851
- const prSpin = p3.spinner();
852
- prSpin.start("Opening pull request\u2026");
853
- try {
854
- pr = await openPullRequest({
855
- token: ghToken,
856
- repo: newRepoFull,
857
- head: branch,
858
- base: created.defaultBranch,
859
- title: `init: bring up region ${region}`,
860
- body: [
861
- `Initial region deployment for **${region}** (${publicConfig.domain}).`,
862
- "",
863
- "Adds `infra/cloudflare/environments/prod.tfvars`. Merging this PR triggers",
864
- "`cloudflare-infra.yml` which runs `terraform apply` against the",
865
- `\`${publicConfig.tfOrg}\` Terraform Cloud organization, provisioning the Cloudflare`,
866
- "Tunnel, DNS records, R2 buckets, and Pages project.",
820
+ }
821
+ async function seedRepoSecrets(args) {
822
+ const { publicConfig, newRepoFull } = args;
823
+ const secrets = [
824
+ { name: "CLOUDFLARE_API_TOKEN", value: publicConfig.cfToken },
825
+ { name: "CLOUDFLARE_ACCOUNT_ID", value: publicConfig.cfAccount },
826
+ { name: "CLOUDFLARE_ZONE_ID", value: publicConfig.cfZone },
827
+ { name: "TF_API_TOKEN", value: publicConfig.tfToken },
828
+ { name: "TF_CLOUD_ORGANIZATION", value: publicConfig.tfOrg }
829
+ ];
830
+ const secSpin = p3.spinner();
831
+ secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
832
+ const seeded = [];
833
+ for (const s of secrets) {
834
+ const r = await setRepoSecret({ repo: newRepoFull, name: s.name, value: s.value });
835
+ if (!r.written) {
836
+ secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
837
+ const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
838
+ p3.cancel(
839
+ [
840
+ seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
841
+ `Still pending: ${remaining.join(", ")}.`,
867
842
  "",
868
- `Generated by \`create-op-node init\`.`
843
+ `Make sure \`gh\` is installed and signed in (\`gh auth login\`).`,
844
+ `Re-run with --use-existing-repo to retry (GitHub will overwrite the already-set secrets idempotently).`
869
845
  ].join("\n")
870
- });
871
- } catch (err) {
872
- prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
873
- p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
846
+ );
874
847
  process.exit(1);
875
848
  }
849
+ seeded.push(s.name);
850
+ }
851
+ secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
852
+ }
853
+ async function openInfraPr(args) {
854
+ const { opts, ghToken, region, newRepoFull, publicConfig, created } = args;
855
+ const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
856
+ const setupSpin = p3.spinner();
857
+ setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
858
+ try {
859
+ await createBranch({
860
+ token: ghToken,
861
+ repo: newRepoFull,
862
+ branch,
863
+ fromBranch: created.defaultBranch
864
+ });
865
+ const tfvars = renderProdTfvars({
866
+ project: opts.project ?? "opuspopuli",
867
+ domain: publicConfig.domain
868
+ });
869
+ await commitFile({
870
+ token: ghToken,
871
+ repo: newRepoFull,
872
+ branch,
873
+ path: "infra/cloudflare/environments/prod.tfvars",
874
+ content: tfvars,
875
+ message: `init: prod.tfvars for ${region}`
876
+ });
877
+ } catch (err) {
878
+ setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
879
+ p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
880
+ process.exit(1);
881
+ }
882
+ setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
883
+ const prSpin = p3.spinner();
884
+ prSpin.start("Opening pull request\u2026");
885
+ try {
886
+ const pr = await openPullRequest({
887
+ token: ghToken,
888
+ repo: newRepoFull,
889
+ head: branch,
890
+ base: created.defaultBranch,
891
+ title: `init: bring up region ${region}`,
892
+ body: [
893
+ `Initial region deployment for **${region}** (${publicConfig.domain}).`,
894
+ "",
895
+ "Adds `infra/cloudflare/environments/prod.tfvars`. Merging this PR triggers",
896
+ "`cloudflare-infra.yml` which runs `terraform apply` against the",
897
+ `\`${publicConfig.tfOrg}\` Terraform Cloud organization, provisioning the Cloudflare`,
898
+ "Tunnel, DNS records, R2 buckets, and Pages project.",
899
+ "",
900
+ `Generated by \`create-op-node init\`.`
901
+ ].join("\n")
902
+ });
876
903
  prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
904
+ return pr;
905
+ } catch (err) {
906
+ prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
907
+ p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
908
+ process.exit(1);
877
909
  }
910
+ }
911
+ async function persistPgsodiumKey(args) {
912
+ const { keychain, opts, region } = args;
878
913
  const pgsodiumCoords = { region, account: "pgsodium-root-key" };
879
914
  const keyLabel = `org.opuspopuli.${region}/pgsodium-root-key`;
880
- if (keychain.available) {
881
- const existing = opts.overwrite ? null : await readSecret(pgsodiumCoords);
882
- if (existing && /^[a-f0-9]{64}$/.test(existing)) {
883
- p3.note(
884
- `${pc2.green("\u2713")} Re-using existing pgsodium master key from Keychain (${pc2.cyan(keyLabel)}). Pass --overwrite to rotate.`,
885
- "Secret"
886
- );
887
- } else {
888
- const fresh = generatePgsodiumRootKey();
889
- const r = await saveSecret(pgsodiumCoords, fresh);
890
- if (r.written) {
891
- p3.note(
892
- `${pc2.green("\u2713")} pgsodium master key${r.updated ? " rotated" : " saved"} to Keychain as ${pc2.cyan(keyLabel)}.`,
893
- "Secret"
894
- );
895
- } else {
896
- await printKeyForManualSave(fresh, keyLabel, r.reason);
897
- }
898
- }
899
- } else {
900
- const fresh = generatePgsodiumRootKey();
901
- await printKeyForManualSave(fresh, keyLabel);
915
+ if (!keychain.available) {
916
+ const fresh2 = generatePgsodiumRootKey();
917
+ await printKeyForManualSave(fresh2, keyLabel);
918
+ return;
902
919
  }
903
- if (!publicConfig) {
904
- p3.outro(
905
- pc2.cyan(
906
- [
907
- `Region ${region} provisioned for local-only mode.`,
908
- `Repo: https://github.com/${newRepoFull}`,
909
- ``,
910
- `Next: on the Studio (fresh or otherwise \u2014 bootstrap installs`,
911
- `brew packages it needs), run:`,
912
- ` npx create-op-node bootstrap --region ${region} --local-only`,
913
- ``,
914
- `When you're ready to expose publicly, re-run BOTH \`init\` and`,
915
- `\`bootstrap\` without --local-only. Same region repo + pgsodium key`,
916
- `promote cleanly to the production-shaped deploy.`
917
- ].join("\n")
918
- )
920
+ const existing = opts.overwrite ? null : await readSecret(pgsodiumCoords);
921
+ if (existing && /^[a-f0-9]{64}$/.test(existing)) {
922
+ p3.note(
923
+ `${pc2.green("\u2713")} Re-using existing pgsodium master key from Keychain (${pc2.cyan(keyLabel)}). Pass --overwrite to rotate.`,
924
+ "Secret"
919
925
  );
920
926
  return;
921
927
  }
922
- if (!pr) {
923
- throw new Error("init: PR creation step did not run despite publicConfig present");
928
+ const fresh = generatePgsodiumRootKey();
929
+ const r = await saveSecret(pgsodiumCoords, fresh);
930
+ if (r.written) {
931
+ p3.note(
932
+ `${pc2.green("\u2713")} pgsodium master key${r.updated ? " rotated" : " saved"} to Keychain as ${pc2.cyan(keyLabel)}.`,
933
+ "Secret"
934
+ );
935
+ } else {
936
+ await printKeyForManualSave(fresh, keyLabel, r.reason);
924
937
  }
938
+ }
939
+ function printLocalOnlyOutro(args) {
940
+ const { region, newRepoFull } = args;
941
+ p3.outro(
942
+ pc2.cyan(
943
+ [
944
+ `Region ${region} provisioned for local-only mode.`,
945
+ `Repo: https://github.com/${newRepoFull}`,
946
+ ``,
947
+ `Next: on the Studio (fresh or otherwise \u2014 bootstrap installs`,
948
+ `brew packages it needs), run:`,
949
+ ` npx create-op-node bootstrap --region ${region} --local-only`,
950
+ ``,
951
+ `When you're ready to expose publicly, re-run BOTH \`init\` and`,
952
+ `\`bootstrap\` without --local-only. Same region repo + pgsodium key`,
953
+ `promote cleanly to the production-shaped deploy.`
954
+ ].join("\n")
955
+ )
956
+ );
957
+ }
958
+ async function pauseForMerge(args) {
959
+ const { pr, opts } = args;
925
960
  if (opts.skipWait) {
926
961
  p3.outro(
927
962
  pc2.cyan(
928
963
  `Review + merge ${pr.htmlUrl} when ready. Re-run with no --skip-wait or run \`npx create-op-node verify\` afterwards.`
929
964
  )
930
965
  );
931
- return;
966
+ return false;
932
967
  }
933
968
  p3.note(
934
969
  [
@@ -939,15 +974,17 @@ var initCommand = new Command("init").description(
939
974
  ].join("\n"),
940
975
  "Next step: review + merge"
941
976
  );
942
- const merged = unwrap(
943
- await p3.confirm({ message: "PR merged?", initialValue: false })
944
- );
977
+ const merged = unwrap(await p3.confirm({ message: "PR merged?", initialValue: false }));
945
978
  if (!merged) {
946
979
  p3.outro(
947
980
  pc2.cyan(`No worries \u2014 re-run \`create-op-node init --skip-wait\` later to skip this step, or finish manually.`)
948
981
  );
949
- return;
982
+ return false;
950
983
  }
984
+ return true;
985
+ }
986
+ async function fetchTunnelToken(args) {
987
+ const { publicConfig } = args;
951
988
  const ws = await findWorkspace({
952
989
  token: publicConfig.tfToken,
953
990
  organization: publicConfig.tfOrg,
@@ -971,27 +1008,33 @@ var initCommand = new Command("init").description(
971
1008
  );
972
1009
  process.exit(1);
973
1010
  }
1011
+ return tunnelToken;
1012
+ }
1013
+ async function persistTunnelToken(args) {
1014
+ const { keychain, region, tunnelToken } = args;
974
1015
  const tunnelCoords = { region, account: "tunnel-token" };
975
1016
  const tunnelLabel = `org.opuspopuli.${region}/tunnel-token`;
976
- if (keychain.available) {
977
- const r = await saveSecret(tunnelCoords, tunnelToken);
978
- if (r.written) {
979
- p3.note(
980
- `${pc2.green("\u2713")} Tunnel token saved to Keychain as ${pc2.cyan(tunnelLabel)}.`,
981
- "Secret"
982
- );
983
- } else {
984
- await printKeyForManualSave(tunnelToken, tunnelLabel, r.reason);
985
- }
986
- } else {
1017
+ if (!keychain.available) {
987
1018
  await printKeyForManualSave(tunnelToken, tunnelLabel);
1019
+ return;
1020
+ }
1021
+ const r = await saveSecret(tunnelCoords, tunnelToken);
1022
+ if (r.written) {
1023
+ p3.note(
1024
+ `${pc2.green("\u2713")} Tunnel token saved to Keychain as ${pc2.cyan(tunnelLabel)}.`,
1025
+ "Secret"
1026
+ );
1027
+ } else {
1028
+ await printKeyForManualSave(tunnelToken, tunnelLabel, r.reason);
988
1029
  }
1030
+ }
1031
+ function printDoneOutro(region) {
989
1032
  p3.outro(
990
1033
  pc2.cyan(
991
1034
  `Region ${region} is provisioned. Next: \`npx create-op-node bootstrap\` on the Mac Studio.`
992
1035
  )
993
1036
  );
994
- });
1037
+ }
995
1038
  var MIN_TOKEN_LENGTH = {
996
1039
  cloudflare: 40,
997
1040
  tfc: 40,
@@ -2041,7 +2084,7 @@ var bootstrapCommand = new Command("bootstrap").description(
2041
2084
  ).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
2042
2085
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
2043
2086
  const nodeType = await selectNodeType(opts);
2044
- const region = await resolveRegion(opts);
2087
+ const region = await resolveRegion2(opts);
2045
2088
  const owner = opts.owner ?? "OpusPopuli";
2046
2089
  const repoName = `opuspopuli-node-${region}`;
2047
2090
  const composeFile = opts.composeFile ?? defaultComposeFiles(opts);
@@ -2095,7 +2138,7 @@ async function selectNodeType(opts) {
2095
2138
  }
2096
2139
  return nodeType;
2097
2140
  }
2098
- async function resolveRegion(opts) {
2141
+ async function resolveRegion2(opts) {
2099
2142
  return opts.region ? opts.region : unwrap(
2100
2143
  await p3.text({
2101
2144
  message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
@@ -2847,87 +2890,89 @@ async function runReset(input, deps = DEFAULT_DEPS) {
2847
2890
  phases.push(ph);
2848
2891
  deps.onPhase?.(ph);
2849
2892
  };
2893
+ push(await resetStopStackPhase(input, deps));
2894
+ push(await resetLaunchAgentPhase(input, deps));
2895
+ push(await resetDockerLogoutPhase(input, deps));
2896
+ return { phases };
2897
+ }
2898
+ async function resetStopStackPhase(input, deps) {
2850
2899
  if (!input.stack) {
2851
- push({
2900
+ return {
2852
2901
  name: RESET_PHASES.STOP_STACK,
2853
2902
  status: "skipped",
2854
2903
  detail: "--skip-stack or no repo path resolved"
2855
- });
2856
- } else if (input.dryRun) {
2857
- push({
2904
+ };
2905
+ }
2906
+ if (input.dryRun) {
2907
+ return {
2858
2908
  name: RESET_PHASES.STOP_STACK,
2859
2909
  status: "dry-run",
2860
2910
  detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}${input.stack.wipeImages ? " --rmi all" : ""}`
2861
- });
2862
- } else {
2863
- const result2 = await deps.composeDown({
2864
- files: input.stack.composeFiles,
2865
- cwd: input.stack.repoPath,
2866
- ...input.stack.envFile ? { envFile: input.stack.envFile } : {},
2867
- wipeVolumes: input.stack.wipeVolumes,
2868
- removeOrphans: input.stack.removeOrphans,
2869
- ...input.stack.wipeImages ? { removeImages: "all" } : {}
2870
- });
2871
- if (result2.ok) {
2872
- const bits = [
2873
- input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved",
2874
- input.stack.wipeImages ? "images removed (next bootstrap will re-pull)" : null
2875
- ].filter(Boolean);
2876
- push({
2877
- name: RESET_PHASES.STOP_STACK,
2878
- status: "ok",
2879
- detail: bits.join("; ")
2880
- });
2881
- } else {
2882
- push({ name: RESET_PHASES.STOP_STACK, status: "fail", detail: result2.reason ?? "unknown failure" });
2883
- }
2911
+ };
2884
2912
  }
2913
+ const result2 = await deps.composeDown({
2914
+ files: input.stack.composeFiles,
2915
+ cwd: input.stack.repoPath,
2916
+ ...input.stack.envFile ? { envFile: input.stack.envFile } : {},
2917
+ wipeVolumes: input.stack.wipeVolumes,
2918
+ removeOrphans: input.stack.removeOrphans,
2919
+ ...input.stack.wipeImages ? { removeImages: "all" } : {}
2920
+ });
2921
+ if (!result2.ok) {
2922
+ return { name: RESET_PHASES.STOP_STACK, status: "fail", detail: result2.reason ?? "unknown failure" };
2923
+ }
2924
+ const bits = [
2925
+ input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved",
2926
+ input.stack.wipeImages ? "images removed (next bootstrap will re-pull)" : null
2927
+ ].filter(Boolean);
2928
+ return { name: RESET_PHASES.STOP_STACK, status: "ok", detail: bits.join("; ") };
2929
+ }
2930
+ async function resetLaunchAgentPhase(input, deps) {
2885
2931
  if (!input.launchAgent) {
2886
- push({ name: RESET_PHASES.LAUNCH_AGENT, status: "skipped", detail: "--skip-launch-agent or no plist found" });
2887
- } else if (input.dryRun) {
2888
- push({
2932
+ return { name: RESET_PHASES.LAUNCH_AGENT, status: "skipped", detail: "--skip-launch-agent or no plist found" };
2933
+ }
2934
+ if (input.dryRun) {
2935
+ return {
2889
2936
  name: RESET_PHASES.LAUNCH_AGENT,
2890
2937
  status: "dry-run",
2891
2938
  detail: `would unload ${input.launchAgent.paths.plistFile}, rm plist${input.launchAgent.keepKeyFile ? "" : " + key file"}`
2892
- });
2893
- } else {
2894
- const result2 = await deps.teardownLaunchAgent(
2895
- input.launchAgent.paths,
2896
- { keepKeyFile: input.launchAgent.keepKeyFile }
2897
- );
2898
- if (result2.ok) {
2899
- const removed = result2.steps.filter((s) => s.step !== "unload").map((s) => s.step).join(", ");
2900
- push({ name: RESET_PHASES.LAUNCH_AGENT, status: "ok", detail: `unloaded + ${removed}` });
2901
- } else {
2902
- const fail = result2.steps.find((s) => !s.ok);
2903
- push({
2904
- name: RESET_PHASES.LAUNCH_AGENT,
2905
- status: "fail",
2906
- detail: `${fail?.step}: ${fail?.reason ?? "unknown"}`
2907
- });
2908
- }
2939
+ };
2909
2940
  }
2941
+ const result2 = await deps.teardownLaunchAgent(
2942
+ input.launchAgent.paths,
2943
+ { keepKeyFile: input.launchAgent.keepKeyFile }
2944
+ );
2945
+ if (!result2.ok) {
2946
+ const fail = result2.steps.find((s) => !s.ok);
2947
+ return {
2948
+ name: RESET_PHASES.LAUNCH_AGENT,
2949
+ status: "fail",
2950
+ detail: `${fail?.step}: ${fail?.reason ?? "unknown"}`
2951
+ };
2952
+ }
2953
+ const removed = result2.steps.filter((s) => s.step !== "unload").map((s) => s.step).join(", ");
2954
+ return { name: RESET_PHASES.LAUNCH_AGENT, status: "ok", detail: `unloaded + ${removed}` };
2955
+ }
2956
+ async function resetDockerLogoutPhase(input, deps) {
2910
2957
  if (!input.dockerLogout) {
2911
- push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "skipped", detail: "--skip-docker-logout" });
2912
- } else if (input.dryRun) {
2913
- push({
2958
+ return { name: RESET_PHASES.DOCKER_LOGOUT, status: "skipped", detail: "--skip-docker-logout" };
2959
+ }
2960
+ if (input.dryRun) {
2961
+ return {
2914
2962
  name: RESET_PHASES.DOCKER_LOGOUT,
2915
2963
  status: "dry-run",
2916
2964
  detail: `would run: docker logout ${input.dockerLogout.registry}`
2917
- });
2918
- } else {
2919
- const result2 = await deps.dockerLogout(input.dockerLogout.registry);
2920
- if (result2.ok) {
2921
- push({
2922
- name: RESET_PHASES.DOCKER_LOGOUT,
2923
- status: "ok",
2924
- detail: `credentials removed from ${input.dockerLogout.registry}`
2925
- });
2926
- } else {
2927
- push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "warn", detail: result2.reason ?? "unknown" });
2928
- }
2965
+ };
2929
2966
  }
2930
- return { phases };
2967
+ const result2 = await deps.dockerLogout(input.dockerLogout.registry);
2968
+ if (result2.ok) {
2969
+ return {
2970
+ name: RESET_PHASES.DOCKER_LOGOUT,
2971
+ status: "ok",
2972
+ detail: `credentials removed from ${input.dockerLogout.registry}`
2973
+ };
2974
+ }
2975
+ return { name: RESET_PHASES.DOCKER_LOGOUT, status: "warn", detail: result2.reason ?? "unknown" };
2931
2976
  }
2932
2977
  async function fileExists2(path) {
2933
2978
  try {
@@ -2983,6 +3028,43 @@ var resetCommand = new Command("reset").description(
2983
3028
  const wipeImages = opts.wipeImages ?? false;
2984
3029
  const wipeData = (opts.wipeData ?? false) || wipeImages;
2985
3030
  const removeOrphans = opts.removeOrphans ?? true;
3031
+ const region = await resolveResetRegion(opts);
3032
+ const owner = opts.owner ?? "OpusPopuli";
3033
+ const repoName = `opuspopuli-node-${region}`;
3034
+ const launchAgentPaths = defaultPaths();
3035
+ const plistExists = await fileExists2(launchAgentPaths.plistFile);
3036
+ const keyFileExists = await fileExists2(launchAgentPaths.keyFile);
3037
+ const { repoPath, stackSkipReason } = await locateResetRepo(opts, owner, repoName);
3038
+ const runningContainers = repoPath ? await composePs({
3039
+ files: resolveComposeFiles(repoPath, opts.composeFile),
3040
+ cwd: repoPath,
3041
+ ...opts.envFile ? { envFile: opts.envFile } : {}
3042
+ }) : null;
3043
+ renderSnapshotNote({
3044
+ region,
3045
+ repoPath,
3046
+ stackSkipReason,
3047
+ runningContainers,
3048
+ plistExists,
3049
+ keyFileExists,
3050
+ launchAgentPaths,
3051
+ opts,
3052
+ wipeData,
3053
+ wipeImages
3054
+ });
3055
+ await confirmReset({ opts, region, wipeData });
3056
+ const input = buildResetInput({
3057
+ opts,
3058
+ repoPath,
3059
+ plistExists,
3060
+ launchAgentPaths,
3061
+ wipeData,
3062
+ wipeImages,
3063
+ removeOrphans
3064
+ });
3065
+ await runResetWithSpinners({ opts, region, wipeData, input });
3066
+ });
3067
+ async function resolveResetRegion(opts) {
2986
3068
  const region = opts.region ? opts.region : unwrap(
2987
3069
  await p3.text({
2988
3070
  message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
@@ -2994,51 +3076,52 @@ var resetCommand = new Command("reset").description(
2994
3076
  p3.cancel(`--region ${JSON.stringify(region)} is not a valid region slug.`);
2995
3077
  process.exit(2);
2996
3078
  }
2997
- const owner = opts.owner ?? "OpusPopuli";
2998
- const repoName = `opuspopuli-node-${region}`;
2999
- const launchAgentPaths = defaultPaths();
3000
- const plistExists = await fileExists2(launchAgentPaths.plistFile);
3001
- const keyFileExists = await fileExists2(launchAgentPaths.keyFile);
3002
- let repoPath;
3003
- let stackSkipReason;
3004
- if (!opts.skipStack) {
3005
- const located = await locateOrCloneRepo({
3006
- owner,
3007
- name: repoName,
3008
- cwd: process.cwd(),
3009
- allowClone: false,
3010
- ...opts.repoDir ? { explicit: opts.repoDir } : {}
3011
- });
3012
- switch (located.kind) {
3013
- case "found":
3014
- repoPath = located.path;
3015
- break;
3016
- case "explicit-not-a-node-repo":
3017
- p3.cancel(
3018
- `--repo-dir ${JSON.stringify(located.path)} doesn't look like a node repo (missing one of: ${NODE_REPO_MARKERS.join(", ")}). Verify the path or drop --repo-dir to let reset search the cwd.`
3019
- );
3020
- process.exit(2);
3021
- break;
3022
- case "clone-disallowed":
3023
- stackSkipReason = `no checkout found at ${process.cwd()}; pass --repo-dir to target one`;
3024
- break;
3025
- case "cloned":
3026
- case "gh-not-installed":
3027
- case "clone-failed":
3028
- stackSkipReason = `unexpected outcome ${located.kind} from locateOrCloneRepo with allowClone=false`;
3029
- break;
3030
- }
3031
- } else {
3032
- stackSkipReason = "--skip-stack";
3033
- }
3034
- let runningContainers = null;
3035
- if (repoPath) {
3036
- runningContainers = await composePs({
3037
- files: resolveComposeFiles(repoPath, opts.composeFile),
3038
- cwd: repoPath,
3039
- ...opts.envFile ? { envFile: opts.envFile } : {}
3040
- });
3079
+ return region;
3080
+ }
3081
+ async function locateResetRepo(opts, owner, repoName) {
3082
+ if (opts.skipStack) {
3083
+ return { stackSkipReason: "--skip-stack" };
3084
+ }
3085
+ const located = await locateOrCloneRepo({
3086
+ owner,
3087
+ name: repoName,
3088
+ cwd: process.cwd(),
3089
+ allowClone: false,
3090
+ ...opts.repoDir ? { explicit: opts.repoDir } : {}
3091
+ });
3092
+ switch (located.kind) {
3093
+ case "found":
3094
+ return { repoPath: located.path };
3095
+ case "explicit-not-a-node-repo":
3096
+ p3.cancel(
3097
+ `--repo-dir ${JSON.stringify(located.path)} doesn't look like a node repo (missing one of: ${NODE_REPO_MARKERS.join(", ")}). Verify the path or drop --repo-dir to let reset search the cwd.`
3098
+ );
3099
+ return process.exit(2);
3100
+ case "clone-disallowed":
3101
+ return { stackSkipReason: `no checkout found at ${process.cwd()}; pass --repo-dir to target one` };
3102
+ case "cloned":
3103
+ case "gh-not-installed":
3104
+ case "clone-failed":
3105
+ return {
3106
+ stackSkipReason: `unexpected outcome ${located.kind} from locateOrCloneRepo with allowClone=false`
3107
+ };
3108
+ default:
3109
+ return assertNever();
3041
3110
  }
3111
+ }
3112
+ function renderSnapshotNote(args) {
3113
+ const {
3114
+ region,
3115
+ repoPath,
3116
+ stackSkipReason,
3117
+ runningContainers,
3118
+ plistExists,
3119
+ keyFileExists,
3120
+ launchAgentPaths,
3121
+ opts,
3122
+ wipeData,
3123
+ wipeImages
3124
+ } = args;
3042
3125
  let runningContainersLabel;
3043
3126
  if (runningContainers === null) {
3044
3127
  runningContainersLabel = pc2.dim("unknown");
@@ -3061,6 +3144,9 @@ var resetCommand = new Command("reset").description(
3061
3144
  ].join("\n"),
3062
3145
  "Snapshot"
3063
3146
  );
3147
+ }
3148
+ async function confirmReset(args) {
3149
+ const { opts, region, wipeData } = args;
3064
3150
  if (wipeData && !opts.dryRun) {
3065
3151
  unwrap(
3066
3152
  await p3.text({
@@ -3068,7 +3154,9 @@ var resetCommand = new Command("reset").description(
3068
3154
  validate: (v) => v === region ? void 0 : `must match the region label exactly`
3069
3155
  })
3070
3156
  );
3071
- } else if (!opts.dryRun && !opts.yes) {
3157
+ return;
3158
+ }
3159
+ if (!opts.dryRun && !opts.yes) {
3072
3160
  const cont = unwrap(
3073
3161
  await p3.confirm({
3074
3162
  message: "Proceed with reset? (volumes will be preserved)",
@@ -3080,6 +3168,9 @@ var resetCommand = new Command("reset").description(
3080
3168
  process.exit(0);
3081
3169
  }
3082
3170
  }
3171
+ }
3172
+ function buildResetInput(args) {
3173
+ const { opts, repoPath, plistExists, launchAgentPaths, wipeData, wipeImages, removeOrphans } = args;
3083
3174
  const stack = repoPath ? {
3084
3175
  repoPath,
3085
3176
  composeFiles: resolveComposeFiles(repoPath, opts.composeFile),
@@ -3093,11 +3184,20 @@ var resetCommand = new Command("reset").description(
3093
3184
  keepKeyFile: opts.keepKeyFile ?? false
3094
3185
  } : void 0;
3095
3186
  const dockerLogoutInput = !opts.skipDockerLogout ? { registry: opts.registry ?? GHCR_REGISTRY } : void 0;
3187
+ return {
3188
+ ...stack ? { stack } : {},
3189
+ ...launchAgent ? { launchAgent } : {},
3190
+ ...dockerLogoutInput ? { dockerLogout: dockerLogoutInput } : {},
3191
+ dryRun: opts.dryRun ?? false
3192
+ };
3193
+ }
3194
+ async function runResetWithSpinners(args) {
3195
+ const { opts, region, wipeData, input } = args;
3096
3196
  const phaseSpins = /* @__PURE__ */ new Map();
3097
3197
  const actingPhases = [];
3098
- if (stack && !opts.dryRun) actingPhases.push(RESET_PHASES.STOP_STACK);
3099
- if (launchAgent && !opts.dryRun) actingPhases.push(RESET_PHASES.LAUNCH_AGENT);
3100
- if (dockerLogoutInput && !opts.dryRun) actingPhases.push(RESET_PHASES.DOCKER_LOGOUT);
3198
+ if (input.stack && !input.dryRun) actingPhases.push(RESET_PHASES.STOP_STACK);
3199
+ if (input.launchAgent && !input.dryRun) actingPhases.push(RESET_PHASES.LAUNCH_AGENT);
3200
+ if (input.dockerLogout && !input.dryRun) actingPhases.push(RESET_PHASES.DOCKER_LOGOUT);
3101
3201
  for (const name of actingPhases) {
3102
3202
  const s = p3.spinner();
3103
3203
  s.start(`${name}\u2026`);
@@ -3113,30 +3213,27 @@ var resetCommand = new Command("reset").description(
3113
3213
  p3.log.info(line);
3114
3214
  }
3115
3215
  };
3116
- const report = await runReset(
3117
- {
3118
- ...stack ? { stack } : {},
3119
- ...launchAgent ? { launchAgent } : {},
3120
- ...dockerLogoutInput ? { dockerLogout: dockerLogoutInput } : {},
3121
- dryRun: opts.dryRun ?? false
3122
- },
3123
- { ...DEFAULT_DEPS, onPhase: renderPhase }
3124
- );
3216
+ const report = await runReset(input, { ...DEFAULT_DEPS, onPhase: renderPhase });
3125
3217
  for (const [, spin] of phaseSpins) spin.stop(pc2.dim("\u2014 skipped"));
3218
+ reportResetOutcome({ report, opts, wipeData, region });
3219
+ }
3220
+ function reportResetOutcome(args) {
3221
+ const { report, opts, wipeData, region } = args;
3126
3222
  const failed = report.phases.filter((ph) => ph.status === "fail").length;
3127
3223
  if (failed > 0) {
3128
3224
  p3.outro(pc2.red(`${failed} step${failed === 1 ? "" : "s"} failed.`));
3129
3225
  process.exit(1);
3130
- } else if (opts.dryRun) {
3226
+ }
3227
+ if (opts.dryRun) {
3131
3228
  p3.outro(pc2.cyan("Dry run complete. Re-run without --dry-run to apply."));
3132
- } else {
3133
- p3.outro(
3134
- pc2.green(
3135
- wipeData ? `Reset complete \u2014 volumes wiped. Re-run \`create-op-node bootstrap --region ${region}\` to start fresh.` : `Reset complete \u2014 volumes preserved. Re-run \`create-op-node bootstrap --region ${region}\` to bring the stack back up.`
3136
- )
3137
- );
3229
+ return;
3138
3230
  }
3139
- });
3231
+ p3.outro(
3232
+ pc2.green(
3233
+ wipeData ? `Reset complete \u2014 volumes wiped. Re-run \`create-op-node bootstrap --region ${region}\` to start fresh.` : `Reset complete \u2014 volumes preserved. Re-run \`create-op-node bootstrap --region ${region}\` to bring the stack back up.`
3234
+ )
3235
+ );
3236
+ }
3140
3237
 
3141
3238
  // src/lib/cosign.ts
3142
3239
  var COSIGN_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
@@ -4577,7 +4674,7 @@ async function looksLikeRegionsRepo(dir) {
4577
4674
  }
4578
4675
 
4579
4676
  // src/cli.ts
4580
- var VERSION = "0.10.6";
4677
+ var VERSION = "0.10.8";
4581
4678
  var program = new Command();
4582
4679
  program.name("create-op-node").description(
4583
4680
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."