create-op-node 0.4.0 → 0.6.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 +73 -7
- package/dist/cli.js +263 -148
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ That's it. The wizard walks you through:
|
|
|
16
16
|
5. **pgsodium master key** — generates a fresh 64-hex root key, stores it in your **macOS login Keychain** as `org.opuspopuli.<region>/pgsodium-root-key`. No third-party password manager required.
|
|
17
17
|
6. **Tunnel token retrieval** — after `terraform apply` lands, fetches the Tunnel token from Terraform Cloud outputs and stores it alongside the pgsodium key in your Keychain.
|
|
18
18
|
|
|
19
|
+
> Doing **local dev / testing first** (no public exposure yet)?
|
|
20
|
+
> See [Local-only mode](#local-only-mode-no-cloudflare) — `init --local-only`
|
|
21
|
+
> skips the Cloudflare/TFC/PR phases entirely.
|
|
22
|
+
|
|
19
23
|
Then on the Mac Studio itself:
|
|
20
24
|
|
|
21
25
|
```bash
|
|
@@ -24,18 +28,79 @@ npx create-op-node bootstrap
|
|
|
24
28
|
|
|
25
29
|
Configures macOS power settings, installs Homebrew + the CLI tool list, sets up Docker Desktop + Tailscale + Ollama, clones the node repo you created, reads the pgsodium key + Tunnel token from the Studio's Keychain (or prompts you to paste them once, then persists for re-runs), writes the LaunchAgent plist, logs into ghcr.io, pulls + warms the LLM model, and finally `docker compose --profile public pull && up -d` brings the whole stack online. Health-check loop waits until all containers are `(healthy)`.
|
|
26
30
|
|
|
31
|
+
### Choosing the LLM model
|
|
32
|
+
|
|
33
|
+
By default bootstrap pulls `qwen3.5:9b` (LLM) and `nomic-embed-text`
|
|
34
|
+
(embeddings) — small enough to validate the inference path on first
|
|
35
|
+
run. Override with flags:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Just swap the LLM, keep the default embedding model:
|
|
39
|
+
npx create-op-node bootstrap --region us-ca --llm-model llama3.3:70b
|
|
40
|
+
|
|
41
|
+
# Override both:
|
|
42
|
+
npx create-op-node bootstrap \
|
|
43
|
+
--region us-ca \
|
|
44
|
+
--llm-model llama3.3:70b \
|
|
45
|
+
--embedding-model mxbai-embed-large
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The chosen models flow two places:
|
|
49
|
+
|
|
50
|
+
1. **Ollama**: bootstrap pulls + warms them so the daemon has them
|
|
51
|
+
resident before the stack comes up. The embedding model pulls first
|
|
52
|
+
(small, fast feedback); the LLM pulls second (can be tens of GB).
|
|
53
|
+
2. **LaunchAgent**: the plist exports `LLM_MODEL` and `EMBEDDINGS_MODEL`
|
|
54
|
+
into the launchd session, which Docker Desktop inherits — so compose
|
|
55
|
+
services read them via env, no `.env.production` edits needed.
|
|
56
|
+
|
|
57
|
+
> **`--embedding-model` only takes effect when the knowledge service
|
|
58
|
+
> runs with `EMBEDDINGS_PROVIDER=ollama`.** The default provider is
|
|
59
|
+
> `xenova` (in-process), which bundles its own embedding model and
|
|
60
|
+
> ignores both `EMBEDDINGS_MODEL` and the local Ollama model. Setting
|
|
61
|
+
> `EMBEDDINGS_PROVIDER=ollama` is a separate decision (set in your
|
|
62
|
+
> region repo's `.env.production`) — see `docs/provider-pattern.md`.
|
|
63
|
+
|
|
64
|
+
> **Template contract**: for `--llm-model` to actually change the
|
|
65
|
+
> running model, the region repo's `docker-compose-prod.yml` must use
|
|
66
|
+
> `${LLM_MODEL:-qwen3.5:9b}` (or similar) on the knowledge service's
|
|
67
|
+
> `environment:` block. The current `opuspopuli-node` template does;
|
|
68
|
+
> a fork that hardcodes the value would ignore the flag silently.
|
|
69
|
+
|
|
70
|
+
For RAM sizing, the [Docker resources doc](https://github.com/OpusPopuli/opuspopuli-node/blob/main/docs/docker-resources.md)
|
|
71
|
+
has a tier table: 9B-class needs ~8 GB Ollama; 70B-class needs ~50 GB;
|
|
72
|
+
frontier MoE needs ~80 GB. Allocate Docker the remainder.
|
|
73
|
+
|
|
74
|
+
To switch models post-bootstrap, re-run with the new flag and
|
|
75
|
+
`docker compose down && up -d` to pick up the changed env.
|
|
76
|
+
|
|
27
77
|
### Local-only mode (no Cloudflare)
|
|
28
78
|
|
|
79
|
+
For local dev / testing — frontend on your laptop, backend on the Studio
|
|
80
|
+
over Tailscale, no public exposure. The flow mirrors production-init →
|
|
81
|
+
production-bootstrap, just with the Cloudflare half cut out on both
|
|
82
|
+
sides.
|
|
83
|
+
|
|
84
|
+
**On the laptop:**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx create-op-node init --region us-ca --local-only
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This creates the region repo from the `OpusPopuli/opuspopuli-node`
|
|
91
|
+
template (private, no public exposure), generates the pgsodium master
|
|
92
|
+
key, and saves it to Keychain. No Cloudflare, no Terraform Cloud, no PR
|
|
93
|
+
to merge.
|
|
94
|
+
|
|
95
|
+
**On the Studio:**
|
|
96
|
+
|
|
29
97
|
```bash
|
|
30
98
|
npx create-op-node bootstrap --region us-ca --local-only
|
|
31
99
|
```
|
|
32
100
|
|
|
33
|
-
|
|
34
|
-
hits the Studio over Tailscale, no public exposure. Differences from
|
|
35
|
-
the standard run:
|
|
101
|
+
Differences from production bootstrap:
|
|
36
102
|
|
|
37
|
-
- **No Tunnel token required.** `init`
|
|
38
|
-
key isn't in Keychain, bootstrap generates one inline and persists it.
|
|
103
|
+
- **No Tunnel token required.** `init --local-only` skipped that phase.
|
|
39
104
|
- **`cloudflared` stays down.** It's gated behind the `public` compose
|
|
40
105
|
profile, which `--local-only` doesn't activate. Bootstrap also evicts
|
|
41
106
|
any leftover cloudflared from a prior public run so it doesn't strand
|
|
@@ -47,8 +112,9 @@ the standard run:
|
|
|
47
112
|
exported into the launchd session.
|
|
48
113
|
- **Outro tells you to use Tailscale**, not `npx create-op-node verify`.
|
|
49
114
|
|
|
50
|
-
When you're ready to
|
|
51
|
-
|
|
115
|
+
When you're ready to expose publicly, re-run **both** commands without
|
|
116
|
+
`--local-only`. Same region repo, same pgsodium key — promotes
|
|
117
|
+
cleanly to the production-shaped deploy.
|
|
52
118
|
|
|
53
119
|
> **Template version**: this mode depends on the `opuspopuli-node`
|
|
54
120
|
> template having `profiles: [public]` on its cloudflared service. If
|
package/dist/cli.js
CHANGED
|
@@ -536,10 +536,23 @@ var initCommand = new Command("init").description(
|
|
|
536
536
|
"--use-existing-repo",
|
|
537
537
|
"Continue using a previously-created node repo (don't fail on 'already exists')"
|
|
538
538
|
).default(false)
|
|
539
|
-
).addOption(new Option("--skip-wait", "Don't poll for Terraform apply; exit after PR open").default(false)).addOption(
|
|
539
|
+
).addOption(new Option("--skip-wait", "Don't poll for Terraform apply; exit after PR open").default(false)).addOption(
|
|
540
|
+
new Option(
|
|
541
|
+
"--local-only",
|
|
542
|
+
"Skip the Cloudflare/TFC/PR/tunnel phases. Creates the region repo from template + stores a pgsodium key \u2014 enough for `bootstrap --local-only` to work."
|
|
543
|
+
).default(false)
|
|
544
|
+
).addOption(new Option("-y, --yes", "Skip the final confirmation").default(false)).action(async (opts) => {
|
|
540
545
|
p3.intro(pc2.bgCyan(pc2.black(" create-op-node init ")));
|
|
541
546
|
p3.note(
|
|
542
|
-
[
|
|
547
|
+
opts.localOnly ? [
|
|
548
|
+
"Local-only setup \u2014 creates the region repo from template + a pgsodium key.",
|
|
549
|
+
"No Cloudflare, no Terraform Cloud, no public exposure.",
|
|
550
|
+
"",
|
|
551
|
+
pc2.dim("Phase 1: laptop side (now) \u2014 GitHub repo from template + Keychain."),
|
|
552
|
+
pc2.dim("Phase 2: Studio side \u2014 `create-op-node bootstrap --local-only`."),
|
|
553
|
+
"",
|
|
554
|
+
pc2.dim("When you're ready to expose publicly, re-run `init` without --local-only.")
|
|
555
|
+
].join("\n") : [
|
|
543
556
|
"This walks you from a sealed Mac Studio + a Cloudflare account to a",
|
|
544
557
|
"live federation node serving traffic at api.<your-domain>.",
|
|
545
558
|
"",
|
|
@@ -555,62 +568,25 @@ var initCommand = new Command("init").description(
|
|
|
555
568
|
validate: (v) => /^[a-z0-9-]{2,32}$/.test(v ?? "") ? void 0 : "lowercase letters, digits, hyphens; 2\u201332 chars"
|
|
556
569
|
})
|
|
557
570
|
);
|
|
558
|
-
const domain = opts.domain ? opts.domain : unwrap(
|
|
559
|
-
await p3.text({
|
|
560
|
-
message: "Domain registered in Cloudflare?",
|
|
561
|
-
placeholder: "example.org",
|
|
562
|
-
validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Missing a TLD?"
|
|
563
|
-
})
|
|
564
|
-
);
|
|
565
571
|
const owner = opts.owner ?? "OpusPopuli";
|
|
566
572
|
const newRepoName = `opuspopuli-node-${region}`;
|
|
567
573
|
const newRepoFull = `${owner}/${newRepoName}`;
|
|
568
|
-
|
|
569
|
-
opts
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (!cfProbe.ok) {
|
|
583
|
-
cfSpin.stop(pc2.red("\u2717 Cloudflare token check failed."));
|
|
584
|
-
for (const issue of cfProbe.issues) console.error(` - ${issue}`);
|
|
585
|
-
p3.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
|
|
586
|
-
process.exit(1);
|
|
587
|
-
}
|
|
588
|
-
cfSpin.stop(pc2.green("\u2713 Cloudflare token valid, 5 scopes present."));
|
|
589
|
-
const tfToken = await collectSecret(
|
|
590
|
-
opts.tfToken,
|
|
591
|
-
"Terraform Cloud user/team API token",
|
|
592
|
-
"tfc"
|
|
593
|
-
);
|
|
594
|
-
const tfOrg = opts.tfOrg ? opts.tfOrg : unwrap(
|
|
595
|
-
await p3.text({
|
|
596
|
-
message: "Terraform Cloud organization name?",
|
|
597
|
-
placeholder: "op-region-ca"
|
|
598
|
-
})
|
|
599
|
-
);
|
|
600
|
-
const tfSpin = p3.spinner();
|
|
601
|
-
tfSpin.start("Verifying Terraform Cloud token + organization\u2026");
|
|
602
|
-
const tfProbe = await probeTfcToken({ token: tfToken, organization: tfOrg });
|
|
603
|
-
if (!tfProbe.ok) {
|
|
604
|
-
tfSpin.stop(pc2.red("\u2717 Terraform Cloud check failed."));
|
|
605
|
-
for (const issue of tfProbe.issues) console.error(` - ${issue}`);
|
|
606
|
-
p3.cancel("Fix the token / org, then re-run.");
|
|
607
|
-
process.exit(1);
|
|
574
|
+
if (opts.localOnly) {
|
|
575
|
+
const ignored = listIgnoredLocalOnlyFlags(opts);
|
|
576
|
+
if (ignored.length > 0) {
|
|
577
|
+
p3.note(
|
|
578
|
+
pc2.yellow(
|
|
579
|
+
`\u26A0 The following ${ignored.length === 1 ? "flag is" : "flags are"} ignored with --local-only:
|
|
580
|
+
${ignored.join(", ")}
|
|
581
|
+
` + pc2.dim(
|
|
582
|
+
"These configure Cloudflare / TFC / PR-merge flow, which --local-only skips."
|
|
583
|
+
)
|
|
584
|
+
),
|
|
585
|
+
"Notice"
|
|
586
|
+
);
|
|
587
|
+
}
|
|
608
588
|
}
|
|
609
|
-
|
|
610
|
-
pc2.green(
|
|
611
|
-
`\u2713 TFC token valid${tfProbe.userName ? ` (as ${tfProbe.userName})` : ""}, org "${tfOrg}" reachable.`
|
|
612
|
-
)
|
|
613
|
-
);
|
|
589
|
+
const publicConfig = opts.localOnly ? null : await collectPublicConfig(opts);
|
|
614
590
|
let ghToken = opts.ghToken;
|
|
615
591
|
if (!ghToken) {
|
|
616
592
|
const fromCli = await ghTokenFromCli();
|
|
@@ -636,12 +612,23 @@ var initCommand = new Command("init").description(
|
|
|
636
612
|
` + pc2.dim("Note: items don't sync to iCloud Keychain via the security CLI.\n") + pc2.dim("On the Studio, bootstrap will read locally and prompt you to paste if missing.") : `\u26A0 ${keychain.reason ?? "Keychain unavailable"}. Secrets will be printed for manual stash.`;
|
|
637
613
|
p3.note(kcNote, "Secret store");
|
|
638
614
|
p3.note(
|
|
639
|
-
[
|
|
615
|
+
opts.localOnly ? [
|
|
640
616
|
`Region label: ${pc2.cyan(region)}`,
|
|
641
|
-
`Domain: ${pc2.cyan(domain)}`,
|
|
642
617
|
`New node repo: ${pc2.cyan(newRepoFull)}`,
|
|
643
618
|
`Template: ${pc2.dim(opts.template ?? "OpusPopuli/opuspopuli-node")}`,
|
|
644
|
-
`
|
|
619
|
+
`GH org write access: ${pc2.dim(`required for ${owner}`)}`,
|
|
620
|
+
``,
|
|
621
|
+
`Will create the repo from template (private, no Cloudflare),`,
|
|
622
|
+
`and store a fresh pgsodium key in Keychain. That's it \u2014 no PR,`,
|
|
623
|
+
`no terraform, no tunnel token. Bootstrap on the Studio with`,
|
|
624
|
+
`\`bootstrap --local-only --region ${region}\` next.`
|
|
625
|
+
].join("\n") : [
|
|
626
|
+
`Region label: ${pc2.cyan(region)}`,
|
|
627
|
+
`Domain: ${pc2.cyan(publicConfig?.domain ?? "")}`,
|
|
628
|
+
`New node repo: ${pc2.cyan(newRepoFull)}`,
|
|
629
|
+
`Template: ${pc2.dim(opts.template ?? "OpusPopuli/opuspopuli-node")}`,
|
|
630
|
+
`TFC organization: ${pc2.cyan(publicConfig?.tfOrg ?? "")}`,
|
|
631
|
+
`GH org write access: ${pc2.dim(`required for ${owner}`)}`,
|
|
645
632
|
``,
|
|
646
633
|
`Will create the repo, seed 5 secrets, write prod.tfvars on a branch,`,
|
|
647
634
|
`open a PR, generate a fresh pgsodium key, and (after you merge) wait`,
|
|
@@ -697,90 +684,94 @@ var initCommand = new Command("init").description(
|
|
|
697
684
|
defaultBranch: "main"
|
|
698
685
|
};
|
|
699
686
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
687
|
+
if (publicConfig) {
|
|
688
|
+
const secrets = [
|
|
689
|
+
{ name: "CLOUDFLARE_API_TOKEN", value: publicConfig.cfToken },
|
|
690
|
+
{ name: "CLOUDFLARE_ACCOUNT_ID", value: publicConfig.cfAccount },
|
|
691
|
+
{ name: "CLOUDFLARE_ZONE_ID", value: publicConfig.cfZone },
|
|
692
|
+
{ name: "TF_API_TOKEN", value: publicConfig.tfToken },
|
|
693
|
+
{ name: "TF_CLOUD_ORGANIZATION", value: publicConfig.tfOrg }
|
|
694
|
+
];
|
|
695
|
+
const secSpin = p3.spinner();
|
|
696
|
+
secSpin.start(`Seeding ${secrets.length} repo secrets\u2026`);
|
|
697
|
+
const seeded = [];
|
|
698
|
+
for (const s of secrets) {
|
|
699
|
+
const r = await setRepoSecret({ repo: newRepoFull, name: s.name, value: s.value });
|
|
700
|
+
if (!r.written) {
|
|
701
|
+
secSpin.stop(pc2.red(`\u2717 Failed to set ${s.name}: ${r.reason ?? "unknown"}`));
|
|
702
|
+
const remaining = secrets.slice(secrets.findIndex((x) => x.name === s.name)).map((x) => x.name);
|
|
703
|
+
p3.cancel(
|
|
704
|
+
[
|
|
705
|
+
seeded.length > 0 ? `Already seeded on ${newRepoFull}: ${seeded.join(", ")}.` : `Nothing seeded yet on ${newRepoFull}.`,
|
|
706
|
+
`Still pending: ${remaining.join(", ")}.`,
|
|
707
|
+
"",
|
|
708
|
+
`Make sure \`gh\` is installed and signed in (\`gh auth login\`).`,
|
|
709
|
+
`Re-run with --use-existing-repo to retry (GitHub will overwrite the already-set secrets idempotently).`
|
|
710
|
+
].join("\n")
|
|
711
|
+
);
|
|
712
|
+
process.exit(1);
|
|
713
|
+
}
|
|
714
|
+
seeded.push(s.name);
|
|
715
|
+
}
|
|
716
|
+
secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
|
|
717
|
+
}
|
|
718
|
+
let pr = null;
|
|
719
|
+
if (publicConfig) {
|
|
720
|
+
const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
|
|
721
|
+
const setupSpin = p3.spinner();
|
|
722
|
+
setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
|
|
723
|
+
try {
|
|
724
|
+
await createBranch({
|
|
725
|
+
token: ghToken,
|
|
726
|
+
repo: newRepoFull,
|
|
727
|
+
branch,
|
|
728
|
+
fromBranch: created.defaultBranch
|
|
729
|
+
});
|
|
730
|
+
const tfvars = renderProdTfvars({
|
|
731
|
+
project: opts.project ?? "opuspopuli",
|
|
732
|
+
domain: publicConfig.domain
|
|
733
|
+
});
|
|
734
|
+
await commitFile({
|
|
735
|
+
token: ghToken,
|
|
736
|
+
repo: newRepoFull,
|
|
737
|
+
branch,
|
|
738
|
+
path: "infra/cloudflare/environments/prod.tfvars",
|
|
739
|
+
content: tfvars,
|
|
740
|
+
message: `init: prod.tfvars for ${region}`
|
|
741
|
+
});
|
|
742
|
+
} catch (err) {
|
|
743
|
+
setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
|
|
744
|
+
p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
|
|
745
|
+
process.exit(1);
|
|
746
|
+
}
|
|
747
|
+
setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
|
|
748
|
+
const prSpin = p3.spinner();
|
|
749
|
+
prSpin.start("Opening pull request\u2026");
|
|
750
|
+
try {
|
|
751
|
+
pr = await openPullRequest({
|
|
752
|
+
token: ghToken,
|
|
753
|
+
repo: newRepoFull,
|
|
754
|
+
head: branch,
|
|
755
|
+
base: created.defaultBranch,
|
|
756
|
+
title: `init: bring up region ${region}`,
|
|
757
|
+
body: [
|
|
758
|
+
`Initial region deployment for **${region}** (${publicConfig.domain}).`,
|
|
759
|
+
"",
|
|
760
|
+
"Adds `infra/cloudflare/environments/prod.tfvars`. Merging this PR triggers",
|
|
761
|
+
"`cloudflare-infra.yml` which runs `terraform apply` against the",
|
|
762
|
+
`\`${publicConfig.tfOrg}\` Terraform Cloud organization, provisioning the Cloudflare`,
|
|
763
|
+
"Tunnel, DNS records, R2 buckets, and Pages project.",
|
|
719
764
|
"",
|
|
720
|
-
`
|
|
721
|
-
`Re-run with --use-existing-repo to retry (GitHub will overwrite the already-set secrets idempotently).`
|
|
765
|
+
`Generated by \`create-op-node init\`.`
|
|
722
766
|
].join("\n")
|
|
723
|
-
);
|
|
767
|
+
});
|
|
768
|
+
} catch (err) {
|
|
769
|
+
prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
|
|
770
|
+
p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
|
|
724
771
|
process.exit(1);
|
|
725
772
|
}
|
|
726
|
-
|
|
773
|
+
prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
|
|
727
774
|
}
|
|
728
|
-
secSpin.stop(pc2.green(`\u2713 Seeded ${secrets.length} secrets on ${newRepoFull}`));
|
|
729
|
-
const branch = `init/region-${region}-${isoStampUtc(/* @__PURE__ */ new Date())}`;
|
|
730
|
-
const setupSpin = p3.spinner();
|
|
731
|
-
setupSpin.start(`Writing prod.tfvars on branch ${branch}\u2026`);
|
|
732
|
-
try {
|
|
733
|
-
await createBranch({
|
|
734
|
-
token: ghToken,
|
|
735
|
-
repo: newRepoFull,
|
|
736
|
-
branch,
|
|
737
|
-
fromBranch: created.defaultBranch
|
|
738
|
-
});
|
|
739
|
-
const tfvars = renderProdTfvars({
|
|
740
|
-
project: opts.project ?? "opuspopuli",
|
|
741
|
-
domain
|
|
742
|
-
});
|
|
743
|
-
await commitFile({
|
|
744
|
-
token: ghToken,
|
|
745
|
-
repo: newRepoFull,
|
|
746
|
-
branch,
|
|
747
|
-
path: "infra/cloudflare/environments/prod.tfvars",
|
|
748
|
-
content: tfvars,
|
|
749
|
-
message: `init: prod.tfvars for ${region}`
|
|
750
|
-
});
|
|
751
|
-
} catch (err) {
|
|
752
|
-
setupSpin.stop(pc2.red(`\u2717 Couldn't write tfvars: ${err.message}`));
|
|
753
|
-
p3.cancel("The repo exists but the branch / commit failed. Open it on GitHub and inspect.");
|
|
754
|
-
process.exit(1);
|
|
755
|
-
}
|
|
756
|
-
setupSpin.stop(pc2.green("\u2713 Wrote prod.tfvars"));
|
|
757
|
-
const prSpin = p3.spinner();
|
|
758
|
-
prSpin.start("Opening pull request\u2026");
|
|
759
|
-
let pr;
|
|
760
|
-
try {
|
|
761
|
-
pr = await openPullRequest({
|
|
762
|
-
token: ghToken,
|
|
763
|
-
repo: newRepoFull,
|
|
764
|
-
head: branch,
|
|
765
|
-
base: created.defaultBranch,
|
|
766
|
-
title: `init: bring up region ${region}`,
|
|
767
|
-
body: [
|
|
768
|
-
`Initial region deployment for **${region}** (${domain}).`,
|
|
769
|
-
"",
|
|
770
|
-
"Adds `infra/cloudflare/environments/prod.tfvars`. Merging this PR triggers",
|
|
771
|
-
"`cloudflare-infra.yml` which runs `terraform apply` against the",
|
|
772
|
-
`\`${tfOrg}\` Terraform Cloud organization, provisioning the Cloudflare`,
|
|
773
|
-
"Tunnel, DNS records, R2 buckets, and Pages project.",
|
|
774
|
-
"",
|
|
775
|
-
`Generated by \`create-op-node init\`.`
|
|
776
|
-
].join("\n")
|
|
777
|
-
});
|
|
778
|
-
} catch (err) {
|
|
779
|
-
prSpin.stop(pc2.red(`\u2717 Couldn't open PR: ${err.message}`));
|
|
780
|
-
p3.cancel("Branch + tfvars are committed; open the PR by hand on GitHub.");
|
|
781
|
-
process.exit(1);
|
|
782
|
-
}
|
|
783
|
-
prSpin.stop(pc2.green(`\u2713 Opened PR #${pr.number}`));
|
|
784
775
|
const pgsodiumCoords = { region, account: "pgsodium-root-key" };
|
|
785
776
|
const keyLabel = `org.opuspopuli.${region}/pgsodium-root-key`;
|
|
786
777
|
if (keychain.available) {
|
|
@@ -806,6 +797,28 @@ var initCommand = new Command("init").description(
|
|
|
806
797
|
const fresh = generatePgsodiumRootKey();
|
|
807
798
|
await printKeyForManualSave(fresh, keyLabel);
|
|
808
799
|
}
|
|
800
|
+
if (!publicConfig) {
|
|
801
|
+
p3.outro(
|
|
802
|
+
pc2.cyan(
|
|
803
|
+
[
|
|
804
|
+
`Region ${region} provisioned for local-only mode.`,
|
|
805
|
+
`Repo: https://github.com/${newRepoFull}`,
|
|
806
|
+
``,
|
|
807
|
+
`Next: on the Studio (fresh or otherwise \u2014 bootstrap installs`,
|
|
808
|
+
`brew packages it needs), run:`,
|
|
809
|
+
` npx create-op-node bootstrap --region ${region} --local-only`,
|
|
810
|
+
``,
|
|
811
|
+
`When you're ready to expose publicly, re-run BOTH \`init\` and`,
|
|
812
|
+
`\`bootstrap\` without --local-only. Same region repo + pgsodium key`,
|
|
813
|
+
`promote cleanly to the production-shaped deploy.`
|
|
814
|
+
].join("\n")
|
|
815
|
+
)
|
|
816
|
+
);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (!pr) {
|
|
820
|
+
throw new Error("init: PR creation step did not run despite publicConfig present");
|
|
821
|
+
}
|
|
809
822
|
if (opts.skipWait) {
|
|
810
823
|
p3.outro(
|
|
811
824
|
pc2.cyan(
|
|
@@ -833,19 +846,19 @@ var initCommand = new Command("init").description(
|
|
|
833
846
|
return;
|
|
834
847
|
}
|
|
835
848
|
const ws = await findWorkspace({
|
|
836
|
-
token: tfToken,
|
|
837
|
-
organization: tfOrg,
|
|
849
|
+
token: publicConfig.tfToken,
|
|
850
|
+
organization: publicConfig.tfOrg,
|
|
838
851
|
tags: ["opuspopuli", "cloudflare"]
|
|
839
852
|
});
|
|
840
853
|
if (!ws) {
|
|
841
854
|
p3.cancel(
|
|
842
|
-
`Couldn't find a TFC workspace tagged opuspopuli + cloudflare in ${tfOrg}. Check the workflow's run log; the workspace is created on first apply.`
|
|
855
|
+
`Couldn't find a TFC workspace tagged opuspopuli + cloudflare in ${publicConfig.tfOrg}. Check the workflow's run log; the workspace is created on first apply.`
|
|
843
856
|
);
|
|
844
857
|
process.exit(1);
|
|
845
858
|
}
|
|
846
859
|
const tunnelToken = await waitForApplyAndFetchTunnelToken({
|
|
847
|
-
token: tfToken,
|
|
848
|
-
organization: tfOrg,
|
|
860
|
+
token: publicConfig.tfToken,
|
|
861
|
+
organization: publicConfig.tfOrg,
|
|
849
862
|
workspaceId: ws.id,
|
|
850
863
|
runId: ws.currentRunId
|
|
851
864
|
});
|
|
@@ -882,6 +895,73 @@ var MIN_TOKEN_LENGTH = {
|
|
|
882
895
|
github: 40,
|
|
883
896
|
generic: 20
|
|
884
897
|
};
|
|
898
|
+
function listIgnoredLocalOnlyFlags(opts) {
|
|
899
|
+
const ignored = [];
|
|
900
|
+
if (opts.domain) ignored.push("--domain");
|
|
901
|
+
if (opts.cfToken) ignored.push("--cf-token");
|
|
902
|
+
if (opts.cfAccount) ignored.push("--cf-account");
|
|
903
|
+
if (opts.cfZone) ignored.push("--cf-zone");
|
|
904
|
+
if (opts.tfToken) ignored.push("--tf-token");
|
|
905
|
+
if (opts.tfOrg) ignored.push("--tf-org");
|
|
906
|
+
if (opts.skipWait) ignored.push("--skip-wait");
|
|
907
|
+
return ignored;
|
|
908
|
+
}
|
|
909
|
+
async function collectPublicConfig(opts) {
|
|
910
|
+
const domain = opts.domain ? opts.domain : unwrap(
|
|
911
|
+
await p3.text({
|
|
912
|
+
message: "Domain registered in Cloudflare?",
|
|
913
|
+
placeholder: "example.org",
|
|
914
|
+
validate: (v) => !v ? "Required" : v.includes(".") ? void 0 : "Missing a TLD?"
|
|
915
|
+
})
|
|
916
|
+
);
|
|
917
|
+
const cfToken = await collectSecret(
|
|
918
|
+
opts.cfToken,
|
|
919
|
+
"Cloudflare Account API token (input hidden)",
|
|
920
|
+
"cloudflare"
|
|
921
|
+
);
|
|
922
|
+
const cfAccount = await collectId(opts.cfAccount, "Cloudflare account ID");
|
|
923
|
+
const cfZone = await collectId(opts.cfZone, `Cloudflare zone ID for ${domain}`);
|
|
924
|
+
const cfSpin = p3.spinner();
|
|
925
|
+
cfSpin.start("Verifying Cloudflare token + 5 scopes\u2026");
|
|
926
|
+
const cfProbe = await probeCloudflareToken({
|
|
927
|
+
token: cfToken,
|
|
928
|
+
accountId: cfAccount,
|
|
929
|
+
zoneId: cfZone
|
|
930
|
+
});
|
|
931
|
+
if (!cfProbe.ok) {
|
|
932
|
+
cfSpin.stop(pc2.red("\u2717 Cloudflare token check failed."));
|
|
933
|
+
for (const issue of cfProbe.issues) console.error(` - ${issue}`);
|
|
934
|
+
p3.cancel("Fix the token in the Cloudflare dashboard, then re-run.");
|
|
935
|
+
process.exit(1);
|
|
936
|
+
}
|
|
937
|
+
cfSpin.stop(pc2.green("\u2713 Cloudflare token valid, 5 scopes present."));
|
|
938
|
+
const tfToken = await collectSecret(
|
|
939
|
+
opts.tfToken,
|
|
940
|
+
"Terraform Cloud user/team API token",
|
|
941
|
+
"tfc"
|
|
942
|
+
);
|
|
943
|
+
const tfOrg = opts.tfOrg ? opts.tfOrg : unwrap(
|
|
944
|
+
await p3.text({
|
|
945
|
+
message: "Terraform Cloud organization name?",
|
|
946
|
+
placeholder: "op-region-ca"
|
|
947
|
+
})
|
|
948
|
+
);
|
|
949
|
+
const tfSpin = p3.spinner();
|
|
950
|
+
tfSpin.start("Verifying Terraform Cloud token + organization\u2026");
|
|
951
|
+
const tfProbe = await probeTfcToken({ token: tfToken, organization: tfOrg });
|
|
952
|
+
if (!tfProbe.ok) {
|
|
953
|
+
tfSpin.stop(pc2.red("\u2717 Terraform Cloud check failed."));
|
|
954
|
+
for (const issue of tfProbe.issues) console.error(` - ${issue}`);
|
|
955
|
+
p3.cancel("Fix the token / org, then re-run.");
|
|
956
|
+
process.exit(1);
|
|
957
|
+
}
|
|
958
|
+
tfSpin.stop(
|
|
959
|
+
pc2.green(
|
|
960
|
+
`\u2713 TFC token valid${tfProbe.userName ? ` (as ${tfProbe.userName})` : ""}, org "${tfOrg}" reachable.`
|
|
961
|
+
)
|
|
962
|
+
);
|
|
963
|
+
return { domain, cfToken, cfAccount, cfZone, tfToken, tfOrg };
|
|
964
|
+
}
|
|
885
965
|
async function collectSecret(preset, message, kind = "generic") {
|
|
886
966
|
if (preset) return preset;
|
|
887
967
|
const min = MIN_TOKEN_LENGTH[kind];
|
|
@@ -1109,10 +1189,21 @@ async function writePgsodiumKeyFile(key, keyFile) {
|
|
|
1109
1189
|
return { ok: false, reason: `writing ${keyFile} failed: ${err.message}` };
|
|
1110
1190
|
}
|
|
1111
1191
|
}
|
|
1192
|
+
var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
1112
1193
|
function renderLaunchAgentPlist(input) {
|
|
1113
1194
|
if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
|
|
1114
1195
|
throw new Error("Tunnel token contains characters outside the expected base64-url set");
|
|
1115
1196
|
}
|
|
1197
|
+
if (input.llmModel !== void 0 && !MODEL_NAME_RE.test(input.llmModel)) {
|
|
1198
|
+
throw new Error(
|
|
1199
|
+
`llmModel ${JSON.stringify(input.llmModel)} contains characters not allowed in a launchd setenv value`
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
if (input.embeddingModel !== void 0 && !MODEL_NAME_RE.test(input.embeddingModel)) {
|
|
1203
|
+
throw new Error(
|
|
1204
|
+
`embeddingModel ${JSON.stringify(input.embeddingModel)} contains characters not allowed in a launchd setenv value`
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1116
1207
|
if (!SAFE_PATH_RE.test(input.keyFilePath)) {
|
|
1117
1208
|
throw new Error(
|
|
1118
1209
|
`keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
|
|
@@ -1120,7 +1211,9 @@ function renderLaunchAgentPlist(input) {
|
|
|
1120
1211
|
}
|
|
1121
1212
|
const setenvLines = [
|
|
1122
1213
|
`launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
|
|
1123
|
-
...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : []
|
|
1214
|
+
...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : [],
|
|
1215
|
+
...input.llmModel !== void 0 ? [`launchctl setenv LLM_MODEL "${input.llmModel}"`] : [],
|
|
1216
|
+
...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : []
|
|
1124
1217
|
];
|
|
1125
1218
|
const command = setenvLines.join("; ");
|
|
1126
1219
|
return [
|
|
@@ -1175,7 +1268,9 @@ async function setupLaunchAgent(input) {
|
|
|
1175
1268
|
try {
|
|
1176
1269
|
plistContent = renderLaunchAgentPlist({
|
|
1177
1270
|
keyFilePath: paths.keyFile,
|
|
1178
|
-
...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {}
|
|
1271
|
+
...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {},
|
|
1272
|
+
...input.llmModel !== void 0 ? { llmModel: input.llmModel } : {},
|
|
1273
|
+
...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {}
|
|
1179
1274
|
});
|
|
1180
1275
|
} catch (err) {
|
|
1181
1276
|
return { ok: false, paths, step: "plist", reason: err.message };
|
|
@@ -1395,7 +1490,8 @@ function assessHealth(snapshots, requireHealthy) {
|
|
|
1395
1490
|
|
|
1396
1491
|
// src/lib/ollama.ts
|
|
1397
1492
|
var OLLAMA_URL = "http://localhost:11434";
|
|
1398
|
-
var
|
|
1493
|
+
var DEFAULT_LLM_MODEL = "qwen3.5:9b";
|
|
1494
|
+
var DEFAULT_EMBEDDING_MODEL = "nomic-embed-text";
|
|
1399
1495
|
var PROBE_ALPINE_TAG = "3.20";
|
|
1400
1496
|
async function startOllamaService() {
|
|
1401
1497
|
const res = await safeExeca("brew", ["services", "start", "ollama"]);
|
|
@@ -1566,7 +1662,17 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1566
1662
|
"--skip-launch-agent",
|
|
1567
1663
|
"Skip the LaunchAgent setup (assumes one is already in place)"
|
|
1568
1664
|
).default(false)
|
|
1569
|
-
).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(
|
|
1665
|
+
).addOption(new Option("--skip-ollama", "Skip the Ollama model pull + warm").default(false)).addOption(
|
|
1666
|
+
new Option(
|
|
1667
|
+
"--llm-model <model>",
|
|
1668
|
+
`Ollama LLM model to pull and warm. Default: ${DEFAULT_LLM_MODEL}. Examples: \`llama3.3:70b\`, \`qwen2.5:72b\`. Memory sizing table: docs/docker-resources.md in the opuspopuli-node template (or your region repo's checkout).`
|
|
1669
|
+
)
|
|
1670
|
+
).addOption(
|
|
1671
|
+
new Option(
|
|
1672
|
+
"--embedding-model <model>",
|
|
1673
|
+
`Ollama embedding model. Default: ${DEFAULT_EMBEDDING_MODEL}. Only takes effect when the knowledge service runs with EMBEDDINGS_PROVIDER=ollama (otherwise embeddings are computed in-process via xenova).`
|
|
1674
|
+
)
|
|
1675
|
+
).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
|
|
1570
1676
|
new Option(
|
|
1571
1677
|
"--local-only",
|
|
1572
1678
|
"Run for local dev / testing: no Tunnel token required, cloudflared stays down. Auto-generates the pgsodium key if not in Keychain (init unnecessary)."
|
|
@@ -1584,6 +1690,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1584
1690
|
const repoName = `opuspopuli-node-${region}`;
|
|
1585
1691
|
const composeFileDefault = opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
|
|
1586
1692
|
const composeFile = opts.composeFile ?? composeFileDefault;
|
|
1693
|
+
const [embeddingModel, llmModel] = resolveModels(opts);
|
|
1587
1694
|
const sysSpin = p3.spinner();
|
|
1588
1695
|
sysSpin.start("Inspecting macOS\u2026");
|
|
1589
1696
|
const snap = await inspectSystem();
|
|
@@ -1719,7 +1826,9 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1719
1826
|
laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
|
|
1720
1827
|
const la = await setupLaunchAgent({
|
|
1721
1828
|
pgsodiumKey,
|
|
1722
|
-
...tunnelToken !== void 0 ? { tunnelToken } : {}
|
|
1829
|
+
...tunnelToken !== void 0 ? { tunnelToken } : {},
|
|
1830
|
+
llmModel,
|
|
1831
|
+
embeddingModel
|
|
1723
1832
|
});
|
|
1724
1833
|
if (!la.ok) {
|
|
1725
1834
|
laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
|
|
@@ -1767,7 +1876,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1767
1876
|
process.exit(1);
|
|
1768
1877
|
}
|
|
1769
1878
|
}
|
|
1770
|
-
const modelReport = await setupModels(
|
|
1879
|
+
const modelReport = await setupModels([embeddingModel, llmModel], (model, status) => {
|
|
1771
1880
|
olSpin.message(`${status}: ${model}`);
|
|
1772
1881
|
});
|
|
1773
1882
|
olSpin.stop(
|
|
@@ -1886,6 +1995,12 @@ function resolveComposeFiles(repoPath, composeFile) {
|
|
|
1886
1995
|
const inputs = composeFile ?? ["docker-compose-prod.yml"];
|
|
1887
1996
|
return inputs.map((f) => f.startsWith("/") ? f : join(repoPath, f));
|
|
1888
1997
|
}
|
|
1998
|
+
function resolveModels(opts) {
|
|
1999
|
+
return [
|
|
2000
|
+
opts.embeddingModel ?? DEFAULT_EMBEDDING_MODEL,
|
|
2001
|
+
opts.llmModel ?? DEFAULT_LLM_MODEL
|
|
2002
|
+
];
|
|
2003
|
+
}
|
|
1889
2004
|
function kvBool(b) {
|
|
1890
2005
|
return b ? pc2.green("on") : pc2.yellow("off");
|
|
1891
2006
|
}
|
|
@@ -3728,7 +3843,7 @@ async function looksLikeRegionsRepo(dir) {
|
|
|
3728
3843
|
}
|
|
3729
3844
|
|
|
3730
3845
|
// src/cli.ts
|
|
3731
|
-
var VERSION = "0.
|
|
3846
|
+
var VERSION = "0.6.0";
|
|
3732
3847
|
var program = new Command();
|
|
3733
3848
|
program.name("create-op-node").description(
|
|
3734
3849
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|