create-op-node 0.6.0 → 0.8.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 +33 -3
- package/dist/cli.js +104 -11
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,9 +30,23 @@ Configures macOS power settings, installs Homebrew + the CLI tool list, sets up
|
|
|
30
30
|
|
|
31
31
|
### Choosing the LLM model
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
36
50
|
|
|
37
51
|
```bash
|
|
38
52
|
# Just swap the LLM, keep the default embedding model:
|
|
@@ -188,6 +202,11 @@ npx create-op-node reset --region us-ca --dry-run
|
|
|
188
202
|
|
|
189
203
|
# Nuke from orbit: containers + volumes + LaunchAgent + ghcr credentials.
|
|
190
204
|
npx create-op-node reset --region us-ca --wipe-data
|
|
205
|
+
|
|
206
|
+
# Even more: ALSO drop all docker images (forces re-pull on next bootstrap).
|
|
207
|
+
# Use this for the "wipe everything and start over" iteration loop while
|
|
208
|
+
# debugging a fresh bootstrap. Implies --wipe-data.
|
|
209
|
+
npx create-op-node reset --region us-ca --wipe-images
|
|
191
210
|
```
|
|
192
211
|
|
|
193
212
|
## Verifying a live node
|
|
@@ -527,6 +546,17 @@ pnpm build # tsup → dist/
|
|
|
527
546
|
node dist/cli.js --help # test the built binary
|
|
528
547
|
```
|
|
529
548
|
|
|
549
|
+
### Releases
|
|
550
|
+
|
|
551
|
+
Releases are automated via [release-please](https://github.com/googleapis/release-please-action):
|
|
552
|
+
|
|
553
|
+
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.
|
|
554
|
+
2. **Merge PRs to `main`** as normal.
|
|
555
|
+
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.
|
|
556
|
+
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`.
|
|
557
|
+
|
|
558
|
+
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.
|
|
559
|
+
|
|
530
560
|
## License
|
|
531
561
|
|
|
532
562
|
[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
|
@@ -1151,6 +1151,13 @@ async function enableAutoRestartOnPowerFailure() {
|
|
|
1151
1151
|
async function disableDiskSleep() {
|
|
1152
1152
|
return runSudoPmset(["disksleep", "0"]);
|
|
1153
1153
|
}
|
|
1154
|
+
async function detectUnifiedMemoryGB() {
|
|
1155
|
+
const res = await safeExeca("sysctl", ["-n", "hw.memsize"]);
|
|
1156
|
+
if (res === null || res.exitCode !== 0) return null;
|
|
1157
|
+
const bytes = Number.parseInt(res.stdout.trim(), 10);
|
|
1158
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
|
1159
|
+
return Math.round(bytes / 2 ** 30);
|
|
1160
|
+
}
|
|
1154
1161
|
async function runSudoPmset(args) {
|
|
1155
1162
|
const res = await safeExeca("sudo", ["pmset", "-a", ...args]);
|
|
1156
1163
|
if (res === null) {
|
|
@@ -1372,8 +1379,14 @@ async function composeDown(opts) {
|
|
|
1372
1379
|
const flags = ["down"];
|
|
1373
1380
|
if (opts.wipeVolumes) flags.push("-v");
|
|
1374
1381
|
if (opts.removeOrphans) flags.push("--remove-orphans");
|
|
1382
|
+
if (opts.removeImages) flags.push("--rmi", opts.removeImages);
|
|
1383
|
+
const label = [
|
|
1384
|
+
"compose down",
|
|
1385
|
+
opts.wipeVolumes ? "-v" : "",
|
|
1386
|
+
opts.removeImages ? `--rmi ${opts.removeImages}` : ""
|
|
1387
|
+
].filter(Boolean).join(" ");
|
|
1375
1388
|
const res = await safeExeca("docker", composeArgs(opts, flags));
|
|
1376
|
-
return result(res,
|
|
1389
|
+
return result(res, label);
|
|
1377
1390
|
}
|
|
1378
1391
|
async function dockerLogout(registry = GHCR_REGISTRY) {
|
|
1379
1392
|
const res = await safeExeca("docker", ["logout", registry]);
|
|
@@ -1690,7 +1703,11 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1690
1703
|
const repoName = `opuspopuli-node-${region}`;
|
|
1691
1704
|
const composeFileDefault = opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
|
|
1692
1705
|
const composeFile = opts.composeFile ?? composeFileDefault;
|
|
1693
|
-
const
|
|
1706
|
+
const llmModelChoice = opts.llmModel ?? await selectLlmModel(opts);
|
|
1707
|
+
const [embeddingModel, llmModel] = resolveModels({
|
|
1708
|
+
llmModel: llmModelChoice,
|
|
1709
|
+
...opts.embeddingModel !== void 0 ? { embeddingModel: opts.embeddingModel } : {}
|
|
1710
|
+
});
|
|
1694
1711
|
const sysSpin = p3.spinner();
|
|
1695
1712
|
sysSpin.start("Inspecting macOS\u2026");
|
|
1696
1713
|
const snap = await inspectSystem();
|
|
@@ -1751,7 +1768,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1751
1768
|
unwrap(await p3.confirm({ message: "Homebrew installed?", initialValue: true }));
|
|
1752
1769
|
}
|
|
1753
1770
|
const brewSpin = p3.spinner();
|
|
1754
|
-
brewSpin.start("Installing Studio packages\u2026");
|
|
1771
|
+
brewSpin.start("Installing Studio packages\u2026 (~5\u201315 min first run)");
|
|
1755
1772
|
const report = await installPackages(STUDIO_PACKAGES, (pkg, status) => {
|
|
1756
1773
|
brewSpin.message(`${status}: ${pkg.name}`);
|
|
1757
1774
|
});
|
|
@@ -1852,7 +1869,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1852
1869
|
ghcrSpin.stop(pc2.green("\u2713 Logged in to ghcr.io."));
|
|
1853
1870
|
if (!opts.skipOllama) {
|
|
1854
1871
|
const olSpin = p3.spinner();
|
|
1855
|
-
olSpin.start(
|
|
1872
|
+
olSpin.start(`Pulling + warming Ollama models\u2026 (${estimatedPullTime(llmModel)})`);
|
|
1856
1873
|
let olHealth = await checkOllamaHealth();
|
|
1857
1874
|
if (!olHealth.reachable) {
|
|
1858
1875
|
olSpin.message("Ollama not reachable \u2014 running `brew services start ollama`\u2026");
|
|
@@ -1917,7 +1934,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1917
1934
|
);
|
|
1918
1935
|
}
|
|
1919
1936
|
const pullSpin = p3.spinner();
|
|
1920
|
-
pullSpin.start("Pulling images from ghcr.io\u2026");
|
|
1937
|
+
pullSpin.start("Pulling images from ghcr.io\u2026 (~5\u201315 min first run, < 1 min cached)");
|
|
1921
1938
|
const pull = await composePull(composeOpts);
|
|
1922
1939
|
if (!pull.ok) {
|
|
1923
1940
|
pullSpin.stop(pc2.red("\u2717 compose pull failed."));
|
|
@@ -1935,7 +1952,7 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1935
1952
|
}
|
|
1936
1953
|
upSpin.stop(pc2.green("\u2713 Containers started \u2014 waiting for healthy\u2026"));
|
|
1937
1954
|
const healthSpin = p3.spinner();
|
|
1938
|
-
healthSpin.start("Polling for healthy containers (5s, up to 5
|
|
1955
|
+
healthSpin.start("Polling for healthy containers\u2026 (every 5s, up to 5 min)");
|
|
1939
1956
|
const outcome = await waitForHealthy(composeOpts, {
|
|
1940
1957
|
onPoll: (snaps) => {
|
|
1941
1958
|
const healthy = snaps.filter((s) => s.health === "healthy").length;
|
|
@@ -2001,6 +2018,69 @@ function resolveModels(opts) {
|
|
|
2001
2018
|
opts.llmModel ?? DEFAULT_LLM_MODEL
|
|
2002
2019
|
];
|
|
2003
2020
|
}
|
|
2021
|
+
var LLM_MODEL_CHOICES = [
|
|
2022
|
+
{
|
|
2023
|
+
value: "qwen2.5:72b",
|
|
2024
|
+
label: "qwen2.5:72b",
|
|
2025
|
+
hint: "72B, ~50 GB Ollama RAM. Recommended for 128 GB Studios. Best Spanish + multilingual quality. Pull ~40 GB, 30\u201360 min."
|
|
2026
|
+
},
|
|
2027
|
+
{
|
|
2028
|
+
value: "qwen2.5:32b",
|
|
2029
|
+
label: "qwen2.5:32b",
|
|
2030
|
+
hint: "32B, ~22 GB Ollama RAM. Middle-tier for 64 GB Studios. Solid Spanish, faster than 72B. Pull ~20 GB, 15\u201330 min."
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
value: "qwen3.5:9b",
|
|
2034
|
+
label: "qwen3.5:9b",
|
|
2035
|
+
hint: "9B, ~8 GB Ollama RAM. Validation / smaller Studios (36\u201348 GB). Pull ~5 GB, 3\u20135 min."
|
|
2036
|
+
}
|
|
2037
|
+
];
|
|
2038
|
+
var OTHER_SENTINEL = "__OTHER__";
|
|
2039
|
+
function recommendLlmModel(ramGB) {
|
|
2040
|
+
if (ramGB === null) return null;
|
|
2041
|
+
if (ramGB >= 96) return "qwen2.5:72b";
|
|
2042
|
+
if (ramGB >= 48) return "qwen2.5:32b";
|
|
2043
|
+
return "qwen3.5:9b";
|
|
2044
|
+
}
|
|
2045
|
+
async function selectLlmModel(opts) {
|
|
2046
|
+
if (opts.yes) return DEFAULT_LLM_MODEL;
|
|
2047
|
+
const ramGB = await detectUnifiedMemoryGB();
|
|
2048
|
+
const recommended = recommendLlmModel(ramGB) ?? "qwen2.5:72b";
|
|
2049
|
+
const ramNote = ramGB !== null ? `Detected ${ramGB} GB unified memory \u2014 pre-selecting ${recommended}.` : `Couldn't detect Studio memory \u2014 defaulting to ${recommended} (override if your config differs).`;
|
|
2050
|
+
p3.note(ramNote, "Hardware");
|
|
2051
|
+
const choice = unwrap(
|
|
2052
|
+
await p3.select({
|
|
2053
|
+
message: "Choose the LLM model to pull + run",
|
|
2054
|
+
initialValue: recommended,
|
|
2055
|
+
options: [
|
|
2056
|
+
...LLM_MODEL_CHOICES.map((c) => ({ value: c.value, label: c.label, hint: c.hint })),
|
|
2057
|
+
{
|
|
2058
|
+
value: OTHER_SENTINEL,
|
|
2059
|
+
label: "Other\u2026",
|
|
2060
|
+
hint: "Specify any Ollama model name (e.g. mistral-small:24b)"
|
|
2061
|
+
}
|
|
2062
|
+
]
|
|
2063
|
+
})
|
|
2064
|
+
);
|
|
2065
|
+
if (choice !== OTHER_SENTINEL) return choice;
|
|
2066
|
+
return unwrap(
|
|
2067
|
+
await p3.text({
|
|
2068
|
+
message: "Ollama model name",
|
|
2069
|
+
placeholder: "e.g. mistral-small:24b",
|
|
2070
|
+
validate: (v) => /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(v ?? "") ? void 0 : "Letters, digits, `.`, `:`, `_`, `-`, `/` only; must start with alphanumeric"
|
|
2071
|
+
})
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
function estimatedPullTime(model) {
|
|
2075
|
+
if (/\d+x\d+b/i.test(model)) return "~60+ min for frontier MoE";
|
|
2076
|
+
const match = /(\d+)b\b/i.exec(model);
|
|
2077
|
+
if (!match) return "time depends on model size";
|
|
2078
|
+
const size = Number.parseInt(match[1], 10);
|
|
2079
|
+
if (size <= 13) return "~3\u20135 min for \u2264 13B-class";
|
|
2080
|
+
if (size <= 35) return "~15\u201330 min for ~32B-class";
|
|
2081
|
+
if (size <= 80) return "~30\u201360 min for 70B-class";
|
|
2082
|
+
return "~60+ min for frontier-class";
|
|
2083
|
+
}
|
|
2004
2084
|
function kvBool(b) {
|
|
2005
2085
|
return b ? pc2.green("on") : pc2.yellow("off");
|
|
2006
2086
|
}
|
|
@@ -2163,7 +2243,7 @@ async function runReset(input, deps = DEFAULT_DEPS) {
|
|
|
2163
2243
|
push({
|
|
2164
2244
|
name: RESET_PHASES.STOP_STACK,
|
|
2165
2245
|
status: "dry-run",
|
|
2166
|
-
detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}`
|
|
2246
|
+
detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}${input.stack.wipeImages ? " --rmi all" : ""}`
|
|
2167
2247
|
});
|
|
2168
2248
|
} else {
|
|
2169
2249
|
const result2 = await deps.composeDown({
|
|
@@ -2171,13 +2251,18 @@ async function runReset(input, deps = DEFAULT_DEPS) {
|
|
|
2171
2251
|
cwd: input.stack.repoPath,
|
|
2172
2252
|
...input.stack.envFile ? { envFile: input.stack.envFile } : {},
|
|
2173
2253
|
wipeVolumes: input.stack.wipeVolumes,
|
|
2174
|
-
removeOrphans: input.stack.removeOrphans
|
|
2254
|
+
removeOrphans: input.stack.removeOrphans,
|
|
2255
|
+
...input.stack.wipeImages ? { removeImages: "all" } : {}
|
|
2175
2256
|
});
|
|
2176
2257
|
if (result2.ok) {
|
|
2258
|
+
const bits = [
|
|
2259
|
+
input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved",
|
|
2260
|
+
input.stack.wipeImages ? "images removed (next bootstrap will re-pull)" : null
|
|
2261
|
+
].filter(Boolean);
|
|
2177
2262
|
push({
|
|
2178
2263
|
name: RESET_PHASES.STOP_STACK,
|
|
2179
2264
|
status: "ok",
|
|
2180
|
-
detail:
|
|
2265
|
+
detail: bits.join("; ")
|
|
2181
2266
|
});
|
|
2182
2267
|
} else {
|
|
2183
2268
|
push({ name: RESET_PHASES.STOP_STACK, status: "fail", detail: result2.reason ?? "unknown failure" });
|
|
@@ -2264,6 +2349,11 @@ var resetCommand = new Command("reset").description(
|
|
|
2264
2349
|
"--wipe-data",
|
|
2265
2350
|
"DESTROYS docker volumes (adds -v to `compose down`). Requires retyping the region label as confirmation. Default off \u2014 volumes preserved."
|
|
2266
2351
|
).default(false)
|
|
2352
|
+
).addOption(
|
|
2353
|
+
new Option(
|
|
2354
|
+
"--wipe-images",
|
|
2355
|
+
'ALSO remove all docker images referenced by the compose file (adds --rmi all). Forces a fresh pull on next bootstrap. Implies --wipe-data semantics for the iteration loop ("wipe everything and start over"). Default off.'
|
|
2356
|
+
).default(false)
|
|
2267
2357
|
).addOption(
|
|
2268
2358
|
new Option(
|
|
2269
2359
|
"--no-remove-orphans",
|
|
@@ -2276,7 +2366,8 @@ var resetCommand = new Command("reset").description(
|
|
|
2276
2366
|
).default(false)
|
|
2277
2367
|
).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) => {
|
|
2278
2368
|
p3.intro(pc2.bgCyan(pc2.black(" create-op-node reset ")));
|
|
2279
|
-
const
|
|
2369
|
+
const wipeImages = opts.wipeImages ?? false;
|
|
2370
|
+
const wipeData = (opts.wipeData ?? false) || wipeImages;
|
|
2280
2371
|
const removeOrphans = opts.removeOrphans ?? true;
|
|
2281
2372
|
const region = opts.region ? opts.region : unwrap(
|
|
2282
2373
|
await p3.text({
|
|
@@ -2342,6 +2433,7 @@ var resetCommand = new Command("reset").description(
|
|
|
2342
2433
|
`pgsodium key file: ${keyFileExists ? pc2.cyan(launchAgentPaths.keyFile) : pc2.dim("not present")}`,
|
|
2343
2434
|
`Registry to log out: ${pc2.cyan(opts.registry ?? GHCR_REGISTRY)}`,
|
|
2344
2435
|
`Volume policy: ${wipeData ? pc2.red("WIPE") : pc2.green("preserve (default)")}`,
|
|
2436
|
+
`Image policy: ${wipeImages ? pc2.red("WIPE (re-pull on next bootstrap)") : pc2.green("keep (default)")}`,
|
|
2345
2437
|
`Dry run: ${opts.dryRun ? pc2.yellow("yes") : pc2.dim("no")}`
|
|
2346
2438
|
].join("\n"),
|
|
2347
2439
|
"Snapshot"
|
|
@@ -2369,6 +2461,7 @@ var resetCommand = new Command("reset").description(
|
|
|
2369
2461
|
repoPath,
|
|
2370
2462
|
composeFiles: resolveComposeFiles(repoPath, opts.composeFile),
|
|
2371
2463
|
wipeVolumes: wipeData,
|
|
2464
|
+
wipeImages,
|
|
2372
2465
|
removeOrphans,
|
|
2373
2466
|
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
2374
2467
|
} : void 0;
|
|
@@ -3843,7 +3936,7 @@ async function looksLikeRegionsRepo(dir) {
|
|
|
3843
3936
|
}
|
|
3844
3937
|
|
|
3845
3938
|
// src/cli.ts
|
|
3846
|
-
var VERSION = "0.
|
|
3939
|
+
var VERSION = "0.8.0";
|
|
3847
3940
|
var program = new Command();
|
|
3848
3941
|
program.name("create-op-node").description(
|
|
3849
3942
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|