@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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { createFakeSpawn } from "../testSupport/spawnMock.js";
|
|
7
|
+
import { registerInstallCommand } from "./install.js";
|
|
8
|
+
const state = vi.hoisted(() => ({
|
|
9
|
+
handler: ((_cmd, _args) => ({ code: 127 })),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("node:child_process", () => ({
|
|
12
|
+
spawn: (command, args) => createFakeSpawn((c, a) => state.handler(c, a))(command, args),
|
|
13
|
+
}));
|
|
14
|
+
function noDockerNoNvidia() {
|
|
15
|
+
return (cmd) => {
|
|
16
|
+
if (cmd === "python3" || cmd === "python" || cmd === "which")
|
|
17
|
+
return { code: 127, spawnError: true };
|
|
18
|
+
if (cmd === "docker")
|
|
19
|
+
return { code: 127, spawnError: true };
|
|
20
|
+
if (cmd === "nvidia-smi" || cmd === "nvcc")
|
|
21
|
+
return { code: 127, spawnError: true };
|
|
22
|
+
return { code: 1 };
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function dirSnapshot(dir) {
|
|
26
|
+
if (!fs.existsSync(dir))
|
|
27
|
+
return [];
|
|
28
|
+
return fs.readdirSync(dir, { recursive: true }).map(String).sort();
|
|
29
|
+
}
|
|
30
|
+
function writeManifest(modelsDir, name, yaml) {
|
|
31
|
+
const dir = path.join(modelsDir, name);
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(dir, "manifest.yaml"), yaml);
|
|
34
|
+
}
|
|
35
|
+
const CPU_PYTHON_MANIFEST = (name) => `
|
|
36
|
+
schemaVersion: 1
|
|
37
|
+
name: ${name}
|
|
38
|
+
displayName: Test CPU Model
|
|
39
|
+
modelVersion: "1.0.0"
|
|
40
|
+
adapterVersion: "0.1.0"
|
|
41
|
+
description: test
|
|
42
|
+
category: other
|
|
43
|
+
status: beta
|
|
44
|
+
runtimes:
|
|
45
|
+
- kind: python
|
|
46
|
+
python: "3.11"
|
|
47
|
+
installer: uv
|
|
48
|
+
requirements:
|
|
49
|
+
- name: torch
|
|
50
|
+
version: "2.2.1"
|
|
51
|
+
hardware:
|
|
52
|
+
platforms: [darwin-arm64, linux-x64]
|
|
53
|
+
input:
|
|
54
|
+
formats: [".pdb"]
|
|
55
|
+
required: true
|
|
56
|
+
outputs:
|
|
57
|
+
- id: out
|
|
58
|
+
glob: "*.txt"
|
|
59
|
+
required: true
|
|
60
|
+
`;
|
|
61
|
+
const UNSUPPORTED_PYTHON_VERSION_MANIFEST = (name) => `
|
|
62
|
+
schemaVersion: 1
|
|
63
|
+
name: ${name}
|
|
64
|
+
displayName: Test Old Python Model
|
|
65
|
+
modelVersion: "1.0.0"
|
|
66
|
+
adapterVersion: "0.1.0"
|
|
67
|
+
description: test
|
|
68
|
+
category: other
|
|
69
|
+
status: beta
|
|
70
|
+
runtimes:
|
|
71
|
+
- kind: python
|
|
72
|
+
python: "3.8"
|
|
73
|
+
installer: uv
|
|
74
|
+
requirements:
|
|
75
|
+
- name: torch
|
|
76
|
+
version: "2.2.1"
|
|
77
|
+
hardware:
|
|
78
|
+
platforms: [darwin-arm64, linux-x64]
|
|
79
|
+
input:
|
|
80
|
+
formats: [".pdb"]
|
|
81
|
+
required: true
|
|
82
|
+
outputs:
|
|
83
|
+
- id: out
|
|
84
|
+
glob: "*.txt"
|
|
85
|
+
required: true
|
|
86
|
+
`;
|
|
87
|
+
const DOCKER_GPU_REQUIRED_MANIFEST = (name) => `
|
|
88
|
+
schemaVersion: 1
|
|
89
|
+
name: ${name}
|
|
90
|
+
displayName: Test Docker GPU Model
|
|
91
|
+
modelVersion: "1.0.0"
|
|
92
|
+
adapterVersion: "0.1.0"
|
|
93
|
+
description: test
|
|
94
|
+
category: other
|
|
95
|
+
status: beta
|
|
96
|
+
runtimes:
|
|
97
|
+
- kind: docker
|
|
98
|
+
image: example/test:latest
|
|
99
|
+
digest: "sha256:${"a".repeat(64)}"
|
|
100
|
+
gpu: required
|
|
101
|
+
hardware:
|
|
102
|
+
platforms: [darwin-arm64, linux-x64]
|
|
103
|
+
input:
|
|
104
|
+
formats: [".pdb"]
|
|
105
|
+
required: true
|
|
106
|
+
outputs:
|
|
107
|
+
- id: out
|
|
108
|
+
glob: "*.txt"
|
|
109
|
+
required: true
|
|
110
|
+
`;
|
|
111
|
+
let modelsDir;
|
|
112
|
+
let moldeskHome;
|
|
113
|
+
let logs;
|
|
114
|
+
let errors;
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
modelsDir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-models-"));
|
|
117
|
+
moldeskHome = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-home-"));
|
|
118
|
+
process.env["MOLDESK_MODELS_DIR"] = modelsDir;
|
|
119
|
+
process.env["MOLDESK_HOME"] = moldeskHome;
|
|
120
|
+
logs = [];
|
|
121
|
+
errors = [];
|
|
122
|
+
vi.spyOn(console, "log").mockImplementation((msg) => {
|
|
123
|
+
logs.push(String(msg));
|
|
124
|
+
});
|
|
125
|
+
vi.spyOn(console, "error").mockImplementation((msg) => {
|
|
126
|
+
errors.push(String(msg));
|
|
127
|
+
});
|
|
128
|
+
state.handler = noDockerNoNvidia();
|
|
129
|
+
});
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
vi.restoreAllMocks();
|
|
132
|
+
delete process.env["MOLDESK_MODELS_DIR"];
|
|
133
|
+
delete process.env["MOLDESK_HOME"];
|
|
134
|
+
fs.rmSync(modelsDir, { recursive: true, force: true });
|
|
135
|
+
fs.rmSync(moldeskHome, { recursive: true, force: true });
|
|
136
|
+
});
|
|
137
|
+
function buildProgram() {
|
|
138
|
+
const program = new Command();
|
|
139
|
+
program.exitOverride();
|
|
140
|
+
registerInstallCommand(program);
|
|
141
|
+
return program;
|
|
142
|
+
}
|
|
143
|
+
describe("install (real command, faked process boundary only)", () => {
|
|
144
|
+
it("performs zero filesystem writes for an unsupported install", async () => {
|
|
145
|
+
writeManifest(modelsDir, "old-python", UNSUPPORTED_PYTHON_VERSION_MANIFEST("old-python"));
|
|
146
|
+
const before = dirSnapshot(moldeskHome);
|
|
147
|
+
const program = buildProgram();
|
|
148
|
+
await program.parseAsync(["install", "old-python"], { from: "user" });
|
|
149
|
+
const after = dirSnapshot(moldeskHome);
|
|
150
|
+
expect(after).toEqual(before);
|
|
151
|
+
expect(errors.some((e) => e.includes("No files were written."))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
it("--json output is valid JSON for an unsupported install", async () => {
|
|
154
|
+
writeManifest(modelsDir, "old-python", UNSUPPORTED_PYTHON_VERSION_MANIFEST("old-python"));
|
|
155
|
+
const program = buildProgram();
|
|
156
|
+
await program.parseAsync(["install", "old-python", "--json"], { from: "user" });
|
|
157
|
+
const parsed = JSON.parse(logs.join("\n"));
|
|
158
|
+
expect(parsed.status).toBe("unsupported");
|
|
159
|
+
expect(parsed.compatibility.runtimes[0].reasons.some((r) => r.code === "MANAGED_PYTHON_VERSION_UNSUPPORTED")).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
it("--json output is valid JSON for a compatible install", async () => {
|
|
162
|
+
writeManifest(modelsDir, "good-python", CPU_PYTHON_MANIFEST("good-python"));
|
|
163
|
+
const program = buildProgram();
|
|
164
|
+
await program.parseAsync(["install", "good-python", "--json"], { from: "user" });
|
|
165
|
+
const parsed = JSON.parse(logs.join("\n"));
|
|
166
|
+
expect(parsed.status).toBe("compatible");
|
|
167
|
+
expect(parsed.compatibility.selectedRuntime).toBe("python");
|
|
168
|
+
});
|
|
169
|
+
it("human output includes remediation text per reason", async () => {
|
|
170
|
+
writeManifest(modelsDir, "old-python", UNSUPPORTED_PYTHON_VERSION_MANIFEST("old-python"));
|
|
171
|
+
const program = buildProgram();
|
|
172
|
+
await program.parseAsync(["install", "old-python"], { from: "user" });
|
|
173
|
+
expect(errors.some((e) => e.includes("MANAGED_PYTHON_VERSION_UNSUPPORTED") && e.includes("Use a manifest requesting one of"))).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
it("rejects an unsupported managed Python version manifest", async () => {
|
|
176
|
+
writeManifest(modelsDir, "old-python", UNSUPPORTED_PYTHON_VERSION_MANIFEST("old-python"));
|
|
177
|
+
const program = buildProgram();
|
|
178
|
+
await program.parseAsync(["install", "old-python"], { from: "user" });
|
|
179
|
+
expect(errors.some((e) => e.includes("MANAGED_PYTHON_VERSION_UNSUPPORTED"))).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
it("a docker-gpu-required manifest with unverified GPU access surfaces DOCKER_GPU_UNVERIFIED, not UNAVAILABLE", async () => {
|
|
182
|
+
writeManifest(modelsDir, "gpu-model", DOCKER_GPU_REQUIRED_MANIFEST("gpu-model"));
|
|
183
|
+
// A registered nvidia runtime leaves Tier 1 as "unknown" (unverified) rather than
|
|
184
|
+
// "unavailable" — this is the case a real GPU-capable Docker host would present.
|
|
185
|
+
state.handler = (cmd, args) => {
|
|
186
|
+
if (cmd === "docker" && args[0] === "--version")
|
|
187
|
+
return { code: 0, stdout: "Docker version 24.0.7\n" };
|
|
188
|
+
if (cmd === "docker" && args[0] === "info")
|
|
189
|
+
return { code: 0, stdout: JSON.stringify({ Runtimes: { runc: {}, nvidia: {} } }) };
|
|
190
|
+
if (cmd === "docker" && args[0] === "image")
|
|
191
|
+
return { code: 1, stderr: "No such image" };
|
|
192
|
+
return { code: 127, spawnError: true };
|
|
193
|
+
};
|
|
194
|
+
const program = buildProgram();
|
|
195
|
+
// Declined download (no --yes, no interactive stdin in test) -> stays unverified.
|
|
196
|
+
await program.parseAsync(["install", "gpu-model", "--json"], { from: "user" });
|
|
197
|
+
const parsed = JSON.parse(logs.join("\n"));
|
|
198
|
+
expect(parsed.status).toBe("unsupported");
|
|
199
|
+
const dockerReasons = parsed.compatibility.runtimes[0].reasons.map((r) => r.code);
|
|
200
|
+
expect(dockerReasons).toContain("DOCKER_GPU_UNVERIFIED");
|
|
201
|
+
expect(dockerReasons).not.toContain("DOCKER_GPU_UNAVAILABLE");
|
|
202
|
+
});
|
|
203
|
+
it("--yes confirms the download and a successful pull+probe transitions the install to compatible", async () => {
|
|
204
|
+
writeManifest(modelsDir, "gpu-model", DOCKER_GPU_REQUIRED_MANIFEST("gpu-model"));
|
|
205
|
+
state.handler = (cmd, args) => {
|
|
206
|
+
if (cmd === "docker" && args[0] === "--version")
|
|
207
|
+
return { code: 0, stdout: "Docker version 24.0.7\n" };
|
|
208
|
+
if (cmd === "docker" && args[0] === "info")
|
|
209
|
+
return { code: 0, stdout: JSON.stringify({ Runtimes: { runc: {}, nvidia: {} } }) };
|
|
210
|
+
if (cmd === "docker" && args[0] === "image")
|
|
211
|
+
return { code: 1, stderr: "No such image" }; // not cached
|
|
212
|
+
if (cmd === "docker" && args[0] === "pull")
|
|
213
|
+
return { code: 0, stdout: "Status: Downloaded newer image" };
|
|
214
|
+
if (cmd === "docker" && args[0] === "run")
|
|
215
|
+
return { code: 0, stdout: "GPU 0: NVIDIA A100" };
|
|
216
|
+
return { code: 127, spawnError: true };
|
|
217
|
+
};
|
|
218
|
+
const program = buildProgram();
|
|
219
|
+
await program.parseAsync(["install", "gpu-model", "--json", "--yes"], { from: "user" });
|
|
220
|
+
const parsed = JSON.parse(logs.join("\n"));
|
|
221
|
+
expect(parsed.status).toBe("compatible");
|
|
222
|
+
expect(parsed.selectedRuntime).toBe("docker");
|
|
223
|
+
});
|
|
224
|
+
it("unknown model prints a message and writes no files", async () => {
|
|
225
|
+
const before = dirSnapshot(moldeskHome);
|
|
226
|
+
const program = buildProgram();
|
|
227
|
+
await program.parseAsync(["install", "does-not-exist"], { from: "user" });
|
|
228
|
+
expect(dirSnapshot(moldeskHome)).toEqual(before);
|
|
229
|
+
expect(errors.some((e) => e.includes("Unknown model"))).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
});
|
package/dist/commands/list.js
CHANGED
|
@@ -1,20 +1,72 @@
|
|
|
1
|
-
import { listAvailableModels, listInstalledModels } from "@moldesk/core";
|
|
1
|
+
import { evaluateCompatibility, getSystemReport, listAvailableModels, listInstalledModels, shortReason, } from "@moldesk/core";
|
|
2
|
+
function parseRuntime(value) {
|
|
3
|
+
if (value === "python" || value === "docker")
|
|
4
|
+
return value;
|
|
5
|
+
console.error(`Invalid --runtime "${value}". Expected python|docker.`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
throw new Error("unreachable");
|
|
8
|
+
}
|
|
2
9
|
export function registerListCommand(program) {
|
|
3
10
|
program
|
|
4
11
|
.command("list")
|
|
5
|
-
.description("List available
|
|
6
|
-
.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
.description("List available models with compatibility")
|
|
13
|
+
.option("--json", "print raw manifests + compatibility results as JSON")
|
|
14
|
+
.option("--runtime <kind>", "constrain compatibility selection to python|docker")
|
|
15
|
+
.option("--installed", "show installed models only")
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
try {
|
|
18
|
+
const requestedRuntime = options.runtime ? parseRuntime(options.runtime) : undefined;
|
|
19
|
+
const available = listAvailableModels();
|
|
20
|
+
const installed = listInstalledModels();
|
|
21
|
+
const installedNames = new Set(installed.map((m) => m.model));
|
|
22
|
+
if (options.installed) {
|
|
23
|
+
if (options.json) {
|
|
24
|
+
console.log(JSON.stringify({ installed }, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (installed.length === 0) {
|
|
28
|
+
console.log("No models installed yet.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const model of installed) {
|
|
32
|
+
console.log(` ${model.model} ${model.modelVersion} (${model.runtime.kind}, ${model.status})`);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (available.length === 0) {
|
|
37
|
+
if (options.json) {
|
|
38
|
+
console.log(JSON.stringify([], null, 2));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log("No models found in the registry.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const report = await getSystemReport();
|
|
45
|
+
const rows = available.map((manifest) => {
|
|
46
|
+
const compatibility = evaluateCompatibility(manifest, report, { requestedRuntime });
|
|
47
|
+
return { manifest, compatibility };
|
|
48
|
+
});
|
|
49
|
+
if (options.json) {
|
|
50
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const header = `${"MODEL".padEnd(14)} ${"CATEGORY".padEnd(21)} ${"STATUS".padEnd(11)} COMPATIBILITY`;
|
|
54
|
+
console.log(header);
|
|
55
|
+
for (const { manifest, compatibility } of rows) {
|
|
56
|
+
const installedMark = installedNames.has(manifest.name) ? "*" : " ";
|
|
57
|
+
const compat = compatibility.status === "compatible"
|
|
58
|
+
? "compatible"
|
|
59
|
+
: compatibility.status === "warning"
|
|
60
|
+
? `warning: ${shortReason(compatibility)}`
|
|
61
|
+
: manifest.status === "planned"
|
|
62
|
+
? "not installable"
|
|
63
|
+
: `unsupported: ${shortReason(compatibility)}`;
|
|
64
|
+
console.log(`${installedMark}${manifest.name.padEnd(13)} ${manifest.category.padEnd(21)} ${manifest.status.padEnd(11)} ${compat}`);
|
|
65
|
+
}
|
|
13
66
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
console.log(` ${model.name.padEnd(12)} ${model.version.padEnd(10)} ${status}`);
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
69
|
+
process.exitCode = 1;
|
|
18
70
|
}
|
|
19
71
|
});
|
|
20
72
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { createFakeSpawn } from "../testSupport/spawnMock.js";
|
|
7
|
+
import { registerListCommand } from "./list.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
|
+
function writeManifest(modelsDir, name, yaml) {
|
|
15
|
+
const dir = path.join(modelsDir, name);
|
|
16
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(dir, "manifest.yaml"), yaml);
|
|
18
|
+
}
|
|
19
|
+
const CPU_PYTHON_MANIFEST = (name) => `
|
|
20
|
+
schemaVersion: 1
|
|
21
|
+
name: ${name}
|
|
22
|
+
displayName: Test CPU Model
|
|
23
|
+
modelVersion: "1.0.0"
|
|
24
|
+
adapterVersion: "0.1.0"
|
|
25
|
+
description: test
|
|
26
|
+
category: other
|
|
27
|
+
status: beta
|
|
28
|
+
runtimes:
|
|
29
|
+
- kind: python
|
|
30
|
+
python: "3.11"
|
|
31
|
+
installer: uv
|
|
32
|
+
requirements:
|
|
33
|
+
- name: torch
|
|
34
|
+
version: "2.2.1"
|
|
35
|
+
hardware:
|
|
36
|
+
platforms: [darwin-arm64, linux-x64]
|
|
37
|
+
input:
|
|
38
|
+
formats: [".pdb"]
|
|
39
|
+
required: true
|
|
40
|
+
outputs:
|
|
41
|
+
- id: out
|
|
42
|
+
glob: "*.txt"
|
|
43
|
+
required: true
|
|
44
|
+
`;
|
|
45
|
+
const UNSUPPORTED_PYTHON_VERSION_MANIFEST = (name) => `
|
|
46
|
+
schemaVersion: 1
|
|
47
|
+
name: ${name}
|
|
48
|
+
displayName: Test Old Python Model
|
|
49
|
+
modelVersion: "1.0.0"
|
|
50
|
+
adapterVersion: "0.1.0"
|
|
51
|
+
description: test
|
|
52
|
+
category: other
|
|
53
|
+
status: beta
|
|
54
|
+
runtimes:
|
|
55
|
+
- kind: python
|
|
56
|
+
python: "3.8"
|
|
57
|
+
installer: uv
|
|
58
|
+
requirements:
|
|
59
|
+
- name: torch
|
|
60
|
+
version: "2.2.1"
|
|
61
|
+
hardware:
|
|
62
|
+
platforms: [darwin-arm64, linux-x64]
|
|
63
|
+
input:
|
|
64
|
+
formats: [".pdb"]
|
|
65
|
+
required: true
|
|
66
|
+
outputs:
|
|
67
|
+
- id: out
|
|
68
|
+
glob: "*.txt"
|
|
69
|
+
required: true
|
|
70
|
+
`;
|
|
71
|
+
const DOCKER_GPU_REQUIRED_MANIFEST = (name) => `
|
|
72
|
+
schemaVersion: 1
|
|
73
|
+
name: ${name}
|
|
74
|
+
displayName: Test Docker GPU Model
|
|
75
|
+
modelVersion: "1.0.0"
|
|
76
|
+
adapterVersion: "0.1.0"
|
|
77
|
+
description: test
|
|
78
|
+
category: other
|
|
79
|
+
status: beta
|
|
80
|
+
runtimes:
|
|
81
|
+
- kind: docker
|
|
82
|
+
image: example/test:latest
|
|
83
|
+
digest: "sha256:${"b".repeat(64)}"
|
|
84
|
+
gpu: required
|
|
85
|
+
hardware:
|
|
86
|
+
platforms: [darwin-arm64, linux-x64]
|
|
87
|
+
input:
|
|
88
|
+
formats: [".pdb"]
|
|
89
|
+
required: true
|
|
90
|
+
outputs:
|
|
91
|
+
- id: out
|
|
92
|
+
glob: "*.txt"
|
|
93
|
+
required: true
|
|
94
|
+
`;
|
|
95
|
+
let modelsDir;
|
|
96
|
+
let logs;
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
modelsDir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-models-"));
|
|
99
|
+
process.env["MOLDESK_MODELS_DIR"] = modelsDir;
|
|
100
|
+
logs = [];
|
|
101
|
+
vi.spyOn(console, "log").mockImplementation((msg) => {
|
|
102
|
+
logs.push(String(msg));
|
|
103
|
+
});
|
|
104
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
105
|
+
// Nvidia runtime registered but never explicitly probed/pulled by `list`.
|
|
106
|
+
state.handler = (cmd, args) => {
|
|
107
|
+
if (cmd === "docker" && args[0] === "--version")
|
|
108
|
+
return { code: 0, stdout: "Docker version 24.0.7\n" };
|
|
109
|
+
if (cmd === "docker" && args[0] === "info")
|
|
110
|
+
return { code: 0, stdout: JSON.stringify({ Runtimes: { runc: {}, nvidia: {} } }) };
|
|
111
|
+
if (cmd === "docker")
|
|
112
|
+
throw new Error(`list must never run/pull docker images (got: ${args.join(" ")})`);
|
|
113
|
+
return { code: 127, spawnError: true };
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
vi.restoreAllMocks();
|
|
118
|
+
delete process.env["MOLDESK_MODELS_DIR"];
|
|
119
|
+
fs.rmSync(modelsDir, { recursive: true, force: true });
|
|
120
|
+
});
|
|
121
|
+
function buildProgram() {
|
|
122
|
+
const program = new Command();
|
|
123
|
+
program.exitOverride();
|
|
124
|
+
registerListCommand(program);
|
|
125
|
+
return program;
|
|
126
|
+
}
|
|
127
|
+
describe("list (real command, faked process boundary only)", () => {
|
|
128
|
+
it("never runs or pulls a Docker image, even for a docker-gpu-required manifest", async () => {
|
|
129
|
+
writeManifest(modelsDir, "gpu-model", DOCKER_GPU_REQUIRED_MANIFEST("gpu-model"));
|
|
130
|
+
const program = buildProgram();
|
|
131
|
+
await program.parseAsync(["list"], { from: "user" });
|
|
132
|
+
// The handler above throws if `docker run`/`docker pull` is ever invoked;
|
|
133
|
+
// reaching this line without a thrown error is the assertion.
|
|
134
|
+
expect(logs.join("\n")).toContain("gpu-model");
|
|
135
|
+
});
|
|
136
|
+
it("--json output is valid JSON and includes a per-runtime breakdown for each row", async () => {
|
|
137
|
+
writeManifest(modelsDir, "good-python", CPU_PYTHON_MANIFEST("good-python"));
|
|
138
|
+
writeManifest(modelsDir, "old-python", UNSUPPORTED_PYTHON_VERSION_MANIFEST("old-python"));
|
|
139
|
+
const program = buildProgram();
|
|
140
|
+
await program.parseAsync(["list", "--json"], { from: "user" });
|
|
141
|
+
const rows = JSON.parse(logs.join("\n"));
|
|
142
|
+
expect(rows).toHaveLength(2);
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
expect(Array.isArray(row.compatibility.runtimes)).toBe(true);
|
|
145
|
+
expect(row.compatibility.runtimes.length).toBeGreaterThan(0);
|
|
146
|
+
}
|
|
147
|
+
const goodRow = rows.find((r) => r.manifest.name === "good-python");
|
|
148
|
+
const oldRow = rows.find((r) => r.manifest.name === "old-python");
|
|
149
|
+
expect(goodRow?.compatibility.status).toBe("compatible");
|
|
150
|
+
expect(oldRow?.compatibility.status).toBe("unsupported");
|
|
151
|
+
});
|
|
152
|
+
it("human-mode table's compatibility column matches evaluateCompatibility's status for compatible/unsupported/docker-unverified rows", async () => {
|
|
153
|
+
writeManifest(modelsDir, "good-python", CPU_PYTHON_MANIFEST("good-python"));
|
|
154
|
+
writeManifest(modelsDir, "old-python", UNSUPPORTED_PYTHON_VERSION_MANIFEST("old-python"));
|
|
155
|
+
writeManifest(modelsDir, "gpu-model", DOCKER_GPU_REQUIRED_MANIFEST("gpu-model"));
|
|
156
|
+
const program = buildProgram();
|
|
157
|
+
await program.parseAsync(["list"], { from: "user" });
|
|
158
|
+
const lines = logs.join("\n").split("\n");
|
|
159
|
+
const line = (name) => lines.find((l) => l.includes(name));
|
|
160
|
+
expect(line("good-python")).toMatch(/\bcompatible\b/);
|
|
161
|
+
expect(line("old-python")).toMatch(/unsupported: MANAGED_PYTHON_VERSION_UNSUPPORTED/);
|
|
162
|
+
expect(line("gpu-model")).toMatch(/unsupported: DOCKER_GPU_UNVERIFIED/);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/dist/commands/run.js
CHANGED
|
@@ -1,9 +1,93 @@
|
|
|
1
|
+
import { MoldeskError, runModel } from "@moldesk/core";
|
|
2
|
+
function parseRuntime(value) {
|
|
3
|
+
if (value === "python" || value === "docker")
|
|
4
|
+
return value;
|
|
5
|
+
console.error(`Invalid --runtime "${value}". Expected python|docker.`);
|
|
6
|
+
process.exit(2);
|
|
7
|
+
throw new Error("unreachable");
|
|
8
|
+
}
|
|
9
|
+
function collectParam(value, previous) {
|
|
10
|
+
const eq = value.indexOf("=");
|
|
11
|
+
if (eq <= 0) {
|
|
12
|
+
console.error(`Invalid --param "${value}". Expected key=value.`);
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
return { ...previous, [value.slice(0, eq)]: value.slice(eq + 1) };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Maps `MoldeskError.code` for usage errors thrown *before* a run directory is
|
|
19
|
+
* allocated (spec §4.2 steps 1-3). Run outcomes (succeeded/failed/cancelled)
|
|
20
|
+
* never throw — their exit code (0/7/8) comes from `result.status` instead.
|
|
21
|
+
*/
|
|
22
|
+
const EXIT_CODES = {
|
|
23
|
+
UNKNOWN_MODEL: 3,
|
|
24
|
+
ADAPTER_NOT_FOUND: 3,
|
|
25
|
+
MODEL_NOT_INSTALLED: 5,
|
|
26
|
+
INVALID_RUN_PARAMS: 2,
|
|
27
|
+
INVALID_RUN_INPUT: 2,
|
|
28
|
+
RUN_RUNTIME_SELECTION_REQUIRED: 2,
|
|
29
|
+
RUNTIME_EXECUTION_UNSUPPORTED: 4,
|
|
30
|
+
};
|
|
31
|
+
function exitCodeFor(error) {
|
|
32
|
+
if (error instanceof MoldeskError && error.code in EXIT_CODES)
|
|
33
|
+
return EXIT_CODES[error.code];
|
|
34
|
+
return 10;
|
|
35
|
+
}
|
|
1
36
|
export function registerRunCommand(program) {
|
|
2
37
|
program
|
|
3
38
|
.command("run <model> <input>")
|
|
4
|
-
.description("Run a model against an input file
|
|
5
|
-
.
|
|
6
|
-
|
|
7
|
-
|
|
39
|
+
.description("Run a model against an input file")
|
|
40
|
+
.option("--output <directory>", "copy declared outputs to this directory")
|
|
41
|
+
.option("--runtime <kind>", "select runtime python|docker (no silent fallback)")
|
|
42
|
+
.option("--param <key=value>", "set a model parameter (repeatable)", collectParam, {})
|
|
43
|
+
.option("--json", "print the run result as JSON")
|
|
44
|
+
.action(async (model, input, options) => {
|
|
45
|
+
const runtime = options.runtime ? parseRuntime(options.runtime) : undefined;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const onSignal = () => controller.abort();
|
|
48
|
+
process.once("SIGINT", onSignal);
|
|
49
|
+
process.once("SIGTERM", onSignal);
|
|
50
|
+
try {
|
|
51
|
+
const result = await runModel(model, input, {
|
|
52
|
+
runtime,
|
|
53
|
+
params: options.param,
|
|
54
|
+
outputDir: options.output,
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
onLog: options.json
|
|
57
|
+
? undefined
|
|
58
|
+
: (stream, chunk) => (stream === "stdout" ? process.stdout : process.stderr).write(chunk),
|
|
59
|
+
});
|
|
60
|
+
if (options.json) {
|
|
61
|
+
console.log(JSON.stringify({ model, ...result }, null, 2));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.log(`${result.record.model}: ${result.status}.`);
|
|
65
|
+
for (const output of result.record.outputs)
|
|
66
|
+
console.log(` ${output.id}: ${output.path}`);
|
|
67
|
+
if (result.record.error)
|
|
68
|
+
console.error(` ${result.record.error.code}: ${result.record.error.message}`);
|
|
69
|
+
if (result.exportError)
|
|
70
|
+
console.error(` --output copy failed: ${result.exportError.message}`);
|
|
71
|
+
console.log(` Run directory: ${result.record.command.cwd}`);
|
|
72
|
+
}
|
|
73
|
+
if (result.status === "cancelled")
|
|
74
|
+
process.exitCode = 8;
|
|
75
|
+
else if (result.status === "failed" || result.exportError)
|
|
76
|
+
process.exitCode = 7;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
if (options.json) {
|
|
81
|
+
console.log(JSON.stringify({ model, status: "error", message, code: error instanceof MoldeskError ? error.code : undefined }, null, 2));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
console.error(message);
|
|
85
|
+
}
|
|
86
|
+
process.exitCode = exitCodeFor(error);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
process.removeListener("SIGINT", onSignal);
|
|
90
|
+
process.removeListener("SIGTERM", onSignal);
|
|
91
|
+
}
|
|
8
92
|
});
|
|
9
93
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|