create-op-node 0.1.0 → 0.2.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 +55 -1
- package/dist/cli.js +498 -198
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
3
|
import pc2 from 'picocolors';
|
|
4
|
-
import * as
|
|
4
|
+
import * as p3 from '@clack/prompts';
|
|
5
5
|
import { execa } from 'execa';
|
|
6
6
|
import { Octokit } from '@octokit/rest';
|
|
7
7
|
import { randomBytes } from 'crypto';
|
|
8
8
|
import { join, resolve, dirname } from 'path';
|
|
9
|
-
import { mkdir, writeFile, access, chmod } from 'fs/promises';
|
|
9
|
+
import { mkdir, writeFile, access, rm, chmod } from 'fs/promises';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
11
|
import { readFileSync } from 'fs';
|
|
12
12
|
import * as tls from 'tls';
|
|
@@ -304,6 +304,13 @@ async function readSecretFromOp(input) {
|
|
|
304
304
|
const value = res.stdout.trim();
|
|
305
305
|
return value.length > 0 ? value : null;
|
|
306
306
|
}
|
|
307
|
+
function unwrap(value) {
|
|
308
|
+
if (p3.isCancel(value)) {
|
|
309
|
+
p3.cancel("Cancelled.");
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
307
314
|
var _client = null;
|
|
308
315
|
var _clientToken = null;
|
|
309
316
|
function client(token) {
|
|
@@ -513,13 +520,6 @@ async function waitForApply(input, budgets = DEFAULT_BUDGETS, deps = realDeps) {
|
|
|
513
520
|
}
|
|
514
521
|
|
|
515
522
|
// src/commands/init.ts
|
|
516
|
-
function unwrap(value) {
|
|
517
|
-
if (p2.isCancel(value)) {
|
|
518
|
-
p2.cancel("Cancelled.");
|
|
519
|
-
process.exit(0);
|
|
520
|
-
}
|
|
521
|
-
return value;
|
|
522
|
-
}
|
|
523
523
|
async function ghTokenFromCli() {
|
|
524
524
|
const r = await safeExeca("gh", ["auth", "token"]);
|
|
525
525
|
if (r === null || r.exitCode !== 0) return null;
|
|
@@ -543,8 +543,8 @@ var initCommand = new Command("init").description(
|
|
|
543
543
|
"Continue using a previously-created node repo (don't fail on 'already exists')"
|
|
544
544
|
).default(false)
|
|
545
545
|
).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) => {
|
|
546
|
-
|
|
547
|
-
|
|
546
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node init ")));
|
|
547
|
+
p3.note(
|
|
548
548
|
[
|
|
549
549
|
"This walks you from a sealed Mac Studio + a Cloudflare account to a",
|
|
550
550
|
"live federation node serving traffic at api.<your-domain>.",
|
|
@@ -555,14 +555,14 @@ var initCommand = new Command("init").description(
|
|
|
555
555
|
"Welcome"
|
|
556
556
|
);
|
|
557
557
|
const region = opts.region ? opts.region : unwrap(
|
|
558
|
-
await
|
|
558
|
+
await p3.text({
|
|
559
559
|
message: "Short region label (used as a prefix in 1Password + R2)?",
|
|
560
560
|
placeholder: "us-ca",
|
|
561
561
|
validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
562
562
|
})
|
|
563
563
|
);
|
|
564
564
|
const domain = opts.domain ? opts.domain : unwrap(
|
|
565
|
-
await
|
|
565
|
+
await p3.text({
|
|
566
566
|
message: "Domain registered in Cloudflare?",
|
|
567
567
|
placeholder: "example.org",
|
|
568
568
|
validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Missing a TLD?"
|
|
@@ -578,7 +578,7 @@ var initCommand = new Command("init").description(
|
|
|
578
578
|
);
|
|
579
579
|
const cfAccount = await collectId(opts.cfAccount, "Cloudflare account ID");
|
|
580
580
|
const cfZone = await collectId(opts.cfZone, `Cloudflare zone ID for ${domain}`);
|
|
581
|
-
const cfSpin =
|
|
581
|
+
const cfSpin = p3.spinner();
|
|
582
582
|
cfSpin.start("Verifying Cloudflare token + 5 scopes\u2026");
|
|
583
583
|
const cfProbe = await probeCloudflareToken({
|
|
584
584
|
token: cfToken,
|
|
@@ -588,7 +588,7 @@ var initCommand = new Command("init").description(
|
|
|
588
588
|
if (!cfProbe.ok) {
|
|
589
589
|
cfSpin.stop(pc2.red("\u2717 Cloudflare token check failed."));
|
|
590
590
|
for (const issue of cfProbe.issues) console.error(` - ${issue}`);
|
|
591
|
-
|
|
591
|
+
p3.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
|
|
592
592
|
process.exit(1);
|
|
593
593
|
}
|
|
594
594
|
cfSpin.stop(pc2.green("\u2713 Cloudflare token valid, 5 scopes present."));
|
|
@@ -598,18 +598,18 @@ var initCommand = new Command("init").description(
|
|
|
598
598
|
"tfc"
|
|
599
599
|
);
|
|
600
600
|
const tfOrg = opts.tfOrg ? opts.tfOrg : unwrap(
|
|
601
|
-
await
|
|
601
|
+
await p3.text({
|
|
602
602
|
message: "Terraform Cloud organization name?",
|
|
603
603
|
placeholder: "op-region-ca"
|
|
604
604
|
})
|
|
605
605
|
);
|
|
606
|
-
const tfSpin =
|
|
606
|
+
const tfSpin = p3.spinner();
|
|
607
607
|
tfSpin.start("Verifying Terraform Cloud token + organization\u2026");
|
|
608
608
|
const tfProbe = await probeTfcToken({ token: tfToken, organization: tfOrg });
|
|
609
609
|
if (!tfProbe.ok) {
|
|
610
610
|
tfSpin.stop(pc2.red("\u2717 Terraform Cloud check failed."));
|
|
611
611
|
for (const issue of tfProbe.issues) console.error(` - ${issue}`);
|
|
612
|
-
|
|
612
|
+
p3.cancel("Fix the token / org, then re-run.");
|
|
613
613
|
process.exit(1);
|
|
614
614
|
}
|
|
615
615
|
tfSpin.stop(
|
|
@@ -622,7 +622,7 @@ var initCommand = new Command("init").description(
|
|
|
622
622
|
const fromCli = await ghTokenFromCli();
|
|
623
623
|
if (fromCli) {
|
|
624
624
|
const useCli = unwrap(
|
|
625
|
-
await
|
|
625
|
+
await p3.confirm({
|
|
626
626
|
message: "Found a signed-in `gh` CLI \u2014 use its token?",
|
|
627
627
|
initialValue: true
|
|
628
628
|
})
|
|
@@ -639,8 +639,8 @@ var initCommand = new Command("init").description(
|
|
|
639
639
|
}
|
|
640
640
|
const op = await detectOp();
|
|
641
641
|
const opNote = op.installed ? op.signedIn ? `\u2713 1Password CLI signed in${op.email ? ` (${op.email})` : ""} \u2014 pgsodium key + Tunnel token will be saved automatically.` : `\u26A0 1Password CLI installed but not signed in. Run \`op signin\` in another shell, or you'll be prompted to paste secrets.` : `\u26A0 1Password CLI not installed. Secrets will be printed for you to paste into 1Password by hand.`;
|
|
642
|
-
|
|
643
|
-
|
|
642
|
+
p3.note(opNote, "1Password");
|
|
643
|
+
p3.note(
|
|
644
644
|
[
|
|
645
645
|
`Region label: ${pc2.cyan(region)}`,
|
|
646
646
|
`Domain: ${pc2.cyan(domain)}`,
|
|
@@ -656,14 +656,14 @@ var initCommand = new Command("init").description(
|
|
|
656
656
|
);
|
|
657
657
|
if (!opts.yes) {
|
|
658
658
|
const go = unwrap(
|
|
659
|
-
await
|
|
659
|
+
await p3.confirm({ message: "Proceed?", initialValue: true })
|
|
660
660
|
);
|
|
661
661
|
if (!go) {
|
|
662
|
-
|
|
662
|
+
p3.cancel("Cancelled.");
|
|
663
663
|
process.exit(0);
|
|
664
664
|
}
|
|
665
665
|
}
|
|
666
|
-
const repoSpin =
|
|
666
|
+
const repoSpin = p3.spinner();
|
|
667
667
|
repoSpin.start(`Creating ${newRepoFull} from ${opts.template}\u2026`);
|
|
668
668
|
let created;
|
|
669
669
|
try {
|
|
@@ -680,18 +680,18 @@ var initCommand = new Command("init").description(
|
|
|
680
680
|
const message = err.message ?? "unknown error";
|
|
681
681
|
if (status !== 422) {
|
|
682
682
|
repoSpin.stop(pc2.red(`\u2717 Couldn't create repo: ${message}`));
|
|
683
|
-
|
|
683
|
+
p3.cancel("Fix the issue and re-run.");
|
|
684
684
|
process.exit(1);
|
|
685
685
|
}
|
|
686
686
|
repoSpin.stop(pc2.yellow(`\u26A0 ${newRepoFull} already exists.`));
|
|
687
687
|
const reuse = opts.useExistingRepo || unwrap(
|
|
688
|
-
await
|
|
688
|
+
await p3.confirm({
|
|
689
689
|
message: `Continue with the existing ${newRepoFull}? (Re-seeds secrets + re-commits prod.tfvars on a fresh branch.)`,
|
|
690
690
|
initialValue: true
|
|
691
691
|
})
|
|
692
692
|
);
|
|
693
693
|
if (!reuse) {
|
|
694
|
-
|
|
694
|
+
p3.cancel(
|
|
695
695
|
`Delete ${newRepoFull} on GitHub and re-run, or pass --use-existing-repo to skip this prompt.`
|
|
696
696
|
);
|
|
697
697
|
process.exit(0);
|
|
@@ -709,7 +709,7 @@ var initCommand = new Command("init").description(
|
|
|
709
709
|
{ name: "TF_API_TOKEN", value: tfToken },
|
|
710
710
|
{ name: "TF_CLOUD_ORGANIZATION", value: tfOrg }
|
|
711
711
|
];
|
|
712
|
-
const secSpin =
|
|
712
|
+
const secSpin = p3.spinner();
|
|
713
713
|
secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
|
|
714
714
|
const seeded = [];
|
|
715
715
|
for (const s of secrets) {
|
|
@@ -717,7 +717,7 @@ var initCommand = new Command("init").description(
|
|
|
717
717
|
if (!r.written) {
|
|
718
718
|
secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
|
|
719
719
|
const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
|
|
720
|
-
|
|
720
|
+
p3.cancel(
|
|
721
721
|
[
|
|
722
722
|
seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
|
|
723
723
|
`Still pending: ${remaining.join(", ")}.`,
|
|
@@ -732,7 +732,7 @@ var initCommand = new Command("init").description(
|
|
|
732
732
|
}
|
|
733
733
|
secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
|
|
734
734
|
const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
|
|
735
|
-
const setupSpin =
|
|
735
|
+
const setupSpin = p3.spinner();
|
|
736
736
|
setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
|
|
737
737
|
try {
|
|
738
738
|
await createBranch({
|
|
@@ -755,11 +755,11 @@ var initCommand = new Command("init").description(
|
|
|
755
755
|
});
|
|
756
756
|
} catch (err) {
|
|
757
757
|
setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
|
|
758
|
-
|
|
758
|
+
p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
|
|
759
759
|
process.exit(1);
|
|
760
760
|
}
|
|
761
761
|
setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
|
|
762
|
-
const prSpin =
|
|
762
|
+
const prSpin = p3.spinner();
|
|
763
763
|
prSpin.start("Opening pull request\u2026");
|
|
764
764
|
let pr;
|
|
765
765
|
try {
|
|
@@ -782,7 +782,7 @@ var initCommand = new Command("init").description(
|
|
|
782
782
|
});
|
|
783
783
|
} catch (err) {
|
|
784
784
|
prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
|
|
785
|
-
|
|
785
|
+
p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
|
|
786
786
|
process.exit(1);
|
|
787
787
|
}
|
|
788
788
|
prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
|
|
@@ -791,7 +791,7 @@ var initCommand = new Command("init").description(
|
|
|
791
791
|
if (op.installed && op.signedIn) {
|
|
792
792
|
const existing = opts.overwrite ? null : await readSecretFromOp({ title: keyTitle, ...vaultArg });
|
|
793
793
|
if (existing && /^[a-f0-9]{64}$/.test(existing)) {
|
|
794
|
-
|
|
794
|
+
p3.note(
|
|
795
795
|
`${pc2.green("\u2713")} Re-using existing pgsodium master key from 1Password (${pc2.cyan(keyTitle)}). Pass --overwrite to rotate.`,
|
|
796
796
|
"Secret"
|
|
797
797
|
);
|
|
@@ -804,12 +804,12 @@ var initCommand = new Command("init").description(
|
|
|
804
804
|
...vaultArg
|
|
805
805
|
});
|
|
806
806
|
if (r.written) {
|
|
807
|
-
|
|
807
|
+
p3.note(
|
|
808
808
|
`${pc2.green("\u2713")} pgsodium master key${r.alreadyExisted ? " rotated" : " saved"} to 1Password as ${pc2.cyan(keyTitle)}.`,
|
|
809
809
|
"Secret"
|
|
810
810
|
);
|
|
811
811
|
} else if (r.alreadyExisted) {
|
|
812
|
-
|
|
812
|
+
p3.note(
|
|
813
813
|
`${pc2.yellow("!")} 1Password has an item titled ${pc2.cyan(keyTitle)} but its value is NOT a 64-hex pgsodium key. Inspect it in 1Password; re-run with --overwrite to replace.`,
|
|
814
814
|
"Secret"
|
|
815
815
|
);
|
|
@@ -822,14 +822,14 @@ var initCommand = new Command("init").description(
|
|
|
822
822
|
await printKeyForManualSave(fresh, keyTitle);
|
|
823
823
|
}
|
|
824
824
|
if (opts.skipWait) {
|
|
825
|
-
|
|
825
|
+
p3.outro(
|
|
826
826
|
pc2.cyan(
|
|
827
827
|
`Review + merge ${pr.htmlUrl} when ready. Re-run with no --skip-wait or run \`npx create-op-node verify\` afterwards.`
|
|
828
828
|
)
|
|
829
829
|
);
|
|
830
830
|
return;
|
|
831
831
|
}
|
|
832
|
-
|
|
832
|
+
p3.note(
|
|
833
833
|
[
|
|
834
834
|
`Open the PR: ${pc2.cyan(pr.htmlUrl)}`,
|
|
835
835
|
"",
|
|
@@ -839,10 +839,10 @@ var initCommand = new Command("init").description(
|
|
|
839
839
|
"Next step: review + merge"
|
|
840
840
|
);
|
|
841
841
|
const merged = unwrap(
|
|
842
|
-
await
|
|
842
|
+
await p3.confirm({ message: "PR merged?", initialValue: false })
|
|
843
843
|
);
|
|
844
844
|
if (!merged) {
|
|
845
|
-
|
|
845
|
+
p3.outro(
|
|
846
846
|
pc2.cyan(`No worries \u2014 re-run \`create-op-node init --skip-wait\` later to skip this step, or finish manually.`)
|
|
847
847
|
);
|
|
848
848
|
return;
|
|
@@ -853,7 +853,7 @@ var initCommand = new Command("init").description(
|
|
|
853
853
|
tags: ["opuspopuli", "cloudflare"]
|
|
854
854
|
});
|
|
855
855
|
if (!ws) {
|
|
856
|
-
|
|
856
|
+
p3.cancel(
|
|
857
857
|
`Couldn't find a TFC workspace tagged opuspopuli + cloudflare in ${tfOrg}. Check the workflow's run log; the workspace is created on first apply.`
|
|
858
858
|
);
|
|
859
859
|
process.exit(1);
|
|
@@ -865,7 +865,7 @@ var initCommand = new Command("init").description(
|
|
|
865
865
|
runId: ws.currentRunId
|
|
866
866
|
});
|
|
867
867
|
if (!tunnelToken) {
|
|
868
|
-
|
|
868
|
+
p3.cancel(
|
|
869
869
|
`Terraform apply didn't produce a tunnel_token output. Check the run on TFC; if it succeeded, the output may be named differently \u2014 patch and re-run, or fetch via 'terraform output -raw tunnel_token'.`
|
|
870
870
|
);
|
|
871
871
|
process.exit(1);
|
|
@@ -879,7 +879,7 @@ var initCommand = new Command("init").description(
|
|
|
879
879
|
overwrite: true
|
|
880
880
|
});
|
|
881
881
|
if (r.written) {
|
|
882
|
-
|
|
882
|
+
p3.note(
|
|
883
883
|
`${pc2.green("\u2713")} Tunnel token saved to 1Password as ${pc2.cyan(tunnelTitle)}.`,
|
|
884
884
|
"Secret"
|
|
885
885
|
);
|
|
@@ -889,7 +889,7 @@ var initCommand = new Command("init").description(
|
|
|
889
889
|
} else {
|
|
890
890
|
await printKeyForManualSave(tunnelToken, tunnelTitle);
|
|
891
891
|
}
|
|
892
|
-
|
|
892
|
+
p3.outro(
|
|
893
893
|
pc2.cyan(
|
|
894
894
|
`Region ${region} is provisioned. Next: \`npx create-op-node bootstrap\` on the Mac Studio.`
|
|
895
895
|
)
|
|
@@ -905,7 +905,7 @@ async function collectSecret(preset, message, kind = "generic") {
|
|
|
905
905
|
if (preset) return preset;
|
|
906
906
|
const min = MIN_TOKEN_LENGTH[kind];
|
|
907
907
|
const v = unwrap(
|
|
908
|
-
await
|
|
908
|
+
await p3.password({
|
|
909
909
|
message,
|
|
910
910
|
validate: (v2) => v2 && v2.length >= min ? void 0 : `That looks too short \u2014 expected at least ${min} characters for a ${kind} token`
|
|
911
911
|
})
|
|
@@ -915,7 +915,7 @@ async function collectSecret(preset, message, kind = "generic") {
|
|
|
915
915
|
async function collectId(preset, message) {
|
|
916
916
|
if (preset) return preset;
|
|
917
917
|
const v = unwrap(
|
|
918
|
-
await
|
|
918
|
+
await p3.text({
|
|
919
919
|
message,
|
|
920
920
|
placeholder: "0000000000000000000000000000000",
|
|
921
921
|
validate: (v2) => /^[a-f0-9]{32}$/.test(v2 ?? "") ? void 0 : "Expected 32 hex characters"
|
|
@@ -924,7 +924,7 @@ async function collectId(preset, message) {
|
|
|
924
924
|
return v;
|
|
925
925
|
}
|
|
926
926
|
async function printKeyForManualSave(value, title, reason) {
|
|
927
|
-
|
|
927
|
+
p3.note(
|
|
928
928
|
[
|
|
929
929
|
reason ? `${pc2.red("\u2717")} ${reason}` : "",
|
|
930
930
|
`Save this value to 1Password as ${pc2.cyan(title)}:`,
|
|
@@ -936,7 +936,7 @@ async function printKeyForManualSave(value, title, reason) {
|
|
|
936
936
|
"Manual save"
|
|
937
937
|
);
|
|
938
938
|
unwrap(
|
|
939
|
-
await
|
|
939
|
+
await p3.confirm({
|
|
940
940
|
message: "Value stashed in 1Password?",
|
|
941
941
|
initialValue: true
|
|
942
942
|
})
|
|
@@ -947,7 +947,7 @@ function isoStampUtc(d) {
|
|
|
947
947
|
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
|
948
948
|
}
|
|
949
949
|
async function waitForApplyAndFetchTunnelToken(input) {
|
|
950
|
-
const spin =
|
|
950
|
+
const spin = p3.spinner();
|
|
951
951
|
spin.start("Waiting for terraform apply to finish (polling every 10s)\u2026");
|
|
952
952
|
const outcome = await waitForApply({
|
|
953
953
|
token: input.token,
|
|
@@ -1204,6 +1204,30 @@ async function setupLaunchAgent(input) {
|
|
|
1204
1204
|
}
|
|
1205
1205
|
return { ok: true, paths };
|
|
1206
1206
|
}
|
|
1207
|
+
async function teardownLaunchAgent(paths, opts = {}) {
|
|
1208
|
+
const steps = [];
|
|
1209
|
+
const unload = await safeExeca("launchctl", ["unload", paths.plistFile]);
|
|
1210
|
+
steps.push({
|
|
1211
|
+
step: "unload",
|
|
1212
|
+
ok: true,
|
|
1213
|
+
...unload === null ? { reason: "`launchctl` not on PATH" } : {}
|
|
1214
|
+
});
|
|
1215
|
+
try {
|
|
1216
|
+
await rm(paths.plistFile, { force: true });
|
|
1217
|
+
steps.push({ step: "rm-plist", ok: true });
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
steps.push({ step: "rm-plist", ok: false, reason: err.message });
|
|
1220
|
+
}
|
|
1221
|
+
if (!opts.keepKeyFile) {
|
|
1222
|
+
try {
|
|
1223
|
+
await rm(paths.keyFile, { force: true });
|
|
1224
|
+
steps.push({ step: "rm-key-file", ok: true });
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
steps.push({ step: "rm-key-file", ok: false, reason: err.message });
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return { ok: steps.every((s) => s.ok), steps };
|
|
1230
|
+
}
|
|
1207
1231
|
|
|
1208
1232
|
// src/lib/docker.ts
|
|
1209
1233
|
var GHCR_REGISTRY = "ghcr.io";
|
|
@@ -1258,6 +1282,17 @@ async function composeUp(opts) {
|
|
|
1258
1282
|
const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
|
|
1259
1283
|
return result(res, "compose up");
|
|
1260
1284
|
}
|
|
1285
|
+
async function composeDown(opts) {
|
|
1286
|
+
const flags = ["down"];
|
|
1287
|
+
if (opts.wipeVolumes) flags.push("-v");
|
|
1288
|
+
if (opts.removeOrphans) flags.push("--remove-orphans");
|
|
1289
|
+
const res = await safeExeca("docker", composeArgs(opts, flags));
|
|
1290
|
+
return result(res, opts.wipeVolumes ? "compose down -v" : "compose down");
|
|
1291
|
+
}
|
|
1292
|
+
async function dockerLogout(registry = GHCR_REGISTRY) {
|
|
1293
|
+
const res = await safeExeca("docker", ["logout", registry]);
|
|
1294
|
+
return result(res, `docker logout ${registry}`);
|
|
1295
|
+
}
|
|
1261
1296
|
function result(res, label) {
|
|
1262
1297
|
if (res === null) return { ok: false, reason: "`docker` not installed" };
|
|
1263
1298
|
if (res.exitCode !== 0) {
|
|
@@ -1412,10 +1447,10 @@ async function warmModel(name, url = OLLAMA_URL) {
|
|
|
1412
1447
|
body: JSON.stringify({ model: name, prompt: "hi", stream: false })
|
|
1413
1448
|
});
|
|
1414
1449
|
if (!res.ok) {
|
|
1415
|
-
const
|
|
1450
|
+
const text6 = await res.text().catch(() => "");
|
|
1416
1451
|
return {
|
|
1417
1452
|
ok: false,
|
|
1418
|
-
reason: `warm ${name} returned HTTP ${res.status}: ${
|
|
1453
|
+
reason: `warm ${name} returned HTTP ${res.status}: ${text6 || "(no body)"}`
|
|
1419
1454
|
};
|
|
1420
1455
|
}
|
|
1421
1456
|
await res.text();
|
|
@@ -1526,13 +1561,6 @@ async function locateOrCloneRepo(input) {
|
|
|
1526
1561
|
}
|
|
1527
1562
|
|
|
1528
1563
|
// src/commands/bootstrap.ts
|
|
1529
|
-
function unwrap2(value) {
|
|
1530
|
-
if (p2.isCancel(value)) {
|
|
1531
|
-
p2.cancel("Cancelled.");
|
|
1532
|
-
process.exit(0);
|
|
1533
|
-
}
|
|
1534
|
-
return value;
|
|
1535
|
-
}
|
|
1536
1564
|
var bootstrapCommand = new Command("bootstrap").description(
|
|
1537
1565
|
"Configure the Mac Studio and bring the stack up. Run this on the Studio itself, after `init` has finished on your laptop."
|
|
1538
1566
|
).addOption(new Option("--region <slug>", "Region label set during init (e.g. us-ca)")).addOption(new Option("--owner <owner>", "GitHub owner for the node repo").default("OpusPopuli")).addOption(new Option("--vault <vault>", "1Password vault to read secrets from").default("Private")).addOption(new Option("--repo-dir <path>", "Explicit path to a checked-out node repo (overrides cwd + clone)")).addOption(
|
|
@@ -1546,9 +1574,9 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1546
1574
|
"Skip the LaunchAgent setup (assumes one is already in place)"
|
|
1547
1575
|
).default(false)
|
|
1548
1576
|
).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
|
|
1549
|
-
|
|
1550
|
-
const region = opts.region ? opts.region :
|
|
1551
|
-
await
|
|
1577
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
|
|
1578
|
+
const region = opts.region ? opts.region : unwrap(
|
|
1579
|
+
await p3.text({
|
|
1552
1580
|
message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
|
|
1553
1581
|
placeholder: "us-ca",
|
|
1554
1582
|
validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
@@ -1556,17 +1584,17 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1556
1584
|
);
|
|
1557
1585
|
const owner = opts.owner ?? "OpusPopuli";
|
|
1558
1586
|
const repoName = `opuspopuli-node-${region}`;
|
|
1559
|
-
const sysSpin =
|
|
1587
|
+
const sysSpin = p3.spinner();
|
|
1560
1588
|
sysSpin.start("Inspecting macOS\u2026");
|
|
1561
1589
|
const snap = await inspectSystem();
|
|
1562
1590
|
sysSpin.stop(pc2.green("\u2713 macOS inspected."));
|
|
1563
1591
|
if (!snap.isAppleSilicon) {
|
|
1564
|
-
|
|
1592
|
+
p3.cancel(
|
|
1565
1593
|
`Bootstrap requires an Apple Silicon Mac (the runbook targets M-series). Detected: ${snap.osVersion ?? "unknown"} (uname -m != arm64).`
|
|
1566
1594
|
);
|
|
1567
1595
|
process.exit(1);
|
|
1568
1596
|
}
|
|
1569
|
-
|
|
1597
|
+
p3.note(
|
|
1570
1598
|
[
|
|
1571
1599
|
`Hostname: ${pc2.cyan(snap.hostname)}`,
|
|
1572
1600
|
`macOS: ${pc2.cyan(snap.osVersion ?? "(sw_vers failed)")}`,
|
|
@@ -1577,33 +1605,33 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1577
1605
|
"System snapshot"
|
|
1578
1606
|
);
|
|
1579
1607
|
if (!snap.autoRestartOnPowerFailure) {
|
|
1580
|
-
const fix =
|
|
1581
|
-
await
|
|
1608
|
+
const fix = unwrap(
|
|
1609
|
+
await p3.confirm({
|
|
1582
1610
|
message: "Auto-restart-on-power-failure is OFF. Enable now (requires sudo)?",
|
|
1583
1611
|
initialValue: true
|
|
1584
1612
|
})
|
|
1585
1613
|
);
|
|
1586
1614
|
if (fix) {
|
|
1587
1615
|
const r = await enableAutoRestartOnPowerFailure();
|
|
1588
|
-
if (!r.ok)
|
|
1616
|
+
if (!r.ok) p3.note(`${pc2.red("\u2717")} ${r.reason}`, "pmset failed");
|
|
1589
1617
|
}
|
|
1590
1618
|
}
|
|
1591
1619
|
if (!snap.diskSleepDisabled) {
|
|
1592
|
-
const fix =
|
|
1593
|
-
await
|
|
1620
|
+
const fix = unwrap(
|
|
1621
|
+
await p3.confirm({
|
|
1594
1622
|
message: "Disk sleep is enabled. Disable now (requires sudo)?",
|
|
1595
1623
|
initialValue: true
|
|
1596
1624
|
})
|
|
1597
1625
|
);
|
|
1598
1626
|
if (fix) {
|
|
1599
1627
|
const r = await disableDiskSleep();
|
|
1600
|
-
if (!r.ok)
|
|
1628
|
+
if (!r.ok) p3.note(`${pc2.red("\u2717")} ${r.reason}`, "pmset failed");
|
|
1601
1629
|
}
|
|
1602
1630
|
}
|
|
1603
1631
|
if (!opts.skipBrew) {
|
|
1604
1632
|
const brewInfo = await detectBrew();
|
|
1605
1633
|
if (!brewInfo.installed) {
|
|
1606
|
-
|
|
1634
|
+
p3.note(
|
|
1607
1635
|
[
|
|
1608
1636
|
`Homebrew is not installed. Open another shell and run:`,
|
|
1609
1637
|
"",
|
|
@@ -1613,9 +1641,9 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1613
1641
|
].join("\n"),
|
|
1614
1642
|
"Manual step"
|
|
1615
1643
|
);
|
|
1616
|
-
|
|
1644
|
+
unwrap(await p3.confirm({ message: "Homebrew installed?", initialValue: true }));
|
|
1617
1645
|
}
|
|
1618
|
-
const brewSpin =
|
|
1646
|
+
const brewSpin = p3.spinner();
|
|
1619
1647
|
brewSpin.start("Installing Studio packages\u2026");
|
|
1620
1648
|
const report = await installPackages(STUDIO_PACKAGES, (pkg, status) => {
|
|
1621
1649
|
brewSpin.message(`${status}: ${pkg.name}`);
|
|
@@ -1628,7 +1656,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1628
1656
|
)
|
|
1629
1657
|
);
|
|
1630
1658
|
if (report.failed.length > 0) {
|
|
1631
|
-
|
|
1659
|
+
p3.note(
|
|
1632
1660
|
[
|
|
1633
1661
|
report.failed.map((f) => `${pc2.red("\u2022")} ${f.pkg.name}: ${f.reason}`).join("\n"),
|
|
1634
1662
|
"",
|
|
@@ -1637,8 +1665,8 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1637
1665
|
].join("\n"),
|
|
1638
1666
|
"Brew failures"
|
|
1639
1667
|
);
|
|
1640
|
-
const cont =
|
|
1641
|
-
await
|
|
1668
|
+
const cont = unwrap(
|
|
1669
|
+
await p3.confirm({
|
|
1642
1670
|
message: "Continue anyway? (Default no \u2014 recommended to fix and re-run.)",
|
|
1643
1671
|
initialValue: false
|
|
1644
1672
|
})
|
|
@@ -1648,7 +1676,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1648
1676
|
}
|
|
1649
1677
|
await ensureGhAuth();
|
|
1650
1678
|
await promptTailscaleSignin();
|
|
1651
|
-
const repoSpin =
|
|
1679
|
+
const repoSpin = p3.spinner();
|
|
1652
1680
|
repoSpin.start("Locating your region node repo\u2026");
|
|
1653
1681
|
const located = await locateOrCloneRepo({
|
|
1654
1682
|
owner,
|
|
@@ -1667,7 +1695,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1667
1695
|
);
|
|
1668
1696
|
const op = await detectOp();
|
|
1669
1697
|
if (!op.installed || !op.signedIn) {
|
|
1670
|
-
|
|
1698
|
+
p3.cancel(
|
|
1671
1699
|
"1Password CLI not installed or not signed in. Run `op signin` and re-run bootstrap. (Or paste pgsodium key + Tunnel token by hand \u2014 manual path not in v0.1.)"
|
|
1672
1700
|
);
|
|
1673
1701
|
process.exit(1);
|
|
@@ -1675,7 +1703,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1675
1703
|
const keyTitle = `opuspopuli-${region}-pgsodium-root-key`;
|
|
1676
1704
|
const tunnelTitle = `opuspopuli-${region}-tunnel-token`;
|
|
1677
1705
|
const vaultArg = opts.vault ? { vault: opts.vault } : {};
|
|
1678
|
-
const opSpin =
|
|
1706
|
+
const opSpin = p3.spinner();
|
|
1679
1707
|
opSpin.start("Reading pgsodium key + Tunnel token from 1Password\u2026");
|
|
1680
1708
|
const pgsodiumKey = await readSecretFromOp({ title: keyTitle, ...vaultArg });
|
|
1681
1709
|
const tunnelToken = await readSecretFromOp({ title: tunnelTitle, ...vaultArg });
|
|
@@ -1685,39 +1713,39 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1685
1713
|
pgsodiumKey ? null : keyTitle,
|
|
1686
1714
|
tunnelToken ? null : tunnelTitle
|
|
1687
1715
|
].filter(Boolean);
|
|
1688
|
-
|
|
1716
|
+
p3.cancel(
|
|
1689
1717
|
`Missing in 1Password (vault: ${opts.vault ?? "Private"}): ${missing.join(", ")}. Run \`create-op-node init\` first, or pass --vault if your items live elsewhere.`
|
|
1690
1718
|
);
|
|
1691
1719
|
process.exit(1);
|
|
1692
1720
|
}
|
|
1693
1721
|
if (!PGSODIUM_KEY_RE.test(pgsodiumKey)) {
|
|
1694
1722
|
opSpin.stop(pc2.red("\u2717 pgsodium item exists but isn't a 64-hex key."));
|
|
1695
|
-
|
|
1723
|
+
p3.cancel("Inspect the item in 1Password; it should be 64 lowercase hex characters.");
|
|
1696
1724
|
process.exit(1);
|
|
1697
1725
|
}
|
|
1698
1726
|
opSpin.stop(pc2.green("\u2713 Secrets read from 1Password."));
|
|
1699
1727
|
if (!opts.skipLaunchAgent) {
|
|
1700
|
-
const laSpin =
|
|
1728
|
+
const laSpin = p3.spinner();
|
|
1701
1729
|
laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
|
|
1702
1730
|
const la = await setupLaunchAgent({ pgsodiumKey, tunnelToken });
|
|
1703
1731
|
if (!la.ok) {
|
|
1704
1732
|
laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
|
|
1705
|
-
|
|
1733
|
+
p3.cancel(la.reason ?? "LaunchAgent setup failed.");
|
|
1706
1734
|
process.exit(1);
|
|
1707
1735
|
}
|
|
1708
1736
|
laSpin.stop(pc2.green(`\u2713 LaunchAgent loaded (${la.paths.plistFile}).`));
|
|
1709
1737
|
}
|
|
1710
|
-
const ghcrSpin =
|
|
1738
|
+
const ghcrSpin = p3.spinner();
|
|
1711
1739
|
ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
|
|
1712
1740
|
const ghcr = await loginToGhcr();
|
|
1713
1741
|
if (!ghcr.ok) {
|
|
1714
1742
|
ghcrSpin.stop(pc2.red("\u2717 ghcr.io login failed."));
|
|
1715
|
-
|
|
1743
|
+
p3.cancel(ghcr.reason ?? "ghcr.io login failed.");
|
|
1716
1744
|
process.exit(1);
|
|
1717
1745
|
}
|
|
1718
1746
|
ghcrSpin.stop(pc2.green("\u2713 Logged in to ghcr.io."));
|
|
1719
1747
|
if (!opts.skipOllama) {
|
|
1720
|
-
const olSpin =
|
|
1748
|
+
const olSpin = p3.spinner();
|
|
1721
1749
|
olSpin.start("Pulling + warming Ollama models\u2026");
|
|
1722
1750
|
let olHealth = await checkOllamaHealth();
|
|
1723
1751
|
if (!olHealth.reachable) {
|
|
@@ -1725,7 +1753,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1725
1753
|
const start = await startOllamaService();
|
|
1726
1754
|
if (!start.ok) {
|
|
1727
1755
|
olSpin.stop(pc2.red("\u2717 Couldn't start Ollama service."));
|
|
1728
|
-
|
|
1756
|
+
p3.cancel(
|
|
1729
1757
|
`${start.reason ?? "unknown"} \u2014 start it manually with \`brew services start ollama\` and re-run with --skip-brew --skip-launch-agent.`
|
|
1730
1758
|
);
|
|
1731
1759
|
process.exit(1);
|
|
@@ -1736,7 +1764,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1736
1764
|
olSpin.stop(
|
|
1737
1765
|
pc2.yellow("\u26A0 Ollama service started but daemon not yet answering on :11434.")
|
|
1738
1766
|
);
|
|
1739
|
-
|
|
1767
|
+
p3.cancel(
|
|
1740
1768
|
"Give it another few seconds, then re-run with --skip-brew --skip-launch-agent."
|
|
1741
1769
|
);
|
|
1742
1770
|
process.exit(1);
|
|
@@ -1752,11 +1780,11 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1752
1780
|
);
|
|
1753
1781
|
const probe = await probeHostDockerInternal();
|
|
1754
1782
|
if (!probe.ok) {
|
|
1755
|
-
|
|
1783
|
+
p3.note(`${pc2.yellow("\u26A0")} ${probe.reason}`, "Docker host networking");
|
|
1756
1784
|
}
|
|
1757
1785
|
}
|
|
1758
1786
|
if (opts.skipStack) {
|
|
1759
|
-
|
|
1787
|
+
p3.outro(
|
|
1760
1788
|
pc2.cyan(
|
|
1761
1789
|
`Stack-up skipped. Run \`docker compose -f ${(opts.composeFile ?? ["docker-compose-prod.yml"]).join(" -f ")} pull && up -d\` from ${repoPath} when ready.`
|
|
1762
1790
|
)
|
|
@@ -1769,25 +1797,25 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1769
1797
|
cwd: repoPath,
|
|
1770
1798
|
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
1771
1799
|
};
|
|
1772
|
-
const pullSpin =
|
|
1800
|
+
const pullSpin = p3.spinner();
|
|
1773
1801
|
pullSpin.start("Pulling images from ghcr.io\u2026");
|
|
1774
1802
|
const pull = await composePull(composeOpts);
|
|
1775
1803
|
if (!pull.ok) {
|
|
1776
1804
|
pullSpin.stop(pc2.red("\u2717 compose pull failed."));
|
|
1777
|
-
|
|
1805
|
+
p3.cancel(pull.reason ?? "compose pull failed.");
|
|
1778
1806
|
process.exit(1);
|
|
1779
1807
|
}
|
|
1780
1808
|
pullSpin.stop(pc2.green("\u2713 Images pulled."));
|
|
1781
|
-
const upSpin =
|
|
1809
|
+
const upSpin = p3.spinner();
|
|
1782
1810
|
upSpin.start("Starting the stack\u2026");
|
|
1783
1811
|
const up = await composeUp(composeOpts);
|
|
1784
1812
|
if (!up.ok) {
|
|
1785
1813
|
upSpin.stop(pc2.red("\u2717 compose up failed."));
|
|
1786
|
-
|
|
1814
|
+
p3.cancel(up.reason ?? "compose up failed.");
|
|
1787
1815
|
process.exit(1);
|
|
1788
1816
|
}
|
|
1789
1817
|
upSpin.stop(pc2.green("\u2713 Containers started \u2014 waiting for healthy\u2026"));
|
|
1790
|
-
const healthSpin =
|
|
1818
|
+
const healthSpin = p3.spinner();
|
|
1791
1819
|
healthSpin.start("Polling for healthy containers (5s, up to 5 minutes)\u2026");
|
|
1792
1820
|
const outcome = await waitForHealthy(composeOpts, {
|
|
1793
1821
|
onPoll: (snaps) => {
|
|
@@ -1799,7 +1827,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1799
1827
|
switch (outcome.kind) {
|
|
1800
1828
|
case "healthy":
|
|
1801
1829
|
healthSpin.stop(pc2.green(`\u2713 All ${outcome.snapshots.length} containers healthy.`));
|
|
1802
|
-
|
|
1830
|
+
p3.outro(
|
|
1803
1831
|
pc2.cyan(
|
|
1804
1832
|
`Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
|
|
1805
1833
|
)
|
|
@@ -1807,22 +1835,22 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1807
1835
|
return;
|
|
1808
1836
|
case "unhealthy":
|
|
1809
1837
|
healthSpin.stop(pc2.red(`\u2717 ${outcome.problem}`));
|
|
1810
|
-
|
|
1838
|
+
p3.note(
|
|
1811
1839
|
outcome.snapshots.map((s) => ` ${kvHealth(s.health)} ${s.name} (${s.state})`).join("\n"),
|
|
1812
1840
|
"Container state"
|
|
1813
1841
|
);
|
|
1814
|
-
|
|
1842
|
+
p3.cancel(
|
|
1815
1843
|
`Inspect with \`docker compose -f ${composeFiles.join(" -f ")} logs\`. Fix and re-run.`
|
|
1816
1844
|
);
|
|
1817
1845
|
process.exit(1);
|
|
1818
1846
|
// eslint-disable-next-line no-fallthrough
|
|
1819
1847
|
case "timeout":
|
|
1820
1848
|
healthSpin.stop(pc2.yellow("\u26A0 Timed out waiting for all containers to report healthy."));
|
|
1821
|
-
|
|
1849
|
+
p3.note(
|
|
1822
1850
|
outcome.snapshots.map((s) => ` ${kvHealth(s.health)} ${s.name} (${s.state})`).join("\n"),
|
|
1823
1851
|
"Container state at timeout"
|
|
1824
1852
|
);
|
|
1825
|
-
|
|
1853
|
+
p3.cancel(
|
|
1826
1854
|
`Re-run \`create-op-node bootstrap --skip-brew --skip-launch-agent --skip-ollama\` to skip the already-done phases and poll again once containers settle.`
|
|
1827
1855
|
);
|
|
1828
1856
|
process.exit(1);
|
|
@@ -1862,20 +1890,20 @@ function repoPathFromLocate(out) {
|
|
|
1862
1890
|
function handleLocateError(out, owner, name) {
|
|
1863
1891
|
switch (out.kind) {
|
|
1864
1892
|
case "explicit-not-a-node-repo":
|
|
1865
|
-
|
|
1893
|
+
p3.cancel(
|
|
1866
1894
|
`${out.path} doesn't look like a node repo (missing one of docker-compose-prod.yml / supabase/init/pgsodium_getkey_env.sh). Point --repo-dir at the right place.`
|
|
1867
1895
|
);
|
|
1868
1896
|
return;
|
|
1869
1897
|
case "gh-not-installed":
|
|
1870
|
-
|
|
1898
|
+
p3.cancel("`gh` not installed \u2014 install via `brew install gh` and re-run.");
|
|
1871
1899
|
return;
|
|
1872
1900
|
case "clone-failed":
|
|
1873
|
-
|
|
1901
|
+
p3.cancel(
|
|
1874
1902
|
`Couldn't clone ${owner}/${name}: ${out.reason}. Check repo exists + you have access.`
|
|
1875
1903
|
);
|
|
1876
1904
|
return;
|
|
1877
1905
|
case "clone-disallowed":
|
|
1878
|
-
|
|
1906
|
+
p3.cancel(
|
|
1879
1907
|
"No checked-out node repo found and --no-clone (or similar) was set. Provide --repo-dir."
|
|
1880
1908
|
);
|
|
1881
1909
|
return;
|
|
@@ -1890,12 +1918,12 @@ function handleLocateError(out, owner, name) {
|
|
|
1890
1918
|
async function ensureGhAuth() {
|
|
1891
1919
|
const status = await safeExeca("gh", ["auth", "status"]);
|
|
1892
1920
|
if (status === null) {
|
|
1893
|
-
|
|
1921
|
+
p3.cancel("`gh` not on PATH. Install via `brew install gh` and re-run.");
|
|
1894
1922
|
process.exit(1);
|
|
1895
1923
|
}
|
|
1896
1924
|
if (status.exitCode === 0) return;
|
|
1897
1925
|
const detail = (status.stderr || status.stdout).trim();
|
|
1898
|
-
|
|
1926
|
+
p3.note(
|
|
1899
1927
|
[
|
|
1900
1928
|
"`gh` is not signed in (or signed in to a different account / missing scope).",
|
|
1901
1929
|
detail ? "" : void 0,
|
|
@@ -1909,12 +1937,12 @@ async function ensureGhAuth() {
|
|
|
1909
1937
|
].filter((l) => typeof l === "string").join("\n"),
|
|
1910
1938
|
"Manual step"
|
|
1911
1939
|
);
|
|
1912
|
-
|
|
1940
|
+
unwrap(await p3.confirm({ message: "gh signed in?", initialValue: true }));
|
|
1913
1941
|
}
|
|
1914
1942
|
async function promptTailscaleSignin() {
|
|
1915
1943
|
const status = await safeExeca("tailscale", ["status"]);
|
|
1916
1944
|
if (status !== null && status.exitCode === 0) return;
|
|
1917
|
-
|
|
1945
|
+
p3.note(
|
|
1918
1946
|
[
|
|
1919
1947
|
"Tailscale needs to be signed in (or you can skip \u2014 Tailscale is",
|
|
1920
1948
|
"optional; it provides out-of-band SSH from your laptop. The rest",
|
|
@@ -1928,8 +1956,293 @@ async function promptTailscaleSignin() {
|
|
|
1928
1956
|
].join("\n"),
|
|
1929
1957
|
"Manual step"
|
|
1930
1958
|
);
|
|
1931
|
-
|
|
1959
|
+
unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
|
|
1960
|
+
}
|
|
1961
|
+
var REGION_RE = /^[a-z0-9-]{2,32}$/;
|
|
1962
|
+
var RESET_PHASES = {
|
|
1963
|
+
STOP_STACK: "Stop stack",
|
|
1964
|
+
LAUNCH_AGENT: "LaunchAgent",
|
|
1965
|
+
DOCKER_LOGOUT: "docker logout"
|
|
1966
|
+
};
|
|
1967
|
+
var DEFAULT_DEPS = {
|
|
1968
|
+
composeDown,
|
|
1969
|
+
teardownLaunchAgent,
|
|
1970
|
+
dockerLogout
|
|
1971
|
+
};
|
|
1972
|
+
async function runReset(input, deps = DEFAULT_DEPS) {
|
|
1973
|
+
const phases = [];
|
|
1974
|
+
const push = (ph) => {
|
|
1975
|
+
phases.push(ph);
|
|
1976
|
+
deps.onPhase?.(ph);
|
|
1977
|
+
};
|
|
1978
|
+
if (!input.stack) {
|
|
1979
|
+
push({
|
|
1980
|
+
name: RESET_PHASES.STOP_STACK,
|
|
1981
|
+
status: "skipped",
|
|
1982
|
+
detail: "--skip-stack or no repo path resolved"
|
|
1983
|
+
});
|
|
1984
|
+
} else if (input.dryRun) {
|
|
1985
|
+
push({
|
|
1986
|
+
name: RESET_PHASES.STOP_STACK,
|
|
1987
|
+
status: "dry-run",
|
|
1988
|
+
detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}`
|
|
1989
|
+
});
|
|
1990
|
+
} else {
|
|
1991
|
+
const result2 = await deps.composeDown({
|
|
1992
|
+
files: input.stack.composeFiles,
|
|
1993
|
+
cwd: input.stack.repoPath,
|
|
1994
|
+
...input.stack.envFile ? { envFile: input.stack.envFile } : {},
|
|
1995
|
+
wipeVolumes: input.stack.wipeVolumes,
|
|
1996
|
+
removeOrphans: input.stack.removeOrphans
|
|
1997
|
+
});
|
|
1998
|
+
if (result2.ok) {
|
|
1999
|
+
push({
|
|
2000
|
+
name: RESET_PHASES.STOP_STACK,
|
|
2001
|
+
status: "ok",
|
|
2002
|
+
detail: input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved"
|
|
2003
|
+
});
|
|
2004
|
+
} else {
|
|
2005
|
+
push({ name: RESET_PHASES.STOP_STACK, status: "fail", detail: result2.reason ?? "unknown failure" });
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
if (!input.launchAgent) {
|
|
2009
|
+
push({ name: RESET_PHASES.LAUNCH_AGENT, status: "skipped", detail: "--skip-launch-agent or no plist found" });
|
|
2010
|
+
} else if (input.dryRun) {
|
|
2011
|
+
push({
|
|
2012
|
+
name: RESET_PHASES.LAUNCH_AGENT,
|
|
2013
|
+
status: "dry-run",
|
|
2014
|
+
detail: `would unload ${input.launchAgent.paths.plistFile}, rm plist${input.launchAgent.keepKeyFile ? "" : " + key file"}`
|
|
2015
|
+
});
|
|
2016
|
+
} else {
|
|
2017
|
+
const result2 = await deps.teardownLaunchAgent(
|
|
2018
|
+
input.launchAgent.paths,
|
|
2019
|
+
{ keepKeyFile: input.launchAgent.keepKeyFile }
|
|
2020
|
+
);
|
|
2021
|
+
if (result2.ok) {
|
|
2022
|
+
const removed = result2.steps.filter((s) => s.step !== "unload").map((s) => s.step).join(", ");
|
|
2023
|
+
push({ name: RESET_PHASES.LAUNCH_AGENT, status: "ok", detail: `unloaded + ${removed}` });
|
|
2024
|
+
} else {
|
|
2025
|
+
const fail = result2.steps.find((s) => !s.ok);
|
|
2026
|
+
push({
|
|
2027
|
+
name: RESET_PHASES.LAUNCH_AGENT,
|
|
2028
|
+
status: "fail",
|
|
2029
|
+
detail: `${fail?.step}: ${fail?.reason ?? "unknown"}`
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (!input.dockerLogout) {
|
|
2034
|
+
push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "skipped", detail: "--skip-docker-logout" });
|
|
2035
|
+
} else if (input.dryRun) {
|
|
2036
|
+
push({
|
|
2037
|
+
name: RESET_PHASES.DOCKER_LOGOUT,
|
|
2038
|
+
status: "dry-run",
|
|
2039
|
+
detail: `would run: docker logout ${input.dockerLogout.registry}`
|
|
2040
|
+
});
|
|
2041
|
+
} else {
|
|
2042
|
+
const result2 = await deps.dockerLogout(input.dockerLogout.registry);
|
|
2043
|
+
if (result2.ok) {
|
|
2044
|
+
push({
|
|
2045
|
+
name: RESET_PHASES.DOCKER_LOGOUT,
|
|
2046
|
+
status: "ok",
|
|
2047
|
+
detail: `credentials removed from ${input.dockerLogout.registry}`
|
|
2048
|
+
});
|
|
2049
|
+
} else {
|
|
2050
|
+
push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "warn", detail: result2.reason ?? "unknown" });
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return { phases };
|
|
1932
2054
|
}
|
|
2055
|
+
async function fileExists2(path) {
|
|
2056
|
+
try {
|
|
2057
|
+
await access(path);
|
|
2058
|
+
return true;
|
|
2059
|
+
} catch {
|
|
2060
|
+
return false;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
function phaseIcon(ph) {
|
|
2064
|
+
switch (ph.status) {
|
|
2065
|
+
case "ok":
|
|
2066
|
+
return pc2.green("\u2713");
|
|
2067
|
+
case "warn":
|
|
2068
|
+
return pc2.yellow("\u26A0");
|
|
2069
|
+
case "skipped":
|
|
2070
|
+
return pc2.dim("\xB7");
|
|
2071
|
+
case "dry-run":
|
|
2072
|
+
return pc2.cyan("?");
|
|
2073
|
+
case "fail":
|
|
2074
|
+
return pc2.red("\u2717");
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
var resetCommand = new Command("reset").description(
|
|
2078
|
+
"Reverse the Mac Studio side of bootstrap: stop containers, unload the LaunchAgent, remove the pgsodium key file. Preserves docker volumes by default \u2014 pass --wipe-data for a full data nuke (requires retyping the region label)."
|
|
2079
|
+
).addOption(new Option("--region <slug>", "Region label (used for --wipe-data confirmation + finding the repo)")).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")).addOption(
|
|
2080
|
+
new Option(
|
|
2081
|
+
"--compose-file <path>",
|
|
2082
|
+
"Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml"
|
|
2083
|
+
).default(["docker-compose-prod.yml"])
|
|
2084
|
+
).addOption(new Option("--env-file <path>", "Compose --env-file. Default: .env.production")).addOption(
|
|
2085
|
+
new Option(
|
|
2086
|
+
"--wipe-data",
|
|
2087
|
+
"DESTROYS docker volumes (adds -v to `compose down`). Requires retyping the region label as confirmation. Default off \u2014 volumes preserved."
|
|
2088
|
+
).default(false)
|
|
2089
|
+
).addOption(
|
|
2090
|
+
new Option(
|
|
2091
|
+
"--no-remove-orphans",
|
|
2092
|
+
"Do NOT pass --remove-orphans to compose down. Preserves containers from compose files no longer included."
|
|
2093
|
+
)
|
|
2094
|
+
).addOption(new Option("--skip-stack", "Don't touch docker compose").default(false)).addOption(new Option("--skip-launch-agent", "Don't touch the LaunchAgent").default(false)).addOption(
|
|
2095
|
+
new Option(
|
|
2096
|
+
"--keep-key-file",
|
|
2097
|
+
"Preserve the pgsodium key file (only matters when LaunchAgent teardown runs). Useful as belt-and-suspenders backup before a wipe-data run."
|
|
2098
|
+
).default(false)
|
|
2099
|
+
).addOption(new Option("--skip-docker-logout", "Don't run `docker logout`").default(false)).addOption(new Option("--registry <registry>", "Registry to log out of").default(GHCR_REGISTRY)).addOption(new Option("--dry-run", "Show what would happen without acting").default(false)).addOption(new Option("-y, --yes", "Skip non-destructive confirmations (wipe still confirms)").default(false)).action(async (opts) => {
|
|
2100
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node reset ")));
|
|
2101
|
+
const wipeData = opts.wipeData ?? false;
|
|
2102
|
+
const removeOrphans = opts.removeOrphans ?? true;
|
|
2103
|
+
const region = opts.region ? opts.region : unwrap(
|
|
2104
|
+
await p3.text({
|
|
2105
|
+
message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
|
|
2106
|
+
placeholder: "us-ca",
|
|
2107
|
+
validate: (v) => REGION_RE.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
2108
|
+
})
|
|
2109
|
+
);
|
|
2110
|
+
if (!REGION_RE.test(region)) {
|
|
2111
|
+
p3.cancel(`--region ${JSON.stringify(region)} is not a valid region slug.`);
|
|
2112
|
+
process.exit(2);
|
|
2113
|
+
}
|
|
2114
|
+
const owner = opts.owner ?? "OpusPopuli";
|
|
2115
|
+
const repoName = `opuspopuli-node-${region}`;
|
|
2116
|
+
const launchAgentPaths = defaultPaths();
|
|
2117
|
+
const plistExists = await fileExists2(launchAgentPaths.plistFile);
|
|
2118
|
+
const keyFileExists = await fileExists2(launchAgentPaths.keyFile);
|
|
2119
|
+
let repoPath;
|
|
2120
|
+
let stackSkipReason;
|
|
2121
|
+
if (!opts.skipStack) {
|
|
2122
|
+
const located = await locateOrCloneRepo({
|
|
2123
|
+
owner,
|
|
2124
|
+
name: repoName,
|
|
2125
|
+
cwd: process.cwd(),
|
|
2126
|
+
allowClone: false,
|
|
2127
|
+
...opts.repoDir ? { explicit: opts.repoDir } : {}
|
|
2128
|
+
});
|
|
2129
|
+
switch (located.kind) {
|
|
2130
|
+
case "found":
|
|
2131
|
+
repoPath = located.path;
|
|
2132
|
+
break;
|
|
2133
|
+
case "explicit-not-a-node-repo":
|
|
2134
|
+
p3.cancel(
|
|
2135
|
+
`--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.`
|
|
2136
|
+
);
|
|
2137
|
+
process.exit(2);
|
|
2138
|
+
break;
|
|
2139
|
+
case "clone-disallowed":
|
|
2140
|
+
stackSkipReason = `no checkout found at ${process.cwd()}; pass --repo-dir to target one`;
|
|
2141
|
+
break;
|
|
2142
|
+
case "cloned":
|
|
2143
|
+
case "gh-not-installed":
|
|
2144
|
+
case "clone-failed":
|
|
2145
|
+
stackSkipReason = `unexpected outcome ${located.kind} from locateOrCloneRepo with allowClone=false`;
|
|
2146
|
+
break;
|
|
2147
|
+
}
|
|
2148
|
+
} else {
|
|
2149
|
+
stackSkipReason = "--skip-stack";
|
|
2150
|
+
}
|
|
2151
|
+
let runningContainers = null;
|
|
2152
|
+
if (repoPath) {
|
|
2153
|
+
runningContainers = await composePs({
|
|
2154
|
+
files: resolveComposeFiles(repoPath, opts.composeFile),
|
|
2155
|
+
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
p3.note(
|
|
2159
|
+
[
|
|
2160
|
+
`Region: ${pc2.cyan(region)}`,
|
|
2161
|
+
`Repo path: ${repoPath ? pc2.cyan(repoPath) : pc2.dim(`not used (${stackSkipReason ?? "no repo path resolved"})`)}`,
|
|
2162
|
+
`Running containers: ${runningContainers === null ? pc2.dim("unknown") : runningContainers.length === 0 ? pc2.dim("none") : pc2.cyan(`${runningContainers.length} listed by compose ps`)}`,
|
|
2163
|
+
`LaunchAgent plist: ${plistExists ? pc2.cyan(launchAgentPaths.plistFile) : pc2.dim("not present")}`,
|
|
2164
|
+
`pgsodium key file: ${keyFileExists ? pc2.cyan(launchAgentPaths.keyFile) : pc2.dim("not present")}`,
|
|
2165
|
+
`Registry to log out: ${pc2.cyan(opts.registry ?? GHCR_REGISTRY)}`,
|
|
2166
|
+
`Volume policy: ${wipeData ? pc2.red("WIPE") : pc2.green("preserve (default)")}`,
|
|
2167
|
+
`Dry run: ${opts.dryRun ? pc2.yellow("yes") : pc2.dim("no")}`
|
|
2168
|
+
].join("\n"),
|
|
2169
|
+
"Snapshot"
|
|
2170
|
+
);
|
|
2171
|
+
if (wipeData && !opts.dryRun) {
|
|
2172
|
+
unwrap(
|
|
2173
|
+
await p3.text({
|
|
2174
|
+
message: pc2.red(`Type the region label to confirm WIPING all volumes (the slug you'd use with --region):`),
|
|
2175
|
+
validate: (v) => v === region ? void 0 : `must match the region label exactly`
|
|
2176
|
+
})
|
|
2177
|
+
);
|
|
2178
|
+
} else if (!opts.dryRun && !opts.yes) {
|
|
2179
|
+
const cont = unwrap(
|
|
2180
|
+
await p3.confirm({
|
|
2181
|
+
message: "Proceed with reset? (volumes will be preserved)",
|
|
2182
|
+
initialValue: true
|
|
2183
|
+
})
|
|
2184
|
+
);
|
|
2185
|
+
if (!cont) {
|
|
2186
|
+
p3.cancel("Cancelled.");
|
|
2187
|
+
process.exit(0);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
const stack = repoPath ? {
|
|
2191
|
+
repoPath,
|
|
2192
|
+
composeFiles: resolveComposeFiles(repoPath, opts.composeFile),
|
|
2193
|
+
wipeVolumes: wipeData,
|
|
2194
|
+
removeOrphans,
|
|
2195
|
+
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
2196
|
+
} : void 0;
|
|
2197
|
+
const launchAgent = plistExists && !opts.skipLaunchAgent ? {
|
|
2198
|
+
paths: launchAgentPaths,
|
|
2199
|
+
keepKeyFile: opts.keepKeyFile ?? false
|
|
2200
|
+
} : void 0;
|
|
2201
|
+
const dockerLogoutInput = !opts.skipDockerLogout ? { registry: opts.registry ?? GHCR_REGISTRY } : void 0;
|
|
2202
|
+
const phaseSpins = /* @__PURE__ */ new Map();
|
|
2203
|
+
const actingPhases = [];
|
|
2204
|
+
if (stack && !opts.dryRun) actingPhases.push(RESET_PHASES.STOP_STACK);
|
|
2205
|
+
if (launchAgent && !opts.dryRun) actingPhases.push(RESET_PHASES.LAUNCH_AGENT);
|
|
2206
|
+
if (dockerLogoutInput && !opts.dryRun) actingPhases.push(RESET_PHASES.DOCKER_LOGOUT);
|
|
2207
|
+
for (const name of actingPhases) {
|
|
2208
|
+
const s = p3.spinner();
|
|
2209
|
+
s.start(`${name}\u2026`);
|
|
2210
|
+
phaseSpins.set(name, s);
|
|
2211
|
+
}
|
|
2212
|
+
const renderPhase = (ph) => {
|
|
2213
|
+
const spin = phaseSpins.get(ph.name);
|
|
2214
|
+
const line = `${phaseIcon(ph)} ${ph.name}: ${pc2.dim(ph.detail)}`;
|
|
2215
|
+
if (spin) {
|
|
2216
|
+
spin.stop(line);
|
|
2217
|
+
phaseSpins.delete(ph.name);
|
|
2218
|
+
} else {
|
|
2219
|
+
p3.log.info(line);
|
|
2220
|
+
}
|
|
2221
|
+
};
|
|
2222
|
+
const report = await runReset(
|
|
2223
|
+
{
|
|
2224
|
+
...stack ? { stack } : {},
|
|
2225
|
+
...launchAgent ? { launchAgent } : {},
|
|
2226
|
+
...dockerLogoutInput ? { dockerLogout: dockerLogoutInput } : {},
|
|
2227
|
+
dryRun: opts.dryRun ?? false
|
|
2228
|
+
},
|
|
2229
|
+
{ ...DEFAULT_DEPS, onPhase: renderPhase }
|
|
2230
|
+
);
|
|
2231
|
+
for (const [, spin] of phaseSpins) spin.stop(pc2.dim("\u2014 skipped"));
|
|
2232
|
+
const failed = report.phases.filter((ph) => ph.status === "fail").length;
|
|
2233
|
+
if (failed > 0) {
|
|
2234
|
+
p3.outro(pc2.red(`${failed} step${failed === 1 ? "" : "s"} failed.`));
|
|
2235
|
+
process.exit(1);
|
|
2236
|
+
} else if (opts.dryRun) {
|
|
2237
|
+
p3.outro(pc2.cyan("Dry run complete. Re-run without --dry-run to apply."));
|
|
2238
|
+
} else {
|
|
2239
|
+
p3.outro(
|
|
2240
|
+
pc2.green(
|
|
2241
|
+
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.`
|
|
2242
|
+
)
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
1933
2246
|
|
|
1934
2247
|
// src/lib/cosign.ts
|
|
1935
2248
|
var COSIGN_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
|
|
@@ -1965,8 +2278,8 @@ async function cosignVerifyImage(input) {
|
|
|
1965
2278
|
// src/lib/http.ts
|
|
1966
2279
|
var GRAPHQL_ACCEPT = "application/graphql-response+json, application/json";
|
|
1967
2280
|
async function readBodyCapped(res) {
|
|
1968
|
-
const
|
|
1969
|
-
return
|
|
2281
|
+
const text6 = await res.text();
|
|
2282
|
+
return text6.slice(0, BODY_PREVIEW_MAX);
|
|
1970
2283
|
}
|
|
1971
2284
|
async function httpProbe(input) {
|
|
1972
2285
|
const expected = input.expectedStatus ?? 200;
|
|
@@ -2004,22 +2317,22 @@ async function graphqlProbe(input) {
|
|
|
2004
2317
|
body: JSON.stringify({ query: "{ __typename }" }),
|
|
2005
2318
|
signal: ctrl.signal
|
|
2006
2319
|
});
|
|
2007
|
-
const
|
|
2320
|
+
const text6 = await readBodyCapped(res);
|
|
2008
2321
|
if (res.status !== 200) {
|
|
2009
2322
|
return {
|
|
2010
2323
|
ok: false,
|
|
2011
2324
|
status: res.status,
|
|
2012
|
-
reason: `expected HTTP 200, got ${res.status}: ${
|
|
2325
|
+
reason: `expected HTTP 200, got ${res.status}: ${text6}`
|
|
2013
2326
|
};
|
|
2014
2327
|
}
|
|
2015
2328
|
let envelope;
|
|
2016
2329
|
try {
|
|
2017
|
-
envelope = JSON.parse(
|
|
2330
|
+
envelope = JSON.parse(text6);
|
|
2018
2331
|
} catch {
|
|
2019
2332
|
return {
|
|
2020
2333
|
ok: false,
|
|
2021
2334
|
status: res.status,
|
|
2022
|
-
reason: `response was not JSON: ${
|
|
2335
|
+
reason: `response was not JSON: ${text6}`
|
|
2023
2336
|
};
|
|
2024
2337
|
}
|
|
2025
2338
|
const data = envelope.data;
|
|
@@ -2028,7 +2341,7 @@ async function graphqlProbe(input) {
|
|
|
2028
2341
|
return {
|
|
2029
2342
|
ok: false,
|
|
2030
2343
|
status: res.status,
|
|
2031
|
-
reason: `response missing { data: { __typename: string } }: ${
|
|
2344
|
+
reason: `response missing { data: { __typename: string } }: ${text6}`
|
|
2032
2345
|
};
|
|
2033
2346
|
}
|
|
2034
2347
|
return { ok: true, typename };
|
|
@@ -2096,13 +2409,6 @@ function tlsHandshake(input) {
|
|
|
2096
2409
|
}
|
|
2097
2410
|
|
|
2098
2411
|
// src/commands/verify.ts
|
|
2099
|
-
function unwrap3(value) {
|
|
2100
|
-
if (p2.isCancel(value)) {
|
|
2101
|
-
p2.cancel("Cancelled.");
|
|
2102
|
-
process.exit(0);
|
|
2103
|
-
}
|
|
2104
|
-
return value;
|
|
2105
|
-
}
|
|
2106
2412
|
var DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i;
|
|
2107
2413
|
function summarize(report) {
|
|
2108
2414
|
let failed = 0;
|
|
@@ -2118,14 +2424,14 @@ function formatExpiry(days) {
|
|
|
2118
2424
|
if (days === 0) return "expires today";
|
|
2119
2425
|
return `${days}d to expiry`;
|
|
2120
2426
|
}
|
|
2121
|
-
var
|
|
2427
|
+
var DEFAULT_DEPS2 = {
|
|
2122
2428
|
tls: tlsHandshake,
|
|
2123
2429
|
http: httpProbe,
|
|
2124
2430
|
graphql: graphqlProbe,
|
|
2125
2431
|
tunnel: tunnelStatus,
|
|
2126
2432
|
cosign: cosignVerifyImage
|
|
2127
2433
|
};
|
|
2128
|
-
async function runVerify(input, deps =
|
|
2434
|
+
async function runVerify(input, deps = DEFAULT_DEPS2) {
|
|
2129
2435
|
const phases = [];
|
|
2130
2436
|
const push = (ph) => {
|
|
2131
2437
|
phases.push(ph);
|
|
@@ -2244,26 +2550,26 @@ var verifyCommand = new Command("verify").description(
|
|
|
2244
2550
|
).addOption(
|
|
2245
2551
|
new Option("--show-skipped", "Include skipped phases in the summary").default(false)
|
|
2246
2552
|
).action(async (opts) => {
|
|
2247
|
-
|
|
2248
|
-
const domain = opts.domain ? opts.domain :
|
|
2249
|
-
await
|
|
2553
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node verify ")));
|
|
2554
|
+
const domain = opts.domain ? opts.domain : unwrap(
|
|
2555
|
+
await p3.text({
|
|
2250
2556
|
message: "Public domain of the node?",
|
|
2251
2557
|
placeholder: "yournode.example.org",
|
|
2252
2558
|
validate: (v) => DOMAIN_RE.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens, dots; must contain a dot and a TLD"
|
|
2253
2559
|
})
|
|
2254
2560
|
);
|
|
2255
2561
|
if (!DOMAIN_RE.test(domain)) {
|
|
2256
|
-
|
|
2562
|
+
p3.cancel(`--domain ${JSON.stringify(domain)} doesn't look like a domain.`);
|
|
2257
2563
|
process.exit(2);
|
|
2258
2564
|
}
|
|
2259
2565
|
const rawDays = opts.certWarnDays ?? "14";
|
|
2260
2566
|
const certWarnDays = Number.parseInt(rawDays, 10);
|
|
2261
2567
|
if (!Number.isFinite(certWarnDays) || certWarnDays < 0 || !/^\d+$/.test(rawDays)) {
|
|
2262
|
-
|
|
2568
|
+
p3.cancel(`--cert-warn-days must be a non-negative integer (got ${JSON.stringify(rawDays)}).`);
|
|
2263
2569
|
process.exit(2);
|
|
2264
2570
|
}
|
|
2265
2571
|
if (opts.cfToken && opts.cfTokenFile) {
|
|
2266
|
-
|
|
2572
|
+
p3.cancel("Pass either --cf-token or --cf-token-file, not both.");
|
|
2267
2573
|
process.exit(2);
|
|
2268
2574
|
}
|
|
2269
2575
|
let cfToken = opts.cfToken;
|
|
@@ -2271,7 +2577,7 @@ var verifyCommand = new Command("verify").description(
|
|
|
2271
2577
|
try {
|
|
2272
2578
|
cfToken = readTokenFile(opts.cfTokenFile);
|
|
2273
2579
|
} catch (err) {
|
|
2274
|
-
|
|
2580
|
+
p3.cancel(err.message);
|
|
2275
2581
|
process.exit(2);
|
|
2276
2582
|
}
|
|
2277
2583
|
}
|
|
@@ -2287,10 +2593,10 @@ var verifyCommand = new Command("verify").description(
|
|
|
2287
2593
|
activeSpin = null;
|
|
2288
2594
|
};
|
|
2289
2595
|
const deps = {
|
|
2290
|
-
...
|
|
2596
|
+
...DEFAULT_DEPS2,
|
|
2291
2597
|
onPhase: renderPhase
|
|
2292
2598
|
};
|
|
2293
|
-
activeSpin =
|
|
2599
|
+
activeSpin = p3.spinner();
|
|
2294
2600
|
activeSpin.start("Running checks\u2026");
|
|
2295
2601
|
const report = await runVerify(
|
|
2296
2602
|
{
|
|
@@ -2309,24 +2615,24 @@ var verifyCommand = new Command("verify").description(
|
|
|
2309
2615
|
const visible = opts.showSkipped ?? false ? report.phases : report.phases.filter((ph) => ph.status !== "skipped");
|
|
2310
2616
|
const lines = visible.map((ph, i) => {
|
|
2311
2617
|
const idx = report.phases.indexOf(ph) + 1;
|
|
2312
|
-
return `${
|
|
2618
|
+
return `${phaseIcon2(ph)} [${idx}/${totalPhases}] ${ph.name}: ${pc2.dim(ph.detail)}`;
|
|
2313
2619
|
});
|
|
2314
2620
|
const skippedCount = report.phases.length - visible.length;
|
|
2315
2621
|
if (skippedCount > 0 && !opts.showSkipped) {
|
|
2316
2622
|
lines.push(pc2.dim(`\xB7 ${skippedCount} phase${skippedCount === 1 ? "" : "s"} skipped (run with --show-skipped to see them)`));
|
|
2317
2623
|
}
|
|
2318
|
-
|
|
2624
|
+
p3.note(lines.join("\n"), "Summary");
|
|
2319
2625
|
const summary = summarize(report);
|
|
2320
2626
|
if (summary.ok) {
|
|
2321
|
-
|
|
2627
|
+
p3.outro(
|
|
2322
2628
|
summary.warned === 0 ? pc2.green("All checks passed.") : pc2.yellow(`Passed with ${summary.warned} warning${summary.warned === 1 ? "" : "s"}.`)
|
|
2323
2629
|
);
|
|
2324
2630
|
} else {
|
|
2325
|
-
|
|
2631
|
+
p3.outro(pc2.red(`${summary.failed} check${summary.failed === 1 ? "" : "s"} failed.`));
|
|
2326
2632
|
process.exit(1);
|
|
2327
2633
|
}
|
|
2328
2634
|
});
|
|
2329
|
-
function
|
|
2635
|
+
function phaseIcon2(ph) {
|
|
2330
2636
|
switch (ph.status) {
|
|
2331
2637
|
case "ok":
|
|
2332
2638
|
return pc2.green("\u2713");
|
|
@@ -2339,7 +2645,7 @@ function phaseIcon(ph) {
|
|
|
2339
2645
|
}
|
|
2340
2646
|
}
|
|
2341
2647
|
function formatPhaseLine(prefix, ph) {
|
|
2342
|
-
const icon =
|
|
2648
|
+
const icon = phaseIcon2(ph);
|
|
2343
2649
|
const colorize = ph.status === "ok" ? pc2.green : ph.status === "warn" ? pc2.yellow : ph.status === "skipped" ? pc2.dim : pc2.red;
|
|
2344
2650
|
return colorize(`${icon} ${prefix}: ${ph.detail}`);
|
|
2345
2651
|
}
|
|
@@ -3122,13 +3428,6 @@ function validateRegionConfig(file) {
|
|
|
3122
3428
|
}
|
|
3123
3429
|
|
|
3124
3430
|
// src/commands/region.ts
|
|
3125
|
-
function unwrap4(value) {
|
|
3126
|
-
if (p2.isCancel(value)) {
|
|
3127
|
-
p2.cancel("Cancelled.");
|
|
3128
|
-
process.exit(0);
|
|
3129
|
-
}
|
|
3130
|
-
return value;
|
|
3131
|
-
}
|
|
3132
3431
|
var regionCommand = new Command("region").description(
|
|
3133
3432
|
"Scaffold a schema-valid region config for the OpusPopuli/opuspopuli-regions repo. Run this from inside your checkout of that repo."
|
|
3134
3433
|
).addOption(
|
|
@@ -3141,8 +3440,8 @@ var regionCommand = new Command("region").description(
|
|
|
3141
3440
|
"current directory"
|
|
3142
3441
|
)
|
|
3143
3442
|
).addOption(new Option("-f, --force", "Overwrite an existing config file").default(false)).action(async (opts) => {
|
|
3144
|
-
|
|
3145
|
-
|
|
3443
|
+
p3.intro(pc2.bgMagenta(pc2.black(" create-op-node region ")));
|
|
3444
|
+
p3.note(
|
|
3146
3445
|
[
|
|
3147
3446
|
"Scaffolds a declarative region config JSON for opuspopuli-regions \u2014",
|
|
3148
3447
|
"the file that defines WHAT civic data a region collects.",
|
|
@@ -3156,19 +3455,19 @@ var regionCommand = new Command("region").description(
|
|
|
3156
3455
|
const targetDir = opts.outDir ?? process.cwd();
|
|
3157
3456
|
const looksRight = await looksLikeRegionsRepo(targetDir);
|
|
3158
3457
|
if (!looksRight) {
|
|
3159
|
-
const proceed =
|
|
3160
|
-
await
|
|
3458
|
+
const proceed = unwrap(
|
|
3459
|
+
await p3.confirm({
|
|
3161
3460
|
message: `${targetDir} doesn't look like an opuspopuli-regions checkout (no schema/region-plugin.schema.json). Continue anyway?`,
|
|
3162
3461
|
initialValue: false
|
|
3163
3462
|
})
|
|
3164
3463
|
);
|
|
3165
3464
|
if (!proceed) {
|
|
3166
|
-
|
|
3465
|
+
p3.cancel("Cancelled \u2014 run again from your opuspopuli-regions checkout, or pass --out-dir.");
|
|
3167
3466
|
process.exit(0);
|
|
3168
3467
|
}
|
|
3169
3468
|
}
|
|
3170
|
-
const level = opts.level === "state" || opts.level === "county" ? opts.level :
|
|
3171
|
-
await
|
|
3469
|
+
const level = opts.level === "state" || opts.level === "county" ? opts.level : unwrap(
|
|
3470
|
+
await p3.select({
|
|
3172
3471
|
message: "What level is this region?",
|
|
3173
3472
|
options: [
|
|
3174
3473
|
{ value: "state", label: "State", hint: "e.g. California" },
|
|
@@ -3176,8 +3475,8 @@ var regionCommand = new Command("region").description(
|
|
|
3176
3475
|
]
|
|
3177
3476
|
})
|
|
3178
3477
|
);
|
|
3179
|
-
const rawName = opts.name ??
|
|
3180
|
-
await
|
|
3478
|
+
const rawName = opts.name ?? unwrap(
|
|
3479
|
+
await p3.text({
|
|
3181
3480
|
message: level === "county" ? "County name?" : "State name?",
|
|
3182
3481
|
placeholder: level === "county" ? "Alameda" : "California",
|
|
3183
3482
|
validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
|
|
@@ -3185,62 +3484,62 @@ var regionCommand = new Command("region").description(
|
|
|
3185
3484
|
);
|
|
3186
3485
|
let parentSlug;
|
|
3187
3486
|
if (level === "county") {
|
|
3188
|
-
parentSlug = opts.parent ??
|
|
3189
|
-
await
|
|
3487
|
+
parentSlug = opts.parent ?? unwrap(
|
|
3488
|
+
await p3.text({
|
|
3190
3489
|
message: "Parent state slug?",
|
|
3191
3490
|
placeholder: "california",
|
|
3192
3491
|
validate: (v) => isValidSlug(v ?? "") ? void 0 : "kebab-case (lowercase letters, digits, hyphens)"
|
|
3193
3492
|
})
|
|
3194
3493
|
);
|
|
3195
3494
|
if (!isValidSlug(parentSlug)) {
|
|
3196
|
-
|
|
3495
|
+
p3.cancel(`Parent slug "${parentSlug}" must be kebab-case.`);
|
|
3197
3496
|
process.exit(1);
|
|
3198
3497
|
}
|
|
3199
3498
|
}
|
|
3200
3499
|
const ownSlug = slugify(rawName);
|
|
3201
3500
|
const regionId = level === "county" ? `${parentSlug}-${ownSlug}` : ownSlug;
|
|
3202
3501
|
if (!isValidSlug(regionId)) {
|
|
3203
|
-
|
|
3502
|
+
p3.cancel(`Derived regionId "${regionId}" is not valid kebab-case. Try a simpler name.`);
|
|
3204
3503
|
process.exit(1);
|
|
3205
3504
|
}
|
|
3206
|
-
const displayName =
|
|
3207
|
-
await
|
|
3505
|
+
const displayName = unwrap(
|
|
3506
|
+
await p3.text({
|
|
3208
3507
|
message: "Display name?",
|
|
3209
3508
|
defaultValue: rawName
|
|
3210
3509
|
})
|
|
3211
3510
|
);
|
|
3212
3511
|
const regionName = displayName || rawName;
|
|
3213
|
-
const description =
|
|
3214
|
-
await
|
|
3512
|
+
const description = unwrap(
|
|
3513
|
+
await p3.text({
|
|
3215
3514
|
message: "One-line description of the data coverage?",
|
|
3216
3515
|
placeholder: level === "county" ? `Civic data for ${rawName} County` : `Civic data for the state of ${rawName}`,
|
|
3217
3516
|
validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
|
|
3218
3517
|
})
|
|
3219
3518
|
);
|
|
3220
|
-
const stateCode = (opts.stateCode ??
|
|
3221
|
-
await
|
|
3519
|
+
const stateCode = (opts.stateCode ?? unwrap(
|
|
3520
|
+
await p3.text({
|
|
3222
3521
|
message: "Two-letter state code?",
|
|
3223
3522
|
placeholder: "CA",
|
|
3224
3523
|
validate: (v) => isValidStateCode((v ?? "").toUpperCase()) ? void 0 : "Two letters, e.g. CA"
|
|
3225
3524
|
})
|
|
3226
3525
|
)).toUpperCase();
|
|
3227
3526
|
if (!isValidStateCode(stateCode)) {
|
|
3228
|
-
|
|
3527
|
+
p3.cancel(`State code "${stateCode}" must be two letters.`);
|
|
3229
3528
|
process.exit(1);
|
|
3230
3529
|
}
|
|
3231
|
-
const fipsCode = opts.fips ??
|
|
3232
|
-
await
|
|
3530
|
+
const fipsCode = opts.fips ?? unwrap(
|
|
3531
|
+
await p3.text({
|
|
3233
3532
|
message: level === "county" ? "County FIPS (5 digits)?" : "State FIPS (2 digits)?",
|
|
3234
3533
|
placeholder: level === "county" ? "06001" : "06",
|
|
3235
3534
|
validate: (v) => isValidFips(v ?? "", level) ? void 0 : `Expected ${level === "county" ? "5" : "2"} digits`
|
|
3236
3535
|
})
|
|
3237
3536
|
);
|
|
3238
3537
|
if (!isValidFips(fipsCode, level)) {
|
|
3239
|
-
|
|
3538
|
+
p3.cancel(`FIPS "${fipsCode}" is the wrong length for a ${level}.`);
|
|
3240
3539
|
process.exit(1);
|
|
3241
3540
|
}
|
|
3242
|
-
const timezone = opts.timezone ??
|
|
3243
|
-
await
|
|
3541
|
+
const timezone = opts.timezone ?? unwrap(
|
|
3542
|
+
await p3.text({
|
|
3244
3543
|
message: "IANA timezone?",
|
|
3245
3544
|
defaultValue: "America/Los_Angeles",
|
|
3246
3545
|
placeholder: "America/Los_Angeles",
|
|
@@ -3249,35 +3548,35 @@ var regionCommand = new Command("region").description(
|
|
|
3249
3548
|
);
|
|
3250
3549
|
const dataSources = [];
|
|
3251
3550
|
do {
|
|
3252
|
-
const url =
|
|
3253
|
-
await
|
|
3551
|
+
const url = unwrap(
|
|
3552
|
+
await p3.text({
|
|
3254
3553
|
message: `Data source #${dataSources.length + 1} \u2014 URL?`,
|
|
3255
3554
|
placeholder: "https://example.gov/meetings",
|
|
3256
3555
|
validate: (v) => /^https?:\/\//.test(v ?? "") ? void 0 : "Must start with http(s)://"
|
|
3257
3556
|
})
|
|
3258
3557
|
);
|
|
3259
|
-
const dataType =
|
|
3260
|
-
await
|
|
3558
|
+
const dataType = unwrap(
|
|
3559
|
+
await p3.select({
|
|
3261
3560
|
message: "Data type?",
|
|
3262
3561
|
options: DATA_TYPES.map((t) => ({ value: t, label: t }))
|
|
3263
3562
|
})
|
|
3264
3563
|
);
|
|
3265
|
-
const sourceType =
|
|
3266
|
-
await
|
|
3564
|
+
const sourceType = unwrap(
|
|
3565
|
+
await p3.select({
|
|
3267
3566
|
message: "Source type?",
|
|
3268
3567
|
initialValue: "html_scrape",
|
|
3269
3568
|
options: SOURCE_TYPES.map((t) => ({ value: t, label: t }))
|
|
3270
3569
|
})
|
|
3271
3570
|
);
|
|
3272
|
-
const contentGoal =
|
|
3273
|
-
await
|
|
3571
|
+
const contentGoal = unwrap(
|
|
3572
|
+
await p3.text({
|
|
3274
3573
|
message: "Content goal (what should the scraper extract)?",
|
|
3275
3574
|
placeholder: "Fetch upcoming board meeting agendas and minutes",
|
|
3276
3575
|
validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
|
|
3277
3576
|
})
|
|
3278
3577
|
);
|
|
3279
|
-
const category =
|
|
3280
|
-
await
|
|
3578
|
+
const category = unwrap(
|
|
3579
|
+
await p3.text({
|
|
3281
3580
|
message: "Category label (optional)?",
|
|
3282
3581
|
placeholder: "Board of Supervisors"
|
|
3283
3582
|
})
|
|
@@ -3285,8 +3584,8 @@ var regionCommand = new Command("region").description(
|
|
|
3285
3584
|
const src = { url, dataType, sourceType, contentGoal };
|
|
3286
3585
|
if (category && category.trim().length > 0) src.category = category.trim();
|
|
3287
3586
|
dataSources.push(src);
|
|
3288
|
-
const again =
|
|
3289
|
-
await
|
|
3587
|
+
const again = unwrap(
|
|
3588
|
+
await p3.confirm({ message: "Add another data source?", initialValue: false })
|
|
3290
3589
|
);
|
|
3291
3590
|
if (!again) break;
|
|
3292
3591
|
} while (true);
|
|
@@ -3309,8 +3608,8 @@ var regionCommand = new Command("region").description(
|
|
|
3309
3608
|
const file = buildRegionConfig(input);
|
|
3310
3609
|
const issues = validateRegionConfig(file);
|
|
3311
3610
|
if (issues.length > 0) {
|
|
3312
|
-
|
|
3313
|
-
|
|
3611
|
+
p3.note(issues.map((i) => `${pc2.red("\u2022")} ${i}`).join("\n"), "Validation failed");
|
|
3612
|
+
p3.cancel("The generated config would not pass the regions repo CI. Re-run and adjust.");
|
|
3314
3613
|
process.exit(1);
|
|
3315
3614
|
}
|
|
3316
3615
|
const relPath = regionFilePath({
|
|
@@ -3322,21 +3621,21 @@ var regionCommand = new Command("region").description(
|
|
|
3322
3621
|
const absPath = resolve(targetDir, relPath);
|
|
3323
3622
|
const json = `${JSON.stringify(file, null, 2)}
|
|
3324
3623
|
`;
|
|
3325
|
-
|
|
3326
|
-
if (!opts.force && await
|
|
3327
|
-
|
|
3624
|
+
p3.note(json, `${relPath} (preview)`);
|
|
3625
|
+
if (!opts.force && await fileExists3(absPath)) {
|
|
3626
|
+
p3.cancel(`${relPath} already exists. Re-run with --force to overwrite.`);
|
|
3328
3627
|
process.exit(1);
|
|
3329
3628
|
}
|
|
3330
|
-
const write =
|
|
3331
|
-
await
|
|
3629
|
+
const write = unwrap(
|
|
3630
|
+
await p3.confirm({ message: `Write ${relPath}?`, initialValue: true })
|
|
3332
3631
|
);
|
|
3333
3632
|
if (!write) {
|
|
3334
|
-
|
|
3633
|
+
p3.cancel("Nothing written.");
|
|
3335
3634
|
process.exit(0);
|
|
3336
3635
|
}
|
|
3337
3636
|
await mkdir(dirname(absPath), { recursive: true });
|
|
3338
3637
|
await writeFile(absPath, json, "utf8");
|
|
3339
|
-
|
|
3638
|
+
p3.note(
|
|
3340
3639
|
[
|
|
3341
3640
|
`${pc2.green("\u2713")} Wrote ${pc2.cyan(relPath)}`,
|
|
3342
3641
|
"",
|
|
@@ -3347,9 +3646,9 @@ var regionCommand = new Command("region").description(
|
|
|
3347
3646
|
].join("\n"),
|
|
3348
3647
|
"Done"
|
|
3349
3648
|
);
|
|
3350
|
-
|
|
3649
|
+
p3.outro(pc2.magenta(`Region scaffolded: ${regionId}`));
|
|
3351
3650
|
});
|
|
3352
|
-
async function
|
|
3651
|
+
async function fileExists3(path) {
|
|
3353
3652
|
try {
|
|
3354
3653
|
await access(path);
|
|
3355
3654
|
return true;
|
|
@@ -3359,20 +3658,21 @@ async function fileExists2(path) {
|
|
|
3359
3658
|
}
|
|
3360
3659
|
async function looksLikeRegionsRepo(dir) {
|
|
3361
3660
|
const [hasSchema, hasRegions] = await Promise.all([
|
|
3362
|
-
|
|
3363
|
-
|
|
3661
|
+
fileExists3(join(dir, "schema", "region-plugin.schema.json")),
|
|
3662
|
+
fileExists3(join(dir, "regions"))
|
|
3364
3663
|
]);
|
|
3365
3664
|
return hasSchema && hasRegions;
|
|
3366
3665
|
}
|
|
3367
3666
|
|
|
3368
3667
|
// src/cli.ts
|
|
3369
|
-
var VERSION = "0.
|
|
3668
|
+
var VERSION = "0.2.0";
|
|
3370
3669
|
var program = new Command();
|
|
3371
3670
|
program.name("create-op-node").description(
|
|
3372
3671
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|
|
3373
3672
|
).version(VERSION, "-v, --version", "show version");
|
|
3374
3673
|
program.addCommand(initCommand);
|
|
3375
3674
|
program.addCommand(bootstrapCommand);
|
|
3675
|
+
program.addCommand(resetCommand);
|
|
3376
3676
|
program.addCommand(verifyCommand);
|
|
3377
3677
|
program.addCommand(regionCommand);
|
|
3378
3678
|
if (process.argv.length === 2) {
|