@moldesk/cli 0.0.1 → 0.0.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/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +63 -14
- package/dist/commands/doctor.test.d.ts +1 -0
- package/dist/commands/doctor.test.js +108 -0
- package/dist/commands/install.js +234 -6
- package/dist/commands/install.test.d.ts +1 -0
- package/dist/commands/install.test.js +231 -0
- package/dist/commands/list.js +65 -13
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +164 -0
- package/dist/commands/run.js +88 -4
- package/dist/commands/run.test.d.ts +1 -0
- package/dist/commands/run.test.js +136 -0
- package/dist/commands/uninstall.d.ts +2 -0
- package/dist/commands/uninstall.js +65 -0
- package/dist/index.js +2 -0
- package/dist/testSupport/spawnMock.d.ts +21 -0
- package/dist/testSupport/spawnMock.js +29 -0
- package/package.json +6 -4
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
});
|
package/dist/commands/install.js
CHANGED
|
@@ -1,17 +1,245 @@
|
|
|
1
|
-
import {
|
|
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
|
|
6
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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 {};
|