@openpalm/lib 0.11.2-rc.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.2-rc.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",
@@ -275,6 +275,11 @@ export async function composeDown(
275
275
  profiles?: string[];
276
276
  removeVolumes?: boolean;
277
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;
278
283
  }
279
284
  ): Promise<DockerResult> {
280
285
  await runPreflight(options);
@@ -284,6 +289,7 @@ export async function composeDown(
284
289
  const args = buildComposeArgs(options);
285
290
  args.push("down");
286
291
  if (options.removeVolumes) args.push("-v");
292
+ if (options.removeOrphans) args.push("--remove-orphans");
287
293
  return run(args, undefined);
288
294
  }
289
295
 
@@ -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 };
@@ -93,6 +93,81 @@ describe("recommendSetup", () => {
93
93
  });
94
94
  });
95
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
+
96
171
  describe("gpuToProfileVariant", () => {
97
172
  test("nvidia->cuda, amd->rocm, apple->cpu, unknown->cpu", () => {
98
173
  expect(gpuToProfileVariant(gpu("nvidia", 8192))).toBe("cuda");
@@ -45,6 +45,16 @@ export type SetupRecommendationInput = {
45
45
  * apple GPU is routed to host-Ollama guidance instead of enable-ollama.
46
46
  */
47
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;
48
58
  };
49
59
 
50
60
  export type SetupRecommendation =
@@ -67,20 +77,40 @@ const labelHostProviders = (h: DetectedHostProvider[]): string =>
67
77
  * Decide what setup should do, given detected providers + hardware.
68
78
  *
69
79
  * Order (first match wins):
70
- * 1. cloud provider connected -> use it.
71
- * 2. host-local provider running -> add it, proceed.
72
- * 3. darwin + apple GPU -> guide to HOST Ollama (Metal); never in-stack.
73
- * 4. capable GPU (>= threshold) -> enable in-stack Ollama.
74
- * 5. 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.
75
86
  */
76
87
  export function recommendSetup(input: SetupRecommendationInput): SetupRecommendation {
77
88
  const { cloudProviders, hostProviders, gpu } = input;
78
89
  const platform = input.platform ?? process.platform;
90
+ const hostCredentialCount = input.hostCredentialCount ?? 0;
79
91
 
80
92
  if (cloudProviders.length > 0) {
81
93
  return { action: "use-cloud", cloudProviders };
82
94
  }
83
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
+
84
114
  if (hostProviders.length > 0) {
85
115
  return {
86
116
  action: "use-host-providers",
@@ -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