create-op-node 0.12.4 → 0.14.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/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';
@@ -1502,7 +1502,6 @@ async function writePgsodiumKeyFile(key, keyFile) {
1502
1502
  return { ok: false, reason: `writing ${keyFile} failed: ${err.message}` };
1503
1503
  }
1504
1504
  }
1505
- var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
1506
1505
  function renderLaunchAgentPlist(input) {
1507
1506
  validatePlistInput(input);
1508
1507
  const command = buildSetenvCommand(input);
@@ -1528,16 +1527,6 @@ function validatePlistInput(input) {
1528
1527
  if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1529
1528
  throw new Error("Tunnel token contains characters outside the expected base64-url set");
1530
1529
  }
1531
- if (input.llmModel !== void 0 && !MODEL_NAME_RE.test(input.llmModel)) {
1532
- throw new Error(
1533
- `llmModel ${JSON.stringify(input.llmModel)} contains characters not allowed in a launchd setenv value`
1534
- );
1535
- }
1536
- if (input.embeddingModel !== void 0 && !MODEL_NAME_RE.test(input.embeddingModel)) {
1537
- throw new Error(
1538
- `embeddingModel ${JSON.stringify(input.embeddingModel)} contains characters not allowed in a launchd setenv value`
1539
- );
1540
- }
1541
1530
  if (!SAFE_PATH_RE.test(input.keyFilePath)) {
1542
1531
  throw new Error(
1543
1532
  `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
@@ -1571,8 +1560,6 @@ function buildSetenvCommand(input) {
1571
1560
  // of — this is word-split safety, not new injection surface. (#36)
1572
1561
  `launchctl setenv PGSODIUM_ROOT_KEY "$(cat "${input.keyFilePath}")"`,
1573
1562
  ...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : [],
1574
- ...input.llmModel !== void 0 ? [`launchctl setenv LLM_MODEL "${input.llmModel}"`] : [],
1575
- ...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : [],
1576
1563
  ...input.postgresPassword !== void 0 ? [`launchctl setenv POSTGRES_PASSWORD "${input.postgresPassword}"`] : [],
1577
1564
  ...input.jwtSecret !== void 0 ? [`launchctl setenv JWT_SECRET "${input.jwtSecret}"`] : [],
1578
1565
  ...input.supabaseAnonKey !== void 0 ? [`launchctl setenv SUPABASE_ANON_KEY "${input.supabaseAnonKey}"`] : [],
@@ -1611,8 +1598,6 @@ function plistInputFromSetup(input, keyFilePath) {
1611
1598
  return {
1612
1599
  keyFilePath,
1613
1600
  ...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {},
1614
- ...input.llmModel !== void 0 ? { llmModel: input.llmModel } : {},
1615
- ...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {},
1616
1601
  ...input.postgresPassword !== void 0 ? { postgresPassword: input.postgresPassword } : {},
1617
1602
  ...input.jwtSecret !== void 0 ? { jwtSecret: input.jwtSecret } : {},
1618
1603
  ...input.supabaseAnonKey !== void 0 ? { supabaseAnonKey: input.supabaseAnonKey } : {},
@@ -2107,8 +2092,9 @@ async function cosignVerifyImage(input) {
2107
2092
  var OLLAMA_URL = "http://localhost:11434";
2108
2093
  var OLLAMA_HEALTH_TIMEOUT_MS = 5e3;
2109
2094
  var OLLAMA_WARM_TIMEOUT_MS = 12e4;
2110
- var DEFAULT_LLM_MODEL = "qwen3.5:9b";
2095
+ var DEFAULT_LLM_MODEL = "qwen2.5:7b";
2111
2096
  var DEFAULT_EMBEDDING_MODEL = "nomic-embed-text";
2097
+ var DEFAULT_EMBEDDINGS_PROVIDER = "xenova";
2112
2098
  var PROBE_ALPINE_TAG = "3.20";
2113
2099
  async function startOllamaService() {
2114
2100
  const res = await safeExeca("brew", ["services", "start", "ollama"]);
@@ -2136,6 +2122,11 @@ async function checkOllamaHealth(url = OLLAMA_URL) {
2136
2122
  clearTimeout(timer);
2137
2123
  }
2138
2124
  }
2125
+ function modelPresent(configured, installed) {
2126
+ const norm = (m) => m.includes(":") ? m : `${m}:latest`;
2127
+ const target = norm(configured);
2128
+ return installed.some((m) => norm(m) === target);
2129
+ }
2139
2130
  async function pullModel(name) {
2140
2131
  const res = await safeExeca("ollama", ["pull", name]);
2141
2132
  if (res === null) return { ok: false, reason: "`ollama` not on PATH" };
@@ -2227,6 +2218,157 @@ async function setupModels(models, onProgress) {
2227
2218
  }
2228
2219
  return report;
2229
2220
  }
2221
+ var MANAGED_BEGIN = "# >>> op-node managed >>>";
2222
+ var MANAGED_END = "# <<< op-node managed <<<";
2223
+ var MANAGED_KEYS = [
2224
+ "LLM_MODEL",
2225
+ "EMBEDDINGS_PROVIDER",
2226
+ "EMBEDDINGS_MODEL",
2227
+ "NODE_ENV"
2228
+ ];
2229
+ var ENV_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
2230
+ function selectionToPairs(sel) {
2231
+ return [
2232
+ ["LLM_MODEL", sel.llmModel],
2233
+ ["EMBEDDINGS_PROVIDER", sel.embeddingsProvider],
2234
+ ["EMBEDDINGS_MODEL", sel.embeddingModel],
2235
+ ["NODE_ENV", sel.nodeEnv]
2236
+ ];
2237
+ }
2238
+ function parseEnvContent(content) {
2239
+ const out = /* @__PURE__ */ new Map();
2240
+ for (const raw of content.split("\n")) {
2241
+ const line = raw.trim();
2242
+ if (line.length === 0 || line.startsWith("#")) continue;
2243
+ const eq = line.indexOf("=");
2244
+ if (eq <= 0) continue;
2245
+ out.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
2246
+ }
2247
+ return out;
2248
+ }
2249
+ function splitManaged(content) {
2250
+ const lines = content.split("\n");
2251
+ const beginIdx = lines.findIndex((l) => l.trim() === MANAGED_BEGIN);
2252
+ if (beginIdx === -1) {
2253
+ return { before: lines, after: [], blockValues: /* @__PURE__ */ new Map(), hasBlock: false };
2254
+ }
2255
+ const endIdx = lines.findIndex((l, i) => i > beginIdx && l.trim() === MANAGED_END);
2256
+ if (endIdx === -1) {
2257
+ return {
2258
+ before: lines.slice(0, beginIdx),
2259
+ after: [],
2260
+ blockValues: parseEnvContent(lines.slice(beginIdx + 1).join("\n")),
2261
+ hasBlock: true
2262
+ };
2263
+ }
2264
+ return {
2265
+ before: lines.slice(0, beginIdx),
2266
+ after: lines.slice(endIdx + 1),
2267
+ blockValues: parseEnvContent(lines.slice(beginIdx + 1, endIdx).join("\n")),
2268
+ hasBlock: true
2269
+ };
2270
+ }
2271
+ function stripManagedKeyLines(region) {
2272
+ return region.filter((raw) => {
2273
+ const line = raw.trim();
2274
+ if (line.length === 0 || line.startsWith("#")) return true;
2275
+ const eq = line.indexOf("=");
2276
+ if (eq <= 0) return true;
2277
+ return !MANAGED_KEYS.includes(line.slice(0, eq).trim());
2278
+ });
2279
+ }
2280
+ function renderBlock(values) {
2281
+ return [
2282
+ MANAGED_BEGIN,
2283
+ "# Auto-generated by `create-op-node bootstrap`. Non-secret model config,",
2284
+ "# read by docker compose via `.env` auto-load. Edit LLM_MODEL here to",
2285
+ "# change the model for every service; re-running bootstrap preserves your",
2286
+ "# edits unless you explicitly re-select a model. Secrets do NOT live here.",
2287
+ ...values.map(([k, v]) => `${k}=${v}`),
2288
+ MANAGED_END
2289
+ ];
2290
+ }
2291
+ function buildManagedEnvContent(existing, selection, opts = {}) {
2292
+ const split = splitManaged(existing);
2293
+ const importedBefore = filterManaged(parseEnvContent(split.before.join("\n")));
2294
+ const importedAfter = filterManaged(parseEnvContent(split.after.join("\n")));
2295
+ const resolved = [];
2296
+ for (const [key, selected] of selectionToPairs(selection)) {
2297
+ const existingVal = split.blockValues.get(key) ?? importedBefore.get(key) ?? importedAfter.get(key);
2298
+ const final = opts.overwrite ? selected ?? existingVal : existingVal ?? selected;
2299
+ if (final === void 0) continue;
2300
+ if (!ENV_VALUE_RE.test(final)) {
2301
+ return { error: `${key} value ${JSON.stringify(final)} is not safe for a .env line` };
2302
+ }
2303
+ resolved.push([key, final]);
2304
+ }
2305
+ const before = stripManagedKeyLines(split.before);
2306
+ const after = stripManagedKeyLines(split.after);
2307
+ const block = renderBlock(resolved);
2308
+ const parts = [joinTrimmed(before), block.join("\n"), joinTrimmed(after)].filter(
2309
+ (s) => s.length > 0
2310
+ );
2311
+ return { content: parts.join("\n\n") + "\n" };
2312
+ }
2313
+ function filterManaged(all) {
2314
+ const out = /* @__PURE__ */ new Map();
2315
+ for (const key of MANAGED_KEYS) {
2316
+ const v = all.get(key);
2317
+ if (v !== void 0) out.set(key, v);
2318
+ }
2319
+ return out;
2320
+ }
2321
+ function joinTrimmed(region) {
2322
+ const joined = region.join("\n");
2323
+ let start = 0;
2324
+ let end = joined.length;
2325
+ while (start < end && joined[start] === "\n") start++;
2326
+ while (end > start && joined[end - 1] === "\n") end--;
2327
+ return joined.slice(start, end);
2328
+ }
2329
+ async function readEnvModelConfig(repoDir) {
2330
+ let content;
2331
+ try {
2332
+ content = await readFile(join(repoDir, ".env"), "utf8");
2333
+ } catch {
2334
+ return {};
2335
+ }
2336
+ const map = parseEnvContent(content);
2337
+ const cfg = {};
2338
+ const llm = map.get("LLM_MODEL");
2339
+ const emb = map.get("EMBEDDINGS_MODEL");
2340
+ const prov = map.get("EMBEDDINGS_PROVIDER");
2341
+ const node = map.get("NODE_ENV");
2342
+ if (llm !== void 0) cfg.llmModel = llm;
2343
+ if (emb !== void 0) cfg.embeddingModel = emb;
2344
+ if (prov !== void 0) cfg.embeddingsProvider = prov;
2345
+ if (node !== void 0) cfg.nodeEnv = node;
2346
+ return cfg;
2347
+ }
2348
+ async function writeManagedEnv(repoDir, selection, opts = {}) {
2349
+ const target = join(repoDir, ".env");
2350
+ let existing = "";
2351
+ let existingMode;
2352
+ try {
2353
+ existing = await readFile(target, "utf8");
2354
+ existingMode = (await stat(target)).mode & 511;
2355
+ } catch {
2356
+ }
2357
+ const built = buildManagedEnvContent(existing, selection, opts);
2358
+ if ("error" in built) return { ok: false, reason: built.error };
2359
+ if (built.content === existing) return { ok: true, path: target, unchanged: true };
2360
+ const mode = existingMode ?? 420;
2361
+ const tmp = `${target}.tmp.${process.pid}`;
2362
+ try {
2363
+ await mkdir(dirname(target), { recursive: true });
2364
+ await writeFile(tmp, built.content, { mode });
2365
+ await chmod(tmp, mode);
2366
+ await rename(tmp, target);
2367
+ return { ok: true, path: target };
2368
+ } catch (err) {
2369
+ return { ok: false, reason: `writing ${target} failed: ${err.message}` };
2370
+ }
2371
+ }
2230
2372
  var NODE_REPO_MARKERS = [
2231
2373
  "docker-compose-prod.yml",
2232
2374
  "supabase/init/pgsodium_getkey_env.sh"
@@ -2300,6 +2442,11 @@ var bootstrapCommand = new Command("bootstrap").description(
2300
2442
  "--embedding-model <model>",
2301
2443
  `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).`
2302
2444
  )
2445
+ ).addOption(
2446
+ new Option(
2447
+ "--embeddings-provider <provider>",
2448
+ `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.`
2449
+ ).choices(["xenova", "ollama"])
2303
2450
  ).addOption(
2304
2451
  new Option(
2305
2452
  "--supabase-url <url>",
@@ -2342,6 +2489,7 @@ var bootstrapCommand = new Command("bootstrap").description(
2342
2489
  llmModel: llmModelChoice,
2343
2490
  ...opts.embeddingModel !== void 0 ? { embeddingModel: opts.embeddingModel } : {}
2344
2491
  });
2492
+ const embeddingsProvider = opts.embeddingsProvider ?? DEFAULT_EMBEDDINGS_PROVIDER;
2345
2493
  await runSystemChecksPhase();
2346
2494
  await runBrewPhase(opts);
2347
2495
  await ensureGhAuth();
@@ -2349,11 +2497,18 @@ var bootstrapCommand = new Command("bootstrap").description(
2349
2497
  const repoPath = await locateRegionRepoPhase(opts, owner, repoName);
2350
2498
  await unlockKeychainPhase();
2351
2499
  const secrets = await collectSecretsPhase({ region, nodeType, opts });
2352
- await runLaunchAgentPhase({ opts, secrets, llmModel, embeddingModel });
2500
+ await runLaunchAgentPhase({ opts, secrets });
2353
2501
  await installWrapperPhase({ repoPath, region, promptServiceUrl: secrets.promptServiceUrl });
2354
2502
  await loginGhcrPhase();
2355
- await runOllamaPhase({ opts, embeddingModel, llmModel });
2356
- await runStackPhase({ opts, repoPath, region, composeFile, secrets, llmModel, embeddingModel });
2503
+ await runOllamaPhase({ opts, embeddingModel, llmModel, embeddingsProvider });
2504
+ await runEnvFilePhase({
2505
+ repoPath,
2506
+ llmModel,
2507
+ embeddingModel,
2508
+ embeddingsProvider,
2509
+ localOnly: Boolean(opts.localOnly)
2510
+ });
2511
+ await runStackPhase({ opts, repoPath, region, composeFile, secrets });
2357
2512
  });
2358
2513
  function defaultComposeFiles(opts) {
2359
2514
  return opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
@@ -2717,15 +2872,13 @@ async function collectSecretsPhase(args) {
2717
2872
  return { ...core, ...promptSecrets, promptServiceUrl, supabaseUrl };
2718
2873
  }
2719
2874
  async function runLaunchAgentPhase(args) {
2720
- const { opts, secrets, llmModel, embeddingModel } = args;
2875
+ const { opts, secrets } = args;
2721
2876
  if (opts.skipLaunchAgent) return;
2722
2877
  const laSpin = p3.spinner();
2723
2878
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
2724
2879
  const la = await setupLaunchAgent({
2725
2880
  pgsodiumKey: secrets.pgsodiumKey,
2726
2881
  ...secrets.tunnelToken !== void 0 ? { tunnelToken: secrets.tunnelToken } : {},
2727
- llmModel,
2728
- embeddingModel,
2729
2882
  postgresPassword: secrets.postgresPassword,
2730
2883
  jwtSecret: secrets.jwtSecret,
2731
2884
  supabaseAnonKey: secrets.supabaseAnonKey,
@@ -2769,8 +2922,12 @@ async function loginGhcrPhase() {
2769
2922
  }
2770
2923
  ghcrSpin.stop(pc2.green("\u2713 Logged in to ghcr.io."));
2771
2924
  }
2925
+ function modelsToPull(args) {
2926
+ const { provider, llmModel, embeddingModel } = args;
2927
+ return provider === "ollama" ? [embeddingModel, llmModel] : [llmModel];
2928
+ }
2772
2929
  async function runOllamaPhase(args) {
2773
- const { opts, embeddingModel, llmModel } = args;
2930
+ const { opts, embeddingModel, llmModel, embeddingsProvider } = args;
2774
2931
  if (opts.skipOllama) return;
2775
2932
  const olSpin = p3.spinner();
2776
2933
  olSpin.start(`Pulling + warming Ollama models\u2026 (${estimatedPullTime(llmModel)})`);
@@ -2797,7 +2954,8 @@ async function runOllamaPhase(args) {
2797
2954
  process.exit(1);
2798
2955
  }
2799
2956
  }
2800
- const modelReport = await setupModels([embeddingModel, llmModel], (model, status) => {
2957
+ const toPull = modelsToPull({ provider: embeddingsProvider, llmModel, embeddingModel });
2958
+ const modelReport = await setupModels(toPull, (model, status) => {
2801
2959
  olSpin.message(`${status}: ${model}`);
2802
2960
  });
2803
2961
  olSpin.stop(
@@ -2805,13 +2963,44 @@ async function runOllamaPhase(args) {
2805
2963
  `\u2713 ${modelReport.pulled.length} pulled, ${modelReport.alreadyPresent.length} present, ${modelReport.warmed.length} warmed`
2806
2964
  ) : pc2.yellow(`\u26A0 ${modelReport.failed.length} model pull(s) failed`)
2807
2965
  );
2966
+ if (embeddingsProvider !== "ollama") {
2967
+ p3.note(
2968
+ `${pc2.dim("\xB7")} Skipped pulling the embedding model \u2014 EMBEDDINGS_PROVIDER=${embeddingsProvider} computes embeddings in-process (no Ollama model needed).`,
2969
+ "Embeddings"
2970
+ );
2971
+ }
2808
2972
  const probe = await probeHostDockerInternal();
2809
2973
  if (!probe.ok) {
2810
2974
  p3.note(`${pc2.yellow("\u26A0")} ${probe.reason}`, "Docker host networking");
2811
2975
  }
2812
2976
  }
2977
+ async function runEnvFilePhase(args) {
2978
+ const { repoPath, llmModel, embeddingModel, embeddingsProvider, localOnly } = args;
2979
+ const envSpin = p3.spinner();
2980
+ envSpin.start("Writing model config to the node .env\u2026");
2981
+ const res = await writeManagedEnv(
2982
+ repoPath,
2983
+ {
2984
+ llmModel,
2985
+ embeddingModel,
2986
+ embeddingsProvider,
2987
+ // NODE_ENV=development only for local-dev nodes; production nodes leave
2988
+ // it unset (the compose default / container images decide).
2989
+ ...localOnly ? { nodeEnv: "development" } : {}
2990
+ },
2991
+ { overwrite: true }
2992
+ );
2993
+ if (!res.ok) {
2994
+ envSpin.stop(pc2.red("\u2717 Failed to write the node .env."));
2995
+ p3.cancel(res.reason ?? "writing .env failed.");
2996
+ process.exit(1);
2997
+ }
2998
+ envSpin.stop(
2999
+ res.unchanged ? pc2.green(`\u2713 Model config already current in ${res.path}.`) : pc2.green(`\u2713 Wrote model config to ${res.path} (LLM_MODEL=${llmModel}).`)
3000
+ );
3001
+ }
2813
3002
  function buildComposeEnv(args) {
2814
- const { secrets, llmModel, embeddingModel } = args;
3003
+ const { secrets } = args;
2815
3004
  return {
2816
3005
  PGSODIUM_ROOT_KEY: secrets.pgsodiumKey,
2817
3006
  POSTGRES_PASSWORD: secrets.postgresPassword,
@@ -2839,9 +3028,12 @@ function buildComposeEnv(args) {
2839
3028
  ...secrets.promptsDbPassword !== void 0 ? { PROMPTS_DB_PASSWORD: secrets.promptsDbPassword } : {},
2840
3029
  ...secrets.promptServiceApiKey !== void 0 ? { PROMPT_SERVICE_API_KEY: secrets.promptServiceApiKey } : {},
2841
3030
  ...secrets.promptServiceApiKeys !== void 0 ? { PROMPT_SERVICE_API_KEYS: secrets.promptServiceApiKeys } : {},
2842
- ...secrets.promptServiceAdminApiKeys !== void 0 ? { PROMPT_SERVICE_ADMIN_API_KEYS: secrets.promptServiceAdminApiKeys } : {},
2843
- ...llmModel ? { LLM_MODEL: llmModel } : {},
2844
- ...embeddingModel ? { EMBEDDINGS_MODEL: embeddingModel } : {}
3031
+ ...secrets.promptServiceAdminApiKeys !== void 0 ? { PROMPT_SERVICE_ADMIN_API_KEYS: secrets.promptServiceAdminApiKeys } : {}
3032
+ // Model config (LLM_MODEL / EMBEDDINGS_MODEL / NODE_ENV) is intentionally
3033
+ // NOT injected here it lives in the node's `.env` (runEnvFilePhase),
3034
+ // which docker compose auto-loads. Injecting it into the subprocess env
3035
+ // would shadow `.env` (shell env > .env) and reintroduce the drift the
3036
+ // single-source `.env` exists to eliminate.
2845
3037
  };
2846
3038
  }
2847
3039
  function checkPublicProfileSecrets(env) {
@@ -2921,7 +3113,7 @@ cosign is required for the fail-closed signature gate \u2014 install it (\`brew
2921
3113
  );
2922
3114
  }
2923
3115
  async function runStackPhase(args) {
2924
- const { opts, repoPath, region, composeFile, secrets, llmModel, embeddingModel } = args;
3116
+ const { opts, repoPath, region, composeFile, secrets } = args;
2925
3117
  if (opts.skipStack) {
2926
3118
  const profileFlag = opts.localOnly ? "" : "--profile public ";
2927
3119
  p3.outro(
@@ -2932,7 +3124,7 @@ async function runStackPhase(args) {
2932
3124
  return;
2933
3125
  }
2934
3126
  const composeFiles = resolveComposeFiles(repoPath, composeFile);
2935
- const composeEnv = buildComposeEnv({ secrets, llmModel, embeddingModel });
3127
+ const composeEnv = buildComposeEnv({ secrets });
2936
3128
  const composeOpts = {
2937
3129
  files: composeFiles,
2938
3130
  cwd: repoPath,
@@ -3056,32 +3248,38 @@ function resolveModels(opts) {
3056
3248
  }
3057
3249
  var LLM_MODEL_CHOICES = [
3058
3250
  {
3059
- value: "qwen2.5:72b",
3060
- label: "qwen2.5:72b",
3061
- hint: "72B, ~50 GB Ollama RAM. Pre-selected on 96 GB+ Studios. Best Spanish + multilingual quality. Pull ~40 GB, 30\u201360 min."
3251
+ value: "qwen3.6:35b-a3b",
3252
+ label: "qwen3.6:35b-a3b",
3253
+ 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."
3062
3254
  },
3063
3255
  {
3064
- value: "qwen2.5:32b",
3065
- label: "qwen2.5:32b",
3066
- hint: "32B, ~22 GB Ollama RAM. Pre-selected on 48\u201395 GB Studios. Solid Spanish, faster than 72B. Pull ~20 GB, 15\u201330 min."
3256
+ value: "qwen3:14b",
3257
+ label: "qwen3:14b",
3258
+ 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."
3067
3259
  },
3068
3260
  {
3069
- value: "qwen3.5:9b",
3070
- label: "qwen3.5:9b",
3071
- hint: "9B, ~8 GB Ollama RAM. Pre-selected on smaller Studios (< 48 GB); also the -y / scripted default. Pull ~5 GB, 3\u20135 min."
3261
+ value: "qwen2.5:7b",
3262
+ label: "qwen2.5:7b",
3263
+ hint: "7B, ~5 GB footprint. Pre-selected on 16\u201332 GB nodes; also the -y / scripted default. Pull ~5 GB, 3\u20135 min."
3264
+ },
3265
+ {
3266
+ value: "qwen2.5:3b",
3267
+ label: "qwen2.5:3b",
3268
+ hint: "3B, ~2\u20133 GB footprint. Pre-selected on \u2264 16 GB nodes (Mac Mini). Lightest option. Pull ~2 GB, 2\u20133 min."
3072
3269
  }
3073
3270
  ];
3074
3271
  var OTHER_SENTINEL = "__OTHER__";
3075
3272
  function recommendLlmModel(ramGB) {
3076
3273
  if (ramGB === null) return null;
3077
- if (ramGB >= 96) return "qwen2.5:72b";
3078
- if (ramGB >= 48) return "qwen2.5:32b";
3079
- return "qwen3.5:9b";
3274
+ if (ramGB >= 64) return "qwen3.6:35b-a3b";
3275
+ if (ramGB >= 32) return "qwen3:14b";
3276
+ if (ramGB >= 16) return "qwen2.5:7b";
3277
+ return "qwen2.5:3b";
3080
3278
  }
3081
3279
  async function selectLlmModel(opts) {
3082
3280
  if (opts.yes) return DEFAULT_LLM_MODEL;
3083
3281
  const ramGB = await detectUnifiedMemoryGB();
3084
- const recommended = recommendLlmModel(ramGB) ?? "qwen2.5:72b";
3282
+ const recommended = recommendLlmModel(ramGB) ?? DEFAULT_LLM_MODEL;
3085
3283
  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).`;
3086
3284
  p3.note(ramNote, "Hardware");
3087
3285
  const choice = unwrap(
@@ -3799,7 +3997,8 @@ var DEFAULT_DEPS2 = {
3799
3997
  http: httpProbe,
3800
3998
  graphql: graphqlProbe,
3801
3999
  tunnel: tunnelStatus,
3802
- cosign: cosignVerifyImage
4000
+ cosign: cosignVerifyImage,
4001
+ ollama: checkOllamaHealth
3803
4002
  };
3804
4003
  async function runVerify(input, deps = DEFAULT_DEPS2) {
3805
4004
  const phases = [];
@@ -3810,6 +4009,7 @@ async function runVerify(input, deps = DEFAULT_DEPS2) {
3810
4009
  await verifyTlsPhase(input, deps, push);
3811
4010
  await verifyHealthPhase(input, deps, push);
3812
4011
  await verifyGraphqlPhase(input, deps, push);
4012
+ await verifyOllamaModelsPhase(input, deps, push);
3813
4013
  await verifyCloudflarePhase(input, deps, push);
3814
4014
  await verifyCosignPhase(input, deps, push);
3815
4015
  return { phases };
@@ -3847,6 +4047,39 @@ async function verifyGraphqlPhase(input, deps, push) {
3847
4047
  push({ name: "GraphQL { __typename }", status: "fail", detail: gql.reason });
3848
4048
  }
3849
4049
  }
4050
+ async function verifyOllamaModelsPhase(input, deps, push) {
4051
+ const cfg = input.ollama;
4052
+ if (!cfg?.llmModel) {
4053
+ push({
4054
+ name: "Ollama models",
4055
+ status: "skipped",
4056
+ detail: "pass --llm-model (or run on the node so its .env is read) to enable"
4057
+ });
4058
+ return;
4059
+ }
4060
+ const health = await deps.ollama();
4061
+ if (!health.reachable) {
4062
+ push({
4063
+ name: "Ollama models",
4064
+ status: "fail",
4065
+ detail: "configured model set but the local Ollama daemon is unreachable on :11434 \u2014 start it with `brew services start ollama` (run this check on the node)"
4066
+ });
4067
+ return;
4068
+ }
4069
+ const required = [cfg.llmModel];
4070
+ if (cfg.provider === "ollama" && cfg.embeddingModel) required.push(cfg.embeddingModel);
4071
+ const missing = required.filter((m) => !modelPresent(m, health.models));
4072
+ if (missing.length > 0) {
4073
+ const remedy = missing.map((m) => `ollama pull ${m}`).join(" && ");
4074
+ push({
4075
+ name: "Ollama models",
4076
+ status: "fail",
4077
+ detail: `not installed: ${missing.join(", ")} \u2014 remedy: ${remedy}`
4078
+ });
4079
+ return;
4080
+ }
4081
+ push({ name: "Ollama models", status: "ok", detail: `${required.join(", ")} present` });
4082
+ }
3850
4083
  async function verifyCloudflarePhase(input, deps, push) {
3851
4084
  const cfFields = [
3852
4085
  ["--cf-token", input.cf?.token],
@@ -3931,6 +4164,17 @@ var verifyCommand = new Command("verify").description(
3931
4164
  )
3932
4165
  ).addOption(
3933
4166
  new Option("--cert-warn-days <n>", "Warn if cert expires within N days").default("14")
4167
+ ).addOption(
4168
+ new Option(
4169
+ "--llm-model <model>",
4170
+ "Assert this model is present in the local Ollama (catches config\u2194runtime drift). Defaults to LLM_MODEL from the node .env when run on the node."
4171
+ )
4172
+ ).addOption(
4173
+ new Option("--embedding-model <model>", "Also assert this embedding model is present (only checked when --embeddings-provider=ollama).")
4174
+ ).addOption(
4175
+ new Option("--embeddings-provider <provider>", "Embeddings provider the node runs; the embedding model is only asserted for `ollama`.").choices(["xenova", "ollama"])
4176
+ ).addOption(
4177
+ new Option("--repo-dir <path>", "Node repo dir whose .env supplies the model config when --llm-model is omitted (default: cwd).")
3934
4178
  ).addOption(
3935
4179
  new Option("--show-skipped", "Include skipped phases in the summary").default(false)
3936
4180
  ).action(async (opts) => {
@@ -3940,14 +4184,16 @@ var verifyCommand = new Command("verify").description(
3940
4184
  const cfToken = resolveCfToken(opts);
3941
4185
  const apiHost = opts.apiHost ?? `api.${domain}`;
3942
4186
  const images = opts.image ?? [];
3943
- const totalPhases = 4 + (images.length === 0 ? 1 : images.length);
4187
+ const ollama = await resolveOllamaVerifyInput(opts);
4188
+ const totalPhases = 5 + (images.length === 0 ? 1 : images.length);
3944
4189
  const report = await runVerifyWithSpinner({
3945
4190
  apiHost,
3946
4191
  certWarnDays,
3947
4192
  cfToken,
3948
4193
  opts,
3949
4194
  images,
3950
- totalPhases
4195
+ totalPhases,
4196
+ ...ollama ? { ollama } : {}
3951
4197
  });
3952
4198
  renderVerifySummary(report, { opts, totalPhases });
3953
4199
  reportVerifyOutcome(report);
@@ -4018,8 +4264,34 @@ function resolveCfToken(opts) {
4018
4264
  process.exit(2);
4019
4265
  }
4020
4266
  }
4267
+ function normalizeProvider(v) {
4268
+ return v === "ollama" || v === "xenova" ? v : void 0;
4269
+ }
4270
+ function mergeOllamaModelConfig(opts, envCfg) {
4271
+ const llmModel = opts.llmModel ?? envCfg.llmModel;
4272
+ if (!llmModel) return void 0;
4273
+ const embeddingModel = opts.embeddingModel ?? envCfg.embeddingModel;
4274
+ const provider = normalizeProvider(opts.embeddingsProvider ?? envCfg.embeddingsProvider);
4275
+ return {
4276
+ llmModel,
4277
+ ...embeddingModel ? { embeddingModel } : {},
4278
+ ...provider ? { provider } : {}
4279
+ };
4280
+ }
4281
+ async function resolveOllamaVerifyInput(opts) {
4282
+ const explicit = opts.repoDir !== void 0;
4283
+ const dir = opts.repoDir ?? process.cwd();
4284
+ const envCfg = explicit || await looksLikeNodeRepo(dir) ? await readEnvModelConfig(dir) : {};
4285
+ if (opts.embeddingsProvider === void 0 && envCfg.embeddingsProvider !== void 0 && normalizeProvider(envCfg.embeddingsProvider) === void 0) {
4286
+ p3.note(
4287
+ `${pc2.yellow("\u26A0")} Unrecognized EMBEDDINGS_PROVIDER=${envCfg.embeddingsProvider} in .env \u2014 expected xenova|ollama; the embedding-model check is disabled.`,
4288
+ "verify"
4289
+ );
4290
+ }
4291
+ return mergeOllamaModelConfig(opts, envCfg);
4292
+ }
4021
4293
  async function runVerifyWithSpinner(args) {
4022
- const { apiHost, certWarnDays, cfToken, opts, images, totalPhases } = args;
4294
+ const { apiHost, certWarnDays, cfToken, opts, images, totalPhases, ollama } = args;
4023
4295
  let phaseIndex = 0;
4024
4296
  let activeSpin = null;
4025
4297
  const renderPhase = (ph) => {
@@ -4040,7 +4312,8 @@ async function runVerifyWithSpinner(args) {
4040
4312
  ...opts.cfAccountId ? { accountId: opts.cfAccountId } : {},
4041
4313
  ...opts.tunnelId ? { tunnelId: opts.tunnelId } : {}
4042
4314
  },
4043
- images
4315
+ images,
4316
+ ...ollama ? { ollama } : {}
4044
4317
  },
4045
4318
  deps
4046
4319
  );
@@ -5155,7 +5428,7 @@ function withDefaultSubcommand(argv, known) {
5155
5428
  }
5156
5429
 
5157
5430
  // src/cli.ts
5158
- var VERSION = "0.12.4";
5431
+ var VERSION = "0.14.0";
5159
5432
  var program = new Command();
5160
5433
  program.name("create-op-node").description(
5161
5434
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."