@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,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,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerUninstallCommand(program: Command): void;
@@ -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.1",
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.1"
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
  }