create-op-node 0.12.3 → 0.13.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
@@ -32,18 +32,21 @@ Configures macOS power settings, installs Homebrew + the CLI tool list, sets up
32
32
 
33
33
  When you run `bootstrap` interactively (no `-y`, no `--llm-model` flag),
34
34
  you'll get a curated picker that **pre-selects** a model based on the
35
- Studio's detected unified memory:
36
-
37
- - `qwen2.5:72b` — 72B, ~50 GB Ollama RAM. Pre-selected on 96 GB+ Studios.
38
- Best Spanish + multilingual quality.
39
- - `qwen2.5:32b` — 32B, ~22 GB Ollama RAM. Pre-selected on 48–95 GB Studios
40
- (e.g. 64 GB).
41
- - `qwen3.5:9b` — 9B, ~8 GB Ollama RAM. Pre-selected on smaller Studios
42
- (< 48 GB), and the default for non-interactive `-y` runs (see below).
35
+ node's detected unified memory. Tiers are conservative — the LLM shares
36
+ memory with ~22 containers + Postgres, so they size on *total* footprint,
37
+ not param count:
38
+
39
+ - `qwen3.6:35b-a3b` — 35B MoE (~3B active, fast on Apple Silicon),
40
+ ~24 GB footprint. Pre-selected on 64 GB+ nodes. Best Spanish +
41
+ multilingual quality.
42
+ - `qwen3:14b` ~9 GB footprint. Pre-selected on 32–64 GB nodes.
43
+ - `qwen2.5:7b` — ~5 GB footprint. Pre-selected on 16–32 GB nodes, and
44
+ the default for non-interactive `-y` runs (see below).
45
+ - `qwen2.5:3b` — ~2–3 GB footprint. Pre-selected on ≤ 16 GB nodes (Mac Mini).
43
46
  - `Other…` — any Ollama model name you specify.
44
47
 
45
- (If memory detection fails, the picker falls back to pre-selecting
46
- `qwen2.5:72b`.)
48
+ (If memory detection fails, the picker falls back to pre-selecting the
49
+ conservative scripted default, `qwen2.5:7b`.)
47
50
 
48
51
  The picker is Qwen-only because the Opus Populi platform serves
49
52
  Spanish-speaking civic users and Qwen has the strongest Spanish (and
@@ -51,7 +54,7 @@ broader multilingual) capability of the open-weight models in this
51
54
  size class. Pick "Other…" for non-Qwen models.
52
55
 
53
56
  For non-interactive runs (`-y`) or scripted invocations, the LLM
54
- defaults to `qwen3.5:9b` (small, fast) — pass `--llm-model` to override:
57
+ defaults to `qwen2.5:7b` (small, fast) — pass `--llm-model` to override:
55
58
 
56
59
  ```bash
57
60
  # Just swap the LLM, keep the default embedding model:
@@ -69,29 +72,43 @@ The chosen models flow two places:
69
72
  1. **Ollama**: bootstrap pulls + warms them so the daemon has them
70
73
  resident before the stack comes up. The embedding model pulls first
71
74
  (small, fast feedback); the LLM pulls second (can be tens of GB).
72
- 2. **LaunchAgent**: the plist exports `LLM_MODEL` and `EMBEDDINGS_MODEL`
73
- into the launchd session, which Docker Desktop inherits so compose
74
- services read them via env, no `.env.production` edits needed.
75
-
76
- > **`--embedding-model` only takes effect when the knowledge service
77
- > runs with `EMBEDDINGS_PROVIDER=ollama`.** The default provider is
78
- > `xenova` (in-process), which bundles its own embedding model and
79
- > ignores both `EMBEDDINGS_MODEL` and the local Ollama model. Setting
80
- > `EMBEDDINGS_PROVIDER=ollama` is a separate decision (set in your
81
- > region repo's `.env.production`) see `docs/provider-pattern.md`.
82
-
83
- > **Template contract**: for `--llm-model` to actually change the
84
- > running model, the region repo's `docker-compose-prod.yml` must use
85
- > `${LLM_MODEL:-qwen3.5:9b}` (or similar) on the knowledge service's
86
- > `environment:` block. The current `opuspopuli-node` template does;
87
- > a fork that hardcodes the value would ignore the flag silently.
75
+ 2. **The node `.env`**: bootstrap writes `LLM_MODEL`, `EMBEDDINGS_MODEL`,
76
+ and `EMBEDDINGS_PROVIDER` into a managed block of the region repo's
77
+ `.env`, which docker compose auto-loads. This is the **single source of
78
+ truth** — every service (and every future partial recreate) resolves
79
+ `${LLM_MODEL:-…}` to the same value, regardless of shell/launchd env.
80
+ Model config is deliberately *not* exported via the LaunchAgent: a
81
+ `launchctl setenv` value would shadow `.env` at interpolation time
82
+ (shell env > `.env`), reintroducing the exact drift the `.env` prevents.
83
+
84
+ > **Managed block, not full ownership.** bootstrap owns only the region
85
+ > between `# >>> op-node managed >>>` and `# <<< op-node managed <<<` in
86
+ > your `.env` anything you hand-add outside it is preserved. On first
87
+ > run against a pre-existing `.env`, your existing model values are adopted
88
+ > into the block rather than overwritten; the block is only rewritten when
89
+ > you explicitly (re)select a model. Secrets never go in `.env` they stay
90
+ > in the macOS Keychain and are hydrated by `bin/op-compose`.
91
+
92
+ > **`--embeddings-provider` picks where embeddings run.** The default is
93
+ > `xenova` (in-process), which bundles its own embedding model and ignores
94
+ > `EMBEDDINGS_MODEL`. Pass `--embeddings-provider ollama` to use the host
95
+ > daemon with `--embedding-model` — the value is written to `.env` as the
96
+ > single source of truth. See `docs/provider-pattern.md`.
97
+
98
+ > **Template contract**: for `LLM_MODEL` to actually change the running
99
+ > model, the region repo's `docker-compose-prod.yml` must use
100
+ > `${LLM_MODEL:-…}` on the knowledge service's `environment:` block. The
101
+ > current `opuspopuli-node` template does; a fork that hardcodes the value
102
+ > would ignore the `.env` silently.
88
103
 
89
104
  For RAM sizing, the [Docker resources doc](https://github.com/OpusPopuli/opuspopuli-node/blob/main/docs/docker-resources.md)
90
- has a tier table: 9B-class needs ~8 GB Ollama; 70B-class needs ~50 GB;
91
- frontier MoE needs ~80 GB. Allocate Docker the remainder.
105
+ has a tier table: a 7B-class model needs ~5 GB, the 35B-a3b MoE ~24 GB.
106
+ Allocate Docker the remainder of unified memory.
92
107
 
93
- To switch models post-bootstrap, re-run with the new flag and
94
- `docker compose down && up -d` to pick up the changed env.
108
+ To switch models post-bootstrap, either re-run bootstrap with the new
109
+ `--llm-model` (rewrites the managed block) or edit `LLM_MODEL` in the
110
+ managed block of your `.env` directly, then `./bin/op-compose -f
111
+ docker-compose-prod.yml up -d` to pick up the change.
95
112
 
96
113
  ### Local-only mode (no Cloudflare)
97
114
 
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Octokit } from '@octokit/rest';
7
7
  import _sodium from 'libsodium-wrappers';
8
8
  import { randomBytes, createHmac } from 'crypto';
9
9
  import { join, resolve, dirname } from 'path';
10
- import { readFile, mkdir, writeFile, access, chmod, rename, rm } from 'fs/promises';
10
+ import { readFile, mkdir, writeFile, access, chmod, rename, stat, rm } from 'fs/promises';
11
11
  import { homedir } from 'os';
12
12
  import { readFileSync } from 'fs';
13
13
  import * as tls from 'tls';
@@ -1493,6 +1493,7 @@ async function writePgsodiumKeyFile(key, keyFile) {
1493
1493
  try {
1494
1494
  const dir = dirname(keyFile);
1495
1495
  await mkdir(dir, { recursive: true, mode: 448 });
1496
+ await rm(keyFile, { force: true });
1496
1497
  await writeFile(keyFile, key, { mode: 256 });
1497
1498
  await chmod(dir, 448);
1498
1499
  await chmod(keyFile, 256);
@@ -1501,7 +1502,6 @@ async function writePgsodiumKeyFile(key, keyFile) {
1501
1502
  return { ok: false, reason: `writing ${keyFile} failed: ${err.message}` };
1502
1503
  }
1503
1504
  }
1504
- var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
1505
1505
  function renderLaunchAgentPlist(input) {
1506
1506
  validatePlistInput(input);
1507
1507
  const command = buildSetenvCommand(input);
@@ -1527,16 +1527,6 @@ function validatePlistInput(input) {
1527
1527
  if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1528
1528
  throw new Error("Tunnel token contains characters outside the expected base64-url set");
1529
1529
  }
1530
- if (input.llmModel !== void 0 && !MODEL_NAME_RE.test(input.llmModel)) {
1531
- throw new Error(
1532
- `llmModel ${JSON.stringify(input.llmModel)} contains characters not allowed in a launchd setenv value`
1533
- );
1534
- }
1535
- if (input.embeddingModel !== void 0 && !MODEL_NAME_RE.test(input.embeddingModel)) {
1536
- throw new Error(
1537
- `embeddingModel ${JSON.stringify(input.embeddingModel)} contains characters not allowed in a launchd setenv value`
1538
- );
1539
- }
1540
1530
  if (!SAFE_PATH_RE.test(input.keyFilePath)) {
1541
1531
  throw new Error(
1542
1532
  `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
@@ -1570,8 +1560,6 @@ function buildSetenvCommand(input) {
1570
1560
  // of — this is word-split safety, not new injection surface. (#36)
1571
1561
  `launchctl setenv PGSODIUM_ROOT_KEY "$(cat "${input.keyFilePath}")"`,
1572
1562
  ...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : [],
1573
- ...input.llmModel !== void 0 ? [`launchctl setenv LLM_MODEL "${input.llmModel}"`] : [],
1574
- ...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : [],
1575
1563
  ...input.postgresPassword !== void 0 ? [`launchctl setenv POSTGRES_PASSWORD "${input.postgresPassword}"`] : [],
1576
1564
  ...input.jwtSecret !== void 0 ? [`launchctl setenv JWT_SECRET "${input.jwtSecret}"`] : [],
1577
1565
  ...input.supabaseAnonKey !== void 0 ? [`launchctl setenv SUPABASE_ANON_KEY "${input.supabaseAnonKey}"`] : [],
@@ -1610,8 +1598,6 @@ function plistInputFromSetup(input, keyFilePath) {
1610
1598
  return {
1611
1599
  keyFilePath,
1612
1600
  ...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {},
1613
- ...input.llmModel !== void 0 ? { llmModel: input.llmModel } : {},
1614
- ...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {},
1615
1601
  ...input.postgresPassword !== void 0 ? { postgresPassword: input.postgresPassword } : {},
1616
1602
  ...input.jwtSecret !== void 0 ? { jwtSecret: input.jwtSecret } : {},
1617
1603
  ...input.supabaseAnonKey !== void 0 ? { supabaseAnonKey: input.supabaseAnonKey } : {},
@@ -2073,7 +2059,7 @@ function assessAllRunning(snapshots) {
2073
2059
 
2074
2060
  // src/lib/cosign.ts
2075
2061
  var COSIGN_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
2076
- var DEFAULT_IDENTITY_REGEXP = "^https://github\\.com/OpusPopuli/opuspopuli/\\.github/workflows/release\\.yml@refs/heads/.*$";
2062
+ var DEFAULT_IDENTITY_REGEXP = "^https://github\\.com/OpusPopuli/(?:opuspopuli|prompt-service)/\\.github/workflows/release\\.yml@refs/heads/.*$";
2077
2063
  async function cosignVerifyImage(input) {
2078
2064
  const idRegex = input.certificateIdentityRegexp ?? DEFAULT_IDENTITY_REGEXP;
2079
2065
  const issuer = input.certificateOidcIssuer ?? COSIGN_OIDC_ISSUER;
@@ -2106,8 +2092,9 @@ async function cosignVerifyImage(input) {
2106
2092
  var OLLAMA_URL = "http://localhost:11434";
2107
2093
  var OLLAMA_HEALTH_TIMEOUT_MS = 5e3;
2108
2094
  var OLLAMA_WARM_TIMEOUT_MS = 12e4;
2109
- var DEFAULT_LLM_MODEL = "qwen3.5:9b";
2095
+ var DEFAULT_LLM_MODEL = "qwen2.5:7b";
2110
2096
  var DEFAULT_EMBEDDING_MODEL = "nomic-embed-text";
2097
+ var DEFAULT_EMBEDDINGS_PROVIDER = "xenova";
2111
2098
  var PROBE_ALPINE_TAG = "3.20";
2112
2099
  async function startOllamaService() {
2113
2100
  const res = await safeExeca("brew", ["services", "start", "ollama"]);
@@ -2226,6 +2213,138 @@ async function setupModels(models, onProgress) {
2226
2213
  }
2227
2214
  return report;
2228
2215
  }
2216
+ var MANAGED_BEGIN = "# >>> op-node managed >>>";
2217
+ var MANAGED_END = "# <<< op-node managed <<<";
2218
+ var MANAGED_KEYS = [
2219
+ "LLM_MODEL",
2220
+ "EMBEDDINGS_PROVIDER",
2221
+ "EMBEDDINGS_MODEL",
2222
+ "NODE_ENV"
2223
+ ];
2224
+ var ENV_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
2225
+ function selectionToPairs(sel) {
2226
+ return [
2227
+ ["LLM_MODEL", sel.llmModel],
2228
+ ["EMBEDDINGS_PROVIDER", sel.embeddingsProvider],
2229
+ ["EMBEDDINGS_MODEL", sel.embeddingModel],
2230
+ ["NODE_ENV", sel.nodeEnv]
2231
+ ];
2232
+ }
2233
+ function parseEnvContent(content) {
2234
+ const out = /* @__PURE__ */ new Map();
2235
+ for (const raw of content.split("\n")) {
2236
+ const line = raw.trim();
2237
+ if (line.length === 0 || line.startsWith("#")) continue;
2238
+ const eq = line.indexOf("=");
2239
+ if (eq <= 0) continue;
2240
+ out.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
2241
+ }
2242
+ return out;
2243
+ }
2244
+ function splitManaged(content) {
2245
+ const lines = content.split("\n");
2246
+ const beginIdx = lines.findIndex((l) => l.trim() === MANAGED_BEGIN);
2247
+ if (beginIdx === -1) {
2248
+ return { before: lines, after: [], blockValues: /* @__PURE__ */ new Map(), hasBlock: false };
2249
+ }
2250
+ const endIdx = lines.findIndex((l, i) => i > beginIdx && l.trim() === MANAGED_END);
2251
+ if (endIdx === -1) {
2252
+ return {
2253
+ before: lines.slice(0, beginIdx),
2254
+ after: [],
2255
+ blockValues: parseEnvContent(lines.slice(beginIdx + 1).join("\n")),
2256
+ hasBlock: true
2257
+ };
2258
+ }
2259
+ return {
2260
+ before: lines.slice(0, beginIdx),
2261
+ after: lines.slice(endIdx + 1),
2262
+ blockValues: parseEnvContent(lines.slice(beginIdx + 1, endIdx).join("\n")),
2263
+ hasBlock: true
2264
+ };
2265
+ }
2266
+ function stripManagedKeyLines(region) {
2267
+ return region.filter((raw) => {
2268
+ const line = raw.trim();
2269
+ if (line.length === 0 || line.startsWith("#")) return true;
2270
+ const eq = line.indexOf("=");
2271
+ if (eq <= 0) return true;
2272
+ return !MANAGED_KEYS.includes(line.slice(0, eq).trim());
2273
+ });
2274
+ }
2275
+ function renderBlock(values) {
2276
+ return [
2277
+ MANAGED_BEGIN,
2278
+ "# Auto-generated by `create-op-node bootstrap`. Non-secret model config,",
2279
+ "# read by docker compose via `.env` auto-load. Edit LLM_MODEL here to",
2280
+ "# change the model for every service; re-running bootstrap preserves your",
2281
+ "# edits unless you explicitly re-select a model. Secrets do NOT live here.",
2282
+ ...values.map(([k, v]) => `${k}=${v}`),
2283
+ MANAGED_END
2284
+ ];
2285
+ }
2286
+ function buildManagedEnvContent(existing, selection, opts = {}) {
2287
+ const split = splitManaged(existing);
2288
+ const importedBefore = filterManaged(parseEnvContent(split.before.join("\n")));
2289
+ const importedAfter = filterManaged(parseEnvContent(split.after.join("\n")));
2290
+ const resolved = [];
2291
+ for (const [key, selected] of selectionToPairs(selection)) {
2292
+ const existingVal = split.blockValues.get(key) ?? importedBefore.get(key) ?? importedAfter.get(key);
2293
+ const final = opts.overwrite ? selected ?? existingVal : existingVal ?? selected;
2294
+ if (final === void 0) continue;
2295
+ if (!ENV_VALUE_RE.test(final)) {
2296
+ return { error: `${key} value ${JSON.stringify(final)} is not safe for a .env line` };
2297
+ }
2298
+ resolved.push([key, final]);
2299
+ }
2300
+ const before = stripManagedKeyLines(split.before);
2301
+ const after = stripManagedKeyLines(split.after);
2302
+ const block = renderBlock(resolved);
2303
+ const parts = [joinTrimmed(before), block.join("\n"), joinTrimmed(after)].filter(
2304
+ (s) => s.length > 0
2305
+ );
2306
+ return { content: parts.join("\n\n") + "\n" };
2307
+ }
2308
+ function filterManaged(all) {
2309
+ const out = /* @__PURE__ */ new Map();
2310
+ for (const key of MANAGED_KEYS) {
2311
+ const v = all.get(key);
2312
+ if (v !== void 0) out.set(key, v);
2313
+ }
2314
+ return out;
2315
+ }
2316
+ function joinTrimmed(region) {
2317
+ const joined = region.join("\n");
2318
+ let start = 0;
2319
+ let end = joined.length;
2320
+ while (start < end && joined[start] === "\n") start++;
2321
+ while (end > start && joined[end - 1] === "\n") end--;
2322
+ return joined.slice(start, end);
2323
+ }
2324
+ async function writeManagedEnv(repoDir, selection, opts = {}) {
2325
+ const target = join(repoDir, ".env");
2326
+ let existing = "";
2327
+ let existingMode;
2328
+ try {
2329
+ existing = await readFile(target, "utf8");
2330
+ existingMode = (await stat(target)).mode & 511;
2331
+ } catch {
2332
+ }
2333
+ const built = buildManagedEnvContent(existing, selection, opts);
2334
+ if ("error" in built) return { ok: false, reason: built.error };
2335
+ if (built.content === existing) return { ok: true, path: target, unchanged: true };
2336
+ const mode = existingMode ?? 420;
2337
+ const tmp = `${target}.tmp.${process.pid}`;
2338
+ try {
2339
+ await mkdir(dirname(target), { recursive: true });
2340
+ await writeFile(tmp, built.content, { mode });
2341
+ await chmod(tmp, mode);
2342
+ await rename(tmp, target);
2343
+ return { ok: true, path: target };
2344
+ } catch (err) {
2345
+ return { ok: false, reason: `writing ${target} failed: ${err.message}` };
2346
+ }
2347
+ }
2229
2348
  var NODE_REPO_MARKERS = [
2230
2349
  "docker-compose-prod.yml",
2231
2350
  "supabase/init/pgsodium_getkey_env.sh"
@@ -2299,6 +2418,11 @@ var bootstrapCommand = new Command("bootstrap").description(
2299
2418
  "--embedding-model <model>",
2300
2419
  `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).`
2301
2420
  )
2421
+ ).addOption(
2422
+ new Option(
2423
+ "--embeddings-provider <provider>",
2424
+ `Where embeddings are computed. Default: ${DEFAULT_EMBEDDINGS_PROVIDER} (in-process). \`ollama\` uses the host daemon with the embedding model. Written to the node's .env as the single source of truth.`
2425
+ ).choices(["xenova", "ollama"])
2302
2426
  ).addOption(
2303
2427
  new Option(
2304
2428
  "--supabase-url <url>",
@@ -2341,6 +2465,7 @@ var bootstrapCommand = new Command("bootstrap").description(
2341
2465
  llmModel: llmModelChoice,
2342
2466
  ...opts.embeddingModel !== void 0 ? { embeddingModel: opts.embeddingModel } : {}
2343
2467
  });
2468
+ const embeddingsProvider = opts.embeddingsProvider ?? DEFAULT_EMBEDDINGS_PROVIDER;
2344
2469
  await runSystemChecksPhase();
2345
2470
  await runBrewPhase(opts);
2346
2471
  await ensureGhAuth();
@@ -2348,11 +2473,18 @@ var bootstrapCommand = new Command("bootstrap").description(
2348
2473
  const repoPath = await locateRegionRepoPhase(opts, owner, repoName);
2349
2474
  await unlockKeychainPhase();
2350
2475
  const secrets = await collectSecretsPhase({ region, nodeType, opts });
2351
- await runLaunchAgentPhase({ opts, secrets, llmModel, embeddingModel });
2476
+ await runLaunchAgentPhase({ opts, secrets });
2352
2477
  await installWrapperPhase({ repoPath, region, promptServiceUrl: secrets.promptServiceUrl });
2353
2478
  await loginGhcrPhase();
2354
2479
  await runOllamaPhase({ opts, embeddingModel, llmModel });
2355
- await runStackPhase({ opts, repoPath, region, composeFile, secrets, llmModel, embeddingModel });
2480
+ await runEnvFilePhase({
2481
+ repoPath,
2482
+ llmModel,
2483
+ embeddingModel,
2484
+ embeddingsProvider,
2485
+ localOnly: Boolean(opts.localOnly)
2486
+ });
2487
+ await runStackPhase({ opts, repoPath, region, composeFile, secrets });
2356
2488
  });
2357
2489
  function defaultComposeFiles(opts) {
2358
2490
  return opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
@@ -2716,15 +2848,13 @@ async function collectSecretsPhase(args) {
2716
2848
  return { ...core, ...promptSecrets, promptServiceUrl, supabaseUrl };
2717
2849
  }
2718
2850
  async function runLaunchAgentPhase(args) {
2719
- const { opts, secrets, llmModel, embeddingModel } = args;
2851
+ const { opts, secrets } = args;
2720
2852
  if (opts.skipLaunchAgent) return;
2721
2853
  const laSpin = p3.spinner();
2722
2854
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
2723
2855
  const la = await setupLaunchAgent({
2724
2856
  pgsodiumKey: secrets.pgsodiumKey,
2725
2857
  ...secrets.tunnelToken !== void 0 ? { tunnelToken: secrets.tunnelToken } : {},
2726
- llmModel,
2727
- embeddingModel,
2728
2858
  postgresPassword: secrets.postgresPassword,
2729
2859
  jwtSecret: secrets.jwtSecret,
2730
2860
  supabaseAnonKey: secrets.supabaseAnonKey,
@@ -2809,8 +2939,33 @@ async function runOllamaPhase(args) {
2809
2939
  p3.note(`${pc2.yellow("\u26A0")} ${probe.reason}`, "Docker host networking");
2810
2940
  }
2811
2941
  }
2942
+ async function runEnvFilePhase(args) {
2943
+ const { repoPath, llmModel, embeddingModel, embeddingsProvider, localOnly } = args;
2944
+ const envSpin = p3.spinner();
2945
+ envSpin.start("Writing model config to the node .env\u2026");
2946
+ const res = await writeManagedEnv(
2947
+ repoPath,
2948
+ {
2949
+ llmModel,
2950
+ embeddingModel,
2951
+ embeddingsProvider,
2952
+ // NODE_ENV=development only for local-dev nodes; production nodes leave
2953
+ // it unset (the compose default / container images decide).
2954
+ ...localOnly ? { nodeEnv: "development" } : {}
2955
+ },
2956
+ { overwrite: true }
2957
+ );
2958
+ if (!res.ok) {
2959
+ envSpin.stop(pc2.red("\u2717 Failed to write the node .env."));
2960
+ p3.cancel(res.reason ?? "writing .env failed.");
2961
+ process.exit(1);
2962
+ }
2963
+ envSpin.stop(
2964
+ res.unchanged ? pc2.green(`\u2713 Model config already current in ${res.path}.`) : pc2.green(`\u2713 Wrote model config to ${res.path} (LLM_MODEL=${llmModel}).`)
2965
+ );
2966
+ }
2812
2967
  function buildComposeEnv(args) {
2813
- const { secrets, llmModel, embeddingModel } = args;
2968
+ const { secrets } = args;
2814
2969
  return {
2815
2970
  PGSODIUM_ROOT_KEY: secrets.pgsodiumKey,
2816
2971
  POSTGRES_PASSWORD: secrets.postgresPassword,
@@ -2838,9 +2993,12 @@ function buildComposeEnv(args) {
2838
2993
  ...secrets.promptsDbPassword !== void 0 ? { PROMPTS_DB_PASSWORD: secrets.promptsDbPassword } : {},
2839
2994
  ...secrets.promptServiceApiKey !== void 0 ? { PROMPT_SERVICE_API_KEY: secrets.promptServiceApiKey } : {},
2840
2995
  ...secrets.promptServiceApiKeys !== void 0 ? { PROMPT_SERVICE_API_KEYS: secrets.promptServiceApiKeys } : {},
2841
- ...secrets.promptServiceAdminApiKeys !== void 0 ? { PROMPT_SERVICE_ADMIN_API_KEYS: secrets.promptServiceAdminApiKeys } : {},
2842
- ...llmModel ? { LLM_MODEL: llmModel } : {},
2843
- ...embeddingModel ? { EMBEDDINGS_MODEL: embeddingModel } : {}
2996
+ ...secrets.promptServiceAdminApiKeys !== void 0 ? { PROMPT_SERVICE_ADMIN_API_KEYS: secrets.promptServiceAdminApiKeys } : {}
2997
+ // Model config (LLM_MODEL / EMBEDDINGS_MODEL / NODE_ENV) is intentionally
2998
+ // NOT injected here it lives in the node's `.env` (runEnvFilePhase),
2999
+ // which docker compose auto-loads. Injecting it into the subprocess env
3000
+ // would shadow `.env` (shell env > .env) and reintroduce the drift the
3001
+ // single-source `.env` exists to eliminate.
2844
3002
  };
2845
3003
  }
2846
3004
  function checkPublicProfileSecrets(env) {
@@ -2920,7 +3078,7 @@ cosign is required for the fail-closed signature gate \u2014 install it (\`brew
2920
3078
  );
2921
3079
  }
2922
3080
  async function runStackPhase(args) {
2923
- const { opts, repoPath, region, composeFile, secrets, llmModel, embeddingModel } = args;
3081
+ const { opts, repoPath, region, composeFile, secrets } = args;
2924
3082
  if (opts.skipStack) {
2925
3083
  const profileFlag = opts.localOnly ? "" : "--profile public ";
2926
3084
  p3.outro(
@@ -2931,7 +3089,7 @@ async function runStackPhase(args) {
2931
3089
  return;
2932
3090
  }
2933
3091
  const composeFiles = resolveComposeFiles(repoPath, composeFile);
2934
- const composeEnv = buildComposeEnv({ secrets, llmModel, embeddingModel });
3092
+ const composeEnv = buildComposeEnv({ secrets });
2935
3093
  const composeOpts = {
2936
3094
  files: composeFiles,
2937
3095
  cwd: repoPath,
@@ -3055,32 +3213,38 @@ function resolveModels(opts) {
3055
3213
  }
3056
3214
  var LLM_MODEL_CHOICES = [
3057
3215
  {
3058
- value: "qwen2.5:72b",
3059
- label: "qwen2.5:72b",
3060
- hint: "72B, ~50 GB Ollama RAM. Pre-selected on 96 GB+ Studios. Best Spanish + multilingual quality. Pull ~40 GB, 30\u201360 min."
3216
+ value: "qwen3.6:35b-a3b",
3217
+ label: "qwen3.6:35b-a3b",
3218
+ hint: "35B MoE, ~3B active \u2014 fast on Apple Silicon. ~24 GB footprint. Pre-selected on 64 GB+ nodes. Best Spanish + multilingual quality. Pull ~24 GB, 30\u201360 min."
3219
+ },
3220
+ {
3221
+ value: "qwen3:14b",
3222
+ label: "qwen3:14b",
3223
+ hint: "14B, ~9 GB footprint. Pre-selected on 32\u201364 GB nodes. Solid Spanish, faster than the 35B MoE. Pull ~9 GB, 10\u201320 min."
3061
3224
  },
3062
3225
  {
3063
- value: "qwen2.5:32b",
3064
- label: "qwen2.5:32b",
3065
- hint: "32B, ~22 GB Ollama RAM. Pre-selected on 48\u201395 GB Studios. Solid Spanish, faster than 72B. Pull ~20 GB, 15\u201330 min."
3226
+ value: "qwen2.5:7b",
3227
+ label: "qwen2.5:7b",
3228
+ hint: "7B, ~5 GB footprint. Pre-selected on 16\u201332 GB nodes; also the -y / scripted default. Pull ~5 GB, 3\u20135 min."
3066
3229
  },
3067
3230
  {
3068
- value: "qwen3.5:9b",
3069
- label: "qwen3.5:9b",
3070
- hint: "9B, ~8 GB Ollama RAM. Pre-selected on smaller Studios (< 48 GB); also the -y / scripted default. Pull ~5 GB, 3\u20135 min."
3231
+ value: "qwen2.5:3b",
3232
+ label: "qwen2.5:3b",
3233
+ hint: "3B, ~2\u20133 GB footprint. Pre-selected on \u2264 16 GB nodes (Mac Mini). Lightest option. Pull ~2 GB, 2\u20133 min."
3071
3234
  }
3072
3235
  ];
3073
3236
  var OTHER_SENTINEL = "__OTHER__";
3074
3237
  function recommendLlmModel(ramGB) {
3075
3238
  if (ramGB === null) return null;
3076
- if (ramGB >= 96) return "qwen2.5:72b";
3077
- if (ramGB >= 48) return "qwen2.5:32b";
3078
- return "qwen3.5:9b";
3239
+ if (ramGB >= 64) return "qwen3.6:35b-a3b";
3240
+ if (ramGB >= 32) return "qwen3:14b";
3241
+ if (ramGB >= 16) return "qwen2.5:7b";
3242
+ return "qwen2.5:3b";
3079
3243
  }
3080
3244
  async function selectLlmModel(opts) {
3081
3245
  if (opts.yes) return DEFAULT_LLM_MODEL;
3082
3246
  const ramGB = await detectUnifiedMemoryGB();
3083
- const recommended = recommendLlmModel(ramGB) ?? "qwen2.5:72b";
3247
+ const recommended = recommendLlmModel(ramGB) ?? DEFAULT_LLM_MODEL;
3084
3248
  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).`;
3085
3249
  p3.note(ramNote, "Hardware");
3086
3250
  const choice = unwrap(
@@ -5154,7 +5318,7 @@ function withDefaultSubcommand(argv, known) {
5154
5318
  }
5155
5319
 
5156
5320
  // src/cli.ts
5157
- var VERSION = "0.12.3";
5321
+ var VERSION = "0.13.0";
5158
5322
  var program = new Command();
5159
5323
  program.name("create-op-node").description(
5160
5324
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."