@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 +1 -1
- package/src/control-plane/docker.test.ts +61 -0
- package/src/control-plane/docker.ts +92 -1
- package/src/control-plane/hardware-detect.ts +33 -1
- package/src/control-plane/setup-recommendation.test.ts +54 -2
- package/src/control-plane/setup-recommendation.ts +31 -2
- package/src/index.ts +3 -2
package/package.json
CHANGED
|
@@ -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,
|
|
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.
|
|
62
|
-
* 4.
|
|
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,
|