create-op-node 0.1.0 → 0.3.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 +124 -7
- package/dist/cli.js +625 -327
- 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';
|
|
@@ -225,85 +225,86 @@ async function safeExeca(cmd, args, options) {
|
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
// src/lib/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
if (whoami === null || whoami.exitCode !== 0) {
|
|
237
|
-
return { installed, signedIn: false };
|
|
238
|
-
}
|
|
239
|
-
let email;
|
|
240
|
-
try {
|
|
241
|
-
const parsed = JSON.parse(whoami.stdout);
|
|
242
|
-
email = parsed.email;
|
|
243
|
-
} catch {
|
|
244
|
-
}
|
|
245
|
-
return {
|
|
246
|
-
installed,
|
|
247
|
-
signedIn: true,
|
|
248
|
-
...email ? { email } : {}
|
|
249
|
-
};
|
|
228
|
+
// src/lib/keychain.ts
|
|
229
|
+
var SERVICE_PREFIX = "org.opuspopuli";
|
|
230
|
+
function serviceFor(region) {
|
|
231
|
+
return `${SERVICE_PREFIX}.${region}`;
|
|
232
|
+
}
|
|
233
|
+
function labelFor(coords) {
|
|
234
|
+
const friendly = coords.account === "pgsodium-root-key" ? "pgsodium root key" : "Cloudflare Tunnel token";
|
|
235
|
+
return `Opus Populi (${coords.region}) \u2014 ${friendly}`;
|
|
250
236
|
}
|
|
251
|
-
async function
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
if (existing === null) {
|
|
237
|
+
async function detectKeychain() {
|
|
238
|
+
const res = await safeExeca("security", ["-h"]);
|
|
239
|
+
if (res === null) {
|
|
255
240
|
return {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
reason: "`op` CLI not installed"
|
|
241
|
+
available: false,
|
|
242
|
+
reason: "`security` CLI not on PATH (Keychain requires macOS)"
|
|
259
243
|
};
|
|
260
244
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
"
|
|
281
|
-
|
|
282
|
-
"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
245
|
+
return { available: true };
|
|
246
|
+
}
|
|
247
|
+
async function saveSecret(coords, value) {
|
|
248
|
+
const service = serviceFor(coords.region);
|
|
249
|
+
const label = labelFor(coords);
|
|
250
|
+
const existing = await safeExeca("security", [
|
|
251
|
+
"find-generic-password",
|
|
252
|
+
"-s",
|
|
253
|
+
service,
|
|
254
|
+
"-a",
|
|
255
|
+
coords.account
|
|
256
|
+
]);
|
|
257
|
+
const updated = existing !== null && existing.exitCode === 0;
|
|
258
|
+
const res = await safeExeca("security", [
|
|
259
|
+
"add-generic-password",
|
|
260
|
+
"-U",
|
|
261
|
+
// upsert
|
|
262
|
+
"-s",
|
|
263
|
+
service,
|
|
264
|
+
"-a",
|
|
265
|
+
coords.account,
|
|
266
|
+
"-l",
|
|
267
|
+
label,
|
|
268
|
+
"-D",
|
|
269
|
+
"Opus Populi secret",
|
|
270
|
+
"-w",
|
|
271
|
+
value
|
|
272
|
+
// argv-exposure caveat, see file header
|
|
287
273
|
]);
|
|
288
|
-
if (
|
|
274
|
+
if (res === null) {
|
|
275
|
+
return { written: false, updated: false, reason: "`security` CLI not on PATH" };
|
|
276
|
+
}
|
|
277
|
+
if (res.exitCode !== 0) {
|
|
289
278
|
return {
|
|
290
279
|
written: false,
|
|
291
|
-
|
|
292
|
-
reason: `
|
|
280
|
+
updated,
|
|
281
|
+
reason: `security add-generic-password failed (${res.exitCode ?? "signal"}): ${res.stderr || res.stdout}`
|
|
293
282
|
};
|
|
294
283
|
}
|
|
295
|
-
return { written: true,
|
|
296
|
-
}
|
|
297
|
-
async function
|
|
298
|
-
const
|
|
299
|
-
const res = await safeExeca(
|
|
300
|
-
"
|
|
301
|
-
|
|
302
|
-
|
|
284
|
+
return { written: true, updated };
|
|
285
|
+
}
|
|
286
|
+
async function readSecret(coords) {
|
|
287
|
+
const service = serviceFor(coords.region);
|
|
288
|
+
const res = await safeExeca("security", [
|
|
289
|
+
"find-generic-password",
|
|
290
|
+
"-s",
|
|
291
|
+
service,
|
|
292
|
+
"-a",
|
|
293
|
+
coords.account,
|
|
294
|
+
"-w"
|
|
295
|
+
// print the password to stdout (final flag = read mode)
|
|
296
|
+
]);
|
|
303
297
|
if (res === null || res.exitCode !== 0) return null;
|
|
304
298
|
const value = res.stdout.trim();
|
|
305
299
|
return value.length > 0 ? value : null;
|
|
306
300
|
}
|
|
301
|
+
function unwrap(value) {
|
|
302
|
+
if (p3.isCancel(value)) {
|
|
303
|
+
p3.cancel("Cancelled.");
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
307
308
|
var _client = null;
|
|
308
309
|
var _clientToken = null;
|
|
309
310
|
function client(token) {
|
|
@@ -513,13 +514,6 @@ async function waitForApply(input, budgets = DEFAULT_BUDGETS, deps = realDeps) {
|
|
|
513
514
|
}
|
|
514
515
|
|
|
515
516
|
// 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
517
|
async function ghTokenFromCli() {
|
|
524
518
|
const r = await safeExeca("gh", ["auth", "token"]);
|
|
525
519
|
if (r === null || r.exitCode !== 0) return null;
|
|
@@ -532,10 +526,10 @@ var initCommand = new Command("init").description(
|
|
|
532
526
|
new Option("--template <owner/repo>", "Template repo to clone from").default(
|
|
533
527
|
"OpusPopuli/opuspopuli-node"
|
|
534
528
|
)
|
|
535
|
-
).addOption(new Option("--project <name>", "tfvars project prefix").default("opuspopuli")).addOption(new Option("--cf-token <token>", "Cloudflare Account API token").env("CF_TOKEN")).addOption(new Option("--cf-account <id>", "Cloudflare account ID").env("CF_ACCOUNT")).addOption(new Option("--cf-zone <id>", "Cloudflare zone ID for your domain").env("CF_ZONE")).addOption(new Option("--tf-token <token>", "Terraform Cloud user/team token").env("TF_API_TOKEN")).addOption(new Option("--tf-org <org>", "Terraform Cloud organization").env("TF_CLOUD_ORGANIZATION")).addOption(new Option("--gh-token <token>", "GitHub Personal Access Token (else gh CLI)").env("GH_TOKEN")).addOption(
|
|
529
|
+
).addOption(new Option("--project <name>", "tfvars project prefix").default("opuspopuli")).addOption(new Option("--cf-token <token>", "Cloudflare Account API token").env("CF_TOKEN")).addOption(new Option("--cf-account <id>", "Cloudflare account ID").env("CF_ACCOUNT")).addOption(new Option("--cf-zone <id>", "Cloudflare zone ID for your domain").env("CF_ZONE")).addOption(new Option("--tf-token <token>", "Terraform Cloud user/team token").env("TF_API_TOKEN")).addOption(new Option("--tf-org <org>", "Terraform Cloud organization").env("TF_CLOUD_ORGANIZATION")).addOption(new Option("--gh-token <token>", "GitHub Personal Access Token (else gh CLI)").env("GH_TOKEN")).addOption(
|
|
536
530
|
new Option(
|
|
537
531
|
"--overwrite",
|
|
538
|
-
"Overwrite existing
|
|
532
|
+
"Overwrite existing Keychain items (pgsodium key, Tunnel token) on a re-run"
|
|
539
533
|
).default(false)
|
|
540
534
|
).addOption(
|
|
541
535
|
new Option(
|
|
@@ -543,8 +537,8 @@ var initCommand = new Command("init").description(
|
|
|
543
537
|
"Continue using a previously-created node repo (don't fail on 'already exists')"
|
|
544
538
|
).default(false)
|
|
545
539
|
).addOption(new Option("--skip-wait", "Don't poll for Terraform apply; exit after PR open").default(false)).addOption(new Option("-y, --yes", "Skip the final confirmation").default(false)).action(async (opts) => {
|
|
546
|
-
|
|
547
|
-
|
|
540
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node init ")));
|
|
541
|
+
p3.note(
|
|
548
542
|
[
|
|
549
543
|
"This walks you from a sealed Mac Studio + a Cloudflare account to a",
|
|
550
544
|
"live federation node serving traffic at api.<your-domain>.",
|
|
@@ -555,14 +549,14 @@ var initCommand = new Command("init").description(
|
|
|
555
549
|
"Welcome"
|
|
556
550
|
);
|
|
557
551
|
const region = opts.region ? opts.region : unwrap(
|
|
558
|
-
await
|
|
559
|
-
message: "Short region label (used as
|
|
552
|
+
await p3.text({
|
|
553
|
+
message: "Short region label (used as the Keychain service suffix + R2 prefix)?",
|
|
560
554
|
placeholder: "us-ca",
|
|
561
555
|
validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
562
556
|
})
|
|
563
557
|
);
|
|
564
558
|
const domain = opts.domain ? opts.domain : unwrap(
|
|
565
|
-
await
|
|
559
|
+
await p3.text({
|
|
566
560
|
message: "Domain registered in Cloudflare?",
|
|
567
561
|
placeholder: "example.org",
|
|
568
562
|
validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Missing a TLD?"
|
|
@@ -578,7 +572,7 @@ var initCommand = new Command("init").description(
|
|
|
578
572
|
);
|
|
579
573
|
const cfAccount = await collectId(opts.cfAccount, "Cloudflare account ID");
|
|
580
574
|
const cfZone = await collectId(opts.cfZone, `Cloudflare zone ID for ${domain}`);
|
|
581
|
-
const cfSpin =
|
|
575
|
+
const cfSpin = p3.spinner();
|
|
582
576
|
cfSpin.start("Verifying Cloudflare token + 5 scopes\u2026");
|
|
583
577
|
const cfProbe = await probeCloudflareToken({
|
|
584
578
|
token: cfToken,
|
|
@@ -588,7 +582,7 @@ var initCommand = new Command("init").description(
|
|
|
588
582
|
if (!cfProbe.ok) {
|
|
589
583
|
cfSpin.stop(pc2.red("\u2717 Cloudflare token check failed."));
|
|
590
584
|
for (const issue of cfProbe.issues) console.error(` - ${issue}`);
|
|
591
|
-
|
|
585
|
+
p3.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
|
|
592
586
|
process.exit(1);
|
|
593
587
|
}
|
|
594
588
|
cfSpin.stop(pc2.green("\u2713 Cloudflare token valid, 5 scopes present."));
|
|
@@ -598,18 +592,18 @@ var initCommand = new Command("init").description(
|
|
|
598
592
|
"tfc"
|
|
599
593
|
);
|
|
600
594
|
const tfOrg = opts.tfOrg ? opts.tfOrg : unwrap(
|
|
601
|
-
await
|
|
595
|
+
await p3.text({
|
|
602
596
|
message: "Terraform Cloud organization name?",
|
|
603
597
|
placeholder: "op-region-ca"
|
|
604
598
|
})
|
|
605
599
|
);
|
|
606
|
-
const tfSpin =
|
|
600
|
+
const tfSpin = p3.spinner();
|
|
607
601
|
tfSpin.start("Verifying Terraform Cloud token + organization\u2026");
|
|
608
602
|
const tfProbe = await probeTfcToken({ token: tfToken, organization: tfOrg });
|
|
609
603
|
if (!tfProbe.ok) {
|
|
610
604
|
tfSpin.stop(pc2.red("\u2717 Terraform Cloud check failed."));
|
|
611
605
|
for (const issue of tfProbe.issues) console.error(` - ${issue}`);
|
|
612
|
-
|
|
606
|
+
p3.cancel("Fix the token / org, then re-run.");
|
|
613
607
|
process.exit(1);
|
|
614
608
|
}
|
|
615
609
|
tfSpin.stop(
|
|
@@ -622,7 +616,7 @@ var initCommand = new Command("init").description(
|
|
|
622
616
|
const fromCli = await ghTokenFromCli();
|
|
623
617
|
if (fromCli) {
|
|
624
618
|
const useCli = unwrap(
|
|
625
|
-
await
|
|
619
|
+
await p3.confirm({
|
|
626
620
|
message: "Found a signed-in `gh` CLI \u2014 use its token?",
|
|
627
621
|
initialValue: true
|
|
628
622
|
})
|
|
@@ -637,10 +631,11 @@ var initCommand = new Command("init").description(
|
|
|
637
631
|
"github"
|
|
638
632
|
);
|
|
639
633
|
}
|
|
640
|
-
const
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
634
|
+
const keychain = await detectKeychain();
|
|
635
|
+
const kcNote = keychain.available ? `\u2713 macOS Keychain available \u2014 pgsodium key + Tunnel token will be saved to your login keychain.
|
|
636
|
+
` + 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
|
+
p3.note(kcNote, "Secret store");
|
|
638
|
+
p3.note(
|
|
644
639
|
[
|
|
645
640
|
`Region label: ${pc2.cyan(region)}`,
|
|
646
641
|
`Domain: ${pc2.cyan(domain)}`,
|
|
@@ -656,14 +651,14 @@ var initCommand = new Command("init").description(
|
|
|
656
651
|
);
|
|
657
652
|
if (!opts.yes) {
|
|
658
653
|
const go = unwrap(
|
|
659
|
-
await
|
|
654
|
+
await p3.confirm({ message: "Proceed?", initialValue: true })
|
|
660
655
|
);
|
|
661
656
|
if (!go) {
|
|
662
|
-
|
|
657
|
+
p3.cancel("Cancelled.");
|
|
663
658
|
process.exit(0);
|
|
664
659
|
}
|
|
665
660
|
}
|
|
666
|
-
const repoSpin =
|
|
661
|
+
const repoSpin = p3.spinner();
|
|
667
662
|
repoSpin.start(`Creating ${newRepoFull} from ${opts.template}\u2026`);
|
|
668
663
|
let created;
|
|
669
664
|
try {
|
|
@@ -680,18 +675,18 @@ var initCommand = new Command("init").description(
|
|
|
680
675
|
const message = err.message ?? "unknown error";
|
|
681
676
|
if (status !== 422) {
|
|
682
677
|
repoSpin.stop(pc2.red(`\u2717 Couldn't create repo: ${message}`));
|
|
683
|
-
|
|
678
|
+
p3.cancel("Fix the issue and re-run.");
|
|
684
679
|
process.exit(1);
|
|
685
680
|
}
|
|
686
681
|
repoSpin.stop(pc2.yellow(`\u26A0 ${newRepoFull} already exists.`));
|
|
687
682
|
const reuse = opts.useExistingRepo || unwrap(
|
|
688
|
-
await
|
|
683
|
+
await p3.confirm({
|
|
689
684
|
message: `Continue with the existing ${newRepoFull}? (Re-seeds secrets + re-commits prod.tfvars on a fresh branch.)`,
|
|
690
685
|
initialValue: true
|
|
691
686
|
})
|
|
692
687
|
);
|
|
693
688
|
if (!reuse) {
|
|
694
|
-
|
|
689
|
+
p3.cancel(
|
|
695
690
|
`Delete ${newRepoFull} on GitHub and re-run, or pass --use-existing-repo to skip this prompt.`
|
|
696
691
|
);
|
|
697
692
|
process.exit(0);
|
|
@@ -709,7 +704,7 @@ var initCommand = new Command("init").description(
|
|
|
709
704
|
{ name: "TF_API_TOKEN", value: tfToken },
|
|
710
705
|
{ name: "TF_CLOUD_ORGANIZATION", value: tfOrg }
|
|
711
706
|
];
|
|
712
|
-
const secSpin =
|
|
707
|
+
const secSpin = p3.spinner();
|
|
713
708
|
secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
|
|
714
709
|
const seeded = [];
|
|
715
710
|
for (const s of secrets) {
|
|
@@ -717,7 +712,7 @@ var initCommand = new Command("init").description(
|
|
|
717
712
|
if (!r.written) {
|
|
718
713
|
secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
|
|
719
714
|
const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
|
|
720
|
-
|
|
715
|
+
p3.cancel(
|
|
721
716
|
[
|
|
722
717
|
seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
|
|
723
718
|
`Still pending: ${remaining.join(", ")}.`,
|
|
@@ -732,7 +727,7 @@ var initCommand = new Command("init").description(
|
|
|
732
727
|
}
|
|
733
728
|
secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
|
|
734
729
|
const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
|
|
735
|
-
const setupSpin =
|
|
730
|
+
const setupSpin = p3.spinner();
|
|
736
731
|
setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
|
|
737
732
|
try {
|
|
738
733
|
await createBranch({
|
|
@@ -755,11 +750,11 @@ var initCommand = new Command("init").description(
|
|
|
755
750
|
});
|
|
756
751
|
} catch (err) {
|
|
757
752
|
setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
|
|
758
|
-
|
|
753
|
+
p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
|
|
759
754
|
process.exit(1);
|
|
760
755
|
}
|
|
761
756
|
setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
|
|
762
|
-
const prSpin =
|
|
757
|
+
const prSpin = p3.spinner();
|
|
763
758
|
prSpin.start("Opening pull request\u2026");
|
|
764
759
|
let pr;
|
|
765
760
|
try {
|
|
@@ -782,54 +777,44 @@ var initCommand = new Command("init").description(
|
|
|
782
777
|
});
|
|
783
778
|
} catch (err) {
|
|
784
779
|
prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
|
|
785
|
-
|
|
780
|
+
p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
|
|
786
781
|
process.exit(1);
|
|
787
782
|
}
|
|
788
783
|
prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
|
|
789
|
-
const
|
|
790
|
-
const
|
|
791
|
-
if (
|
|
792
|
-
const existing = opts.overwrite ? null : await
|
|
784
|
+
const pgsodiumCoords = { region, account: "pgsodium-root-key" };
|
|
785
|
+
const keyLabel = `org.opuspopuli.${region}/pgsodium-root-key`;
|
|
786
|
+
if (keychain.available) {
|
|
787
|
+
const existing = opts.overwrite ? null : await readSecret(pgsodiumCoords);
|
|
793
788
|
if (existing && /^[a-f0-9]{64}$/.test(existing)) {
|
|
794
|
-
|
|
795
|
-
`${pc2.green("\u2713")} Re-using existing pgsodium master key from
|
|
789
|
+
p3.note(
|
|
790
|
+
`${pc2.green("\u2713")} Re-using existing pgsodium master key from Keychain (${pc2.cyan(keyLabel)}). Pass --overwrite to rotate.`,
|
|
796
791
|
"Secret"
|
|
797
792
|
);
|
|
798
793
|
} else {
|
|
799
794
|
const fresh = generatePgsodiumRootKey();
|
|
800
|
-
const r = await
|
|
801
|
-
title: keyTitle,
|
|
802
|
-
value: fresh,
|
|
803
|
-
overwrite: opts.overwrite ?? false,
|
|
804
|
-
...vaultArg
|
|
805
|
-
});
|
|
795
|
+
const r = await saveSecret(pgsodiumCoords, fresh);
|
|
806
796
|
if (r.written) {
|
|
807
|
-
|
|
808
|
-
`${pc2.green("\u2713")} pgsodium master key${r.
|
|
809
|
-
"Secret"
|
|
810
|
-
);
|
|
811
|
-
} else if (r.alreadyExisted) {
|
|
812
|
-
p2.note(
|
|
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.`,
|
|
797
|
+
p3.note(
|
|
798
|
+
`${pc2.green("\u2713")} pgsodium master key${r.updated ? " rotated" : " saved"} to Keychain as ${pc2.cyan(keyLabel)}.`,
|
|
814
799
|
"Secret"
|
|
815
800
|
);
|
|
816
801
|
} else {
|
|
817
|
-
await printKeyForManualSave(fresh,
|
|
802
|
+
await printKeyForManualSave(fresh, keyLabel, r.reason);
|
|
818
803
|
}
|
|
819
804
|
}
|
|
820
805
|
} else {
|
|
821
806
|
const fresh = generatePgsodiumRootKey();
|
|
822
|
-
await printKeyForManualSave(fresh,
|
|
807
|
+
await printKeyForManualSave(fresh, keyLabel);
|
|
823
808
|
}
|
|
824
809
|
if (opts.skipWait) {
|
|
825
|
-
|
|
810
|
+
p3.outro(
|
|
826
811
|
pc2.cyan(
|
|
827
812
|
`Review + merge ${pr.htmlUrl} when ready. Re-run with no --skip-wait or run \`npx create-op-node verify\` afterwards.`
|
|
828
813
|
)
|
|
829
814
|
);
|
|
830
815
|
return;
|
|
831
816
|
}
|
|
832
|
-
|
|
817
|
+
p3.note(
|
|
833
818
|
[
|
|
834
819
|
`Open the PR: ${pc2.cyan(pr.htmlUrl)}`,
|
|
835
820
|
"",
|
|
@@ -839,10 +824,10 @@ var initCommand = new Command("init").description(
|
|
|
839
824
|
"Next step: review + merge"
|
|
840
825
|
);
|
|
841
826
|
const merged = unwrap(
|
|
842
|
-
await
|
|
827
|
+
await p3.confirm({ message: "PR merged?", initialValue: false })
|
|
843
828
|
);
|
|
844
829
|
if (!merged) {
|
|
845
|
-
|
|
830
|
+
p3.outro(
|
|
846
831
|
pc2.cyan(`No worries \u2014 re-run \`create-op-node init --skip-wait\` later to skip this step, or finish manually.`)
|
|
847
832
|
);
|
|
848
833
|
return;
|
|
@@ -853,7 +838,7 @@ var initCommand = new Command("init").description(
|
|
|
853
838
|
tags: ["opuspopuli", "cloudflare"]
|
|
854
839
|
});
|
|
855
840
|
if (!ws) {
|
|
856
|
-
|
|
841
|
+
p3.cancel(
|
|
857
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.`
|
|
858
843
|
);
|
|
859
844
|
process.exit(1);
|
|
@@ -865,31 +850,27 @@ var initCommand = new Command("init").description(
|
|
|
865
850
|
runId: ws.currentRunId
|
|
866
851
|
});
|
|
867
852
|
if (!tunnelToken) {
|
|
868
|
-
|
|
853
|
+
p3.cancel(
|
|
869
854
|
`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
855
|
);
|
|
871
856
|
process.exit(1);
|
|
872
857
|
}
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
value: tunnelToken,
|
|
878
|
-
...opts.vault ? { vault: opts.vault } : {},
|
|
879
|
-
overwrite: true
|
|
880
|
-
});
|
|
858
|
+
const tunnelCoords = { region, account: "tunnel-token" };
|
|
859
|
+
const tunnelLabel = `org.opuspopuli.${region}/tunnel-token`;
|
|
860
|
+
if (keychain.available) {
|
|
861
|
+
const r = await saveSecret(tunnelCoords, tunnelToken);
|
|
881
862
|
if (r.written) {
|
|
882
|
-
|
|
883
|
-
`${pc2.green("\u2713")} Tunnel token saved to
|
|
863
|
+
p3.note(
|
|
864
|
+
`${pc2.green("\u2713")} Tunnel token saved to Keychain as ${pc2.cyan(tunnelLabel)}.`,
|
|
884
865
|
"Secret"
|
|
885
866
|
);
|
|
886
867
|
} else {
|
|
887
|
-
await printKeyForManualSave(tunnelToken,
|
|
868
|
+
await printKeyForManualSave(tunnelToken, tunnelLabel, r.reason);
|
|
888
869
|
}
|
|
889
870
|
} else {
|
|
890
|
-
await printKeyForManualSave(tunnelToken,
|
|
871
|
+
await printKeyForManualSave(tunnelToken, tunnelLabel);
|
|
891
872
|
}
|
|
892
|
-
|
|
873
|
+
p3.outro(
|
|
893
874
|
pc2.cyan(
|
|
894
875
|
`Region ${region} is provisioned. Next: \`npx create-op-node bootstrap\` on the Mac Studio.`
|
|
895
876
|
)
|
|
@@ -905,7 +886,7 @@ async function collectSecret(preset, message, kind = "generic") {
|
|
|
905
886
|
if (preset) return preset;
|
|
906
887
|
const min = MIN_TOKEN_LENGTH[kind];
|
|
907
888
|
const v = unwrap(
|
|
908
|
-
await
|
|
889
|
+
await p3.password({
|
|
909
890
|
message,
|
|
910
891
|
validate: (v2) => v2 && v2.length >= min ? void 0 : `That looks too short \u2014 expected at least ${min} characters for a ${kind} token`
|
|
911
892
|
})
|
|
@@ -915,7 +896,7 @@ async function collectSecret(preset, message, kind = "generic") {
|
|
|
915
896
|
async function collectId(preset, message) {
|
|
916
897
|
if (preset) return preset;
|
|
917
898
|
const v = unwrap(
|
|
918
|
-
await
|
|
899
|
+
await p3.text({
|
|
919
900
|
message,
|
|
920
901
|
placeholder: "0000000000000000000000000000000",
|
|
921
902
|
validate: (v2) => /^[a-f0-9]{32}$/.test(v2 ?? "") ? void 0 : "Expected 32 hex characters"
|
|
@@ -923,21 +904,22 @@ async function collectId(preset, message) {
|
|
|
923
904
|
);
|
|
924
905
|
return v;
|
|
925
906
|
}
|
|
926
|
-
async function printKeyForManualSave(value,
|
|
927
|
-
|
|
907
|
+
async function printKeyForManualSave(value, label, reason) {
|
|
908
|
+
p3.note(
|
|
928
909
|
[
|
|
929
910
|
reason ? `${pc2.red("\u2717")} ${reason}` : "",
|
|
930
|
-
`Save this value to
|
|
911
|
+
`Save this value to your secret store as ${pc2.cyan(label)}:`,
|
|
931
912
|
"",
|
|
932
913
|
pc2.yellow(value),
|
|
933
914
|
"",
|
|
934
|
-
pc2.dim("It will not be shown again.")
|
|
915
|
+
pc2.dim("It will not be shown again."),
|
|
916
|
+
pc2.dim("On the Studio, bootstrap will prompt you to paste it back.")
|
|
935
917
|
].filter((line) => line.length > 0).join("\n"),
|
|
936
918
|
"Manual save"
|
|
937
919
|
);
|
|
938
920
|
unwrap(
|
|
939
|
-
await
|
|
940
|
-
message: "Value stashed
|
|
921
|
+
await p3.confirm({
|
|
922
|
+
message: "Value stashed?",
|
|
941
923
|
initialValue: true
|
|
942
924
|
})
|
|
943
925
|
);
|
|
@@ -947,7 +929,7 @@ function isoStampUtc(d) {
|
|
|
947
929
|
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
|
948
930
|
}
|
|
949
931
|
async function waitForApplyAndFetchTunnelToken(input) {
|
|
950
|
-
const spin =
|
|
932
|
+
const spin = p3.spinner();
|
|
951
933
|
spin.start("Waiting for terraform apply to finish (polling every 10s)\u2026");
|
|
952
934
|
const outcome = await waitForApply({
|
|
953
935
|
token: input.token,
|
|
@@ -1204,6 +1186,30 @@ async function setupLaunchAgent(input) {
|
|
|
1204
1186
|
}
|
|
1205
1187
|
return { ok: true, paths };
|
|
1206
1188
|
}
|
|
1189
|
+
async function teardownLaunchAgent(paths, opts = {}) {
|
|
1190
|
+
const steps = [];
|
|
1191
|
+
const unload = await safeExeca("launchctl", ["unload", paths.plistFile]);
|
|
1192
|
+
steps.push({
|
|
1193
|
+
step: "unload",
|
|
1194
|
+
ok: true,
|
|
1195
|
+
...unload === null ? { reason: "`launchctl` not on PATH" } : {}
|
|
1196
|
+
});
|
|
1197
|
+
try {
|
|
1198
|
+
await rm(paths.plistFile, { force: true });
|
|
1199
|
+
steps.push({ step: "rm-plist", ok: true });
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
steps.push({ step: "rm-plist", ok: false, reason: err.message });
|
|
1202
|
+
}
|
|
1203
|
+
if (!opts.keepKeyFile) {
|
|
1204
|
+
try {
|
|
1205
|
+
await rm(paths.keyFile, { force: true });
|
|
1206
|
+
steps.push({ step: "rm-key-file", ok: true });
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
steps.push({ step: "rm-key-file", ok: false, reason: err.message });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return { ok: steps.every((s) => s.ok), steps };
|
|
1212
|
+
}
|
|
1207
1213
|
|
|
1208
1214
|
// src/lib/docker.ts
|
|
1209
1215
|
var GHCR_REGISTRY = "ghcr.io";
|
|
@@ -1258,6 +1264,17 @@ async function composeUp(opts) {
|
|
|
1258
1264
|
const res = await safeExeca("docker", composeArgs(opts, ["up", "-d", "--remove-orphans"]));
|
|
1259
1265
|
return result(res, "compose up");
|
|
1260
1266
|
}
|
|
1267
|
+
async function composeDown(opts) {
|
|
1268
|
+
const flags = ["down"];
|
|
1269
|
+
if (opts.wipeVolumes) flags.push("-v");
|
|
1270
|
+
if (opts.removeOrphans) flags.push("--remove-orphans");
|
|
1271
|
+
const res = await safeExeca("docker", composeArgs(opts, flags));
|
|
1272
|
+
return result(res, opts.wipeVolumes ? "compose down -v" : "compose down");
|
|
1273
|
+
}
|
|
1274
|
+
async function dockerLogout(registry = GHCR_REGISTRY) {
|
|
1275
|
+
const res = await safeExeca("docker", ["logout", registry]);
|
|
1276
|
+
return result(res, `docker logout ${registry}`);
|
|
1277
|
+
}
|
|
1261
1278
|
function result(res, label) {
|
|
1262
1279
|
if (res === null) return { ok: false, reason: "`docker` not installed" };
|
|
1263
1280
|
if (res.exitCode !== 0) {
|
|
@@ -1412,10 +1429,10 @@ async function warmModel(name, url = OLLAMA_URL) {
|
|
|
1412
1429
|
body: JSON.stringify({ model: name, prompt: "hi", stream: false })
|
|
1413
1430
|
});
|
|
1414
1431
|
if (!res.ok) {
|
|
1415
|
-
const
|
|
1432
|
+
const text6 = await res.text().catch(() => "");
|
|
1416
1433
|
return {
|
|
1417
1434
|
ok: false,
|
|
1418
|
-
reason: `warm ${name} returned HTTP ${res.status}: ${
|
|
1435
|
+
reason: `warm ${name} returned HTTP ${res.status}: ${text6 || "(no body)"}`
|
|
1419
1436
|
};
|
|
1420
1437
|
}
|
|
1421
1438
|
await res.text();
|
|
@@ -1526,16 +1543,9 @@ async function locateOrCloneRepo(input) {
|
|
|
1526
1543
|
}
|
|
1527
1544
|
|
|
1528
1545
|
// 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
1546
|
var bootstrapCommand = new Command("bootstrap").description(
|
|
1537
1547
|
"Configure the Mac Studio and bring the stack up. Run this on the Studio itself, after `init` has finished on your laptop."
|
|
1538
|
-
).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("--
|
|
1548
|
+
).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(
|
|
1539
1549
|
new Option(
|
|
1540
1550
|
"--compose-file <path>",
|
|
1541
1551
|
"Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml + docker-compose-backup.yml"
|
|
@@ -1546,9 +1556,9 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1546
1556
|
"Skip the LaunchAgent setup (assumes one is already in place)"
|
|
1547
1557
|
).default(false)
|
|
1548
1558
|
).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(new Option("-y, --yes", "Skip confirmation prompts").default(false)).action(async (opts) => {
|
|
1549
|
-
|
|
1550
|
-
const region = opts.region ? opts.region :
|
|
1551
|
-
await
|
|
1559
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node bootstrap ")));
|
|
1560
|
+
const region = opts.region ? opts.region : unwrap(
|
|
1561
|
+
await p3.text({
|
|
1552
1562
|
message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
|
|
1553
1563
|
placeholder: "us-ca",
|
|
1554
1564
|
validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
@@ -1556,17 +1566,17 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1556
1566
|
);
|
|
1557
1567
|
const owner = opts.owner ?? "OpusPopuli";
|
|
1558
1568
|
const repoName = `opuspopuli-node-${region}`;
|
|
1559
|
-
const sysSpin =
|
|
1569
|
+
const sysSpin = p3.spinner();
|
|
1560
1570
|
sysSpin.start("Inspecting macOS\u2026");
|
|
1561
1571
|
const snap = await inspectSystem();
|
|
1562
1572
|
sysSpin.stop(pc2.green("\u2713 macOS inspected."));
|
|
1563
1573
|
if (!snap.isAppleSilicon) {
|
|
1564
|
-
|
|
1574
|
+
p3.cancel(
|
|
1565
1575
|
`Bootstrap requires an Apple Silicon Mac (the runbook targets M-series). Detected: ${snap.osVersion ?? "unknown"} (uname -m != arm64).`
|
|
1566
1576
|
);
|
|
1567
1577
|
process.exit(1);
|
|
1568
1578
|
}
|
|
1569
|
-
|
|
1579
|
+
p3.note(
|
|
1570
1580
|
[
|
|
1571
1581
|
`Hostname: ${pc2.cyan(snap.hostname)}`,
|
|
1572
1582
|
`macOS: ${pc2.cyan(snap.osVersion ?? "(sw_vers failed)")}`,
|
|
@@ -1577,33 +1587,33 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1577
1587
|
"System snapshot"
|
|
1578
1588
|
);
|
|
1579
1589
|
if (!snap.autoRestartOnPowerFailure) {
|
|
1580
|
-
const fix =
|
|
1581
|
-
await
|
|
1590
|
+
const fix = unwrap(
|
|
1591
|
+
await p3.confirm({
|
|
1582
1592
|
message: "Auto-restart-on-power-failure is OFF. Enable now (requires sudo)?",
|
|
1583
1593
|
initialValue: true
|
|
1584
1594
|
})
|
|
1585
1595
|
);
|
|
1586
1596
|
if (fix) {
|
|
1587
1597
|
const r = await enableAutoRestartOnPowerFailure();
|
|
1588
|
-
if (!r.ok)
|
|
1598
|
+
if (!r.ok) p3.note(`${pc2.red("\u2717")} ${r.reason}`, "pmset failed");
|
|
1589
1599
|
}
|
|
1590
1600
|
}
|
|
1591
1601
|
if (!snap.diskSleepDisabled) {
|
|
1592
|
-
const fix =
|
|
1593
|
-
await
|
|
1602
|
+
const fix = unwrap(
|
|
1603
|
+
await p3.confirm({
|
|
1594
1604
|
message: "Disk sleep is enabled. Disable now (requires sudo)?",
|
|
1595
1605
|
initialValue: true
|
|
1596
1606
|
})
|
|
1597
1607
|
);
|
|
1598
1608
|
if (fix) {
|
|
1599
1609
|
const r = await disableDiskSleep();
|
|
1600
|
-
if (!r.ok)
|
|
1610
|
+
if (!r.ok) p3.note(`${pc2.red("\u2717")} ${r.reason}`, "pmset failed");
|
|
1601
1611
|
}
|
|
1602
1612
|
}
|
|
1603
1613
|
if (!opts.skipBrew) {
|
|
1604
1614
|
const brewInfo = await detectBrew();
|
|
1605
1615
|
if (!brewInfo.installed) {
|
|
1606
|
-
|
|
1616
|
+
p3.note(
|
|
1607
1617
|
[
|
|
1608
1618
|
`Homebrew is not installed. Open another shell and run:`,
|
|
1609
1619
|
"",
|
|
@@ -1613,9 +1623,9 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1613
1623
|
].join("\n"),
|
|
1614
1624
|
"Manual step"
|
|
1615
1625
|
);
|
|
1616
|
-
|
|
1626
|
+
unwrap(await p3.confirm({ message: "Homebrew installed?", initialValue: true }));
|
|
1617
1627
|
}
|
|
1618
|
-
const brewSpin =
|
|
1628
|
+
const brewSpin = p3.spinner();
|
|
1619
1629
|
brewSpin.start("Installing Studio packages\u2026");
|
|
1620
1630
|
const report = await installPackages(STUDIO_PACKAGES, (pkg, status) => {
|
|
1621
1631
|
brewSpin.message(`${status}: ${pkg.name}`);
|
|
@@ -1628,7 +1638,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1628
1638
|
)
|
|
1629
1639
|
);
|
|
1630
1640
|
if (report.failed.length > 0) {
|
|
1631
|
-
|
|
1641
|
+
p3.note(
|
|
1632
1642
|
[
|
|
1633
1643
|
report.failed.map((f) => `${pc2.red("\u2022")} ${f.pkg.name}: ${f.reason}`).join("\n"),
|
|
1634
1644
|
"",
|
|
@@ -1637,8 +1647,8 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1637
1647
|
].join("\n"),
|
|
1638
1648
|
"Brew failures"
|
|
1639
1649
|
);
|
|
1640
|
-
const cont =
|
|
1641
|
-
await
|
|
1650
|
+
const cont = unwrap(
|
|
1651
|
+
await p3.confirm({
|
|
1642
1652
|
message: "Continue anyway? (Default no \u2014 recommended to fix and re-run.)",
|
|
1643
1653
|
initialValue: false
|
|
1644
1654
|
})
|
|
@@ -1648,7 +1658,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1648
1658
|
}
|
|
1649
1659
|
await ensureGhAuth();
|
|
1650
1660
|
await promptTailscaleSignin();
|
|
1651
|
-
const repoSpin =
|
|
1661
|
+
const repoSpin = p3.spinner();
|
|
1652
1662
|
repoSpin.start("Locating your region node repo\u2026");
|
|
1653
1663
|
const located = await locateOrCloneRepo({
|
|
1654
1664
|
owner,
|
|
@@ -1665,59 +1675,49 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1665
1675
|
repoSpin.stop(
|
|
1666
1676
|
located.kind === "cloned" ? pc2.green(`\u2713 Cloned ${owner}/${repoName} to ${repoPath}`) : pc2.green(`\u2713 Found region repo at ${repoPath}`)
|
|
1667
1677
|
);
|
|
1668
|
-
const
|
|
1669
|
-
if (!
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
);
|
|
1673
|
-
process.exit(1);
|
|
1674
|
-
}
|
|
1675
|
-
const keyTitle = `opuspopuli-${region}-pgsodium-root-key`;
|
|
1676
|
-
const tunnelTitle = `opuspopuli-${region}-tunnel-token`;
|
|
1677
|
-
const vaultArg = opts.vault ? { vault: opts.vault } : {};
|
|
1678
|
-
const opSpin = p2.spinner();
|
|
1679
|
-
opSpin.start("Reading pgsodium key + Tunnel token from 1Password\u2026");
|
|
1680
|
-
const pgsodiumKey = await readSecretFromOp({ title: keyTitle, ...vaultArg });
|
|
1681
|
-
const tunnelToken = await readSecretFromOp({ title: tunnelTitle, ...vaultArg });
|
|
1682
|
-
if (!pgsodiumKey || !tunnelToken) {
|
|
1683
|
-
opSpin.stop(pc2.red("\u2717 Required 1Password items missing."));
|
|
1684
|
-
const missing = [
|
|
1685
|
-
pgsodiumKey ? null : keyTitle,
|
|
1686
|
-
tunnelToken ? null : tunnelTitle
|
|
1687
|
-
].filter(Boolean);
|
|
1688
|
-
p2.cancel(
|
|
1689
|
-
`Missing in 1Password (vault: ${opts.vault ?? "Private"}): ${missing.join(", ")}. Run \`create-op-node init\` first, or pass --vault if your items live elsewhere.`
|
|
1678
|
+
const keychain = await detectKeychain();
|
|
1679
|
+
if (!keychain.available) {
|
|
1680
|
+
p3.cancel(
|
|
1681
|
+
`${keychain.reason ?? "Keychain unavailable"}. Bootstrap requires the macOS Keychain.`
|
|
1690
1682
|
);
|
|
1691
1683
|
process.exit(1);
|
|
1692
1684
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1685
|
+
const pgsodiumKey = await loadOrPromptSecret({
|
|
1686
|
+
region,
|
|
1687
|
+
account: "pgsodium-root-key",
|
|
1688
|
+
label: "pgsodium master key",
|
|
1689
|
+
placeholder: "64 lowercase hex characters",
|
|
1690
|
+
validate: (v) => PGSODIUM_KEY_RE.test(v) ? void 0 : "must be exactly 64 lowercase hex characters"
|
|
1691
|
+
});
|
|
1692
|
+
const tunnelToken = await loadOrPromptSecret({
|
|
1693
|
+
region,
|
|
1694
|
+
account: "tunnel-token",
|
|
1695
|
+
label: "Cloudflare Tunnel token",
|
|
1696
|
+
placeholder: "JWT-style base64url string from `terraform output tunnel_token`",
|
|
1697
|
+
validate: (v) => TUNNEL_TOKEN_RE.test(v) ? void 0 : "tunnel token must be a base64-url JWT"
|
|
1698
|
+
});
|
|
1699
1699
|
if (!opts.skipLaunchAgent) {
|
|
1700
|
-
const laSpin =
|
|
1700
|
+
const laSpin = p3.spinner();
|
|
1701
1701
|
laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
|
|
1702
1702
|
const la = await setupLaunchAgent({ pgsodiumKey, tunnelToken });
|
|
1703
1703
|
if (!la.ok) {
|
|
1704
1704
|
laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
|
|
1705
|
-
|
|
1705
|
+
p3.cancel(la.reason ?? "LaunchAgent setup failed.");
|
|
1706
1706
|
process.exit(1);
|
|
1707
1707
|
}
|
|
1708
1708
|
laSpin.stop(pc2.green(`\u2713 LaunchAgent loaded (${la.paths.plistFile}).`));
|
|
1709
1709
|
}
|
|
1710
|
-
const ghcrSpin =
|
|
1710
|
+
const ghcrSpin = p3.spinner();
|
|
1711
1711
|
ghcrSpin.start("Authenticating Docker to ghcr.io\u2026");
|
|
1712
1712
|
const ghcr = await loginToGhcr();
|
|
1713
1713
|
if (!ghcr.ok) {
|
|
1714
1714
|
ghcrSpin.stop(pc2.red("\u2717 ghcr.io login failed."));
|
|
1715
|
-
|
|
1715
|
+
p3.cancel(ghcr.reason ?? "ghcr.io login failed.");
|
|
1716
1716
|
process.exit(1);
|
|
1717
1717
|
}
|
|
1718
1718
|
ghcrSpin.stop(pc2.green("\u2713 Logged in to ghcr.io."));
|
|
1719
1719
|
if (!opts.skipOllama) {
|
|
1720
|
-
const olSpin =
|
|
1720
|
+
const olSpin = p3.spinner();
|
|
1721
1721
|
olSpin.start("Pulling + warming Ollama models\u2026");
|
|
1722
1722
|
let olHealth = await checkOllamaHealth();
|
|
1723
1723
|
if (!olHealth.reachable) {
|
|
@@ -1725,7 +1725,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1725
1725
|
const start = await startOllamaService();
|
|
1726
1726
|
if (!start.ok) {
|
|
1727
1727
|
olSpin.stop(pc2.red("\u2717 Couldn't start Ollama service."));
|
|
1728
|
-
|
|
1728
|
+
p3.cancel(
|
|
1729
1729
|
`${start.reason ?? "unknown"} \u2014 start it manually with \`brew services start ollama\` and re-run with --skip-brew --skip-launch-agent.`
|
|
1730
1730
|
);
|
|
1731
1731
|
process.exit(1);
|
|
@@ -1736,7 +1736,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1736
1736
|
olSpin.stop(
|
|
1737
1737
|
pc2.yellow("\u26A0 Ollama service started but daemon not yet answering on :11434.")
|
|
1738
1738
|
);
|
|
1739
|
-
|
|
1739
|
+
p3.cancel(
|
|
1740
1740
|
"Give it another few seconds, then re-run with --skip-brew --skip-launch-agent."
|
|
1741
1741
|
);
|
|
1742
1742
|
process.exit(1);
|
|
@@ -1752,11 +1752,11 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1752
1752
|
);
|
|
1753
1753
|
const probe = await probeHostDockerInternal();
|
|
1754
1754
|
if (!probe.ok) {
|
|
1755
|
-
|
|
1755
|
+
p3.note(`${pc2.yellow("\u26A0")} ${probe.reason}`, "Docker host networking");
|
|
1756
1756
|
}
|
|
1757
1757
|
}
|
|
1758
1758
|
if (opts.skipStack) {
|
|
1759
|
-
|
|
1759
|
+
p3.outro(
|
|
1760
1760
|
pc2.cyan(
|
|
1761
1761
|
`Stack-up skipped. Run \`docker compose -f ${(opts.composeFile ?? ["docker-compose-prod.yml"]).join(" -f ")} pull && up -d\` from ${repoPath} when ready.`
|
|
1762
1762
|
)
|
|
@@ -1769,25 +1769,25 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1769
1769
|
cwd: repoPath,
|
|
1770
1770
|
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
1771
1771
|
};
|
|
1772
|
-
const pullSpin =
|
|
1772
|
+
const pullSpin = p3.spinner();
|
|
1773
1773
|
pullSpin.start("Pulling images from ghcr.io\u2026");
|
|
1774
1774
|
const pull = await composePull(composeOpts);
|
|
1775
1775
|
if (!pull.ok) {
|
|
1776
1776
|
pullSpin.stop(pc2.red("\u2717 compose pull failed."));
|
|
1777
|
-
|
|
1777
|
+
p3.cancel(pull.reason ?? "compose pull failed.");
|
|
1778
1778
|
process.exit(1);
|
|
1779
1779
|
}
|
|
1780
1780
|
pullSpin.stop(pc2.green("\u2713 Images pulled."));
|
|
1781
|
-
const upSpin =
|
|
1781
|
+
const upSpin = p3.spinner();
|
|
1782
1782
|
upSpin.start("Starting the stack\u2026");
|
|
1783
1783
|
const up = await composeUp(composeOpts);
|
|
1784
1784
|
if (!up.ok) {
|
|
1785
1785
|
upSpin.stop(pc2.red("\u2717 compose up failed."));
|
|
1786
|
-
|
|
1786
|
+
p3.cancel(up.reason ?? "compose up failed.");
|
|
1787
1787
|
process.exit(1);
|
|
1788
1788
|
}
|
|
1789
1789
|
upSpin.stop(pc2.green("\u2713 Containers started \u2014 waiting for healthy\u2026"));
|
|
1790
|
-
const healthSpin =
|
|
1790
|
+
const healthSpin = p3.spinner();
|
|
1791
1791
|
healthSpin.start("Polling for healthy containers (5s, up to 5 minutes)\u2026");
|
|
1792
1792
|
const outcome = await waitForHealthy(composeOpts, {
|
|
1793
1793
|
onPoll: (snaps) => {
|
|
@@ -1799,7 +1799,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1799
1799
|
switch (outcome.kind) {
|
|
1800
1800
|
case "healthy":
|
|
1801
1801
|
healthSpin.stop(pc2.green(`\u2713 All ${outcome.snapshots.length} containers healthy.`));
|
|
1802
|
-
|
|
1802
|
+
p3.outro(
|
|
1803
1803
|
pc2.cyan(
|
|
1804
1804
|
`Region ${region} is up. Next: verify off-LAN with \`npx create-op-node verify --domain <your-domain>\`.`
|
|
1805
1805
|
)
|
|
@@ -1807,22 +1807,22 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1807
1807
|
return;
|
|
1808
1808
|
case "unhealthy":
|
|
1809
1809
|
healthSpin.stop(pc2.red(`\u2717 ${outcome.problem}`));
|
|
1810
|
-
|
|
1810
|
+
p3.note(
|
|
1811
1811
|
outcome.snapshots.map((s) => ` ${kvHealth(s.health)} ${s.name} (${s.state})`).join("\n"),
|
|
1812
1812
|
"Container state"
|
|
1813
1813
|
);
|
|
1814
|
-
|
|
1814
|
+
p3.cancel(
|
|
1815
1815
|
`Inspect with \`docker compose -f ${composeFiles.join(" -f ")} logs\`. Fix and re-run.`
|
|
1816
1816
|
);
|
|
1817
1817
|
process.exit(1);
|
|
1818
1818
|
// eslint-disable-next-line no-fallthrough
|
|
1819
1819
|
case "timeout":
|
|
1820
1820
|
healthSpin.stop(pc2.yellow("\u26A0 Timed out waiting for all containers to report healthy."));
|
|
1821
|
-
|
|
1821
|
+
p3.note(
|
|
1822
1822
|
outcome.snapshots.map((s) => ` ${kvHealth(s.health)} ${s.name} (${s.state})`).join("\n"),
|
|
1823
1823
|
"Container state at timeout"
|
|
1824
1824
|
);
|
|
1825
|
-
|
|
1825
|
+
p3.cancel(
|
|
1826
1826
|
`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
1827
|
);
|
|
1828
1828
|
process.exit(1);
|
|
@@ -1862,20 +1862,20 @@ function repoPathFromLocate(out) {
|
|
|
1862
1862
|
function handleLocateError(out, owner, name) {
|
|
1863
1863
|
switch (out.kind) {
|
|
1864
1864
|
case "explicit-not-a-node-repo":
|
|
1865
|
-
|
|
1865
|
+
p3.cancel(
|
|
1866
1866
|
`${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
1867
|
);
|
|
1868
1868
|
return;
|
|
1869
1869
|
case "gh-not-installed":
|
|
1870
|
-
|
|
1870
|
+
p3.cancel("`gh` not installed \u2014 install via `brew install gh` and re-run.");
|
|
1871
1871
|
return;
|
|
1872
1872
|
case "clone-failed":
|
|
1873
|
-
|
|
1873
|
+
p3.cancel(
|
|
1874
1874
|
`Couldn't clone ${owner}/${name}: ${out.reason}. Check repo exists + you have access.`
|
|
1875
1875
|
);
|
|
1876
1876
|
return;
|
|
1877
1877
|
case "clone-disallowed":
|
|
1878
|
-
|
|
1878
|
+
p3.cancel(
|
|
1879
1879
|
"No checked-out node repo found and --no-clone (or similar) was set. Provide --repo-dir."
|
|
1880
1880
|
);
|
|
1881
1881
|
return;
|
|
@@ -1890,12 +1890,12 @@ function handleLocateError(out, owner, name) {
|
|
|
1890
1890
|
async function ensureGhAuth() {
|
|
1891
1891
|
const status = await safeExeca("gh", ["auth", "status"]);
|
|
1892
1892
|
if (status === null) {
|
|
1893
|
-
|
|
1893
|
+
p3.cancel("`gh` not on PATH. Install via `brew install gh` and re-run.");
|
|
1894
1894
|
process.exit(1);
|
|
1895
1895
|
}
|
|
1896
1896
|
if (status.exitCode === 0) return;
|
|
1897
1897
|
const detail = (status.stderr || status.stdout).trim();
|
|
1898
|
-
|
|
1898
|
+
p3.note(
|
|
1899
1899
|
[
|
|
1900
1900
|
"`gh` is not signed in (or signed in to a different account / missing scope).",
|
|
1901
1901
|
detail ? "" : void 0,
|
|
@@ -1909,12 +1909,12 @@ async function ensureGhAuth() {
|
|
|
1909
1909
|
].filter((l) => typeof l === "string").join("\n"),
|
|
1910
1910
|
"Manual step"
|
|
1911
1911
|
);
|
|
1912
|
-
|
|
1912
|
+
unwrap(await p3.confirm({ message: "gh signed in?", initialValue: true }));
|
|
1913
1913
|
}
|
|
1914
1914
|
async function promptTailscaleSignin() {
|
|
1915
1915
|
const status = await safeExeca("tailscale", ["status"]);
|
|
1916
1916
|
if (status !== null && status.exitCode === 0) return;
|
|
1917
|
-
|
|
1917
|
+
p3.note(
|
|
1918
1918
|
[
|
|
1919
1919
|
"Tailscale needs to be signed in (or you can skip \u2014 Tailscale is",
|
|
1920
1920
|
"optional; it provides out-of-band SSH from your laptop. The rest",
|
|
@@ -1928,8 +1928,319 @@ async function promptTailscaleSignin() {
|
|
|
1928
1928
|
].join("\n"),
|
|
1929
1929
|
"Manual step"
|
|
1930
1930
|
);
|
|
1931
|
-
|
|
1931
|
+
unwrap(await p3.confirm({ message: "Tailscale signed in (or skipped)?", initialValue: true }));
|
|
1932
|
+
}
|
|
1933
|
+
async function loadOrPromptSecret(input) {
|
|
1934
|
+
const coords = { region: input.region, account: input.account };
|
|
1935
|
+
const spin = p3.spinner();
|
|
1936
|
+
spin.start(`Reading ${input.label} from Keychain\u2026`);
|
|
1937
|
+
const existing = await readSecret(coords);
|
|
1938
|
+
if (existing && input.validate(existing) === void 0) {
|
|
1939
|
+
spin.stop(pc2.green(`\u2713 ${input.label} read from Keychain.`));
|
|
1940
|
+
return existing;
|
|
1941
|
+
}
|
|
1942
|
+
spin.stop(
|
|
1943
|
+
existing ? pc2.yellow(`\u26A0 ${input.label} in Keychain failed validation \u2014 will re-prompt.`) : pc2.dim(`\xB7 ${input.label} not in Keychain \u2014 will prompt.`)
|
|
1944
|
+
);
|
|
1945
|
+
const pasted = unwrap(
|
|
1946
|
+
await p3.password({
|
|
1947
|
+
message: `Paste the ${input.label} for region ${input.region}:`,
|
|
1948
|
+
validate: (v) => input.validate(v ?? "")
|
|
1949
|
+
})
|
|
1950
|
+
);
|
|
1951
|
+
const save = await saveSecret(coords, pasted);
|
|
1952
|
+
if (!save.written) {
|
|
1953
|
+
p3.note(`${pc2.yellow("\u26A0")} Couldn't persist to Keychain: ${save.reason ?? "unknown"}. Continuing anyway.`, "Keychain");
|
|
1954
|
+
} else {
|
|
1955
|
+
p3.note(`${pc2.green("\u2713")} Stored ${input.label} in Keychain for re-runs.`, "Keychain");
|
|
1956
|
+
}
|
|
1957
|
+
return pasted;
|
|
1958
|
+
}
|
|
1959
|
+
var REGION_RE = /^[a-z0-9-]{2,32}$/;
|
|
1960
|
+
var RESET_PHASES = {
|
|
1961
|
+
STOP_STACK: "Stop stack",
|
|
1962
|
+
LAUNCH_AGENT: "LaunchAgent",
|
|
1963
|
+
DOCKER_LOGOUT: "docker logout"
|
|
1964
|
+
};
|
|
1965
|
+
var DEFAULT_DEPS = {
|
|
1966
|
+
composeDown,
|
|
1967
|
+
teardownLaunchAgent,
|
|
1968
|
+
dockerLogout
|
|
1969
|
+
};
|
|
1970
|
+
async function runReset(input, deps = DEFAULT_DEPS) {
|
|
1971
|
+
const phases = [];
|
|
1972
|
+
const push = (ph) => {
|
|
1973
|
+
phases.push(ph);
|
|
1974
|
+
deps.onPhase?.(ph);
|
|
1975
|
+
};
|
|
1976
|
+
if (!input.stack) {
|
|
1977
|
+
push({
|
|
1978
|
+
name: RESET_PHASES.STOP_STACK,
|
|
1979
|
+
status: "skipped",
|
|
1980
|
+
detail: "--skip-stack or no repo path resolved"
|
|
1981
|
+
});
|
|
1982
|
+
} else if (input.dryRun) {
|
|
1983
|
+
push({
|
|
1984
|
+
name: RESET_PHASES.STOP_STACK,
|
|
1985
|
+
status: "dry-run",
|
|
1986
|
+
detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}`
|
|
1987
|
+
});
|
|
1988
|
+
} else {
|
|
1989
|
+
const result2 = await deps.composeDown({
|
|
1990
|
+
files: input.stack.composeFiles,
|
|
1991
|
+
cwd: input.stack.repoPath,
|
|
1992
|
+
...input.stack.envFile ? { envFile: input.stack.envFile } : {},
|
|
1993
|
+
wipeVolumes: input.stack.wipeVolumes,
|
|
1994
|
+
removeOrphans: input.stack.removeOrphans
|
|
1995
|
+
});
|
|
1996
|
+
if (result2.ok) {
|
|
1997
|
+
push({
|
|
1998
|
+
name: RESET_PHASES.STOP_STACK,
|
|
1999
|
+
status: "ok",
|
|
2000
|
+
detail: input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved"
|
|
2001
|
+
});
|
|
2002
|
+
} else {
|
|
2003
|
+
push({ name: RESET_PHASES.STOP_STACK, status: "fail", detail: result2.reason ?? "unknown failure" });
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
if (!input.launchAgent) {
|
|
2007
|
+
push({ name: RESET_PHASES.LAUNCH_AGENT, status: "skipped", detail: "--skip-launch-agent or no plist found" });
|
|
2008
|
+
} else if (input.dryRun) {
|
|
2009
|
+
push({
|
|
2010
|
+
name: RESET_PHASES.LAUNCH_AGENT,
|
|
2011
|
+
status: "dry-run",
|
|
2012
|
+
detail: `would unload ${input.launchAgent.paths.plistFile}, rm plist${input.launchAgent.keepKeyFile ? "" : " + key file"}`
|
|
2013
|
+
});
|
|
2014
|
+
} else {
|
|
2015
|
+
const result2 = await deps.teardownLaunchAgent(
|
|
2016
|
+
input.launchAgent.paths,
|
|
2017
|
+
{ keepKeyFile: input.launchAgent.keepKeyFile }
|
|
2018
|
+
);
|
|
2019
|
+
if (result2.ok) {
|
|
2020
|
+
const removed = result2.steps.filter((s) => s.step !== "unload").map((s) => s.step).join(", ");
|
|
2021
|
+
push({ name: RESET_PHASES.LAUNCH_AGENT, status: "ok", detail: `unloaded + ${removed}` });
|
|
2022
|
+
} else {
|
|
2023
|
+
const fail = result2.steps.find((s) => !s.ok);
|
|
2024
|
+
push({
|
|
2025
|
+
name: RESET_PHASES.LAUNCH_AGENT,
|
|
2026
|
+
status: "fail",
|
|
2027
|
+
detail: `${fail?.step}: ${fail?.reason ?? "unknown"}`
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (!input.dockerLogout) {
|
|
2032
|
+
push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "skipped", detail: "--skip-docker-logout" });
|
|
2033
|
+
} else if (input.dryRun) {
|
|
2034
|
+
push({
|
|
2035
|
+
name: RESET_PHASES.DOCKER_LOGOUT,
|
|
2036
|
+
status: "dry-run",
|
|
2037
|
+
detail: `would run: docker logout ${input.dockerLogout.registry}`
|
|
2038
|
+
});
|
|
2039
|
+
} else {
|
|
2040
|
+
const result2 = await deps.dockerLogout(input.dockerLogout.registry);
|
|
2041
|
+
if (result2.ok) {
|
|
2042
|
+
push({
|
|
2043
|
+
name: RESET_PHASES.DOCKER_LOGOUT,
|
|
2044
|
+
status: "ok",
|
|
2045
|
+
detail: `credentials removed from ${input.dockerLogout.registry}`
|
|
2046
|
+
});
|
|
2047
|
+
} else {
|
|
2048
|
+
push({ name: RESET_PHASES.DOCKER_LOGOUT, status: "warn", detail: result2.reason ?? "unknown" });
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
return { phases };
|
|
2052
|
+
}
|
|
2053
|
+
async function fileExists2(path) {
|
|
2054
|
+
try {
|
|
2055
|
+
await access(path);
|
|
2056
|
+
return true;
|
|
2057
|
+
} catch {
|
|
2058
|
+
return false;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
function phaseIcon(ph) {
|
|
2062
|
+
switch (ph.status) {
|
|
2063
|
+
case "ok":
|
|
2064
|
+
return pc2.green("\u2713");
|
|
2065
|
+
case "warn":
|
|
2066
|
+
return pc2.yellow("\u26A0");
|
|
2067
|
+
case "skipped":
|
|
2068
|
+
return pc2.dim("\xB7");
|
|
2069
|
+
case "dry-run":
|
|
2070
|
+
return pc2.cyan("?");
|
|
2071
|
+
case "fail":
|
|
2072
|
+
return pc2.red("\u2717");
|
|
2073
|
+
}
|
|
1932
2074
|
}
|
|
2075
|
+
var resetCommand = new Command("reset").description(
|
|
2076
|
+
"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)."
|
|
2077
|
+
).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(
|
|
2078
|
+
new Option(
|
|
2079
|
+
"--compose-file <path>",
|
|
2080
|
+
"Repeatable. Compose file relative to repo root. Default: docker-compose-prod.yml"
|
|
2081
|
+
).default(["docker-compose-prod.yml"])
|
|
2082
|
+
).addOption(new Option("--env-file <path>", "Compose --env-file. Default: .env.production")).addOption(
|
|
2083
|
+
new Option(
|
|
2084
|
+
"--wipe-data",
|
|
2085
|
+
"DESTROYS docker volumes (adds -v to `compose down`). Requires retyping the region label as confirmation. Default off \u2014 volumes preserved."
|
|
2086
|
+
).default(false)
|
|
2087
|
+
).addOption(
|
|
2088
|
+
new Option(
|
|
2089
|
+
"--no-remove-orphans",
|
|
2090
|
+
"Do NOT pass --remove-orphans to compose down. Preserves containers from compose files no longer included."
|
|
2091
|
+
)
|
|
2092
|
+
).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(
|
|
2093
|
+
new Option(
|
|
2094
|
+
"--keep-key-file",
|
|
2095
|
+
"Preserve the pgsodium key file (only matters when LaunchAgent teardown runs). Useful as belt-and-suspenders backup before a wipe-data run."
|
|
2096
|
+
).default(false)
|
|
2097
|
+
).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) => {
|
|
2098
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node reset ")));
|
|
2099
|
+
const wipeData = opts.wipeData ?? false;
|
|
2100
|
+
const removeOrphans = opts.removeOrphans ?? true;
|
|
2101
|
+
const region = opts.region ? opts.region : unwrap(
|
|
2102
|
+
await p3.text({
|
|
2103
|
+
message: "Region label (the slug used during init \u2014 e.g. us-ca)?",
|
|
2104
|
+
placeholder: "us-ca",
|
|
2105
|
+
validate: (v) => REGION_RE.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
2106
|
+
})
|
|
2107
|
+
);
|
|
2108
|
+
if (!REGION_RE.test(region)) {
|
|
2109
|
+
p3.cancel(`--region ${JSON.stringify(region)} is not a valid region slug.`);
|
|
2110
|
+
process.exit(2);
|
|
2111
|
+
}
|
|
2112
|
+
const owner = opts.owner ?? "OpusPopuli";
|
|
2113
|
+
const repoName = `opuspopuli-node-${region}`;
|
|
2114
|
+
const launchAgentPaths = defaultPaths();
|
|
2115
|
+
const plistExists = await fileExists2(launchAgentPaths.plistFile);
|
|
2116
|
+
const keyFileExists = await fileExists2(launchAgentPaths.keyFile);
|
|
2117
|
+
let repoPath;
|
|
2118
|
+
let stackSkipReason;
|
|
2119
|
+
if (!opts.skipStack) {
|
|
2120
|
+
const located = await locateOrCloneRepo({
|
|
2121
|
+
owner,
|
|
2122
|
+
name: repoName,
|
|
2123
|
+
cwd: process.cwd(),
|
|
2124
|
+
allowClone: false,
|
|
2125
|
+
...opts.repoDir ? { explicit: opts.repoDir } : {}
|
|
2126
|
+
});
|
|
2127
|
+
switch (located.kind) {
|
|
2128
|
+
case "found":
|
|
2129
|
+
repoPath = located.path;
|
|
2130
|
+
break;
|
|
2131
|
+
case "explicit-not-a-node-repo":
|
|
2132
|
+
p3.cancel(
|
|
2133
|
+
`--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.`
|
|
2134
|
+
);
|
|
2135
|
+
process.exit(2);
|
|
2136
|
+
break;
|
|
2137
|
+
case "clone-disallowed":
|
|
2138
|
+
stackSkipReason = `no checkout found at ${process.cwd()}; pass --repo-dir to target one`;
|
|
2139
|
+
break;
|
|
2140
|
+
case "cloned":
|
|
2141
|
+
case "gh-not-installed":
|
|
2142
|
+
case "clone-failed":
|
|
2143
|
+
stackSkipReason = `unexpected outcome ${located.kind} from locateOrCloneRepo with allowClone=false`;
|
|
2144
|
+
break;
|
|
2145
|
+
}
|
|
2146
|
+
} else {
|
|
2147
|
+
stackSkipReason = "--skip-stack";
|
|
2148
|
+
}
|
|
2149
|
+
let runningContainers = null;
|
|
2150
|
+
if (repoPath) {
|
|
2151
|
+
runningContainers = await composePs({
|
|
2152
|
+
files: resolveComposeFiles(repoPath, opts.composeFile),
|
|
2153
|
+
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
p3.note(
|
|
2157
|
+
[
|
|
2158
|
+
`Region: ${pc2.cyan(region)}`,
|
|
2159
|
+
`Repo path: ${repoPath ? pc2.cyan(repoPath) : pc2.dim(`not used (${stackSkipReason ?? "no repo path resolved"})`)}`,
|
|
2160
|
+
`Running containers: ${runningContainers === null ? pc2.dim("unknown") : runningContainers.length === 0 ? pc2.dim("none") : pc2.cyan(`${runningContainers.length} listed by compose ps`)}`,
|
|
2161
|
+
`LaunchAgent plist: ${plistExists ? pc2.cyan(launchAgentPaths.plistFile) : pc2.dim("not present")}`,
|
|
2162
|
+
`pgsodium key file: ${keyFileExists ? pc2.cyan(launchAgentPaths.keyFile) : pc2.dim("not present")}`,
|
|
2163
|
+
`Registry to log out: ${pc2.cyan(opts.registry ?? GHCR_REGISTRY)}`,
|
|
2164
|
+
`Volume policy: ${wipeData ? pc2.red("WIPE") : pc2.green("preserve (default)")}`,
|
|
2165
|
+
`Dry run: ${opts.dryRun ? pc2.yellow("yes") : pc2.dim("no")}`
|
|
2166
|
+
].join("\n"),
|
|
2167
|
+
"Snapshot"
|
|
2168
|
+
);
|
|
2169
|
+
if (wipeData && !opts.dryRun) {
|
|
2170
|
+
unwrap(
|
|
2171
|
+
await p3.text({
|
|
2172
|
+
message: pc2.red(`Type the region label to confirm WIPING all volumes (the slug you'd use with --region):`),
|
|
2173
|
+
validate: (v) => v === region ? void 0 : `must match the region label exactly`
|
|
2174
|
+
})
|
|
2175
|
+
);
|
|
2176
|
+
} else if (!opts.dryRun && !opts.yes) {
|
|
2177
|
+
const cont = unwrap(
|
|
2178
|
+
await p3.confirm({
|
|
2179
|
+
message: "Proceed with reset? (volumes will be preserved)",
|
|
2180
|
+
initialValue: true
|
|
2181
|
+
})
|
|
2182
|
+
);
|
|
2183
|
+
if (!cont) {
|
|
2184
|
+
p3.cancel("Cancelled.");
|
|
2185
|
+
process.exit(0);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
const stack = repoPath ? {
|
|
2189
|
+
repoPath,
|
|
2190
|
+
composeFiles: resolveComposeFiles(repoPath, opts.composeFile),
|
|
2191
|
+
wipeVolumes: wipeData,
|
|
2192
|
+
removeOrphans,
|
|
2193
|
+
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
2194
|
+
} : void 0;
|
|
2195
|
+
const launchAgent = plistExists && !opts.skipLaunchAgent ? {
|
|
2196
|
+
paths: launchAgentPaths,
|
|
2197
|
+
keepKeyFile: opts.keepKeyFile ?? false
|
|
2198
|
+
} : void 0;
|
|
2199
|
+
const dockerLogoutInput = !opts.skipDockerLogout ? { registry: opts.registry ?? GHCR_REGISTRY } : void 0;
|
|
2200
|
+
const phaseSpins = /* @__PURE__ */ new Map();
|
|
2201
|
+
const actingPhases = [];
|
|
2202
|
+
if (stack && !opts.dryRun) actingPhases.push(RESET_PHASES.STOP_STACK);
|
|
2203
|
+
if (launchAgent && !opts.dryRun) actingPhases.push(RESET_PHASES.LAUNCH_AGENT);
|
|
2204
|
+
if (dockerLogoutInput && !opts.dryRun) actingPhases.push(RESET_PHASES.DOCKER_LOGOUT);
|
|
2205
|
+
for (const name of actingPhases) {
|
|
2206
|
+
const s = p3.spinner();
|
|
2207
|
+
s.start(`${name}\u2026`);
|
|
2208
|
+
phaseSpins.set(name, s);
|
|
2209
|
+
}
|
|
2210
|
+
const renderPhase = (ph) => {
|
|
2211
|
+
const spin = phaseSpins.get(ph.name);
|
|
2212
|
+
const line = `${phaseIcon(ph)} ${ph.name}: ${pc2.dim(ph.detail)}`;
|
|
2213
|
+
if (spin) {
|
|
2214
|
+
spin.stop(line);
|
|
2215
|
+
phaseSpins.delete(ph.name);
|
|
2216
|
+
} else {
|
|
2217
|
+
p3.log.info(line);
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
const report = await runReset(
|
|
2221
|
+
{
|
|
2222
|
+
...stack ? { stack } : {},
|
|
2223
|
+
...launchAgent ? { launchAgent } : {},
|
|
2224
|
+
...dockerLogoutInput ? { dockerLogout: dockerLogoutInput } : {},
|
|
2225
|
+
dryRun: opts.dryRun ?? false
|
|
2226
|
+
},
|
|
2227
|
+
{ ...DEFAULT_DEPS, onPhase: renderPhase }
|
|
2228
|
+
);
|
|
2229
|
+
for (const [, spin] of phaseSpins) spin.stop(pc2.dim("\u2014 skipped"));
|
|
2230
|
+
const failed = report.phases.filter((ph) => ph.status === "fail").length;
|
|
2231
|
+
if (failed > 0) {
|
|
2232
|
+
p3.outro(pc2.red(`${failed} step${failed === 1 ? "" : "s"} failed.`));
|
|
2233
|
+
process.exit(1);
|
|
2234
|
+
} else if (opts.dryRun) {
|
|
2235
|
+
p3.outro(pc2.cyan("Dry run complete. Re-run without --dry-run to apply."));
|
|
2236
|
+
} else {
|
|
2237
|
+
p3.outro(
|
|
2238
|
+
pc2.green(
|
|
2239
|
+
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.`
|
|
2240
|
+
)
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
});
|
|
1933
2244
|
|
|
1934
2245
|
// src/lib/cosign.ts
|
|
1935
2246
|
var COSIGN_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
|
|
@@ -1965,8 +2276,8 @@ async function cosignVerifyImage(input) {
|
|
|
1965
2276
|
// src/lib/http.ts
|
|
1966
2277
|
var GRAPHQL_ACCEPT = "application/graphql-response+json, application/json";
|
|
1967
2278
|
async function readBodyCapped(res) {
|
|
1968
|
-
const
|
|
1969
|
-
return
|
|
2279
|
+
const text6 = await res.text();
|
|
2280
|
+
return text6.slice(0, BODY_PREVIEW_MAX);
|
|
1970
2281
|
}
|
|
1971
2282
|
async function httpProbe(input) {
|
|
1972
2283
|
const expected = input.expectedStatus ?? 200;
|
|
@@ -2004,22 +2315,22 @@ async function graphqlProbe(input) {
|
|
|
2004
2315
|
body: JSON.stringify({ query: "{ __typename }" }),
|
|
2005
2316
|
signal: ctrl.signal
|
|
2006
2317
|
});
|
|
2007
|
-
const
|
|
2318
|
+
const text6 = await readBodyCapped(res);
|
|
2008
2319
|
if (res.status !== 200) {
|
|
2009
2320
|
return {
|
|
2010
2321
|
ok: false,
|
|
2011
2322
|
status: res.status,
|
|
2012
|
-
reason: `expected HTTP 200, got ${res.status}: ${
|
|
2323
|
+
reason: `expected HTTP 200, got ${res.status}: ${text6}`
|
|
2013
2324
|
};
|
|
2014
2325
|
}
|
|
2015
2326
|
let envelope;
|
|
2016
2327
|
try {
|
|
2017
|
-
envelope = JSON.parse(
|
|
2328
|
+
envelope = JSON.parse(text6);
|
|
2018
2329
|
} catch {
|
|
2019
2330
|
return {
|
|
2020
2331
|
ok: false,
|
|
2021
2332
|
status: res.status,
|
|
2022
|
-
reason: `response was not JSON: ${
|
|
2333
|
+
reason: `response was not JSON: ${text6}`
|
|
2023
2334
|
};
|
|
2024
2335
|
}
|
|
2025
2336
|
const data = envelope.data;
|
|
@@ -2028,7 +2339,7 @@ async function graphqlProbe(input) {
|
|
|
2028
2339
|
return {
|
|
2029
2340
|
ok: false,
|
|
2030
2341
|
status: res.status,
|
|
2031
|
-
reason: `response missing { data: { __typename: string } }: ${
|
|
2342
|
+
reason: `response missing { data: { __typename: string } }: ${text6}`
|
|
2032
2343
|
};
|
|
2033
2344
|
}
|
|
2034
2345
|
return { ok: true, typename };
|
|
@@ -2096,13 +2407,6 @@ function tlsHandshake(input) {
|
|
|
2096
2407
|
}
|
|
2097
2408
|
|
|
2098
2409
|
// 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
2410
|
var DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i;
|
|
2107
2411
|
function summarize(report) {
|
|
2108
2412
|
let failed = 0;
|
|
@@ -2118,14 +2422,14 @@ function formatExpiry(days) {
|
|
|
2118
2422
|
if (days === 0) return "expires today";
|
|
2119
2423
|
return `${days}d to expiry`;
|
|
2120
2424
|
}
|
|
2121
|
-
var
|
|
2425
|
+
var DEFAULT_DEPS2 = {
|
|
2122
2426
|
tls: tlsHandshake,
|
|
2123
2427
|
http: httpProbe,
|
|
2124
2428
|
graphql: graphqlProbe,
|
|
2125
2429
|
tunnel: tunnelStatus,
|
|
2126
2430
|
cosign: cosignVerifyImage
|
|
2127
2431
|
};
|
|
2128
|
-
async function runVerify(input, deps =
|
|
2432
|
+
async function runVerify(input, deps = DEFAULT_DEPS2) {
|
|
2129
2433
|
const phases = [];
|
|
2130
2434
|
const push = (ph) => {
|
|
2131
2435
|
phases.push(ph);
|
|
@@ -2244,26 +2548,26 @@ var verifyCommand = new Command("verify").description(
|
|
|
2244
2548
|
).addOption(
|
|
2245
2549
|
new Option("--show-skipped", "Include skipped phases in the summary").default(false)
|
|
2246
2550
|
).action(async (opts) => {
|
|
2247
|
-
|
|
2248
|
-
const domain = opts.domain ? opts.domain :
|
|
2249
|
-
await
|
|
2551
|
+
p3.intro(pc2.bgCyan(pc2.black(" create-op-node verify ")));
|
|
2552
|
+
const domain = opts.domain ? opts.domain : unwrap(
|
|
2553
|
+
await p3.text({
|
|
2250
2554
|
message: "Public domain of the node?",
|
|
2251
2555
|
placeholder: "yournode.example.org",
|
|
2252
2556
|
validate: (v) => DOMAIN_RE.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens, dots; must contain a dot and a TLD"
|
|
2253
2557
|
})
|
|
2254
2558
|
);
|
|
2255
2559
|
if (!DOMAIN_RE.test(domain)) {
|
|
2256
|
-
|
|
2560
|
+
p3.cancel(`--domain ${JSON.stringify(domain)} doesn't look like a domain.`);
|
|
2257
2561
|
process.exit(2);
|
|
2258
2562
|
}
|
|
2259
2563
|
const rawDays = opts.certWarnDays ?? "14";
|
|
2260
2564
|
const certWarnDays = Number.parseInt(rawDays, 10);
|
|
2261
2565
|
if (!Number.isFinite(certWarnDays) || certWarnDays < 0 || !/^\d+$/.test(rawDays)) {
|
|
2262
|
-
|
|
2566
|
+
p3.cancel(`--cert-warn-days must be a non-negative integer (got ${JSON.stringify(rawDays)}).`);
|
|
2263
2567
|
process.exit(2);
|
|
2264
2568
|
}
|
|
2265
2569
|
if (opts.cfToken && opts.cfTokenFile) {
|
|
2266
|
-
|
|
2570
|
+
p3.cancel("Pass either --cf-token or --cf-token-file, not both.");
|
|
2267
2571
|
process.exit(2);
|
|
2268
2572
|
}
|
|
2269
2573
|
let cfToken = opts.cfToken;
|
|
@@ -2271,7 +2575,7 @@ var verifyCommand = new Command("verify").description(
|
|
|
2271
2575
|
try {
|
|
2272
2576
|
cfToken = readTokenFile(opts.cfTokenFile);
|
|
2273
2577
|
} catch (err) {
|
|
2274
|
-
|
|
2578
|
+
p3.cancel(err.message);
|
|
2275
2579
|
process.exit(2);
|
|
2276
2580
|
}
|
|
2277
2581
|
}
|
|
@@ -2287,10 +2591,10 @@ var verifyCommand = new Command("verify").description(
|
|
|
2287
2591
|
activeSpin = null;
|
|
2288
2592
|
};
|
|
2289
2593
|
const deps = {
|
|
2290
|
-
...
|
|
2594
|
+
...DEFAULT_DEPS2,
|
|
2291
2595
|
onPhase: renderPhase
|
|
2292
2596
|
};
|
|
2293
|
-
activeSpin =
|
|
2597
|
+
activeSpin = p3.spinner();
|
|
2294
2598
|
activeSpin.start("Running checks\u2026");
|
|
2295
2599
|
const report = await runVerify(
|
|
2296
2600
|
{
|
|
@@ -2309,24 +2613,24 @@ var verifyCommand = new Command("verify").description(
|
|
|
2309
2613
|
const visible = opts.showSkipped ?? false ? report.phases : report.phases.filter((ph) => ph.status !== "skipped");
|
|
2310
2614
|
const lines = visible.map((ph, i) => {
|
|
2311
2615
|
const idx = report.phases.indexOf(ph) + 1;
|
|
2312
|
-
return `${
|
|
2616
|
+
return `${phaseIcon2(ph)} [${idx}/${totalPhases}] ${ph.name}: ${pc2.dim(ph.detail)}`;
|
|
2313
2617
|
});
|
|
2314
2618
|
const skippedCount = report.phases.length - visible.length;
|
|
2315
2619
|
if (skippedCount > 0 && !opts.showSkipped) {
|
|
2316
2620
|
lines.push(pc2.dim(`\xB7 ${skippedCount} phase${skippedCount === 1 ? "" : "s"} skipped (run with --show-skipped to see them)`));
|
|
2317
2621
|
}
|
|
2318
|
-
|
|
2622
|
+
p3.note(lines.join("\n"), "Summary");
|
|
2319
2623
|
const summary = summarize(report);
|
|
2320
2624
|
if (summary.ok) {
|
|
2321
|
-
|
|
2625
|
+
p3.outro(
|
|
2322
2626
|
summary.warned === 0 ? pc2.green("All checks passed.") : pc2.yellow(`Passed with ${summary.warned} warning${summary.warned === 1 ? "" : "s"}.`)
|
|
2323
2627
|
);
|
|
2324
2628
|
} else {
|
|
2325
|
-
|
|
2629
|
+
p3.outro(pc2.red(`${summary.failed} check${summary.failed === 1 ? "" : "s"} failed.`));
|
|
2326
2630
|
process.exit(1);
|
|
2327
2631
|
}
|
|
2328
2632
|
});
|
|
2329
|
-
function
|
|
2633
|
+
function phaseIcon2(ph) {
|
|
2330
2634
|
switch (ph.status) {
|
|
2331
2635
|
case "ok":
|
|
2332
2636
|
return pc2.green("\u2713");
|
|
@@ -2339,7 +2643,7 @@ function phaseIcon(ph) {
|
|
|
2339
2643
|
}
|
|
2340
2644
|
}
|
|
2341
2645
|
function formatPhaseLine(prefix, ph) {
|
|
2342
|
-
const icon =
|
|
2646
|
+
const icon = phaseIcon2(ph);
|
|
2343
2647
|
const colorize = ph.status === "ok" ? pc2.green : ph.status === "warn" ? pc2.yellow : ph.status === "skipped" ? pc2.dim : pc2.red;
|
|
2344
2648
|
return colorize(`${icon} ${prefix}: ${ph.detail}`);
|
|
2345
2649
|
}
|
|
@@ -3122,13 +3426,6 @@ function validateRegionConfig(file) {
|
|
|
3122
3426
|
}
|
|
3123
3427
|
|
|
3124
3428
|
// 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
3429
|
var regionCommand = new Command("region").description(
|
|
3133
3430
|
"Scaffold a schema-valid region config for the OpusPopuli/opuspopuli-regions repo. Run this from inside your checkout of that repo."
|
|
3134
3431
|
).addOption(
|
|
@@ -3141,8 +3438,8 @@ var regionCommand = new Command("region").description(
|
|
|
3141
3438
|
"current directory"
|
|
3142
3439
|
)
|
|
3143
3440
|
).addOption(new Option("-f, --force", "Overwrite an existing config file").default(false)).action(async (opts) => {
|
|
3144
|
-
|
|
3145
|
-
|
|
3441
|
+
p3.intro(pc2.bgMagenta(pc2.black(" create-op-node region ")));
|
|
3442
|
+
p3.note(
|
|
3146
3443
|
[
|
|
3147
3444
|
"Scaffolds a declarative region config JSON for opuspopuli-regions \u2014",
|
|
3148
3445
|
"the file that defines WHAT civic data a region collects.",
|
|
@@ -3156,19 +3453,19 @@ var regionCommand = new Command("region").description(
|
|
|
3156
3453
|
const targetDir = opts.outDir ?? process.cwd();
|
|
3157
3454
|
const looksRight = await looksLikeRegionsRepo(targetDir);
|
|
3158
3455
|
if (!looksRight) {
|
|
3159
|
-
const proceed =
|
|
3160
|
-
await
|
|
3456
|
+
const proceed = unwrap(
|
|
3457
|
+
await p3.confirm({
|
|
3161
3458
|
message: `${targetDir} doesn't look like an opuspopuli-regions checkout (no schema/region-plugin.schema.json). Continue anyway?`,
|
|
3162
3459
|
initialValue: false
|
|
3163
3460
|
})
|
|
3164
3461
|
);
|
|
3165
3462
|
if (!proceed) {
|
|
3166
|
-
|
|
3463
|
+
p3.cancel("Cancelled \u2014 run again from your opuspopuli-regions checkout, or pass --out-dir.");
|
|
3167
3464
|
process.exit(0);
|
|
3168
3465
|
}
|
|
3169
3466
|
}
|
|
3170
|
-
const level = opts.level === "state" || opts.level === "county" ? opts.level :
|
|
3171
|
-
await
|
|
3467
|
+
const level = opts.level === "state" || opts.level === "county" ? opts.level : unwrap(
|
|
3468
|
+
await p3.select({
|
|
3172
3469
|
message: "What level is this region?",
|
|
3173
3470
|
options: [
|
|
3174
3471
|
{ value: "state", label: "State", hint: "e.g. California" },
|
|
@@ -3176,8 +3473,8 @@ var regionCommand = new Command("region").description(
|
|
|
3176
3473
|
]
|
|
3177
3474
|
})
|
|
3178
3475
|
);
|
|
3179
|
-
const rawName = opts.name ??
|
|
3180
|
-
await
|
|
3476
|
+
const rawName = opts.name ?? unwrap(
|
|
3477
|
+
await p3.text({
|
|
3181
3478
|
message: level === "county" ? "County name?" : "State name?",
|
|
3182
3479
|
placeholder: level === "county" ? "Alameda" : "California",
|
|
3183
3480
|
validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
|
|
@@ -3185,62 +3482,62 @@ var regionCommand = new Command("region").description(
|
|
|
3185
3482
|
);
|
|
3186
3483
|
let parentSlug;
|
|
3187
3484
|
if (level === "county") {
|
|
3188
|
-
parentSlug = opts.parent ??
|
|
3189
|
-
await
|
|
3485
|
+
parentSlug = opts.parent ?? unwrap(
|
|
3486
|
+
await p3.text({
|
|
3190
3487
|
message: "Parent state slug?",
|
|
3191
3488
|
placeholder: "california",
|
|
3192
3489
|
validate: (v) => isValidSlug(v ?? "") ? void 0 : "kebab-case (lowercase letters, digits, hyphens)"
|
|
3193
3490
|
})
|
|
3194
3491
|
);
|
|
3195
3492
|
if (!isValidSlug(parentSlug)) {
|
|
3196
|
-
|
|
3493
|
+
p3.cancel(`Parent slug "${parentSlug}" must be kebab-case.`);
|
|
3197
3494
|
process.exit(1);
|
|
3198
3495
|
}
|
|
3199
3496
|
}
|
|
3200
3497
|
const ownSlug = slugify(rawName);
|
|
3201
3498
|
const regionId = level === "county" ? `${parentSlug}-${ownSlug}` : ownSlug;
|
|
3202
3499
|
if (!isValidSlug(regionId)) {
|
|
3203
|
-
|
|
3500
|
+
p3.cancel(`Derived regionId "${regionId}" is not valid kebab-case. Try a simpler name.`);
|
|
3204
3501
|
process.exit(1);
|
|
3205
3502
|
}
|
|
3206
|
-
const displayName =
|
|
3207
|
-
await
|
|
3503
|
+
const displayName = unwrap(
|
|
3504
|
+
await p3.text({
|
|
3208
3505
|
message: "Display name?",
|
|
3209
3506
|
defaultValue: rawName
|
|
3210
3507
|
})
|
|
3211
3508
|
);
|
|
3212
3509
|
const regionName = displayName || rawName;
|
|
3213
|
-
const description =
|
|
3214
|
-
await
|
|
3510
|
+
const description = unwrap(
|
|
3511
|
+
await p3.text({
|
|
3215
3512
|
message: "One-line description of the data coverage?",
|
|
3216
3513
|
placeholder: level === "county" ? `Civic data for ${rawName} County` : `Civic data for the state of ${rawName}`,
|
|
3217
3514
|
validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
|
|
3218
3515
|
})
|
|
3219
3516
|
);
|
|
3220
|
-
const stateCode = (opts.stateCode ??
|
|
3221
|
-
await
|
|
3517
|
+
const stateCode = (opts.stateCode ?? unwrap(
|
|
3518
|
+
await p3.text({
|
|
3222
3519
|
message: "Two-letter state code?",
|
|
3223
3520
|
placeholder: "CA",
|
|
3224
3521
|
validate: (v) => isValidStateCode((v ?? "").toUpperCase()) ? void 0 : "Two letters, e.g. CA"
|
|
3225
3522
|
})
|
|
3226
3523
|
)).toUpperCase();
|
|
3227
3524
|
if (!isValidStateCode(stateCode)) {
|
|
3228
|
-
|
|
3525
|
+
p3.cancel(`State code "${stateCode}" must be two letters.`);
|
|
3229
3526
|
process.exit(1);
|
|
3230
3527
|
}
|
|
3231
|
-
const fipsCode = opts.fips ??
|
|
3232
|
-
await
|
|
3528
|
+
const fipsCode = opts.fips ?? unwrap(
|
|
3529
|
+
await p3.text({
|
|
3233
3530
|
message: level === "county" ? "County FIPS (5 digits)?" : "State FIPS (2 digits)?",
|
|
3234
3531
|
placeholder: level === "county" ? "06001" : "06",
|
|
3235
3532
|
validate: (v) => isValidFips(v ?? "", level) ? void 0 : `Expected ${level === "county" ? "5" : "2"} digits`
|
|
3236
3533
|
})
|
|
3237
3534
|
);
|
|
3238
3535
|
if (!isValidFips(fipsCode, level)) {
|
|
3239
|
-
|
|
3536
|
+
p3.cancel(`FIPS "${fipsCode}" is the wrong length for a ${level}.`);
|
|
3240
3537
|
process.exit(1);
|
|
3241
3538
|
}
|
|
3242
|
-
const timezone = opts.timezone ??
|
|
3243
|
-
await
|
|
3539
|
+
const timezone = opts.timezone ?? unwrap(
|
|
3540
|
+
await p3.text({
|
|
3244
3541
|
message: "IANA timezone?",
|
|
3245
3542
|
defaultValue: "America/Los_Angeles",
|
|
3246
3543
|
placeholder: "America/Los_Angeles",
|
|
@@ -3249,35 +3546,35 @@ var regionCommand = new Command("region").description(
|
|
|
3249
3546
|
);
|
|
3250
3547
|
const dataSources = [];
|
|
3251
3548
|
do {
|
|
3252
|
-
const url =
|
|
3253
|
-
await
|
|
3549
|
+
const url = unwrap(
|
|
3550
|
+
await p3.text({
|
|
3254
3551
|
message: `Data source #${dataSources.length + 1} \u2014 URL?`,
|
|
3255
3552
|
placeholder: "https://example.gov/meetings",
|
|
3256
3553
|
validate: (v) => /^https?:\/\//.test(v ?? "") ? void 0 : "Must start with http(s)://"
|
|
3257
3554
|
})
|
|
3258
3555
|
);
|
|
3259
|
-
const dataType =
|
|
3260
|
-
await
|
|
3556
|
+
const dataType = unwrap(
|
|
3557
|
+
await p3.select({
|
|
3261
3558
|
message: "Data type?",
|
|
3262
3559
|
options: DATA_TYPES.map((t) => ({ value: t, label: t }))
|
|
3263
3560
|
})
|
|
3264
3561
|
);
|
|
3265
|
-
const sourceType =
|
|
3266
|
-
await
|
|
3562
|
+
const sourceType = unwrap(
|
|
3563
|
+
await p3.select({
|
|
3267
3564
|
message: "Source type?",
|
|
3268
3565
|
initialValue: "html_scrape",
|
|
3269
3566
|
options: SOURCE_TYPES.map((t) => ({ value: t, label: t }))
|
|
3270
3567
|
})
|
|
3271
3568
|
);
|
|
3272
|
-
const contentGoal =
|
|
3273
|
-
await
|
|
3569
|
+
const contentGoal = unwrap(
|
|
3570
|
+
await p3.text({
|
|
3274
3571
|
message: "Content goal (what should the scraper extract)?",
|
|
3275
3572
|
placeholder: "Fetch upcoming board meeting agendas and minutes",
|
|
3276
3573
|
validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
|
|
3277
3574
|
})
|
|
3278
3575
|
);
|
|
3279
|
-
const category =
|
|
3280
|
-
await
|
|
3576
|
+
const category = unwrap(
|
|
3577
|
+
await p3.text({
|
|
3281
3578
|
message: "Category label (optional)?",
|
|
3282
3579
|
placeholder: "Board of Supervisors"
|
|
3283
3580
|
})
|
|
@@ -3285,8 +3582,8 @@ var regionCommand = new Command("region").description(
|
|
|
3285
3582
|
const src = { url, dataType, sourceType, contentGoal };
|
|
3286
3583
|
if (category && category.trim().length > 0) src.category = category.trim();
|
|
3287
3584
|
dataSources.push(src);
|
|
3288
|
-
const again =
|
|
3289
|
-
await
|
|
3585
|
+
const again = unwrap(
|
|
3586
|
+
await p3.confirm({ message: "Add another data source?", initialValue: false })
|
|
3290
3587
|
);
|
|
3291
3588
|
if (!again) break;
|
|
3292
3589
|
} while (true);
|
|
@@ -3309,8 +3606,8 @@ var regionCommand = new Command("region").description(
|
|
|
3309
3606
|
const file = buildRegionConfig(input);
|
|
3310
3607
|
const issues = validateRegionConfig(file);
|
|
3311
3608
|
if (issues.length > 0) {
|
|
3312
|
-
|
|
3313
|
-
|
|
3609
|
+
p3.note(issues.map((i) => `${pc2.red("\u2022")} ${i}`).join("\n"), "Validation failed");
|
|
3610
|
+
p3.cancel("The generated config would not pass the regions repo CI. Re-run and adjust.");
|
|
3314
3611
|
process.exit(1);
|
|
3315
3612
|
}
|
|
3316
3613
|
const relPath = regionFilePath({
|
|
@@ -3322,21 +3619,21 @@ var regionCommand = new Command("region").description(
|
|
|
3322
3619
|
const absPath = resolve(targetDir, relPath);
|
|
3323
3620
|
const json = `${JSON.stringify(file, null, 2)}
|
|
3324
3621
|
`;
|
|
3325
|
-
|
|
3326
|
-
if (!opts.force && await
|
|
3327
|
-
|
|
3622
|
+
p3.note(json, `${relPath} (preview)`);
|
|
3623
|
+
if (!opts.force && await fileExists3(absPath)) {
|
|
3624
|
+
p3.cancel(`${relPath} already exists. Re-run with --force to overwrite.`);
|
|
3328
3625
|
process.exit(1);
|
|
3329
3626
|
}
|
|
3330
|
-
const write =
|
|
3331
|
-
await
|
|
3627
|
+
const write = unwrap(
|
|
3628
|
+
await p3.confirm({ message: `Write ${relPath}?`, initialValue: true })
|
|
3332
3629
|
);
|
|
3333
3630
|
if (!write) {
|
|
3334
|
-
|
|
3631
|
+
p3.cancel("Nothing written.");
|
|
3335
3632
|
process.exit(0);
|
|
3336
3633
|
}
|
|
3337
3634
|
await mkdir(dirname(absPath), { recursive: true });
|
|
3338
3635
|
await writeFile(absPath, json, "utf8");
|
|
3339
|
-
|
|
3636
|
+
p3.note(
|
|
3340
3637
|
[
|
|
3341
3638
|
`${pc2.green("\u2713")} Wrote ${pc2.cyan(relPath)}`,
|
|
3342
3639
|
"",
|
|
@@ -3347,9 +3644,9 @@ var regionCommand = new Command("region").description(
|
|
|
3347
3644
|
].join("\n"),
|
|
3348
3645
|
"Done"
|
|
3349
3646
|
);
|
|
3350
|
-
|
|
3647
|
+
p3.outro(pc2.magenta(`Region scaffolded: ${regionId}`));
|
|
3351
3648
|
});
|
|
3352
|
-
async function
|
|
3649
|
+
async function fileExists3(path) {
|
|
3353
3650
|
try {
|
|
3354
3651
|
await access(path);
|
|
3355
3652
|
return true;
|
|
@@ -3359,20 +3656,21 @@ async function fileExists2(path) {
|
|
|
3359
3656
|
}
|
|
3360
3657
|
async function looksLikeRegionsRepo(dir) {
|
|
3361
3658
|
const [hasSchema, hasRegions] = await Promise.all([
|
|
3362
|
-
|
|
3363
|
-
|
|
3659
|
+
fileExists3(join(dir, "schema", "region-plugin.schema.json")),
|
|
3660
|
+
fileExists3(join(dir, "regions"))
|
|
3364
3661
|
]);
|
|
3365
3662
|
return hasSchema && hasRegions;
|
|
3366
3663
|
}
|
|
3367
3664
|
|
|
3368
3665
|
// src/cli.ts
|
|
3369
|
-
var VERSION = "0.
|
|
3666
|
+
var VERSION = "0.3.0";
|
|
3370
3667
|
var program = new Command();
|
|
3371
3668
|
program.name("create-op-node").description(
|
|
3372
3669
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|
|
3373
3670
|
).version(VERSION, "-v, --version", "show version");
|
|
3374
3671
|
program.addCommand(initCommand);
|
|
3375
3672
|
program.addCommand(bootstrapCommand);
|
|
3673
|
+
program.addCommand(resetCommand);
|
|
3376
3674
|
program.addCommand(verifyCommand);
|
|
3377
3675
|
program.addCommand(regionCommand);
|
|
3378
3676
|
if (process.argv.length === 2) {
|