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/README.md +72 -34
- package/dist/cli.js +323 -50
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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 = "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2844
|
-
|
|
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
|
|
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
|
|
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: "
|
|
3060
|
-
label: "
|
|
3061
|
-
hint: "
|
|
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: "
|
|
3065
|
-
label: "
|
|
3066
|
-
hint: "
|
|
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: "
|
|
3070
|
-
label: "
|
|
3071
|
-
hint: "
|
|
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 >=
|
|
3078
|
-
if (ramGB >=
|
|
3079
|
-
return "
|
|
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) ??
|
|
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
|
|
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.
|
|
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."
|