create-op-node 0.5.0 → 0.7.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 +71 -0
- package/dist/cli.js +103 -11
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,66 @@ npx create-op-node bootstrap
|
|
|
28
28
|
|
|
29
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)`.
|
|
30
30
|
|
|
31
|
+
### Choosing the LLM model
|
|
32
|
+
|
|
33
|
+
When you run `bootstrap` interactively (no `-y`, no `--llm-model` flag),
|
|
34
|
+
you'll get a curated picker:
|
|
35
|
+
|
|
36
|
+
- `qwen2.5:72b` — 72B, ~50 GB Ollama RAM. **Default**, recommended for
|
|
37
|
+
128 GB Studios. Best Spanish + multilingual quality.
|
|
38
|
+
- `qwen2.5:32b` — 32B, ~22 GB Ollama RAM. Middle tier for 64 GB Studios.
|
|
39
|
+
- `qwen3.5:9b` — 9B, ~8 GB Ollama RAM. Validation runs or smaller
|
|
40
|
+
Studios (36–48 GB).
|
|
41
|
+
- `Other…` — any Ollama model name you specify.
|
|
42
|
+
|
|
43
|
+
The picker is Qwen-only because the Opus Populi platform serves
|
|
44
|
+
Spanish-speaking civic users and Qwen has the strongest Spanish (and
|
|
45
|
+
broader multilingual) capability of the open-weight models in this
|
|
46
|
+
size class. Pick "Other…" for non-Qwen models.
|
|
47
|
+
|
|
48
|
+
For non-interactive runs (`-y`) or scripted invocations, the LLM
|
|
49
|
+
defaults to `qwen3.5:9b` (small, fast) — pass `--llm-model` to override:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Just swap the LLM, keep the default embedding model:
|
|
53
|
+
npx create-op-node bootstrap --region us-ca --llm-model llama3.3:70b
|
|
54
|
+
|
|
55
|
+
# Override both:
|
|
56
|
+
npx create-op-node bootstrap \
|
|
57
|
+
--region us-ca \
|
|
58
|
+
--llm-model llama3.3:70b \
|
|
59
|
+
--embedding-model mxbai-embed-large
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The chosen models flow two places:
|
|
63
|
+
|
|
64
|
+
1. **Ollama**: bootstrap pulls + warms them so the daemon has them
|
|
65
|
+
resident before the stack comes up. The embedding model pulls first
|
|
66
|
+
(small, fast feedback); the LLM pulls second (can be tens of GB).
|
|
67
|
+
2. **LaunchAgent**: the plist exports `LLM_MODEL` and `EMBEDDINGS_MODEL`
|
|
68
|
+
into the launchd session, which Docker Desktop inherits — so compose
|
|
69
|
+
services read them via env, no `.env.production` edits needed.
|
|
70
|
+
|
|
71
|
+
> **`--embedding-model` only takes effect when the knowledge service
|
|
72
|
+
> runs with `EMBEDDINGS_PROVIDER=ollama`.** The default provider is
|
|
73
|
+
> `xenova` (in-process), which bundles its own embedding model and
|
|
74
|
+
> ignores both `EMBEDDINGS_MODEL` and the local Ollama model. Setting
|
|
75
|
+
> `EMBEDDINGS_PROVIDER=ollama` is a separate decision (set in your
|
|
76
|
+
> region repo's `.env.production`) — see `docs/provider-pattern.md`.
|
|
77
|
+
|
|
78
|
+
> **Template contract**: for `--llm-model` to actually change the
|
|
79
|
+
> running model, the region repo's `docker-compose-prod.yml` must use
|
|
80
|
+
> `${LLM_MODEL:-qwen3.5:9b}` (or similar) on the knowledge service's
|
|
81
|
+
> `environment:` block. The current `opuspopuli-node` template does;
|
|
82
|
+
> a fork that hardcodes the value would ignore the flag silently.
|
|
83
|
+
|
|
84
|
+
For RAM sizing, the [Docker resources doc](https://github.com/OpusPopuli/opuspopuli-node/blob/main/docs/docker-resources.md)
|
|
85
|
+
has a tier table: 9B-class needs ~8 GB Ollama; 70B-class needs ~50 GB;
|
|
86
|
+
frontier MoE needs ~80 GB. Allocate Docker the remainder.
|
|
87
|
+
|
|
88
|
+
To switch models post-bootstrap, re-run with the new flag and
|
|
89
|
+
`docker compose down && up -d` to pick up the changed env.
|
|
90
|
+
|
|
31
91
|
### Local-only mode (no Cloudflare)
|
|
32
92
|
|
|
33
93
|
For local dev / testing — frontend on your laptop, backend on the Studio
|
|
@@ -481,6 +541,17 @@ pnpm build # tsup → dist/
|
|
|
481
541
|
node dist/cli.js --help # test the built binary
|
|
482
542
|
```
|
|
483
543
|
|
|
544
|
+
### Releases
|
|
545
|
+
|
|
546
|
+
Releases are automated via [release-please](https://github.com/googleapis/release-please-action):
|
|
547
|
+
|
|
548
|
+
1. **Write Conventional Commits.** `feat:` → minor bump (or major on `BREAKING CHANGE:`); `fix:` → patch; `docs:`/`refactor:`/`perf:` show up in CHANGELOG; `chore:`/`test:`/`build:`/`ci:` are hidden.
|
|
549
|
+
2. **Merge PRs to `main`** as normal.
|
|
550
|
+
3. **release-please opens a `chore(main): release X.Y.Z` PR** that bumps `package.json` + `src/cli.ts`'s `VERSION` constant + adds a CHANGELOG section. It updates the PR continuously as more commits land.
|
|
551
|
+
4. **Merge the release PR.** That creates the `vX.Y.Z` tag + a GitHub Release. `publish.yml` fires on the tag push and runs `npm publish --provenance --access public`.
|
|
552
|
+
|
|
553
|
+
No manual version bumps, no manual tagging. To trigger a release with no functional changes (re-publishing after a CI fix, etc.), push a commit with `chore: trigger release` — release-please will skip it but still update the PR.
|
|
554
|
+
|
|
484
555
|
## License
|
|
485
556
|
|
|
486
557
|
[AGPL-3.0-or-later](./LICENSE). The Opus Populi platform code is AGPL-3.0 + dual commercial; this CLI inherits the AGPL-3.0 terms.
|
package/dist/cli.js
CHANGED
|
@@ -1189,10 +1189,21 @@ async function writePgsodiumKeyFile(key, keyFile) {
|
|
|
1189
1189
|
return { ok: false, reason: `writing ${keyFile} failed: ${err.message}` };
|
|
1190
1190
|
}
|
|
1191
1191
|
}
|
|
1192
|
+
var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
1192
1193
|
function renderLaunchAgentPlist(input) {
|
|
1193
1194
|
if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
|
|
1194
1195
|
throw new Error("Tunnel token contains characters outside the expected base64-url set");
|
|
1195
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
|
+
}
|
|
1196
1207
|
if (!SAFE_PATH_RE.test(input.keyFilePath)) {
|
|
1197
1208
|
throw new Error(
|
|
1198
1209
|
`keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
|
|
@@ -1200,7 +1211,9 @@ function renderLaunchAgentPlist(input) {
|
|
|
1200
1211
|
}
|
|
1201
1212
|
const setenvLines = [
|
|
1202
1213
|
`launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
|
|
1203
|
-
...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}"`] : []
|
|
1204
1217
|
];
|
|
1205
1218
|
const command = setenvLines.join("; ");
|
|
1206
1219
|
return [
|
|
@@ -1255,7 +1268,9 @@ async function setupLaunchAgent(input) {
|
|
|
1255
1268
|
try {
|
|
1256
1269
|
plistContent = renderLaunchAgentPlist({
|
|
1257
1270
|
keyFilePath: paths.keyFile,
|
|
1258
|
-
...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 } : {}
|
|
1259
1274
|
});
|
|
1260
1275
|
} catch (err) {
|
|
1261
1276
|
return { ok: false, paths, step: "plist", reason: err.message };
|
|
@@ -1475,7 +1490,8 @@ function assessHealth(snapshots, requireHealthy) {
|
|
|
1475
1490
|
|
|
1476
1491
|
// src/lib/ollama.ts
|
|
1477
1492
|
var OLLAMA_URL = "http://localhost:11434";
|
|
1478
|
-
var
|
|
1493
|
+
var DEFAULT_LLM_MODEL = "qwen3.5:9b";
|
|
1494
|
+
var DEFAULT_EMBEDDING_MODEL = "nomic-embed-text";
|
|
1479
1495
|
var PROBE_ALPINE_TAG = "3.20";
|
|
1480
1496
|
async function startOllamaService() {
|
|
1481
1497
|
const res = await safeExeca("brew", ["services", "start", "ollama"]);
|
|
@@ -1646,7 +1662,17 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1646
1662
|
"--skip-launch-agent",
|
|
1647
1663
|
"Skip the LaunchAgent setup (assumes one is already in place)"
|
|
1648
1664
|
).default(false)
|
|
1649
|
-
).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(
|
|
1650
1676
|
new Option(
|
|
1651
1677
|
"--local-only",
|
|
1652
1678
|
"Run for local dev / testing: no Tunnel token required, cloudflared stays down. Auto-generates the pgsodium key if not in Keychain (init unnecessary)."
|
|
@@ -1664,6 +1690,11 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1664
1690
|
const repoName = `opuspopuli-node-${region}`;
|
|
1665
1691
|
const composeFileDefault = opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
|
|
1666
1692
|
const composeFile = opts.composeFile ?? composeFileDefault;
|
|
1693
|
+
const llmModelChoice = opts.llmModel ?? await selectLlmModel(opts);
|
|
1694
|
+
const [embeddingModel, llmModel] = resolveModels({
|
|
1695
|
+
llmModel: llmModelChoice,
|
|
1696
|
+
...opts.embeddingModel !== void 0 ? { embeddingModel: opts.embeddingModel } : {}
|
|
1697
|
+
});
|
|
1667
1698
|
const sysSpin = p3.spinner();
|
|
1668
1699
|
sysSpin.start("Inspecting macOS\u2026");
|
|
1669
1700
|
const snap = await inspectSystem();
|
|
@@ -1724,7 +1755,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1724
1755
|
unwrap(await p3.confirm({ message: "Homebrew installed?", initialValue: true }));
|
|
1725
1756
|
}
|
|
1726
1757
|
const brewSpin = p3.spinner();
|
|
1727
|
-
brewSpin.start("Installing Studio packages\u2026");
|
|
1758
|
+
brewSpin.start("Installing Studio packages\u2026 (~5\u201315 min first run)");
|
|
1728
1759
|
const report = await installPackages(STUDIO_PACKAGES, (pkg, status) => {
|
|
1729
1760
|
brewSpin.message(`${status}: ${pkg.name}`);
|
|
1730
1761
|
});
|
|
@@ -1799,7 +1830,9 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1799
1830
|
laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
|
|
1800
1831
|
const la = await setupLaunchAgent({
|
|
1801
1832
|
pgsodiumKey,
|
|
1802
|
-
...tunnelToken !== void 0 ? { tunnelToken } : {}
|
|
1833
|
+
...tunnelToken !== void 0 ? { tunnelToken } : {},
|
|
1834
|
+
llmModel,
|
|
1835
|
+
embeddingModel
|
|
1803
1836
|
});
|
|
1804
1837
|
if (!la.ok) {
|
|
1805
1838
|
laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
|
|
@@ -1823,7 +1856,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1823
1856
|
ghcrSpin.stop(pc2.green("\u2713 Logged in to ghcr.io."));
|
|
1824
1857
|
if (!opts.skipOllama) {
|
|
1825
1858
|
const olSpin = p3.spinner();
|
|
1826
|
-
olSpin.start(
|
|
1859
|
+
olSpin.start(`Pulling + warming Ollama models\u2026 (${estimatedPullTime(llmModel)})`);
|
|
1827
1860
|
let olHealth = await checkOllamaHealth();
|
|
1828
1861
|
if (!olHealth.reachable) {
|
|
1829
1862
|
olSpin.message("Ollama not reachable \u2014 running `brew services start ollama`\u2026");
|
|
@@ -1847,7 +1880,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1847
1880
|
process.exit(1);
|
|
1848
1881
|
}
|
|
1849
1882
|
}
|
|
1850
|
-
const modelReport = await setupModels(
|
|
1883
|
+
const modelReport = await setupModels([embeddingModel, llmModel], (model, status) => {
|
|
1851
1884
|
olSpin.message(`${status}: ${model}`);
|
|
1852
1885
|
});
|
|
1853
1886
|
olSpin.stop(
|
|
@@ -1888,7 +1921,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1888
1921
|
);
|
|
1889
1922
|
}
|
|
1890
1923
|
const pullSpin = p3.spinner();
|
|
1891
|
-
pullSpin.start("Pulling images from ghcr.io\u2026");
|
|
1924
|
+
pullSpin.start("Pulling images from ghcr.io\u2026 (~5\u201315 min first run, < 1 min cached)");
|
|
1892
1925
|
const pull = await composePull(composeOpts);
|
|
1893
1926
|
if (!pull.ok) {
|
|
1894
1927
|
pullSpin.stop(pc2.red("\u2717 compose pull failed."));
|
|
@@ -1906,7 +1939,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1906
1939
|
}
|
|
1907
1940
|
upSpin.stop(pc2.green("\u2713 Containers started \u2014 waiting for healthy\u2026"));
|
|
1908
1941
|
const healthSpin = p3.spinner();
|
|
1909
|
-
healthSpin.start("Polling for healthy containers (5s, up to 5
|
|
1942
|
+
healthSpin.start("Polling for healthy containers\u2026 (every 5s, up to 5 min)");
|
|
1910
1943
|
const outcome = await waitForHealthy(composeOpts, {
|
|
1911
1944
|
onPoll: (snaps) => {
|
|
1912
1945
|
const healthy = snaps.filter((s) => s.health === "healthy").length;
|
|
@@ -1966,6 +1999,65 @@ function resolveComposeFiles(repoPath, composeFile) {
|
|
|
1966
1999
|
const inputs = composeFile ?? ["docker-compose-prod.yml"];
|
|
1967
2000
|
return inputs.map((f) => f.startsWith("/") ? f : join(repoPath, f));
|
|
1968
2001
|
}
|
|
2002
|
+
function resolveModels(opts) {
|
|
2003
|
+
return [
|
|
2004
|
+
opts.embeddingModel ?? DEFAULT_EMBEDDING_MODEL,
|
|
2005
|
+
opts.llmModel ?? DEFAULT_LLM_MODEL
|
|
2006
|
+
];
|
|
2007
|
+
}
|
|
2008
|
+
var LLM_MODEL_CHOICES = [
|
|
2009
|
+
{
|
|
2010
|
+
value: "qwen2.5:72b",
|
|
2011
|
+
label: "qwen2.5:72b",
|
|
2012
|
+
hint: "72B, ~50 GB Ollama RAM. Recommended for 128 GB Studios. Best Spanish + multilingual quality. Pull ~40 GB, 30\u201360 min."
|
|
2013
|
+
},
|
|
2014
|
+
{
|
|
2015
|
+
value: "qwen2.5:32b",
|
|
2016
|
+
label: "qwen2.5:32b",
|
|
2017
|
+
hint: "32B, ~22 GB Ollama RAM. Middle-tier for 64 GB Studios. Solid Spanish, faster than 72B. Pull ~20 GB, 15\u201330 min."
|
|
2018
|
+
},
|
|
2019
|
+
{
|
|
2020
|
+
value: "qwen3.5:9b",
|
|
2021
|
+
label: "qwen3.5:9b",
|
|
2022
|
+
hint: "9B, ~8 GB Ollama RAM. Validation / smaller Studios (36\u201348 GB). Pull ~5 GB, 3\u20135 min."
|
|
2023
|
+
}
|
|
2024
|
+
];
|
|
2025
|
+
var OTHER_SENTINEL = "__OTHER__";
|
|
2026
|
+
async function selectLlmModel(opts) {
|
|
2027
|
+
if (opts.yes) return DEFAULT_LLM_MODEL;
|
|
2028
|
+
const choice = unwrap(
|
|
2029
|
+
await p3.select({
|
|
2030
|
+
message: "Choose the LLM model to pull + run",
|
|
2031
|
+
initialValue: "qwen2.5:72b",
|
|
2032
|
+
options: [
|
|
2033
|
+
...LLM_MODEL_CHOICES.map((c) => ({ value: c.value, label: c.label, hint: c.hint })),
|
|
2034
|
+
{
|
|
2035
|
+
value: OTHER_SENTINEL,
|
|
2036
|
+
label: "Other\u2026",
|
|
2037
|
+
hint: "Specify any Ollama model name (e.g. mistral-small:24b)"
|
|
2038
|
+
}
|
|
2039
|
+
]
|
|
2040
|
+
})
|
|
2041
|
+
);
|
|
2042
|
+
if (choice !== OTHER_SENTINEL) return choice;
|
|
2043
|
+
return unwrap(
|
|
2044
|
+
await p3.text({
|
|
2045
|
+
message: "Ollama model name",
|
|
2046
|
+
placeholder: "e.g. mistral-small:24b",
|
|
2047
|
+
validate: (v) => /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(v ?? "") ? void 0 : "Letters, digits, `.`, `:`, `_`, `-`, `/` only; must start with alphanumeric"
|
|
2048
|
+
})
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
function estimatedPullTime(model) {
|
|
2052
|
+
if (/\d+x\d+b/i.test(model)) return "~60+ min for frontier MoE";
|
|
2053
|
+
const match = /(\d+)b\b/i.exec(model);
|
|
2054
|
+
if (!match) return "time depends on model size";
|
|
2055
|
+
const size = Number.parseInt(match[1], 10);
|
|
2056
|
+
if (size <= 13) return "~3\u20135 min for \u2264 13B-class";
|
|
2057
|
+
if (size <= 35) return "~15\u201330 min for ~32B-class";
|
|
2058
|
+
if (size <= 80) return "~30\u201360 min for 70B-class";
|
|
2059
|
+
return "~60+ min for frontier-class";
|
|
2060
|
+
}
|
|
1969
2061
|
function kvBool(b) {
|
|
1970
2062
|
return b ? pc2.green("on") : pc2.yellow("off");
|
|
1971
2063
|
}
|
|
@@ -3808,7 +3900,7 @@ async function looksLikeRegionsRepo(dir) {
|
|
|
3808
3900
|
}
|
|
3809
3901
|
|
|
3810
3902
|
// src/cli.ts
|
|
3811
|
-
var VERSION = "0.
|
|
3903
|
+
var VERSION = "0.7.0";
|
|
3812
3904
|
var program = new Command();
|
|
3813
3905
|
program.name("create-op-node").description(
|
|
3814
3906
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|