@openpalm/lib 0.11.1 → 0.11.2-rc.1

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.1",
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
  /**
@@ -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 {
@@ -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");
@@ -61,13 +94,32 @@ describe("recommendSetup", () => {
61
94
  });
62
95
 
63
96
  describe("gpuToProfileVariant", () => {
64
- test("nvidia->cuda, amd->rocm, unknown->cpu", () => {
97
+ test("nvidia->cuda, amd->rocm, apple->cpu, unknown->cpu", () => {
65
98
  expect(gpuToProfileVariant(gpu("nvidia", 8192))).toBe("cuda");
66
99
  expect(gpuToProfileVariant(gpu("amd", 8192))).toBe("rocm");
100
+ expect(gpuToProfileVariant(gpu("apple", 65536))).toBe("cpu");
67
101
  expect(gpuToProfileVariant(gpu("unknown", 8192))).toBe("cpu");
68
102
  });
69
103
  });
70
104
 
105
+ describe("parseAppleSilicon", () => {
106
+ test("parses hw.memsize bytes -> MiB + vendor apple + model name", () => {
107
+ const stdout = `${16 * 1024 * 1024 * 1024}\nMac15,7\n`;
108
+ const out = parseAppleSilicon(stdout);
109
+ expect(out).toEqual([{ vendor: "apple", name: "Apple Silicon (Mac15,7)", vramMb: 16384 }]);
110
+ });
111
+ test("missing model line -> falls back to arm64", () => {
112
+ const out = parseAppleSilicon(`${8 * 1024 * 1024 * 1024}\n`);
113
+ expect(out[0]?.vendor).toBe("apple");
114
+ expect(out[0]?.name).toBe("Apple Silicon (arm64)");
115
+ expect(out[0]?.vramMb).toBe(8192);
116
+ });
117
+ test("garbage / empty -> []", () => {
118
+ expect(parseAppleSilicon("")).toEqual([]);
119
+ expect(parseAppleSilicon("not-a-number\nMac15,7")).toEqual([]);
120
+ });
121
+ });
122
+
71
123
  describe("parseNvidiaSmi", () => {
72
124
  test("parses name + VRAM (MiB), handles commas in name", () => {
73
125
  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,13 @@ 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;
37
48
  };
38
49
 
39
50
  export type SetupRecommendation =
@@ -58,11 +69,13 @@ const labelHostProviders = (h: DetectedHostProvider[]): string =>
58
69
  * Order (first match wins):
59
70
  * 1. cloud provider connected -> use it.
60
71
  * 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.
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.
63
75
  */
64
76
  export function recommendSetup(input: SetupRecommendationInput): SetupRecommendation {
65
77
  const { cloudProviders, hostProviders, gpu } = input;
78
+ const platform = input.platform ?? process.platform;
66
79
 
67
80
  if (cloudProviders.length > 0) {
68
81
  return { action: "use-cloud", cloudProviders };
@@ -78,6 +91,22 @@ export function recommendSetup(input: SetupRecommendationInput): SetupRecommenda
78
91
  };
79
92
  }
80
93
 
94
+ // macOS: the in-stack Ollama is a Linux container with no access to the Mac's
95
+ // Metal GPU, so enabling it would silently fall back to slow CPU. When the Mac
96
+ // has an Apple-Silicon GPU and nothing is connected yet, steer the user to a
97
+ // native host Ollama (which DOES use Metal) via connect-manually — reusing the
98
+ // existing action avoids a new wizard branch (chosen for minimal UI impact).
99
+ if (platform === "darwin" && gpu && gpu.vendor === "apple") {
100
+ return {
101
+ action: "connect-manually",
102
+ alert:
103
+ "No AI provider was detected. On macOS, fast local models need Ollama running " +
104
+ "natively (it uses your Apple Silicon / Metal GPU) — the bundled in-stack Ollama " +
105
+ "runs in Linux and cannot reach Metal. Install Ollama for macOS (https://ollama.com/download), " +
106
+ "or connect a provider on the next step.",
107
+ };
108
+ }
109
+
81
110
  if (gpu && gpu.vramMb >= MIN_LOCAL_GPU_VRAM_MB) {
82
111
  return {
83
112
  action: "enable-ollama",
package/src/index.ts CHANGED
@@ -260,10 +260,11 @@ export {
260
260
  } from "./control-plane/lifecycle.js";
261
261
 
262
262
  // ── Docker ──────────────────────────────────────────────────────────────
263
- export type { DockerResult } from "./control-plane/docker.js";
263
+ export type { DockerResult, ExistingProject } from "./control-plane/docker.js";
264
264
  export {
265
265
  checkDocker,
266
266
  checkDockerCompose,
267
+ detectExistingProject,
267
268
  resolveComposeProjectName,
268
269
  composePreflight,
269
270
  composeUp,
@@ -299,7 +300,7 @@ export { detectLocalProviders } from "./control-plane/model-runner.js";
299
300
 
300
301
  // ── Hardware detection + setup recommendation ───────────────────────────
301
302
  export type { GpuInfo, GpuVendor } from "./control-plane/hardware-detect.js";
302
- export { detectGpu, parseNvidiaSmi, parseRocmSmi } from "./control-plane/hardware-detect.js";
303
+ export { detectGpu, parseNvidiaSmi, parseRocmSmi, parseAppleSilicon } from "./control-plane/hardware-detect.js";
303
304
  export type {
304
305
  DetectedHostProvider,
305
306
  SetupRecommendation,