@openpalm/lib 0.11.1 → 0.11.2-rc.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.1",
3
+ "version": "0.11.2-rc.2",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -0,0 +1,61 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ detectExistingProject,
4
+ isProjectOurs,
5
+ resolveComposeProjectName,
6
+ } from "./docker.js";
7
+
8
+ describe("isProjectOurs (ours-vs-foreign decision)", () => {
9
+ it("treats a matching working_dir as ours", () => {
10
+ expect(isProjectOurs("/home/me/.openpalm", "/home/me/.openpalm")).toBe(true);
11
+ });
12
+
13
+ it("treats a different working_dir as foreign", () => {
14
+ expect(isProjectOurs("/home/other/.openpalm", "/home/me/.openpalm")).toBe(false);
15
+ });
16
+
17
+ it("treats an empty/unknown working_dir as ours (reconcile, don't refuse)", () => {
18
+ expect(isProjectOurs("", "/home/me/.openpalm")).toBe(true);
19
+ expect(isProjectOurs(" ", "/home/me/.openpalm")).toBe(true);
20
+ });
21
+
22
+ it("ignores surrounding whitespace on the label", () => {
23
+ expect(isProjectOurs(" /home/me/.openpalm \n", "/home/me/.openpalm")).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe("detectExistingProject", () => {
28
+ // Use a project name that cannot possibly match any running container so the
29
+ // result is deterministic whether or not a docker daemon is present:
30
+ // - docker error (no daemon) → { exists:false }
31
+ // - docker ok, no matching label → { exists:false }
32
+ const ghostName = `openpalm-detect-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
+
34
+ it("returns exists:false when no project matches (or docker is unavailable)", async () => {
35
+ const result = await detectExistingProject({
36
+ projectName: ghostName,
37
+ expectedWorkingDir: "/nonexistent/op_home",
38
+ });
39
+ expect(result.exists).toBe(false);
40
+ expect(result.isOurs).toBe(false);
41
+ expect(result.workingDir).toBe("");
42
+ });
43
+ });
44
+
45
+ describe("resolveComposeProjectName", () => {
46
+ const saved = process.env.OP_PROJECT_NAME;
47
+ afterEach(() => {
48
+ if (saved === undefined) delete process.env.OP_PROJECT_NAME;
49
+ else process.env.OP_PROJECT_NAME = saved;
50
+ });
51
+
52
+ it("defaults to openpalm", () => {
53
+ delete process.env.OP_PROJECT_NAME;
54
+ delete process.env.COMPOSE_PROJECT_NAME;
55
+ expect(resolveComposeProjectName({})).toBe("openpalm");
56
+ });
57
+
58
+ it("honors OP_PROJECT_NAME from overrides first", () => {
59
+ expect(resolveComposeProjectName({ OP_PROJECT_NAME: "openpalm-dev" })).toBe("openpalm-dev");
60
+ });
61
+ });
@@ -51,6 +51,83 @@ export function resolveComposeProjectName(envOverrides: Record<string, string> =
51
51
  );
52
52
  }
53
53
 
54
+ /**
55
+ * Result of probing the Docker daemon for an existing compose project that
56
+ * shares our project name.
57
+ *
58
+ * - `exists` — at least one running container carries the project label.
59
+ * - `isOurs` — those containers were launched from THIS install's working
60
+ * dir (compose working_dir label === expectedWorkingDir). When
61
+ * true the caller should reconcile in place (up --force-recreate).
62
+ * When false a DIFFERENT OpenPalm install (e.g. dev vs host) owns
63
+ * the name and the caller must refuse.
64
+ * - `workingDir` — the working_dir label read off the first container, for
65
+ * error messages. Empty string when unknown.
66
+ */
67
+ export type ExistingProject = {
68
+ exists: boolean;
69
+ isOurs: boolean;
70
+ workingDir: string;
71
+ };
72
+
73
+ /**
74
+ * Decide whether a running compose project (identified by its
75
+ * `com.docker.compose.project.working_dir` label) is OURS — i.e. was launched
76
+ * from this install's working dir. An empty/unknown label can't prove foreign,
77
+ * so it counts as ours (reconcile rather than wrongly refuse a redeploy).
78
+ *
79
+ * Pure decision split out from detectExistingProject so the ours-vs-foreign
80
+ * rule is unit-testable without a Docker daemon.
81
+ */
82
+ export function isProjectOurs(workingDirLabel: string, expectedWorkingDir: string): boolean {
83
+ const label = workingDirLabel.trim();
84
+ return label === "" || label === expectedWorkingDir;
85
+ }
86
+
87
+ /**
88
+ * Probe the Docker daemon for a running compose project that shares
89
+ * `projectName`. Decides ours-vs-foreign by comparing the project's
90
+ * `com.docker.compose.project.working_dir` label against `expectedWorkingDir`
91
+ * (the install's OP_HOME / compose context).
92
+ *
93
+ * Returns `{ exists:false }` on any docker error (daemon down, no permission) —
94
+ * detection is best-effort and never blocks the caller; a real failure surfaces
95
+ * later through composeUp.
96
+ */
97
+ export function detectExistingProject(opts: {
98
+ projectName: string;
99
+ expectedWorkingDir: string;
100
+ }): Promise<ExistingProject> {
101
+ const none: ExistingProject = { exists: false, isOurs: false, workingDir: "" };
102
+ return new Promise((resolve) => {
103
+ execFile(
104
+ "docker",
105
+ ["ps", "-q", "--filter", `label=com.docker.compose.project=${opts.projectName}`],
106
+ { timeout: 10_000 },
107
+ (err, stdout) => {
108
+ if (err) return resolve(none);
109
+ const ids = stdout.toString().trim().split(/\s+/).filter(Boolean);
110
+ if (ids.length === 0) return resolve(none);
111
+ execFile(
112
+ "docker",
113
+ [
114
+ "inspect",
115
+ "--format",
116
+ '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}',
117
+ ids[0],
118
+ ],
119
+ { timeout: 10_000 },
120
+ (err2, stdout2) => {
121
+ if (err2) return resolve({ exists: true, isOurs: false, workingDir: "" });
122
+ const workingDir = stdout2.toString().trim();
123
+ resolve({ exists: true, isOurs: isProjectOurs(workingDir, opts.expectedWorkingDir), workingDir });
124
+ },
125
+ );
126
+ },
127
+ );
128
+ });
129
+ }
130
+
54
131
  /** Check if Docker is available */
55
132
  export async function checkDocker(): Promise<DockerResult> {
56
133
  return new Promise((resolve) => {
@@ -172,7 +249,21 @@ export async function composeUp(
172
249
  if (options.forceRecreate) args.push("--force-recreate");
173
250
  if (options.removeOrphans) args.push("--remove-orphans");
174
251
  if (options.services?.length) args.push(...options.services);
175
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
252
+ return run(args, undefined, composeUpTimeoutMs(), collectEnvOverrides(options.envFiles));
253
+ }
254
+
255
+ /**
256
+ * Timeout budget for `compose up`. A first install extracts multi-GB images
257
+ * (voice CUDA ~7.6 GB) onto slow disks; the previous hard 5-minute cap
258
+ * SIGTERM-killed the start mid-extraction and surfaced as an empty/opaque
259
+ * error. Default 30 min, override with OP_COMPOSE_UP_TIMEOUT_MS. Kept bounded
260
+ * (never removed) so a genuinely hung start still eventually fails.
261
+ */
262
+ function composeUpTimeoutMs(): number {
263
+ const raw = process.env.OP_COMPOSE_UP_TIMEOUT_MS?.trim();
264
+ const parsed = raw ? Number(raw) : NaN;
265
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
266
+ return 30 * 60_000;
176
267
  }
177
268
 
178
269
  /**
@@ -184,6 +275,11 @@ export async function composeDown(
184
275
  profiles?: string[];
185
276
  removeVolumes?: boolean;
186
277
  envFiles?: string[];
278
+ // Remove containers for services NOT in the (profile-resolved) compose set.
279
+ // Needed to clean up a previously-enabled-then-disabled profile-gated addon
280
+ // (e.g. in-stack Ollama): with its profile now inactive, `down` alone leaves
281
+ // its stopped container behind because compose no longer "sees" the service.
282
+ removeOrphans?: boolean;
187
283
  }
188
284
  ): Promise<DockerResult> {
189
285
  await runPreflight(options);
@@ -193,6 +289,7 @@ export async function composeDown(
193
289
  const args = buildComposeArgs(options);
194
290
  args.push("down");
195
291
  if (options.removeVolumes) args.push("-v");
292
+ if (options.removeOrphans) args.push("--remove-orphans");
196
293
  return run(args, undefined);
197
294
  }
198
295
 
@@ -11,7 +11,7 @@ import { createLogger } from "../logger.js";
11
11
 
12
12
  const logger = createLogger("hardware-detect");
13
13
 
14
- export type GpuVendor = "nvidia" | "amd" | "unknown";
14
+ export type GpuVendor = "nvidia" | "amd" | "apple" | "unknown";
15
15
 
16
16
  export type GpuInfo = {
17
17
  vendor: GpuVendor;
@@ -27,6 +27,8 @@ type GpuProbe = {
27
27
  args: string[];
28
28
  /** Pure parser: tool stdout -> detected GPUs. Must not throw. */
29
29
  parse: (stdout: string) => GpuInfo[];
30
+ /** Optional gate — when present and false, the probe is skipped entirely. */
31
+ enabled?: boolean;
30
32
  };
31
33
 
32
34
  /** Parse `nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits`. */
@@ -68,6 +70,25 @@ export function parseRocmSmi(stdout: string): GpuInfo[] {
68
70
  return out;
69
71
  }
70
72
 
73
+ /**
74
+ * Parse `sysctl -n hw.memsize hw.model` (two lines: total bytes, then model id)
75
+ * into an Apple-Silicon GpuInfo. `hw.memsize` is UNIFIED memory shared between
76
+ * CPU and GPU, carried here as vramMb for informational display only — callers
77
+ * must NOT treat it like discrete VRAM (see setup-recommendation). Pure; never throws.
78
+ */
79
+ export function parseAppleSilicon(stdout: string): GpuInfo[] {
80
+ const lines = stdout
81
+ .split("\n")
82
+ .map((l) => l.trim())
83
+ .filter(Boolean);
84
+ if (lines.length === 0) return [];
85
+ const bytes = Number.parseInt(lines[0] ?? "", 10);
86
+ if (!Number.isFinite(bytes) || bytes <= 0) return [];
87
+ const vramMb = Math.round(bytes / (1024 * 1024));
88
+ const model = lines[1] && lines[1].length > 0 ? lines[1] : "arm64";
89
+ return [{ vendor: "apple", name: `Apple Silicon (${model})`, vramMb }];
90
+ }
91
+
71
92
  const GPU_PROBES: GpuProbe[] = [
72
93
  {
73
94
  vendor: "nvidia",
@@ -81,6 +102,16 @@ const GPU_PROBES: GpuProbe[] = [
81
102
  args: ["--showmeminfo", "vram", "--showproductname", "--json"],
82
103
  parse: parseRocmSmi,
83
104
  },
105
+ {
106
+ // Apple Silicon Macs expose no nvidia-smi/rocm-smi. Probe macOS sysctl for
107
+ // unified-memory size + model id. Gated to darwin/arm64 so it never runs (and
108
+ // never spawns a missing binary) on Linux/Intel.
109
+ vendor: "apple",
110
+ command: "sysctl",
111
+ args: ["-n", "hw.memsize", "hw.model"],
112
+ parse: parseAppleSilicon,
113
+ enabled: process.platform === "darwin" && process.arch === "arm64",
114
+ },
84
115
  ];
85
116
 
86
117
  function run(command: string, args: string[], timeoutMs = 3_000): Promise<string | null> {
@@ -100,6 +131,7 @@ export async function detectGpu(): Promise<GpuInfo | null> {
100
131
  const found: GpuInfo[] = [];
101
132
  await Promise.all(
102
133
  GPU_PROBES.map(async (probe) => {
134
+ if (probe.enabled === false) return;
103
135
  const stdout = await run(probe.command, probe.args);
104
136
  if (stdout === null) return;
105
137
  try {
@@ -369,17 +369,32 @@ export function buildComposeFileList(state: ControlPlaneState): string[] {
369
369
  return discoverStackOverlays(state.stackDir, state.homeDir);
370
370
  }
371
371
 
372
+ // Channel addons that require the guardian ingress. Mirrors the profile gate on
373
+ // the guardian service in channels.compose.yml (profiles: addon.{chat,api,
374
+ // discord,slack}) and the built-in channel id list used in registry.ts /
375
+ // config-persistence.ts. Guardian is shared infra for these, not an addon
376
+ // service of its own (getAddonServiceNames deliberately excludes it).
377
+ const CHANNEL_ADDON_IDS = ["api", "chat", "discord", "slack"];
378
+
372
379
  export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
373
380
  const composeOpts = buildComposeOptions(state);
374
381
 
375
- // Always force-recreate the core services (assistant + guardian) on upgrade,
376
- // regardless of how the service set is discovered. getAddonServiceNames
377
- // deliberately EXCLUDES guardian, so a fallback that relied on it alone would
378
- // drop guardian from the recreated set when channel profiles are active
379
- // leaving guardian on stale state (issue #450).
380
- const services = new Set<string>(CORE_SERVICES);
381
-
382
- // Prefer compose-derived service list when Docker is available
382
+ // The assistant is the only ALWAYS-on core service. The guardian is channel
383
+ // ingress profile-gated to the channel addons in channels.compose.yml, so
384
+ // with zero channels enabled it is never deployed. Seeding it unconditionally
385
+ // made the installer health-wait on a guardian that never starts (a ~5-minute
386
+ // hang when no channel is selected). Add it back ONLY when a channel is
387
+ // enabled; that also preserves the #450 need to force-recreate guardian on
388
+ // upgrade when channel profiles ARE active (it is excluded from
389
+ // getAddonServiceNames, so the fallback below would otherwise drop it).
390
+ const enabledAddons = listEnabledAddonIds(state.homeDir);
391
+ const channelsEnabled = enabledAddons.some((a) => CHANNEL_ADDON_IDS.includes(a));
392
+ const services = new Set<string>(["assistant"]);
393
+ if (channelsEnabled) services.add("guardian");
394
+
395
+ // Prefer compose-derived service list when Docker is available. Resolved with
396
+ // the active profiles, this already includes guardian iff a channel profile
397
+ // is active — the explicit add above just guarantees it for the fallback.
383
398
  if (composeOpts.files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
384
399
  const result = await composeConfigServices(composeOpts);
385
400
  if (result.ok && result.services.length > 0) {
@@ -388,8 +403,9 @@ export async function buildManagedServices(state: ControlPlaneState): Promise<st
388
403
  }
389
404
  }
390
405
 
391
- // Fallback: static inference from CORE_SERVICES + active addon overlays
392
- for (const addon of listEnabledAddonIds(state.homeDir)) {
406
+ // Fallback: static inference from assistant (+ guardian when channels) +
407
+ // active addon overlays.
408
+ for (const addon of enabledAddons) {
393
409
  for (const s of getAddonServiceNames(state.homeDir, addon)) services.add(s);
394
410
  }
395
411
  return [...services];
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseOllamaHostEnv } from "./model-runner.js";
3
+
4
+ describe("parseOllamaHostEnv", () => {
5
+ // ── null / empty inputs ────────────────────────────────────────────────
6
+ test("undefined → null", () => {
7
+ expect(parseOllamaHostEnv(undefined)).toBeNull();
8
+ });
9
+
10
+ test("empty string → null", () => {
11
+ expect(parseOllamaHostEnv("")).toBeNull();
12
+ });
13
+
14
+ test("whitespace only → null", () => {
15
+ expect(parseOllamaHostEnv(" ")).toBeNull();
16
+ });
17
+
18
+ // ── garbage ────────────────────────────────────────────────────────────
19
+ test("/garbage → null", () => {
20
+ expect(parseOllamaHostEnv("/garbage")).toBeNull();
21
+ });
22
+
23
+ test("has spaces → null", () => {
24
+ expect(parseOllamaHostEnv("my host:1234")).toBeNull();
25
+ });
26
+
27
+ test("port out of range 0 → null", () => {
28
+ expect(parseOllamaHostEnv("0")).toBeNull();
29
+ });
30
+
31
+ test("port out of range 99999 → null", () => {
32
+ expect(parseOllamaHostEnv("99999")).toBeNull();
33
+ });
34
+
35
+ test("invalid host:port (port not numeric) → null", () => {
36
+ expect(parseOllamaHostEnv("localhost:abc")).toBeNull();
37
+ });
38
+
39
+ // ── bare port ──────────────────────────────────────────────────────────
40
+ test("bare port '9999' → http://localhost:9999", () => {
41
+ expect(parseOllamaHostEnv("9999")).toBe("http://localhost:9999");
42
+ });
43
+
44
+ test("bare port '11434' → http://localhost:11434", () => {
45
+ expect(parseOllamaHostEnv("11434")).toBe("http://localhost:11434");
46
+ });
47
+
48
+ // ── host:port ──────────────────────────────────────────────────────────
49
+ test("'127.0.0.1:9999' → http://127.0.0.1:9999", () => {
50
+ expect(parseOllamaHostEnv("127.0.0.1:9999")).toBe("http://127.0.0.1:9999");
51
+ });
52
+
53
+ test("'0.0.0.0:9999' → http://0.0.0.0:9999", () => {
54
+ expect(parseOllamaHostEnv("0.0.0.0:9999")).toBe("http://0.0.0.0:9999");
55
+ });
56
+
57
+ test("'myhost:1234' → http://myhost:1234", () => {
58
+ expect(parseOllamaHostEnv("myhost:1234")).toBe("http://myhost:1234");
59
+ });
60
+
61
+ // ── full HTTP URL ──────────────────────────────────────────────────────
62
+ test("'http://127.0.0.1:9999' → http://127.0.0.1:9999", () => {
63
+ expect(parseOllamaHostEnv("http://127.0.0.1:9999")).toBe("http://127.0.0.1:9999");
64
+ });
65
+
66
+ test("'http://127.0.0.1:9999/some/path' strips path", () => {
67
+ // URL.origin includes scheme+host+port, strips path
68
+ expect(parseOllamaHostEnv("http://127.0.0.1:9999/some/path")).toBe("http://127.0.0.1:9999");
69
+ });
70
+
71
+ // ── HTTPS URL ─────────────────────────────────────────────────────────
72
+ test("'https://h:443' → https://h:443", () => {
73
+ // URL.origin suppresses default port 443 for https
74
+ const result = parseOllamaHostEnv("https://h:443");
75
+ expect(result).toBe("https://h");
76
+ });
77
+
78
+ test("'https://secure.host:8443' → https://secure.host:8443", () => {
79
+ expect(parseOllamaHostEnv("https://secure.host:8443")).toBe("https://secure.host:8443");
80
+ });
81
+
82
+ // ── bare hostname ──────────────────────────────────────────────────────
83
+ test("'localhost' → http://localhost:11434 (default port)", () => {
84
+ expect(parseOllamaHostEnv("localhost")).toBe("http://localhost:11434");
85
+ });
86
+
87
+ test("'my-host.local' → http://my-host.local:11434", () => {
88
+ expect(parseOllamaHostEnv("my-host.local")).toBe("http://my-host.local:11434");
89
+ });
90
+
91
+ // ── whitespace trimming ────────────────────────────────────────────────
92
+ test("leading/trailing whitespace is trimmed", () => {
93
+ expect(parseOllamaHostEnv(" 9999 ")).toBe("http://localhost:9999");
94
+ });
95
+ });
@@ -35,63 +35,193 @@ async function validateOllamaResponse(res: Response): Promise<boolean> {
35
35
  }
36
36
  }
37
37
 
38
- const LOCAL_PROVIDER_PROBES: { provider: string; probes: ProviderProbe[] }[] = [
39
- {
40
- provider: "model-runner",
41
- probes: [
42
- {
43
- url: "http://model-runner.docker.internal/engines/v1/models",
44
- baseUrl: "http://model-runner.docker.internal/engines",
45
- },
46
- {
47
- url: "http://model-runner.docker.internal:12434/engines/v1/models",
48
- baseUrl: "http://model-runner.docker.internal:12434/engines",
49
- },
50
- {
51
- url: "http://host.docker.internal:12434/engines/v1/models",
52
- baseUrl: "http://host.docker.internal:12434/engines",
53
- },
54
- {
55
- url: "http://localhost:12434/engines/v1/models",
56
- baseUrl: "http://localhost:12434/engines",
57
- },
58
- ],
59
- },
60
- {
61
- provider: "ollama",
62
- probes: [
63
- {
64
- // In-stack Ollama (compose service on assistant_net)
65
- url: "http://ollama:11434/api/tags",
66
- baseUrl: "http://ollama:11434",
67
- validate: validateOllamaResponse,
68
- },
38
+ // ── Env-based URL parsers ────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Parse an OLLAMA_HOST env value into a normalized base URL string, or null if
42
+ * the input is absent/malformed.
43
+ *
44
+ * Accepted forms:
45
+ * - bare port: "9999" → "http://localhost:9999"
46
+ * - host:port: "127.0.0.1:9999" → "http://127.0.0.1:9999"
47
+ * - full URL (http/https): "http://h:9999" → "http://h:9999"
48
+ * - bare hostname: "localhost" → "http://localhost:11434" (default port)
49
+ *
50
+ * Returns null for empty string, non-numeric bare tokens that aren't valid
51
+ * hostnames, and any other garbage.
52
+ */
53
+ export function parseOllamaHostEnv(raw: string | undefined): string | null {
54
+ if (!raw || raw.trim() === "") return null;
55
+ const s = raw.trim();
56
+
57
+ // Already a full URL
58
+ if (s.startsWith("http://") || s.startsWith("https://")) {
59
+ try {
60
+ const u = new URL(s);
61
+ // Must have a usable host
62
+ if (!u.hostname) return null;
63
+ // Return origin (scheme + host + port, no path)
64
+ return u.origin;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ // Bare port number e.g. "9999"
71
+ if (/^\d+$/.test(s)) {
72
+ const port = parseInt(s, 10);
73
+ if (port < 1 || port > 65535) return null;
74
+ return `http://localhost:${port}`;
75
+ }
76
+
77
+ // host:port e.g. "127.0.0.1:9999" or "myhost:1234"
78
+ const colonIdx = s.lastIndexOf(":");
79
+ if (colonIdx > 0) {
80
+ const host = s.slice(0, colonIdx);
81
+ const portStr = s.slice(colonIdx + 1);
82
+ if (!/^\d+$/.test(portStr)) return null;
83
+ const port = parseInt(portStr, 10);
84
+ if (port < 1 || port > 65535) return null;
85
+ // Basic hostname/IP validity — must not contain spaces or slashes
86
+ if (/[\s/]/.test(host)) return null;
87
+ return `http://${host}:${port}`;
88
+ }
89
+
90
+ // Bare hostname (no port) — use Ollama's default port
91
+ // Accept only simple hostname-like tokens (letters, digits, hyphens, dots)
92
+ if (/^[a-zA-Z0-9._-]+$/.test(s)) {
93
+ return `http://${s}:11434`;
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Parse a bare port env value (e.g. LMSTUDIO_PORT, MODEL_RUNNER_PORT) into an
101
+ * integer, or null if absent/malformed.
102
+ */
103
+ function parsePortEnv(raw: string | undefined): number | null {
104
+ if (!raw || raw.trim() === "") return null;
105
+ const n = parseInt(raw.trim(), 10);
106
+ if (!Number.isFinite(n) || n < 1 || n > 65535) return null;
107
+ return n;
108
+ }
109
+
110
+ // ── Probe timeout ────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Probe timeout in milliseconds.
114
+ *
115
+ * 5 000 ms is chosen to tolerate slow/loaded machines without blocking the
116
+ * caller for too long. Override with OP_LOCAL_PROBE_TIMEOUT_MS (clamped to
117
+ * a floor of 1 000 ms so the env value can't make probes never-timeout).
118
+ */
119
+ function getProbeTimeoutMs(): number {
120
+ const floor = 1000;
121
+ const envRaw = process.env["OP_LOCAL_PROBE_TIMEOUT_MS"];
122
+ if (envRaw) {
123
+ const n = parseInt(envRaw, 10);
124
+ if (Number.isFinite(n) && n >= floor) return n;
125
+ }
126
+ return 5000;
127
+ }
128
+
129
+ // ── Dynamic probe builders ───────────────────────────────────────────────
130
+
131
+ /** Build the ordered probe list for model-runner, prepending any env-configured port. */
132
+ function buildModelRunnerProbes(): ProviderProbe[] {
133
+ const defaults: ProviderProbe[] = [
134
+ {
135
+ url: "http://model-runner.docker.internal/engines/v1/models",
136
+ baseUrl: "http://model-runner.docker.internal/engines",
137
+ },
138
+ {
139
+ url: "http://model-runner.docker.internal:12434/engines/v1/models",
140
+ baseUrl: "http://model-runner.docker.internal:12434/engines",
141
+ },
142
+ {
143
+ url: "http://host.docker.internal:12434/engines/v1/models",
144
+ baseUrl: "http://host.docker.internal:12434/engines",
145
+ },
146
+ {
147
+ url: "http://localhost:12434/engines/v1/models",
148
+ baseUrl: "http://localhost:12434/engines",
149
+ },
150
+ ];
151
+
152
+ const port = parsePortEnv(process.env["MODEL_RUNNER_PORT"]);
153
+ if (port !== null) {
154
+ return [
69
155
  {
70
- url: "http://host.docker.internal:11434/api/tags",
71
- baseUrl: "http://host.docker.internal:11434",
72
- validate: validateOllamaResponse,
156
+ url: `http://localhost:${port}/engines/v1/models`,
157
+ baseUrl: `http://localhost:${port}/engines`,
73
158
  },
159
+ ...defaults,
160
+ ];
161
+ }
162
+ return defaults;
163
+ }
164
+
165
+ /** Build the ordered probe list for ollama, prepending any env-configured endpoint. */
166
+ function buildOllamaProbes(): ProviderProbe[] {
167
+ const defaults: ProviderProbe[] = [
168
+ {
169
+ // In-stack Ollama (compose service on assistant_net)
170
+ url: "http://ollama:11434/api/tags",
171
+ baseUrl: "http://ollama:11434",
172
+ validate: validateOllamaResponse,
173
+ },
174
+ {
175
+ url: "http://host.docker.internal:11434/api/tags",
176
+ baseUrl: "http://host.docker.internal:11434",
177
+ validate: validateOllamaResponse,
178
+ },
179
+ {
180
+ url: "http://localhost:11434/api/tags",
181
+ baseUrl: "http://localhost:11434",
182
+ validate: validateOllamaResponse,
183
+ },
184
+ ];
185
+
186
+ const base = parseOllamaHostEnv(process.env["OLLAMA_HOST"]);
187
+ if (base !== null) {
188
+ return [
74
189
  {
75
- url: "http://localhost:11434/api/tags",
76
- baseUrl: "http://localhost:11434",
190
+ url: `${base}/api/tags`,
191
+ baseUrl: base,
77
192
  validate: validateOllamaResponse,
78
193
  },
79
- ],
80
- },
81
- {
82
- provider: "lmstudio",
83
- probes: [
84
- {
85
- url: "http://host.docker.internal:1234/v1/models",
86
- baseUrl: "http://host.docker.internal:1234",
87
- },
194
+ ...defaults,
195
+ ];
196
+ }
197
+ return defaults;
198
+ }
199
+
200
+ /** Build the ordered probe list for lmstudio, prepending any env-configured port. */
201
+ function buildLmStudioProbes(): ProviderProbe[] {
202
+ const defaults: ProviderProbe[] = [
203
+ {
204
+ url: "http://host.docker.internal:1234/v1/models",
205
+ baseUrl: "http://host.docker.internal:1234",
206
+ },
207
+ {
208
+ url: "http://localhost:1234/v1/models",
209
+ baseUrl: "http://localhost:1234",
210
+ },
211
+ ];
212
+
213
+ const port = parsePortEnv(process.env["LMSTUDIO_PORT"] ?? process.env["LM_STUDIO_PORT"]);
214
+ if (port !== null) {
215
+ return [
88
216
  {
89
- url: "http://localhost:1234/v1/models",
90
- baseUrl: "http://localhost:1234",
217
+ url: `http://localhost:${port}/v1/models`,
218
+ baseUrl: `http://localhost:${port}`,
91
219
  },
92
- ],
93
- },
94
- ];
220
+ ...defaults,
221
+ ];
222
+ }
223
+ return defaults;
224
+ }
95
225
 
96
226
  // ── Detection ────────────────────────────────────────────────────────────
97
227
 
@@ -100,17 +230,42 @@ const LOCAL_PROVIDER_PROBES: { provider: string; probes: ProviderProbe[] }[] = [
100
230
  * Returns results for all providers (available or not) in parallel.
101
231
  */
102
232
  export async function detectLocalProviders(): Promise<LocalProviderDetection[]> {
233
+ const probeTimeoutMs = getProbeTimeoutMs();
234
+
235
+ const providerProbes = [
236
+ { provider: "model-runner", probes: buildModelRunnerProbes() },
237
+ { provider: "ollama", probes: buildOllamaProbes() },
238
+ { provider: "lmstudio", probes: buildLmStudioProbes() },
239
+ ];
240
+
103
241
  const results = await Promise.all(
104
- LOCAL_PROVIDER_PROBES.map(async ({ provider, probes }) => {
242
+ providerProbes.map(async ({ provider, probes }) => {
105
243
  for (const { url: probeUrl, baseUrl, validate } of probes) {
106
244
  try {
107
245
  const res = await fetch(probeUrl, {
108
- signal: AbortSignal.timeout(3000),
246
+ signal: AbortSignal.timeout(probeTimeoutMs),
109
247
  });
110
248
  if (res.ok) {
111
- if (validate && !(await validate(res))) {
112
- logger.debug("provider probe response failed validation", { provider, url: baseUrl });
113
- continue;
249
+ if (validate) {
250
+ // Clone so we can read the body for debug logging without consuming it
251
+ const resForValidate = res.clone();
252
+ const valid = await validate(res);
253
+ if (!valid) {
254
+ // Read a snippet of the body to aid debugging — 500-char cap
255
+ let bodySnippet = "(unreadable)";
256
+ try {
257
+ const raw = await resForValidate.text();
258
+ bodySnippet = raw.slice(0, 500);
259
+ } catch {
260
+ // ignore
261
+ }
262
+ logger.debug("provider probe response failed validation", {
263
+ provider,
264
+ url: probeUrl,
265
+ bodySnippet,
266
+ });
267
+ continue;
268
+ }
114
269
  }
115
270
  logger.debug("detected local provider", { provider, url: baseUrl });
116
271
  return { provider, url: baseUrl, available: true };
@@ -5,7 +5,7 @@ import {
5
5
  MIN_LOCAL_GPU_VRAM_MB,
6
6
  type SetupRecommendationInput,
7
7
  } from "./setup-recommendation.js";
8
- import { parseNvidiaSmi, parseRocmSmi, type GpuInfo } from "./hardware-detect.js";
8
+ import { parseNvidiaSmi, parseRocmSmi, parseAppleSilicon, type GpuInfo } from "./hardware-detect.js";
9
9
 
10
10
  const base: SetupRecommendationInput = { cloudProviders: [], hostProviders: [], gpu: null };
11
11
  const gpu = (vendor: GpuInfo["vendor"], vramMb: number, name = "Test GPU"): GpuInfo => ({ vendor, name, vramMb });
@@ -53,6 +53,39 @@ describe("recommendSetup", () => {
53
53
  expect(r.action).toBe("connect-manually");
54
54
  });
55
55
 
56
+ test("darwin + apple GPU + no provider -> connect-manually (NOT enable-ollama), Mac-tailored alert", () => {
57
+ const r = recommendSetup({ ...base, platform: "darwin", gpu: gpu("apple", 65536, "Apple Silicon (Mac15,7)") });
58
+ expect(r.action).toBe("connect-manually");
59
+ expect(r.action).not.toBe("enable-ollama");
60
+ if (r.action === "connect-manually") {
61
+ expect(r.alert).toContain("macOS");
62
+ expect(r.alert).toContain("Metal");
63
+ expect(r.alert.toLowerCase()).toContain("ollama");
64
+ }
65
+ });
66
+
67
+ test("darwin + apple GPU never selects cuda/rocm (no in-stack enable)", () => {
68
+ // Even with huge unified memory, darwin+apple must not enable in-stack ollama.
69
+ const r = recommendSetup({ ...base, platform: "darwin", gpu: gpu("apple", 131072) });
70
+ expect(r.action).not.toBe("enable-ollama");
71
+ });
72
+
73
+ test("darwin + host ollama running -> still use-host-providers (wins over apple guidance)", () => {
74
+ const r = recommendSetup({
75
+ ...base,
76
+ platform: "darwin",
77
+ hostProviders: [{ provider: "ollama", url: "http://localhost:11434" }],
78
+ gpu: gpu("apple", 65536),
79
+ });
80
+ expect(r.action).toBe("use-host-providers");
81
+ });
82
+
83
+ test("linux + nvidia >= threshold -> still enable-ollama cuda (unchanged)", () => {
84
+ const r = recommendSetup({ ...base, platform: "linux", gpu: gpu("nvidia", 24576) });
85
+ expect(r.action).toBe("enable-ollama");
86
+ if (r.action === "enable-ollama") expect(r.profileVariant).toBe("cuda");
87
+ });
88
+
56
89
  test("no cloud, no host, no GPU -> connect-manually", () => {
57
90
  const r = recommendSetup(base);
58
91
  expect(r.action).toBe("connect-manually");
@@ -60,14 +93,108 @@ describe("recommendSetup", () => {
60
93
  });
61
94
  });
62
95
 
96
+ describe("hostCredentialCount precedence", () => {
97
+ test("(a) host-configured + capable GPU + no cloud -> NOT enable-ollama (host wins)", () => {
98
+ const r = recommendSetup({
99
+ cloudProviders: [],
100
+ hostProviders: [],
101
+ gpu: gpu("nvidia", 24576),
102
+ hostCredentialCount: 2,
103
+ });
104
+ expect(r.action).not.toBe("enable-ollama");
105
+ expect(r.action).toBe("connect-manually");
106
+ if (r.action === "connect-manually") {
107
+ expect(r.alert).toContain("host OpenCode");
108
+ expect(r.alert).toContain("Import");
109
+ }
110
+ });
111
+
112
+ test("(b) cloud still wins over host-configured", () => {
113
+ const r = recommendSetup({
114
+ cloudProviders: ["openai"],
115
+ hostProviders: [],
116
+ gpu: null,
117
+ hostCredentialCount: 3,
118
+ });
119
+ expect(r.action).toBe("use-cloud");
120
+ });
121
+
122
+ test("(c) host-configured beats a running host Ollama (import hint over auto-add)", () => {
123
+ // When the user has both a running host Ollama AND host OpenCode credentials,
124
+ // the richer "import your existing setup" guidance wins over the auto-add path.
125
+ const r = recommendSetup({
126
+ cloudProviders: [],
127
+ hostProviders: [{ provider: "ollama", url: "http://localhost:11434" }],
128
+ gpu: null,
129
+ hostCredentialCount: 1,
130
+ });
131
+ expect(r.action).toBe("connect-manually");
132
+ expect(r.action).not.toBe("use-host-providers");
133
+ if (r.action === "connect-manually") expect(r.alert).toContain("host OpenCode");
134
+ });
135
+
136
+ test("host-configured with zero credentials -> falls through to normal rules", () => {
137
+ // hostCredentialCount: 0 (or absent) must not suppress the normal GPU path.
138
+ const r = recommendSetup({ ...base, gpu: gpu("nvidia", 24576), hostCredentialCount: 0 });
139
+ expect(r.action).toBe("enable-ollama");
140
+ });
141
+
142
+ test("host-configured omitted (undefined) -> falls through to normal rules", () => {
143
+ // No regression: callers that don't pass hostCredentialCount get the old behaviour.
144
+ const r = recommendSetup({ ...base, gpu: gpu("nvidia", 24576) });
145
+ expect(r.action).toBe("enable-ollama");
146
+ });
147
+
148
+ test("host-configured + no GPU + no cloud -> connect-manually with import alert", () => {
149
+ const r = recommendSetup({ ...base, hostCredentialCount: 1 });
150
+ expect(r.action).toBe("connect-manually");
151
+ if (r.action === "connect-manually") expect(r.alert).toContain("host OpenCode");
152
+ });
153
+
154
+ test("host-configured + darwin apple GPU -> connect-manually (host wins, not apple guidance)", () => {
155
+ // Both host-configured and darwin+apple would return connect-manually, but
156
+ // host-configured takes priority so the alert is the import one, not the Metal one.
157
+ const r = recommendSetup({
158
+ ...base,
159
+ platform: "darwin",
160
+ gpu: gpu("apple", 65536),
161
+ hostCredentialCount: 2,
162
+ });
163
+ expect(r.action).toBe("connect-manually");
164
+ if (r.action === "connect-manually") {
165
+ expect(r.alert).toContain("host OpenCode");
166
+ expect(r.alert).not.toContain("Metal");
167
+ }
168
+ });
169
+ });
170
+
63
171
  describe("gpuToProfileVariant", () => {
64
- test("nvidia->cuda, amd->rocm, unknown->cpu", () => {
172
+ test("nvidia->cuda, amd->rocm, apple->cpu, unknown->cpu", () => {
65
173
  expect(gpuToProfileVariant(gpu("nvidia", 8192))).toBe("cuda");
66
174
  expect(gpuToProfileVariant(gpu("amd", 8192))).toBe("rocm");
175
+ expect(gpuToProfileVariant(gpu("apple", 65536))).toBe("cpu");
67
176
  expect(gpuToProfileVariant(gpu("unknown", 8192))).toBe("cpu");
68
177
  });
69
178
  });
70
179
 
180
+ describe("parseAppleSilicon", () => {
181
+ test("parses hw.memsize bytes -> MiB + vendor apple + model name", () => {
182
+ const stdout = `${16 * 1024 * 1024 * 1024}\nMac15,7\n`;
183
+ const out = parseAppleSilicon(stdout);
184
+ expect(out).toEqual([{ vendor: "apple", name: "Apple Silicon (Mac15,7)", vramMb: 16384 }]);
185
+ });
186
+ test("missing model line -> falls back to arm64", () => {
187
+ const out = parseAppleSilicon(`${8 * 1024 * 1024 * 1024}\n`);
188
+ expect(out[0]?.vendor).toBe("apple");
189
+ expect(out[0]?.name).toBe("Apple Silicon (arm64)");
190
+ expect(out[0]?.vramMb).toBe(8192);
191
+ });
192
+ test("garbage / empty -> []", () => {
193
+ expect(parseAppleSilicon("")).toEqual([]);
194
+ expect(parseAppleSilicon("not-a-number\nMac15,7")).toEqual([]);
195
+ });
196
+ });
197
+
71
198
  describe("parseNvidiaSmi", () => {
72
199
  test("parses name + VRAM (MiB), handles commas in name", () => {
73
200
  const out = parseNvidiaSmi("NVIDIA GeForce RTX 4090, 24564\nNVIDIA A100, 81920\n");
@@ -18,6 +18,10 @@ export const MIN_LOCAL_GPU_VRAM_MB = 8 * 1024;
18
18
  const VENDOR_PROFILE_VARIANT: Record<GpuVendor, "cuda" | "rocm" | "cpu"> = {
19
19
  nvidia: "cuda",
20
20
  amd: "rocm",
21
+ // The in-stack Ollama container on a Mac is a Linux container with no Metal
22
+ // access, so it can only ever run CPU. (On darwin apple GPUs are routed to
23
+ // host-Ollama guidance and never reach enable-ollama — see recommendSetup.)
24
+ apple: "cpu",
21
25
  unknown: "cpu",
22
26
  };
23
27
 
@@ -34,6 +38,23 @@ export type SetupRecommendationInput = {
34
38
  hostProviders: DetectedHostProvider[];
35
39
  /** Best detected GPU, or null. */
36
40
  gpu: GpuInfo | null;
41
+ /**
42
+ * Host platform. Defaults to `process.platform` when omitted, but the decision
43
+ * logic only reads this field (never `process.*`) so the function stays pure.
44
+ * On darwin the in-stack Linux Ollama can't reach the Mac's Metal GPU, so an
45
+ * apple GPU is routed to host-Ollama guidance instead of enable-ollama.
46
+ */
47
+ platform?: NodeJS.Platform;
48
+ /**
49
+ * Number of credentials found in the host user's OpenCode auth.json
50
+ * (~/.local/share/opencode/auth.json). When > 0 the host OpenCode has
51
+ * configured providers that should be imported rather than bypassed by
52
+ * auto-enabling the bundled in-stack Ollama.
53
+ *
54
+ * Gathered by the caller via detectHostOpenCode() — kept out of this module
55
+ * so the function stays pure and unit-testable.
56
+ */
57
+ hostCredentialCount?: number;
37
58
  };
38
59
 
39
60
  export type SetupRecommendation =
@@ -56,18 +77,40 @@ const labelHostProviders = (h: DetectedHostProvider[]): string =>
56
77
  * Decide what setup should do, given detected providers + hardware.
57
78
  *
58
79
  * Order (first match wins):
59
- * 1. cloud provider connected -> use it.
60
- * 2. host-local provider running -> add it, proceed.
61
- * 3. capable GPU (>= threshold) -> enable in-stack Ollama.
62
- * 4. otherwise -> ask the user to connect a provider.
80
+ * 1. cloud provider connected -> use it.
81
+ * 2. host OpenCode has credentials -> steer to import; NEVER auto-enable Ollama.
82
+ * 3. host-local provider running -> add it, proceed.
83
+ * 4. darwin + apple GPU -> guide to HOST Ollama (Metal); never in-stack.
84
+ * 5. capable GPU (>= threshold) -> enable in-stack Ollama.
85
+ * 6. otherwise -> ask the user to connect a provider.
63
86
  */
64
87
  export function recommendSetup(input: SetupRecommendationInput): SetupRecommendation {
65
88
  const { cloudProviders, hostProviders, gpu } = input;
89
+ const platform = input.platform ?? process.platform;
90
+ const hostCredentialCount = input.hostCredentialCount ?? 0;
66
91
 
67
92
  if (cloudProviders.length > 0) {
68
93
  return { action: "use-cloud", cloudProviders };
69
94
  }
70
95
 
96
+ // A host OpenCode installation with credentials outranks auto-enabling the
97
+ // bundled in-stack Ollama. The user already has configured providers — they
98
+ // should import them rather than spin up a new Ollama container. We reuse
99
+ // the existing `connect-manually` action (already handled by the wizard's
100
+ // Providers step) with an import-oriented alert so no new wizard branch is
101
+ // needed. This rule runs BEFORE host-local-provider detection so that even a
102
+ // running host Ollama does not shadow the richer "import your existing setup"
103
+ // guidance when host credentials are present.
104
+ if (hostCredentialCount > 0) {
105
+ return {
106
+ action: "connect-manually",
107
+ alert:
108
+ "Your host OpenCode installation has configured AI providers. " +
109
+ "Import them now to use your existing setup — click \"Import from host OpenCode\" " +
110
+ "on the Providers step, or connect a provider manually.",
111
+ };
112
+ }
113
+
71
114
  if (hostProviders.length > 0) {
72
115
  return {
73
116
  action: "use-host-providers",
@@ -78,6 +121,22 @@ export function recommendSetup(input: SetupRecommendationInput): SetupRecommenda
78
121
  };
79
122
  }
80
123
 
124
+ // macOS: the in-stack Ollama is a Linux container with no access to the Mac's
125
+ // Metal GPU, so enabling it would silently fall back to slow CPU. When the Mac
126
+ // has an Apple-Silicon GPU and nothing is connected yet, steer the user to a
127
+ // native host Ollama (which DOES use Metal) via connect-manually — reusing the
128
+ // existing action avoids a new wizard branch (chosen for minimal UI impact).
129
+ if (platform === "darwin" && gpu && gpu.vendor === "apple") {
130
+ return {
131
+ action: "connect-manually",
132
+ alert:
133
+ "No AI provider was detected. On macOS, fast local models need Ollama running " +
134
+ "natively (it uses your Apple Silicon / Metal GPU) — the bundled in-stack Ollama " +
135
+ "runs in Linux and cannot reach Metal. Install Ollama for macOS (https://ollama.com/download), " +
136
+ "or connect a provider on the next step.",
137
+ };
138
+ }
139
+
81
140
  if (gpu && gpu.vramMb >= MIN_LOCAL_GPU_VRAM_MB) {
82
141
  return {
83
142
  action: "enable-ollama",
@@ -104,10 +104,19 @@ describe("performUpgrade force-recreates managed services (#450)", () => {
104
104
  expect(src).toMatch(/composeUp\(\{[^}]*forceRecreate:\s*true/);
105
105
  });
106
106
 
107
- test("buildManagedServices always includes the core services (guardian)", () => {
107
+ test("buildManagedServices always manages the assistant", () => {
108
108
  const src = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
109
- // Guardian comes from CORE_SERVICES and must be seeded into the set
110
- // regardless of how the rest of the service list is discovered.
111
- expect(src).toContain("new Set<string>(CORE_SERVICES)");
109
+ // The assistant is the only ALWAYS-on core service in the managed set.
110
+ expect(src).toContain('new Set<string>(["assistant"])');
111
+ });
112
+
113
+ test("buildManagedServices adds guardian ONLY when a channel addon is enabled", () => {
114
+ const src = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
115
+ // Guardian is channel ingress: profile-gated to the channel addons, so it
116
+ // must be added conditionally — never unconditionally seeded (that hung the
117
+ // installer on a guardian that never starts when no channel is enabled).
118
+ expect(src).toContain("channelsEnabled");
119
+ expect(src).toMatch(/if \(channelsEnabled\) services\.add\("guardian"\)/);
120
+ expect(src).not.toContain("new Set<string>(CORE_SERVICES)");
112
121
  });
113
122
  });
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  LLM_PROVIDERS,
12
12
  EMBEDDING_DIMS,
13
13
  PROVIDER_KEY_MAP,
14
+ OLLAMA_DEFAULT_MODELS,
14
15
  lookupEmbeddingDims,
15
16
  } from "./provider-constants.js";
16
17
 
@@ -260,10 +261,11 @@ export {
260
261
  } from "./control-plane/lifecycle.js";
261
262
 
262
263
  // ── Docker ──────────────────────────────────────────────────────────────
263
- export type { DockerResult } from "./control-plane/docker.js";
264
+ export type { DockerResult, ExistingProject } from "./control-plane/docker.js";
264
265
  export {
265
266
  checkDocker,
266
267
  checkDockerCompose,
268
+ detectExistingProject,
267
269
  resolveComposeProjectName,
268
270
  composePreflight,
269
271
  composeUp,
@@ -299,7 +301,7 @@ export { detectLocalProviders } from "./control-plane/model-runner.js";
299
301
 
300
302
  // ── Hardware detection + setup recommendation ───────────────────────────
301
303
  export type { GpuInfo, GpuVendor } from "./control-plane/hardware-detect.js";
302
- export { detectGpu, parseNvidiaSmi, parseRocmSmi } from "./control-plane/hardware-detect.js";
304
+ export { detectGpu, parseNvidiaSmi, parseRocmSmi, parseAppleSilicon } from "./control-plane/hardware-detect.js";
303
305
  export type {
304
306
  DetectedHostProvider,
305
307
  SetupRecommendation,