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/README.md +54 -1
- package/dist/cli.js +311 -166
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
569
|
-
opts
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1552
|
-
)
|
|
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(
|
|
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
|
|
1783
|
+
const pgsodiumKey = await loadSecret({
|
|
1686
1784
|
region,
|
|
1687
1785
|
account: "pgsodium-root-key",
|
|
1688
1786
|
label: "pgsodium master key",
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
|
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({
|
|
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(
|
|
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 ${(
|
|
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,
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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."
|