@moldesk/cli 0.0.1 → 0.0.3

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.
@@ -1,2 +1,4 @@
1
1
  import type { Command } from "commander";
2
+ import type { SystemReport } from "@moldesk/core";
3
+ export declare function printHuman(report: SystemReport): void;
2
4
  export declare function registerDoctorCommand(program: Command): void;
@@ -1,22 +1,71 @@
1
1
  import { getSystemReport, VERSION } from "@moldesk/core";
2
2
  import { checkmark, section } from "../utils/format.js";
3
+ function formatBytes(bytes) {
4
+ if (!Number.isFinite(bytes) || bytes < 0)
5
+ return "unknown";
6
+ const gb = bytes / 1024 ** 3;
7
+ if (gb >= 1)
8
+ return `${gb.toFixed(1)} GB`;
9
+ return `${Math.round(bytes / 1024 ** 2)} MB`;
10
+ }
11
+ export function printHuman(report) {
12
+ console.log(`MoleculeDesk ${VERSION}`);
13
+ console.log(section("System"));
14
+ console.log(` OS ${report.platform} (${report.arch})`);
15
+ console.log(` CPU ${report.cpu.model} (${report.cpu.logicalCores} cores)`);
16
+ console.log(` Memory ${formatBytes(report.memory.totalBytes)}`);
17
+ console.log(` Disk ${report.disk.probeFailed ? `unknown (${report.disk.probeError ?? "probe failed"})` : `${formatBytes(report.disk.availableBytes)} free at ${report.disk.path}`}`);
18
+ console.log(` ${checkmark(report.appleSilicon)} Apple Silicon${report.appleSilicon ? "" : " not detected"}`);
19
+ console.log(section("Compute"));
20
+ const py = report.hostPython;
21
+ console.log(` ${checkmark(py.available)} Python ${py.available ? `${py.version ?? "unknown"}${py.path ? ` (${py.path})` : ""}` : (py.error ?? "not found")} (diagnostic only)`);
22
+ const gpuCount = report.nvidia.gpus.length;
23
+ const gpuDetail = report.nvidia.available && gpuCount > 0
24
+ ? report.nvidia.gpus
25
+ .map((g) => `${g.name}${typeof g.totalVramBytes === "number" ? ` (${formatBytes(g.totalVramBytes)} VRAM)` : ""}`)
26
+ .join(", ")
27
+ : null;
28
+ console.log(` ${checkmark(report.nvidia.available)} NVIDIA GPU ${report.nvidia.available
29
+ ? `${gpuCount} GPU${gpuCount === 1 ? "" : "s"}: ${gpuDetail ?? "detected"}` +
30
+ `${report.nvidia.driverCudaVersion ? ` | CUDA (driver max) ${report.nvidia.driverCudaVersion}` : ""}` +
31
+ `${report.nvidia.cudaToolkitVersion ? ` | CUDA toolkit ${report.nvidia.cudaToolkitVersion}` : ""}` +
32
+ `${report.nvidia.driverVersion ? ` | driver ${report.nvidia.driverVersion}` : ""}`
33
+ : (report.nvidia.error ?? "not detected")}`);
34
+ console.log(section("Docker"));
35
+ const docker = report.docker;
36
+ console.log(` ${checkmark(docker.available)} Installed ${docker.available ? (docker.version ?? "yes") : (docker.error ?? "not installed")}`);
37
+ console.log(` ${checkmark(docker.running)} Running ${docker.available ? (docker.running ? "running" : "not running") : "n/a"}`);
38
+ console.log(` GPU access ${docker.gpuAccess}`);
39
+ console.log(section("Notes"));
40
+ if (!report.nvidia.available) {
41
+ console.log(" No NVIDIA GPU detected: CUDA/GPU models will run on CPU only.");
42
+ console.log(" Docker GPU runtimes need the same host NVIDIA driver — Docker does not provide GPU access independently.");
43
+ }
44
+ if (report.docker.available && !report.docker.running) {
45
+ console.log(" Start the Docker daemon to enable docker runtimes.");
46
+ }
47
+ if (report.docker.gpuAccess === "unknown") {
48
+ console.log(" Docker GPU access not yet verified (run `moldesk install` to verify; doctor never pulls images).");
49
+ }
50
+ console.log(" Host Python is diagnostic only; installs use managed uv Python.");
51
+ }
3
52
  export function registerDoctorCommand(program) {
4
53
  program
5
54
  .command("doctor")
6
55
  .description("Check system compatibility for running models")
7
- .action(async () => {
8
- const report = await getSystemReport();
9
- const { hardware, python, docker } = report;
10
- console.log(`MoleculeDesk ${VERSION}`);
11
- console.log(section("System"));
12
- console.log(` OS ${hardware.platform} (${hardware.arch})`);
13
- console.log(` CPU ${hardware.cpuModel} (${hardware.cpuCores} cores)`);
14
- console.log(` Memory ${hardware.totalMemoryGb} GB`);
15
- console.log(` ${checkmark(hardware.isAppleSilicon)} Apple Silicon${hardware.isAppleSilicon ? "" : " not detected"}`);
16
- console.log(section("Compute"));
17
- console.log(` ${checkmark(python.found)} Python ${python.found ? python.version : "not found"}`);
18
- console.log(` ${checkmark(docker.installed)} Docker ${docker.installed ? (docker.running ? `${docker.version} (running)` : `${docker.version} (not running)`) : "not installed"}`);
19
- console.log(section("Models"));
20
- console.log(" No models installed yet. Run `moldesk list` to see what's available.");
56
+ .option("--json", "print the raw system report as JSON")
57
+ .action(async (options) => {
58
+ try {
59
+ const report = await getSystemReport();
60
+ if (options.json) {
61
+ console.log(JSON.stringify(report, null, 2));
62
+ return;
63
+ }
64
+ printHuman(report);
65
+ }
66
+ catch (error) {
67
+ console.error(error instanceof Error ? error.message : String(error));
68
+ process.exitCode = 1;
69
+ }
21
70
  });
22
71
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+ import { Command } from "commander";
6
+ import { createFakeSpawn } from "../testSupport/spawnMock.js";
7
+ import { registerDoctorCommand, printHuman } from "./doctor.js";
8
+ const state = vi.hoisted(() => ({
9
+ handler: ((_cmd, _args) => ({ code: 127, spawnError: true })),
10
+ }));
11
+ vi.mock("node:child_process", () => ({
12
+ spawn: (command, args) => createFakeSpawn((c, a) => state.handler(c, a))(command, args),
13
+ }));
14
+ let moldeskHome;
15
+ let logs;
16
+ beforeEach(() => {
17
+ moldeskHome = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-home-"));
18
+ process.env["MOLDESK_HOME"] = moldeskHome;
19
+ logs = [];
20
+ vi.spyOn(console, "log").mockImplementation((msg) => {
21
+ logs.push(String(msg));
22
+ });
23
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
24
+ state.handler = () => ({ code: 127, spawnError: true });
25
+ });
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ delete process.env["MOLDESK_HOME"];
29
+ fs.rmSync(moldeskHome, { recursive: true, force: true });
30
+ });
31
+ function buildProgram() {
32
+ const program = new Command();
33
+ program.exitOverride();
34
+ registerDoctorCommand(program);
35
+ return program;
36
+ }
37
+ describe("doctor (real command, faked process boundary only)", () => {
38
+ it("never triggers a docker pull or gpu-run probe (doctor stays fast, no pulls)", async () => {
39
+ state.handler = (cmd, args) => {
40
+ expect(args).not.toContain("pull");
41
+ if (cmd === "docker" && args[0] === "run")
42
+ throw new Error("doctor must never run a docker container");
43
+ return { code: 127, spawnError: true };
44
+ };
45
+ const program = buildProgram();
46
+ await program.parseAsync(["doctor"], { from: "user" });
47
+ });
48
+ it("prints GPU count and per-GPU VRAM when GPUs are present", async () => {
49
+ state.handler = (cmd, args) => {
50
+ if (cmd === "nvidia-smi" && args.join(" ").includes("memory.total"))
51
+ return { code: 0, stdout: "0, NVIDIA A100, 81920\n1, NVIDIA A100, 81920\n" };
52
+ if (cmd === "nvidia-smi" && args.includes("-x"))
53
+ return { code: 0, stdout: "<nvidia_smi_log><cuda_version>12.4</cuda_version></nvidia_smi_log>" };
54
+ if (cmd === "nvidia-smi")
55
+ return { code: 0, stdout: "550.54.15\n" };
56
+ return { code: 127, spawnError: true };
57
+ };
58
+ const program = buildProgram();
59
+ await program.parseAsync(["doctor"], { from: "user" });
60
+ const output = logs.join("\n");
61
+ expect(output).toContain("2 GPUs:");
62
+ expect(output).toContain("80.0 GB VRAM");
63
+ expect(output).toContain("CUDA (driver max) 12.4");
64
+ });
65
+ it("renders the corrected no-NVIDIA guidance instead of the misleading Docker-fallback note", async () => {
66
+ const program = buildProgram();
67
+ await program.parseAsync(["doctor"], { from: "user" });
68
+ const output = logs.join("\n");
69
+ expect(output).toContain("No NVIDIA GPU detected: CUDA/GPU models will run on CPU only.");
70
+ expect(output).toContain("Docker GPU runtimes need the same host NVIDIA driver");
71
+ expect(output).not.toMatch(/CPU or Docker fallbacks/);
72
+ });
73
+ it("renders 'unknown (<error>)' instead of '0 MB' when the disk probe failed", () => {
74
+ const report = {
75
+ capturedAt: "2026-01-01T00:00:00.000Z",
76
+ platform: "linux",
77
+ arch: "x64",
78
+ cpu: { model: "Test CPU", logicalCores: 8 },
79
+ memory: { totalBytes: 32 * 1024 ** 3 },
80
+ disk: { availableBytes: 0, path: "/tmp/moldesk", probeFailed: true, probeError: "EACCES: permission denied" },
81
+ appleSilicon: false,
82
+ hostPython: { available: false, error: "no python" },
83
+ docker: { available: false, running: false, gpuAccess: "unknown" },
84
+ nvidia: { available: false, gpus: [], error: "no gpu" },
85
+ };
86
+ printHuman(report);
87
+ const output = logs.join("\n");
88
+ expect(output).toContain("unknown (EACCES: permission denied)");
89
+ expect(output).not.toContain("0 MB");
90
+ });
91
+ it("renders the free-space reading normally when the disk probe succeeds (including a genuine zero)", () => {
92
+ const report = {
93
+ capturedAt: "2026-01-01T00:00:00.000Z",
94
+ platform: "linux",
95
+ arch: "x64",
96
+ cpu: { model: "Test CPU", logicalCores: 8 },
97
+ memory: { totalBytes: 32 * 1024 ** 3 },
98
+ disk: { availableBytes: 0, path: "/tmp/moldesk" },
99
+ appleSilicon: false,
100
+ hostPython: { available: false, error: "no python" },
101
+ docker: { available: false, running: false, gpuAccess: "unknown" },
102
+ nvidia: { available: false, gpus: [], error: "no gpu" },
103
+ };
104
+ printHuman(report);
105
+ const output = logs.join("\n");
106
+ expect(output).toContain("0 MB free at /tmp/moldesk");
107
+ });
108
+ });
@@ -1,17 +1,245 @@
1
- import { listAvailableModels } from "@moldesk/core";
1
+ import { createInterface } from "node:readline/promises";
2
+ import { createInstallationPlan, evaluateCompatibility, getSystemReport, installModel, isImageCachedLocally, listAvailableModels, probeDockerGpuAccess, pullDockerImage, requiresInstallConfirmation, shortReason, } from "@moldesk/core";
3
+ // TODO: pin by digest (`docker manifest inspect nvidia/cuda:12.4.1-base-ubuntu22.04`)
4
+ // before release — kept as a tag here since resolving a real digest needs a
5
+ // reachable Docker daemon/registry, and fabricating one would be worse than
6
+ // a tag (a wrong digest fails every pull; a tag just isn't reproducible yet).
7
+ const DOCKER_GPU_PROBE_IMAGE = "nvidia/cuda:12.4.1-base-ubuntu22.04";
8
+ const PULL_TIMEOUT_MS = 120_000;
9
+ const PROBE_TIMEOUT_MS = 10_000;
10
+ function parseRuntime(value) {
11
+ if (value === "python" || value === "docker")
12
+ return value;
13
+ console.error(`Invalid --runtime "${value}". Expected python|docker.`);
14
+ process.exit(1);
15
+ throw new Error("unreachable");
16
+ }
17
+ function formatBytes(bytes) {
18
+ if (bytes <= 0)
19
+ return "unknown";
20
+ const units = ["B", "KB", "MB", "GB", "TB"];
21
+ let value = bytes;
22
+ let unit = 0;
23
+ while (value >= 1000 && unit < units.length - 1) {
24
+ value /= 1000;
25
+ unit += 1;
26
+ }
27
+ return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
28
+ }
29
+ async function confirmDownload(image) {
30
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
31
+ try {
32
+ const answer = await rl.question(`Verifying Docker GPU access requires downloading ${image}. Continue? [y/N] `);
33
+ return /^y(es)?$/i.test(answer.trim());
34
+ }
35
+ finally {
36
+ rl.close();
37
+ }
38
+ }
39
+ async function confirmInstallation(model) {
40
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
41
+ try {
42
+ const answer = await rl.question(`Install ${model}? [y/N] `);
43
+ return /^y(es)?$/i.test(answer.trim());
44
+ }
45
+ finally {
46
+ rl.close();
47
+ }
48
+ }
49
+ /**
50
+ * Cost-aware confirmation gate: proceeds without asking for small, known-size,
51
+ * non-destructive installs; otherwise prompts interactively, or — with no TTY,
52
+ * `--json`, or `--yes` already declining — fails before any mutation happens.
53
+ */
54
+ async function decideInstallConfirmation(displayName, needsConfirmation, options) {
55
+ if (!needsConfirmation || options.yes)
56
+ return { proceed: true };
57
+ if (options.json || !process.stdin.isTTY || !process.stdout.isTTY) {
58
+ return { proceed: false, reason: "confirmation-required" };
59
+ }
60
+ return (await confirmInstallation(displayName))
61
+ ? { proceed: true }
62
+ : { proceed: false, reason: "cancelled" };
63
+ }
64
+ /**
65
+ * Tier 2: deterministically probe Docker GPU access, but only for the one
66
+ * runtime candidate it could actually rescue — a docker runtime with
67
+ * `gpu: "required"` that is otherwise fully compatible except for an
68
+ * unverified GPU signal. Never runs for `gpu: "optional"`, never runs more
69
+ * than once, and never runs at all from `doctor`/`list`.
70
+ */
71
+ async function verifyDockerGpuAccess(options) {
72
+ const cached = await isImageCachedLocally(DOCKER_GPU_PROBE_IMAGE);
73
+ if (!cached) {
74
+ if (!options.yes) {
75
+ // Can't prompt interactively in --json mode without a stdin TTY; require --yes there.
76
+ if (options.json)
77
+ return "skipped";
78
+ const confirmed = await confirmDownload(DOCKER_GPU_PROBE_IMAGE);
79
+ if (!confirmed)
80
+ return "skipped";
81
+ }
82
+ const pullResult = await pullDockerImage(DOCKER_GPU_PROBE_IMAGE, undefined, { timeoutMs: PULL_TIMEOUT_MS });
83
+ if (!pullResult.ok) {
84
+ console.error(`Could not download ${DOCKER_GPU_PROBE_IMAGE} to verify GPU access: ${pullResult.error ?? "unknown error"}`);
85
+ return "skipped";
86
+ }
87
+ }
88
+ const probeResult = await probeDockerGpuAccess(DOCKER_GPU_PROBE_IMAGE, undefined, { timeoutMs: PROBE_TIMEOUT_MS });
89
+ return probeResult.access;
90
+ }
2
91
  export function registerInstallCommand(program) {
3
92
  program
4
93
  .command("install <model>")
5
- .description("Install a model (coming soon)")
6
- .action((model) => {
94
+ .description("Install a model into an isolated, reproducible environment")
95
+ .option("--runtime <kind>", "select runtime python|docker (no silent fallback)")
96
+ .option("--yes", "accept downloads and installation without prompting")
97
+ .option("--reinstall", "stage and atomically replace an existing installation")
98
+ .option("--json", "print compatibility/plan result as JSON")
99
+ .action(async (model, options) => {
7
100
  const available = listAvailableModels();
8
101
  const manifest = available.find((m) => m.name === model);
9
102
  if (!manifest) {
10
- console.error(`Unknown model "${model}". Run \`moldesk list\` to see available models.`);
103
+ const message = `Unknown model "${model}". Run \`moldesk list\` to see available models.`;
104
+ if (options.json) {
105
+ console.log(JSON.stringify({ model, status: "unknown-model", message }, null, 2));
106
+ }
107
+ else {
108
+ console.error(message);
109
+ }
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ const requestedRuntime = options.runtime ? parseRuntime(options.runtime) : undefined;
114
+ let report = await getSystemReport();
115
+ let compatibility = evaluateCompatibility(manifest, report, { requestedRuntime });
116
+ // Deterministic Tier 2: only for the single docker+gpu:required candidate
117
+ // that would otherwise be selected if GPU access were verified.
118
+ if (!requestedRuntime || requestedRuntime === "docker") {
119
+ const rescuable = compatibility.runtimes.find((rt) => rt.kind === "docker" &&
120
+ rt.status === "unsupported" &&
121
+ rt.reasons.length > 0 &&
122
+ rt.reasons.every((r) => r.code === "DOCKER_GPU_UNVERIFIED"));
123
+ if (rescuable) {
124
+ const verified = await verifyDockerGpuAccess(options);
125
+ if (verified !== "skipped") {
126
+ report = await getSystemReport({ dockerGpuAccess: verified });
127
+ compatibility = evaluateCompatibility(manifest, report, { requestedRuntime });
128
+ }
129
+ }
130
+ }
131
+ if (compatibility.status === "unsupported") {
132
+ const reason = shortReason(compatibility);
133
+ if (options.json) {
134
+ console.log(JSON.stringify({ model, status: "unsupported", compatibility }, null, 2));
135
+ }
136
+ else {
137
+ console.error(`Cannot install "${model}": ${reason || "unsupported on this machine"}.`);
138
+ for (const rt of compatibility.runtimes) {
139
+ console.error(` [${rt.kind}] ${rt.status}`);
140
+ for (const r of rt.reasons) {
141
+ console.error(` ${r.code}: ${r.message}${r.remediation ? ` — ${r.remediation}` : ""}`);
142
+ }
143
+ }
144
+ console.error("No files were written.");
145
+ }
11
146
  process.exitCode = 1;
12
147
  return;
13
148
  }
14
- console.log(`Installing "${model}" is not yet supported.`);
15
- console.log("Track progress at https://github.com/pooriyapfn/moldesk");
149
+ const runtime = manifest.runtimes.find((candidate) => candidate.kind === compatibility.selectedRuntime);
150
+ if (!runtime) {
151
+ console.error(`Cannot resolve selected runtime for "${model}".`);
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ let planned;
156
+ try {
157
+ planned = await createInstallationPlan(manifest, runtime);
158
+ }
159
+ catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ if (options.json) {
162
+ console.log(JSON.stringify({ model, status: compatibility.status, selectedRuntime: runtime.kind, compatibility, installable: false, message }, null, 2));
163
+ }
164
+ else {
165
+ console.error(message);
166
+ }
167
+ process.exitCode = 1;
168
+ return;
169
+ }
170
+ const payload = {
171
+ model,
172
+ status: compatibility.status,
173
+ selectedRuntime: runtime.kind,
174
+ compatibility,
175
+ alreadyInstalled: planned.alreadyInstalled,
176
+ reinstall: options.reinstall ?? false,
177
+ targetDir: planned.targetDir,
178
+ estimatedDownloadBytes: planned.estimatedDownloadBytes,
179
+ estimatedDiskBytes: planned.estimatedDiskBytes,
180
+ steps: planned.plan.steps,
181
+ };
182
+ // An identical fingerprint that's already installed and not being replaced is a
183
+ // successful no-op below — nothing will be mutated, so there is nothing to confirm.
184
+ const isNoop = planned.alreadyInstalled && !options.reinstall;
185
+ const destructive = Boolean(options.reinstall) && planned.alreadyInstalled;
186
+ const needsConfirmation = !isNoop && requiresInstallConfirmation({
187
+ downloadBytes: planned.estimatedDownloadBytes,
188
+ downloadSizeUnknown: planned.downloadSizeUnknown,
189
+ diskBytes: planned.estimatedDiskBytes,
190
+ diskSizeUnknown: planned.diskSizeUnknown,
191
+ }, { destructive });
192
+ if (!options.json) {
193
+ console.log(`Installation plan for ${manifest.displayName} (${runtime.kind}):`);
194
+ for (const [index, step] of planned.plan.steps.entries())
195
+ console.log(` ${index + 1}. ${step.description}`);
196
+ console.log(` Download: ${formatBytes(planned.estimatedDownloadBytes)}${planned.downloadSizeUnknown ? " (partly unknown)" : ""}`);
197
+ console.log(` Additional disk: ${formatBytes(planned.estimatedDiskBytes)}${planned.diskSizeUnknown ? " (unknown)" : ""}`);
198
+ console.log(` Target: ${planned.targetDir}`);
199
+ }
200
+ const decision = await decideInstallConfirmation(manifest.displayName, needsConfirmation, options);
201
+ if (!decision.proceed) {
202
+ if (decision.reason === "confirmation-required") {
203
+ const message = `Installing ${manifest.displayName} requires confirmation (large or unknown-size download/disk use, or a destructive reinstall). Re-run with --yes.`;
204
+ if (options.json)
205
+ console.log(JSON.stringify({ ...payload, status: "confirmation-required", message }, null, 2));
206
+ else
207
+ console.error(message);
208
+ process.exitCode = 1;
209
+ }
210
+ else {
211
+ if (options.json)
212
+ console.log(JSON.stringify({ ...payload, status: "cancelled" }, null, 2));
213
+ else
214
+ console.log("Installation cancelled. Re-run with --yes for non-interactive use.");
215
+ }
216
+ return;
217
+ }
218
+ try {
219
+ const result = await installModel(planned, {
220
+ reinstall: options.reinstall,
221
+ onProgress: options.json ? undefined : (progress) => {
222
+ if (progress.receivedBytes !== undefined && progress.totalBytes) {
223
+ const percent = Math.floor((progress.receivedBytes / progress.totalBytes) * 100);
224
+ console.log(` [${progress.step}] ${progress.message} (${percent}%)`);
225
+ }
226
+ else {
227
+ console.log(` [${progress.step}] ${progress.message}`);
228
+ }
229
+ },
230
+ });
231
+ if (options.json)
232
+ console.log(JSON.stringify({ ...payload, status: result.status, installation: result.installation }, null, 2));
233
+ else
234
+ console.log(`${manifest.displayName}: ${result.status}.`);
235
+ }
236
+ catch (error) {
237
+ const message = error instanceof Error ? error.message : String(error);
238
+ if (options.json)
239
+ console.log(JSON.stringify({ ...payload, status: "failed", message }, null, 2));
240
+ else
241
+ console.error(`Installation failed: ${message}`);
242
+ process.exitCode = 1;
243
+ }
16
244
  });
17
245
  }
@@ -0,0 +1 @@
1
+ export {};