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 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
- 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:
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, opts.wipeVolumes ? "compose down -v" : "compose down");
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 [embeddingModel, llmModel] = resolveModels(opts);
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("Pulling + warming Ollama models\u2026");
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 minutes)\u2026");
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: input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved"
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 wipeData = opts.wipeData ?? false;
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.6.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."