create-op-node 0.13.0 → 0.15.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
@@ -238,7 +238,7 @@ npx create-op-node verify --domain your-domain.example
238
238
  ```
239
239
 
240
240
  Off-LAN health probe of a live node, runnable from anywhere with internet
241
- access. Five phases:
241
+ access. Six phases:
242
242
 
243
243
  1. **TLS handshake** to `api.<domain>:443` — surfaces cert subject, issuer,
244
244
  and days-to-expiry. Warns when the cert is within `--cert-warn-days`
@@ -248,12 +248,22 @@ access. Five phases:
248
248
  3. **`POST https://api.<domain>/api`** with `{ __typename }` must return a
249
249
  valid GraphQL envelope (catches the "TLS green, but a misconfigured
250
250
  proxy returns HTML" case).
251
- 4. **Cloudflare Tunnel status** (optional) — looks up `connections` via
251
+ 4. **Ollama model presence** (optional, node-local) — asserts the configured
252
+ `LLM_MODEL` is actually pulled into the local Ollama, failing loudly with
253
+ the exact `ollama pull <model>` remedy if not. This catches the
254
+ config↔runtime drift that otherwise 404s at inference time (a node set to
255
+ a model that was never downloaded). The model is read from `--llm-model`
256
+ or, when omitted, the node's `.env` (`--repo-dir`, default cwd) — so
257
+ running `verify` **on the node** needs no flags. **Skipped** when no model
258
+ resolves, which keeps an off-LAN `verify --domain …` from tripping it. The
259
+ embedding model is asserted only under `--embeddings-provider ollama`
260
+ (xenova computes embeddings in-process).
261
+ 5. **Cloudflare Tunnel status** (optional) — looks up `connections` via
252
262
  the CF API. Zero connectors registered → warning that cloudflared on
253
263
  the Studio is offline. Requires all three of `--cf-token` (or
254
264
  `--cf-token-file`), `--cf-account-id`, `--tunnel-id`; partial
255
265
  configuration warns + names the missing flag.
256
- 5. **`cosign verify`** (optional, repeatable `--image`) — keyless
266
+ 6. **`cosign verify`** (optional, repeatable `--image`) — keyless
257
267
  verification against the GitHub Actions OIDC issuer + Fulcio +
258
268
  the Rekor transparency log. Silently skipped when `cosign` isn't on
259
269
  `PATH` (install with `brew install cosign` to enable).
@@ -281,6 +291,17 @@ invocations — the latter ends up in `ps` output, the former doesn't.
281
291
  Use `--api-host <host>` to override the default `api.<domain>`
282
292
  construction when your node exposes the API at a different subdomain.
283
293
 
294
+ Run it **on the node** (from the region repo dir) to also catch model
295
+ drift with zero extra flags — the Ollama phase reads `LLM_MODEL` from the
296
+ `.env` bootstrap wrote:
297
+
298
+ ```bash
299
+ cd ~/Development/opuspopuli-node-us-ca
300
+ npx create-op-node verify --domain yournode.example.org
301
+ # or pin the model explicitly from anywhere:
302
+ npx create-op-node verify --domain yournode.example.org --llm-model qwen3.6:35b-a3b
303
+ ```
304
+
284
305
  ## Bootstrapping a region config
285
306
 
286
307
  A node serves data; **what** data it serves is defined by a declarative region
package/dist/cli.js CHANGED
@@ -2122,6 +2122,11 @@ async function checkOllamaHealth(url = OLLAMA_URL) {
2122
2122
  clearTimeout(timer);
2123
2123
  }
2124
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
+ }
2125
2130
  async function pullModel(name) {
2126
2131
  const res = await safeExeca("ollama", ["pull", name]);
2127
2132
  if (res === null) return { ok: false, reason: "`ollama` not on PATH" };
@@ -2321,6 +2326,25 @@ function joinTrimmed(region) {
2321
2326
  while (end > start && joined[end - 1] === "\n") end--;
2322
2327
  return joined.slice(start, end);
2323
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
+ }
2324
2348
  async function writeManagedEnv(repoDir, selection, opts = {}) {
2325
2349
  const target = join(repoDir, ".env");
2326
2350
  let existing = "";
@@ -2476,7 +2500,7 @@ var bootstrapCommand = new Command("bootstrap").description(
2476
2500
  await runLaunchAgentPhase({ opts, secrets });
2477
2501
  await installWrapperPhase({ repoPath, region, promptServiceUrl: secrets.promptServiceUrl });
2478
2502
  await loginGhcrPhase();
2479
- await runOllamaPhase({ opts, embeddingModel, llmModel });
2503
+ await runOllamaPhase({ opts, embeddingModel, llmModel, embeddingsProvider });
2480
2504
  await runEnvFilePhase({
2481
2505
  repoPath,
2482
2506
  llmModel,
@@ -2898,8 +2922,12 @@ async function loginGhcrPhase() {
2898
2922
  }
2899
2923
  ghcrSpin.stop(pc2.green("\u2713 Logged in to ghcr.io."));
2900
2924
  }
2925
+ function modelsToPull(args) {
2926
+ const { provider, llmModel, embeddingModel } = args;
2927
+ return provider === "ollama" ? [embeddingModel, llmModel] : [llmModel];
2928
+ }
2901
2929
  async function runOllamaPhase(args) {
2902
- const { opts, embeddingModel, llmModel } = args;
2930
+ const { opts, embeddingModel, llmModel, embeddingsProvider } = args;
2903
2931
  if (opts.skipOllama) return;
2904
2932
  const olSpin = p3.spinner();
2905
2933
  olSpin.start(`Pulling + warming Ollama models\u2026 (${estimatedPullTime(llmModel)})`);
@@ -2926,7 +2954,8 @@ async function runOllamaPhase(args) {
2926
2954
  process.exit(1);
2927
2955
  }
2928
2956
  }
2929
- const modelReport = await setupModels([embeddingModel, llmModel], (model, status) => {
2957
+ const toPull = modelsToPull({ provider: embeddingsProvider, llmModel, embeddingModel });
2958
+ const modelReport = await setupModels(toPull, (model, status) => {
2930
2959
  olSpin.message(`${status}: ${model}`);
2931
2960
  });
2932
2961
  olSpin.stop(
@@ -2934,6 +2963,12 @@ async function runOllamaPhase(args) {
2934
2963
  `\u2713 ${modelReport.pulled.length} pulled, ${modelReport.alreadyPresent.length} present, ${modelReport.warmed.length} warmed`
2935
2964
  ) : pc2.yellow(`\u26A0 ${modelReport.failed.length} model pull(s) failed`)
2936
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
+ }
2937
2972
  const probe = await probeHostDockerInternal();
2938
2973
  if (!probe.ok) {
2939
2974
  p3.note(`${pc2.yellow("\u26A0")} ${probe.reason}`, "Docker host networking");
@@ -3962,7 +3997,8 @@ var DEFAULT_DEPS2 = {
3962
3997
  http: httpProbe,
3963
3998
  graphql: graphqlProbe,
3964
3999
  tunnel: tunnelStatus,
3965
- cosign: cosignVerifyImage
4000
+ cosign: cosignVerifyImage,
4001
+ ollama: checkOllamaHealth
3966
4002
  };
3967
4003
  async function runVerify(input, deps = DEFAULT_DEPS2) {
3968
4004
  const phases = [];
@@ -3973,11 +4009,17 @@ async function runVerify(input, deps = DEFAULT_DEPS2) {
3973
4009
  await verifyTlsPhase(input, deps, push);
3974
4010
  await verifyHealthPhase(input, deps, push);
3975
4011
  await verifyGraphqlPhase(input, deps, push);
4012
+ await verifyOllamaModelsPhase(input, deps, push);
3976
4013
  await verifyCloudflarePhase(input, deps, push);
3977
4014
  await verifyCosignPhase(input, deps, push);
3978
4015
  return { phases };
3979
4016
  }
4017
+ function skipLocalOnly(name, detail, push) {
4018
+ push({ name, status: "skipped", detail });
4019
+ }
4020
+ var LOCAL_ONLY_NO_ENDPOINT = "local-only node \u2014 no public endpoint to probe";
3980
4021
  async function verifyTlsPhase(input, deps, push) {
4022
+ if (input.localOnly) return skipLocalOnly("TLS handshake", LOCAL_ONLY_NO_ENDPOINT, push);
3981
4023
  const tls2 = await deps.tls({ host: input.apiHost });
3982
4024
  if (!tls2.ok) {
3983
4025
  push({ name: "TLS handshake", status: "fail", detail: tls2.reason });
@@ -3995,6 +4037,7 @@ async function verifyTlsPhase(input, deps, push) {
3995
4037
  push({ name: "TLS handshake", status: "ok", detail: line });
3996
4038
  }
3997
4039
  async function verifyHealthPhase(input, deps, push) {
4040
+ if (input.localOnly) return skipLocalOnly("GET /health", LOCAL_ONLY_NO_ENDPOINT, push);
3998
4041
  const health = await deps.http({ url: `https://${input.apiHost}/health` });
3999
4042
  if (health.ok) {
4000
4043
  push({ name: "GET /health", status: "ok", detail: `HTTP ${health.status}` });
@@ -4003,6 +4046,7 @@ async function verifyHealthPhase(input, deps, push) {
4003
4046
  }
4004
4047
  }
4005
4048
  async function verifyGraphqlPhase(input, deps, push) {
4049
+ if (input.localOnly) return skipLocalOnly("GraphQL { __typename }", LOCAL_ONLY_NO_ENDPOINT, push);
4006
4050
  const gql = await deps.graphql({ url: `https://${input.apiHost}/api` });
4007
4051
  if (gql.ok) {
4008
4052
  push({ name: "GraphQL { __typename }", status: "ok", detail: `typename=${gql.typename}` });
@@ -4010,7 +4054,41 @@ async function verifyGraphqlPhase(input, deps, push) {
4010
4054
  push({ name: "GraphQL { __typename }", status: "fail", detail: gql.reason });
4011
4055
  }
4012
4056
  }
4057
+ async function verifyOllamaModelsPhase(input, deps, push) {
4058
+ const cfg = input.ollama;
4059
+ if (!cfg?.llmModel) {
4060
+ push({
4061
+ name: "Ollama models",
4062
+ status: "skipped",
4063
+ detail: "pass --llm-model (or run on the node so its .env is read) to enable"
4064
+ });
4065
+ return;
4066
+ }
4067
+ const health = await deps.ollama();
4068
+ if (!health.reachable) {
4069
+ push({
4070
+ name: "Ollama models",
4071
+ status: "fail",
4072
+ 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)"
4073
+ });
4074
+ return;
4075
+ }
4076
+ const required = [cfg.llmModel];
4077
+ if (cfg.provider === "ollama" && cfg.embeddingModel) required.push(cfg.embeddingModel);
4078
+ const missing = required.filter((m) => !modelPresent(m, health.models));
4079
+ if (missing.length > 0) {
4080
+ const remedy = missing.map((m) => `ollama pull ${m}`).join(" && ");
4081
+ push({
4082
+ name: "Ollama models",
4083
+ status: "fail",
4084
+ detail: `not installed: ${missing.join(", ")} \u2014 remedy: ${remedy}`
4085
+ });
4086
+ return;
4087
+ }
4088
+ push({ name: "Ollama models", status: "ok", detail: `${required.join(", ")} present` });
4089
+ }
4013
4090
  async function verifyCloudflarePhase(input, deps, push) {
4091
+ if (input.localOnly) return skipLocalOnly("Cloudflare Tunnel", "local-only node \u2014 no tunnel", push);
4014
4092
  const cfFields = [
4015
4093
  ["--cf-token", input.cf?.token],
4016
4094
  ["--cf-account-id", input.cf?.accountId],
@@ -4080,6 +4158,9 @@ function readTokenFile(path) {
4080
4158
  if (!raw) throw new Error(`--cf-token-file ${path} was empty`);
4081
4159
  return raw;
4082
4160
  }
4161
+ function collectImage(value, previous) {
4162
+ return [...previous ?? [], value];
4163
+ }
4083
4164
  var verifyCommand = new Command("verify").description(
4084
4165
  "Off-LAN health probe of a live node \u2014 TLS, Apollo Federation reachability, GraphQL smoke. Run from anywhere with internet access."
4085
4166
  ).addOption(
@@ -4089,28 +4170,46 @@ var verifyCommand = new Command("verify").description(
4089
4170
  ).addOption(new Option("--cf-account-id <id>", "Cloudflare account ID (required with --cf-token)")).addOption(
4090
4171
  new Option("--tunnel-id <id>", "Cloudflare Tunnel ID to query (required with --cf-token)")
4091
4172
  ).addOption(
4092
- new Option("--image <ref>", "Repeatable. Image to cosign-verify (e.g. ghcr.io/opuspopuli/api:tag)").default(
4093
- []
4094
- )
4173
+ new Option("--image <ref>", "Repeatable. Image to cosign-verify (e.g. ghcr.io/opuspopuli/api:tag)").default([]).argParser(collectImage)
4095
4174
  ).addOption(
4096
4175
  new Option("--cert-warn-days <n>", "Warn if cert expires within N days").default("14")
4176
+ ).addOption(
4177
+ new Option(
4178
+ "--llm-model <model>",
4179
+ "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."
4180
+ )
4181
+ ).addOption(
4182
+ new Option("--embedding-model <model>", "Also assert this embedding model is present (only checked when --embeddings-provider=ollama).")
4183
+ ).addOption(
4184
+ new Option("--embeddings-provider <provider>", "Embeddings provider the node runs; the embedding model is only asserted for `ollama`.").choices(["xenova", "ollama"])
4185
+ ).addOption(
4186
+ new Option("--repo-dir <path>", "Node repo dir whose .env supplies the model config when --llm-model is omitted (default: cwd).")
4187
+ ).addOption(
4188
+ new Option(
4189
+ "--local-only",
4190
+ "Node has no public domain/tunnel \u2014 skip the TLS/health/GraphQL/tunnel probes (no domain prompt) and run only the node-local checks (Ollama model presence, cosign)."
4191
+ ).default(false)
4097
4192
  ).addOption(
4098
4193
  new Option("--show-skipped", "Include skipped phases in the summary").default(false)
4099
4194
  ).action(async (opts) => {
4100
4195
  p3.intro(pc2.bgCyan(pc2.black(" create-op-node verify ")));
4101
- const domain = await resolveDomain(opts);
4196
+ const localOnly = opts.localOnly ?? false;
4197
+ const domain = localOnly ? void 0 : await resolveDomain(opts);
4102
4198
  const certWarnDays = resolveCertWarnDays(opts);
4103
4199
  const cfToken = resolveCfToken(opts);
4104
- const apiHost = opts.apiHost ?? `api.${domain}`;
4200
+ const apiHost = localOnly ? "" : opts.apiHost ?? `api.${domain}`;
4105
4201
  const images = opts.image ?? [];
4106
- const totalPhases = 4 + (images.length === 0 ? 1 : images.length);
4202
+ const ollama = await resolveOllamaVerifyInput(opts);
4203
+ const totalPhases = 5 + (images.length === 0 ? 1 : images.length);
4107
4204
  const report = await runVerifyWithSpinner({
4108
4205
  apiHost,
4109
4206
  certWarnDays,
4110
4207
  cfToken,
4111
4208
  opts,
4112
4209
  images,
4113
- totalPhases
4210
+ totalPhases,
4211
+ localOnly,
4212
+ ...ollama ? { ollama } : {}
4114
4213
  });
4115
4214
  renderVerifySummary(report, { opts, totalPhases });
4116
4215
  reportVerifyOutcome(report);
@@ -4181,8 +4280,34 @@ function resolveCfToken(opts) {
4181
4280
  process.exit(2);
4182
4281
  }
4183
4282
  }
4283
+ function normalizeProvider(v) {
4284
+ return v === "ollama" || v === "xenova" ? v : void 0;
4285
+ }
4286
+ function mergeOllamaModelConfig(opts, envCfg) {
4287
+ const llmModel = opts.llmModel ?? envCfg.llmModel;
4288
+ if (!llmModel) return void 0;
4289
+ const embeddingModel = opts.embeddingModel ?? envCfg.embeddingModel;
4290
+ const provider = normalizeProvider(opts.embeddingsProvider ?? envCfg.embeddingsProvider);
4291
+ return {
4292
+ llmModel,
4293
+ ...embeddingModel ? { embeddingModel } : {},
4294
+ ...provider ? { provider } : {}
4295
+ };
4296
+ }
4297
+ async function resolveOllamaVerifyInput(opts) {
4298
+ const explicit = opts.repoDir !== void 0;
4299
+ const dir = opts.repoDir ?? process.cwd();
4300
+ const envCfg = explicit || await looksLikeNodeRepo(dir) ? await readEnvModelConfig(dir) : {};
4301
+ if (opts.embeddingsProvider === void 0 && envCfg.embeddingsProvider !== void 0 && normalizeProvider(envCfg.embeddingsProvider) === void 0) {
4302
+ p3.note(
4303
+ `${pc2.yellow("\u26A0")} Unrecognized EMBEDDINGS_PROVIDER=${envCfg.embeddingsProvider} in .env \u2014 expected xenova|ollama; the embedding-model check is disabled.`,
4304
+ "verify"
4305
+ );
4306
+ }
4307
+ return mergeOllamaModelConfig(opts, envCfg);
4308
+ }
4184
4309
  async function runVerifyWithSpinner(args) {
4185
- const { apiHost, certWarnDays, cfToken, opts, images, totalPhases } = args;
4310
+ const { apiHost, certWarnDays, cfToken, opts, images, totalPhases, localOnly, ollama } = args;
4186
4311
  let phaseIndex = 0;
4187
4312
  let activeSpin = null;
4188
4313
  const renderPhase = (ph) => {
@@ -4203,7 +4328,9 @@ async function runVerifyWithSpinner(args) {
4203
4328
  ...opts.cfAccountId ? { accountId: opts.cfAccountId } : {},
4204
4329
  ...opts.tunnelId ? { tunnelId: opts.tunnelId } : {}
4205
4330
  },
4206
- images
4331
+ images,
4332
+ localOnly,
4333
+ ...ollama ? { ollama } : {}
4207
4334
  },
4208
4335
  deps
4209
4336
  );
@@ -5318,7 +5445,7 @@ function withDefaultSubcommand(argv, known) {
5318
5445
  }
5319
5446
 
5320
5447
  // src/cli.ts
5321
- var VERSION = "0.13.0";
5448
+ var VERSION = "0.15.0";
5322
5449
  var program = new Command();
5323
5450
  program.name("create-op-node").description(
5324
5451
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."