@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.
@@ -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
+ });
@@ -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 and installed models")
6
- .action(() => {
7
- const available = listAvailableModels();
8
- const installed = listInstalledModels();
9
- const installedNames = new Set(installed.map((m) => m.name));
10
- if (available.length === 0) {
11
- console.log("No models found in the registry.");
12
- return;
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
- console.log("Available models:\n");
15
- for (const model of available) {
16
- const status = installedNames.has(model.name) ? "installed" : "not installed";
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
+ });
@@ -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 (coming soon)")
5
- .action((model, input) => {
6
- console.log(`Running "${model}" on "${input}" is not yet supported.`);
7
- console.log("Track progress at https://github.com/pooriyapfn/moldesk");
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 {};