@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.
- 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,136 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { registerRunCommand } from "./run.js";
|
|
4
|
+
const state = vi.hoisted(() => ({ runModel: vi.fn() }));
|
|
5
|
+
vi.mock("@moldesk/core", async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal();
|
|
7
|
+
return { ...actual, runModel: state.runModel };
|
|
8
|
+
});
|
|
9
|
+
class ProcessExitSignal extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super(`process.exit(${code})`);
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function buildProgram() {
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program.exitOverride();
|
|
19
|
+
registerRunCommand(program);
|
|
20
|
+
return program;
|
|
21
|
+
}
|
|
22
|
+
const baseRecord = {
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
id: "run-1",
|
|
25
|
+
status: "succeeded",
|
|
26
|
+
model: "proteinmpnn",
|
|
27
|
+
modelVersion: "v_48_020",
|
|
28
|
+
adapterVersion: "0.1.0",
|
|
29
|
+
moldeskVersion: "0.0.1",
|
|
30
|
+
runtime: "python",
|
|
31
|
+
installationFingerprint: "python-abc",
|
|
32
|
+
manifestSha256: "abc",
|
|
33
|
+
hardware: {},
|
|
34
|
+
input: [],
|
|
35
|
+
parameters: { supplied: {}, effective: {} },
|
|
36
|
+
command: { executable: "python", args: [], cwd: "/runs/run-1" },
|
|
37
|
+
startedAt: new Date().toISOString(),
|
|
38
|
+
outputs: [],
|
|
39
|
+
logs: { stdout: "/runs/run-1/stdout.log", stderr: "/runs/run-1/stderr.log" },
|
|
40
|
+
};
|
|
41
|
+
let logs;
|
|
42
|
+
let errors;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
logs = [];
|
|
45
|
+
errors = [];
|
|
46
|
+
process.exitCode = undefined;
|
|
47
|
+
vi.spyOn(console, "log").mockImplementation((msg) => {
|
|
48
|
+
logs.push(String(msg));
|
|
49
|
+
});
|
|
50
|
+
vi.spyOn(console, "error").mockImplementation((msg) => {
|
|
51
|
+
errors.push(String(msg));
|
|
52
|
+
});
|
|
53
|
+
state.runModel.mockReset();
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
process.exitCode = undefined;
|
|
58
|
+
});
|
|
59
|
+
describe("run (CLI, faked runModel)", () => {
|
|
60
|
+
it("prints the run record as JSON and leaves exit code 0 on success", async () => {
|
|
61
|
+
state.runModel.mockResolvedValue({ status: "succeeded", record: baseRecord });
|
|
62
|
+
const program = buildProgram();
|
|
63
|
+
await program.parseAsync(["run", "proteinmpnn", "input.pdb", "--json"], { from: "user" });
|
|
64
|
+
const parsed = JSON.parse(logs.join("\n"));
|
|
65
|
+
expect(parsed.status).toBe("succeeded");
|
|
66
|
+
expect(process.exitCode).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
it("sets exit code 7 when the run failed", async () => {
|
|
69
|
+
state.runModel.mockResolvedValue({
|
|
70
|
+
status: "failed",
|
|
71
|
+
record: { ...baseRecord, status: "failed", error: { code: "RUN_EXECUTION_FAILED", message: "exit 1" } },
|
|
72
|
+
});
|
|
73
|
+
const program = buildProgram();
|
|
74
|
+
await program.parseAsync(["run", "proteinmpnn", "input.pdb", "--json"], { from: "user" });
|
|
75
|
+
expect(process.exitCode).toBe(7);
|
|
76
|
+
});
|
|
77
|
+
it("sets exit code 8 when the run was cancelled", async () => {
|
|
78
|
+
state.runModel.mockResolvedValue({
|
|
79
|
+
status: "cancelled",
|
|
80
|
+
record: { ...baseRecord, status: "cancelled", error: { code: "RUN_CANCELLED", message: "cancelled" } },
|
|
81
|
+
});
|
|
82
|
+
const program = buildProgram();
|
|
83
|
+
await program.parseAsync(["run", "proteinmpnn", "input.pdb", "--json"], { from: "user" });
|
|
84
|
+
expect(process.exitCode).toBe(8);
|
|
85
|
+
});
|
|
86
|
+
it("sets exit code 7 for a succeeded run whose --output copy collided", async () => {
|
|
87
|
+
state.runModel.mockResolvedValue({
|
|
88
|
+
status: "succeeded",
|
|
89
|
+
record: baseRecord,
|
|
90
|
+
exportError: { code: "OUTPUT_COLLISION", message: "already exists" },
|
|
91
|
+
});
|
|
92
|
+
const program = buildProgram();
|
|
93
|
+
await program.parseAsync(["run", "proteinmpnn", "input.pdb", "--output", "/tmp/out", "--json"], { from: "user" });
|
|
94
|
+
expect(process.exitCode).toBe(7);
|
|
95
|
+
const parsed = JSON.parse(logs.join("\n"));
|
|
96
|
+
expect(parsed.status).toBe("succeeded");
|
|
97
|
+
});
|
|
98
|
+
it("maps a thrown UNKNOWN_MODEL error to exit code 3", async () => {
|
|
99
|
+
const { MoldeskError } = await import("@moldesk/core");
|
|
100
|
+
state.runModel.mockRejectedValue(new MoldeskError({ code: "UNKNOWN_MODEL", message: 'Unknown model "bogus".' }));
|
|
101
|
+
const program = buildProgram();
|
|
102
|
+
await program.parseAsync(["run", "bogus", "input.pdb", "--json"], { from: "user" });
|
|
103
|
+
expect(process.exitCode).toBe(3);
|
|
104
|
+
const parsed = JSON.parse(logs.join("\n"));
|
|
105
|
+
expect(parsed.code).toBe("UNKNOWN_MODEL");
|
|
106
|
+
});
|
|
107
|
+
it("maps a thrown MODEL_NOT_INSTALLED error to exit code 5", async () => {
|
|
108
|
+
const { MoldeskError } = await import("@moldesk/core");
|
|
109
|
+
state.runModel.mockRejectedValue(new MoldeskError({ code: "MODEL_NOT_INSTALLED", message: "not installed" }));
|
|
110
|
+
const program = buildProgram();
|
|
111
|
+
await program.parseAsync(["run", "proteinmpnn", "input.pdb"], { from: "user" });
|
|
112
|
+
expect(process.exitCode).toBe(5);
|
|
113
|
+
expect(errors.some((e) => e.includes("not installed"))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
it("maps an unrecognized thrown error to exit code 10", async () => {
|
|
116
|
+
state.runModel.mockRejectedValue(new Error("boom"));
|
|
117
|
+
const program = buildProgram();
|
|
118
|
+
await program.parseAsync(["run", "proteinmpnn", "input.pdb"], { from: "user" });
|
|
119
|
+
expect(process.exitCode).toBe(10);
|
|
120
|
+
});
|
|
121
|
+
it("forwards --param key=value pairs to runModel", async () => {
|
|
122
|
+
state.runModel.mockResolvedValue({ status: "succeeded", record: baseRecord });
|
|
123
|
+
const program = buildProgram();
|
|
124
|
+
await program.parseAsync(["run", "proteinmpnn", "input.pdb", "--param", "seed=1", "--param", "ca_only=true", "--json"], { from: "user" });
|
|
125
|
+
expect(state.runModel).toHaveBeenCalledWith("proteinmpnn", "input.pdb", expect.objectContaining({ params: { seed: "1", ca_only: "true" } }));
|
|
126
|
+
});
|
|
127
|
+
it("rejects a malformed --param with a usage error (exit 2) before calling runModel", async () => {
|
|
128
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
129
|
+
throw new ProcessExitSignal(typeof code === "number" ? code : undefined);
|
|
130
|
+
});
|
|
131
|
+
const program = buildProgram();
|
|
132
|
+
await expect(program.parseAsync(["run", "proteinmpnn", "input.pdb", "--param", "no-equals-sign"], { from: "user" })).rejects.toThrow(ProcessExitSignal);
|
|
133
|
+
expect(exitSpy).toHaveBeenCalledWith(2);
|
|
134
|
+
expect(state.runModel).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { uninstallModel } from "@moldesk/core";
|
|
3
|
+
function parseRuntime(value) {
|
|
4
|
+
if (value === "python" || value === "docker")
|
|
5
|
+
return value;
|
|
6
|
+
throw new Error(`Invalid --runtime "${value}". Expected python|docker.`);
|
|
7
|
+
}
|
|
8
|
+
async function confirmUninstall(model) {
|
|
9
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
10
|
+
return false;
|
|
11
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
try {
|
|
13
|
+
const answer = await rl.question(`Uninstall ${model} and its isolated environments? [y/N] `);
|
|
14
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
rl.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function registerUninstallCommand(program) {
|
|
21
|
+
program
|
|
22
|
+
.command("uninstall <model>")
|
|
23
|
+
.description("Remove a model's managed installations (shared download cache is retained)")
|
|
24
|
+
.option("--runtime <kind>", "remove only the selected python|docker runtime")
|
|
25
|
+
.option("--all", "remove every installed runtime variant")
|
|
26
|
+
.option("--yes", "uninstall without prompting")
|
|
27
|
+
.option("--json", "print result as JSON")
|
|
28
|
+
.action(async (model, options) => {
|
|
29
|
+
if (options.runtime && options.all) {
|
|
30
|
+
const message = "Use either --runtime or --all, not both.";
|
|
31
|
+
if (options.json)
|
|
32
|
+
console.log(JSON.stringify({ model, status: "failed", message }, null, 2));
|
|
33
|
+
else
|
|
34
|
+
console.error(message);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!options.yes && !await confirmUninstall(model)) {
|
|
39
|
+
if (options.json)
|
|
40
|
+
console.log(JSON.stringify({ model, status: "cancelled" }, null, 2));
|
|
41
|
+
else
|
|
42
|
+
console.log("Uninstall cancelled. Re-run with --yes for non-interactive use.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const removed = await uninstallModel(model, {
|
|
47
|
+
runtime: options.runtime ? parseRuntime(options.runtime) : undefined,
|
|
48
|
+
all: options.all,
|
|
49
|
+
});
|
|
50
|
+
const status = removed.length > 0 ? "uninstalled" : "not-installed";
|
|
51
|
+
if (options.json)
|
|
52
|
+
console.log(JSON.stringify({ model, status, removed }, null, 2));
|
|
53
|
+
else
|
|
54
|
+
console.log(removed.length > 0 ? `${model}: uninstalled ${removed.length} environment(s).` : `${model} is not installed.`);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
if (options.json)
|
|
59
|
+
console.log(JSON.stringify({ model, status: "failed", message }, null, 2));
|
|
60
|
+
else
|
|
61
|
+
console.error(`Uninstall failed: ${message}`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerDoctorCommand } from "./commands/doctor.js";
|
|
|
5
5
|
import { registerListCommand } from "./commands/list.js";
|
|
6
6
|
import { registerInstallCommand } from "./commands/install.js";
|
|
7
7
|
import { registerRunCommand } from "./commands/run.js";
|
|
8
|
+
import { registerUninstallCommand } from "./commands/uninstall.js";
|
|
8
9
|
const program = new Command();
|
|
9
10
|
program
|
|
10
11
|
.name("moldesk")
|
|
@@ -13,5 +14,6 @@ program
|
|
|
13
14
|
registerDoctorCommand(program);
|
|
14
15
|
registerListCommand(program);
|
|
15
16
|
registerInstallCommand(program);
|
|
17
|
+
registerUninstallCommand(program);
|
|
16
18
|
registerRunCommand(program);
|
|
17
19
|
program.parse();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
export interface FakeSpawnResult {
|
|
3
|
+
code?: number;
|
|
4
|
+
stdout?: string;
|
|
5
|
+
stderr?: string;
|
|
6
|
+
/** Simulate `spawn` itself failing (command not on PATH) instead of the process exiting non-zero. */
|
|
7
|
+
spawnError?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export type FakeSpawnHandler = (command: string, args: string[]) => FakeSpawnResult;
|
|
10
|
+
/**
|
|
11
|
+
* Minimal fake `child_process.ChildProcess` good enough for
|
|
12
|
+
* `packages/runtime/src/process/index.ts`'s `runCommand` — stdout/stderr
|
|
13
|
+
* event emitters plus `error`/`close`. Used to fake the process boundary
|
|
14
|
+
* (docker/nvidia-smi/nvcc/python3/which) in CLI-level tests without mocking
|
|
15
|
+
* `@moldesk/core` itself.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createFakeSpawn(handler: FakeSpawnHandler): (command: string, args: string[]) => EventEmitter<[never]> & {
|
|
18
|
+
stdout: EventEmitter;
|
|
19
|
+
stderr: EventEmitter;
|
|
20
|
+
kill: () => void;
|
|
21
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal fake `child_process.ChildProcess` good enough for
|
|
4
|
+
* `packages/runtime/src/process/index.ts`'s `runCommand` — stdout/stderr
|
|
5
|
+
* event emitters plus `error`/`close`. Used to fake the process boundary
|
|
6
|
+
* (docker/nvidia-smi/nvcc/python3/which) in CLI-level tests without mocking
|
|
7
|
+
* `@moldesk/core` itself.
|
|
8
|
+
*/
|
|
9
|
+
export function createFakeSpawn(handler) {
|
|
10
|
+
return (command, args) => {
|
|
11
|
+
const child = new EventEmitter();
|
|
12
|
+
child.stdout = new EventEmitter();
|
|
13
|
+
child.stderr = new EventEmitter();
|
|
14
|
+
child.kill = () => undefined;
|
|
15
|
+
const result = handler(command, args);
|
|
16
|
+
queueMicrotask(() => {
|
|
17
|
+
if (result.spawnError) {
|
|
18
|
+
child.emit("error", new Error("spawn ENOENT"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (result.stdout)
|
|
22
|
+
child.stdout.emit("data", Buffer.from(result.stdout));
|
|
23
|
+
if (result.stderr)
|
|
24
|
+
child.stderr.emit("data", Buffer.from(result.stderr));
|
|
25
|
+
child.emit("close", result.code ?? 0);
|
|
26
|
+
});
|
|
27
|
+
return child;
|
|
28
|
+
};
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moldesk/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "MoleculeDesk CLI — install and run computational biology models",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,16 +21,18 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"commander": "^12.1.0",
|
|
24
|
-
"@moldesk/core": "0.0.
|
|
24
|
+
"@moldesk/core": "0.0.3"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"typescript": "^5.6.3",
|
|
28
28
|
"tsx": "^4.19.1",
|
|
29
|
-
"@types/node": "^20.16.11"
|
|
29
|
+
"@types/node": "^20.16.11",
|
|
30
|
+
"vitest": "^3.0.0"
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|
|
32
33
|
"dev": "tsx src/index.ts",
|
|
33
34
|
"build": "tsc -p tsconfig.json",
|
|
34
|
-
"typecheck": "tsc --noEmit"
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
35
37
|
}
|
|
36
38
|
}
|