@moldesk/cli 0.0.3 → 0.0.5
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/run.js +9 -0
- package/dist/commands/run.test.js +31 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +50 -0
- package/dist/commands/view.test.d.ts +1 -0
- package/dist/commands/view.test.js +93 -0
- package/dist/commands/visualizer.d.ts +2 -0
- package/dist/commands/visualizer.js +109 -0
- package/dist/commands/visualizer.test.d.ts +1 -0
- package/dist/commands/visualizer.test.js +171 -0
- package/dist/index.js +4 -0
- package/dist/utils/report-errors.d.ts +2 -0
- package/dist/utils/report-errors.js +23 -0
- package/package.json +2 -2
package/dist/commands/run.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
1
2
|
import { MoldeskError, runModel } from "@moldesk/core";
|
|
2
3
|
function parseRuntime(value) {
|
|
3
4
|
if (value === "python" || value === "docker")
|
|
@@ -40,6 +41,7 @@ export function registerRunCommand(program) {
|
|
|
40
41
|
.option("--output <directory>", "copy declared outputs to this directory")
|
|
41
42
|
.option("--runtime <kind>", "select runtime python|docker (no silent fallback)")
|
|
42
43
|
.option("--param <key=value>", "set a model parameter (repeatable)", collectParam, {})
|
|
44
|
+
.option("--no-visualize", "skip creating the offline molecular report")
|
|
43
45
|
.option("--json", "print the run result as JSON")
|
|
44
46
|
.action(async (model, input, options) => {
|
|
45
47
|
const runtime = options.runtime ? parseRuntime(options.runtime) : undefined;
|
|
@@ -52,6 +54,7 @@ export function registerRunCommand(program) {
|
|
|
52
54
|
runtime,
|
|
53
55
|
params: options.param,
|
|
54
56
|
outputDir: options.output,
|
|
57
|
+
visualize: options.visualize,
|
|
55
58
|
signal: controller.signal,
|
|
56
59
|
onLog: options.json
|
|
57
60
|
? undefined
|
|
@@ -64,6 +67,12 @@ export function registerRunCommand(program) {
|
|
|
64
67
|
console.log(`${result.record.model}: ${result.status}.`);
|
|
65
68
|
for (const output of result.record.outputs)
|
|
66
69
|
console.log(` ${output.id}: ${output.path}`);
|
|
70
|
+
for (const artifact of result.record.artifacts ?? []) {
|
|
71
|
+
console.log(` report: ${artifact.path}`);
|
|
72
|
+
console.log(` Open in your browser: ${pathToFileURL(artifact.path).href}`);
|
|
73
|
+
}
|
|
74
|
+
for (const warning of result.record.warnings ?? [])
|
|
75
|
+
console.error(` Warning (${warning.code}): ${warning.message}`);
|
|
67
76
|
if (result.record.error)
|
|
68
77
|
console.error(` ${result.record.error.code}: ${result.record.error.message}`);
|
|
69
78
|
if (result.exportError)
|
|
@@ -133,4 +133,35 @@ describe("run (CLI, faked runModel)", () => {
|
|
|
133
133
|
expect(exitSpy).toHaveBeenCalledWith(2);
|
|
134
134
|
expect(state.runModel).not.toHaveBeenCalled();
|
|
135
135
|
});
|
|
136
|
+
it("defaults to visualizing and maps --no-visualize to visualize: false", async () => {
|
|
137
|
+
state.runModel.mockResolvedValue({ status: "succeeded", record: baseRecord });
|
|
138
|
+
await buildProgram().parseAsync(["run", "boltz", "job.yaml", "--json"], { from: "user" });
|
|
139
|
+
expect(state.runModel).toHaveBeenLastCalledWith("boltz", "job.yaml", expect.objectContaining({ visualize: true }));
|
|
140
|
+
await buildProgram().parseAsync(["run", "boltz", "job.yaml", "--no-visualize", "--json"], { from: "user" });
|
|
141
|
+
expect(state.runModel).toHaveBeenLastCalledWith("boltz", "job.yaml", expect.objectContaining({ visualize: false }));
|
|
142
|
+
});
|
|
143
|
+
it("prints the report path and an encoded file URL, and a warning keeps exit code 0", async () => {
|
|
144
|
+
state.runModel.mockResolvedValue({
|
|
145
|
+
status: "succeeded",
|
|
146
|
+
record: {
|
|
147
|
+
...baseRecord,
|
|
148
|
+
artifacts: [{ id: "molecular-report", kind: "molecular-report", path: "/runs/my run/artifacts/viewer.html", sha256: "a", sizeBytes: 1, sourceSha256: [], viewerVersion: "1", bundleSha256: "b" }],
|
|
149
|
+
warnings: [{ code: "VISUALIZATION_FAILED", message: "Could not create the molecular report: boom" }],
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
await buildProgram().parseAsync(["run", "boltz", "job.yaml"], { from: "user" });
|
|
153
|
+
expect(logs).toContain(" report: /runs/my run/artifacts/viewer.html");
|
|
154
|
+
expect(logs).toContain(" Open in your browser: file:///runs/my%20run/artifacts/viewer.html");
|
|
155
|
+
expect(errors.join("\n")).toContain("Warning (VISUALIZATION_FAILED): Could not create the molecular report: boom");
|
|
156
|
+
expect(process.exitCode).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
it("keeps --json parseable with artifacts and warnings present", async () => {
|
|
159
|
+
state.runModel.mockResolvedValue({
|
|
160
|
+
status: "succeeded",
|
|
161
|
+
record: { ...baseRecord, warnings: [{ code: "VISUALIZATION_FAILED", message: "x" }] },
|
|
162
|
+
});
|
|
163
|
+
await buildProgram().parseAsync(["run", "boltz", "job.yaml", "--json"], { from: "user" });
|
|
164
|
+
expect(JSON.parse(logs.join("\n")).record.warnings[0].code).toBe("VISUALIZATION_FAILED");
|
|
165
|
+
expect(errors).toEqual([]);
|
|
166
|
+
});
|
|
136
167
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createStandaloneReport, openInBrowser } from "@moldesk/core";
|
|
2
|
+
import { reportFailure } from "../utils/report-errors.js";
|
|
3
|
+
export function registerViewCommand(program) {
|
|
4
|
+
program
|
|
5
|
+
.command("view <file>")
|
|
6
|
+
.description("Create an offline, interactive 3D report for a PDB or mmCIF file")
|
|
7
|
+
.option("--output <file>", "write the report to this file (must not exist)")
|
|
8
|
+
.option("--open", "open the report in your default browser")
|
|
9
|
+
.option("--json", "print the result as JSON")
|
|
10
|
+
.action(async (file, options) => {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const onSignal = () => controller.abort();
|
|
13
|
+
process.once("SIGINT", onSignal);
|
|
14
|
+
process.once("SIGTERM", onSignal);
|
|
15
|
+
try {
|
|
16
|
+
const result = await createStandaloneReport(file, {
|
|
17
|
+
outputPath: options.output,
|
|
18
|
+
signal: controller.signal,
|
|
19
|
+
});
|
|
20
|
+
const warnings = [];
|
|
21
|
+
let opened;
|
|
22
|
+
if (options.open) {
|
|
23
|
+
const outcome = await openInBrowser(result.path);
|
|
24
|
+
opened = outcome.opened;
|
|
25
|
+
if (!outcome.opened) {
|
|
26
|
+
warnings.push({
|
|
27
|
+
code: "OPEN_FAILED",
|
|
28
|
+
message: `Could not open a browser: ${outcome.error ?? "unknown error"}. The report was saved.`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (options.json) {
|
|
33
|
+
console.log(JSON.stringify({ status: "created", ...result, ...(opened === undefined ? {} : { opened }), warnings }, null, 2));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(`Report created: ${result.path}`);
|
|
37
|
+
console.log(`Open in your browser: ${result.fileUrl}`);
|
|
38
|
+
for (const w of warnings)
|
|
39
|
+
console.error(`Warning: ${w.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
reportFailure(error, options.json);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
process.removeListener("SIGINT", onSignal);
|
|
47
|
+
process.removeListener("SIGTERM", onSignal);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { registerViewCommand } from "./view.js";
|
|
7
|
+
const state = vi.hoisted(() => ({ open: vi.fn() }));
|
|
8
|
+
vi.mock("@moldesk/core", async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal();
|
|
10
|
+
return { ...actual, openInBrowser: state.open };
|
|
11
|
+
});
|
|
12
|
+
const PDB = "ATOM 1 N ALA A 1 11.104 13.207 2.100 1.00 20.00 N\n";
|
|
13
|
+
let dir;
|
|
14
|
+
let out;
|
|
15
|
+
let err;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-cli-view-"));
|
|
18
|
+
process.env.MOLDESK_HOME = path.join(dir, "home");
|
|
19
|
+
out = [];
|
|
20
|
+
err = [];
|
|
21
|
+
vi.spyOn(console, "log").mockImplementation((...a) => void out.push(a.join(" ")));
|
|
22
|
+
vi.spyOn(console, "error").mockImplementation((...a) => void err.push(a.join(" ")));
|
|
23
|
+
process.exitCode = undefined;
|
|
24
|
+
state.open.mockReset();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
delete process.env.MOLDESK_HOME;
|
|
29
|
+
process.exitCode = undefined;
|
|
30
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
async function run(...args) {
|
|
33
|
+
const program = new Command();
|
|
34
|
+
program.exitOverride();
|
|
35
|
+
registerViewCommand(program);
|
|
36
|
+
await program.parseAsync(args, { from: "user" });
|
|
37
|
+
}
|
|
38
|
+
const structure = (name = "p.pdb") => {
|
|
39
|
+
const p = path.join(dir, name);
|
|
40
|
+
fs.writeFileSync(p, PDB);
|
|
41
|
+
return p;
|
|
42
|
+
};
|
|
43
|
+
describe("view", () => {
|
|
44
|
+
it("prints the path and an encoded file URL, and does not open a browser by default", async () => {
|
|
45
|
+
const weird = path.join(dir, "my protein #1.pdb");
|
|
46
|
+
fs.writeFileSync(weird, PDB);
|
|
47
|
+
await run("view", weird, "--output", path.join(dir, "r e p.html"));
|
|
48
|
+
expect(out[0]).toContain(path.join(dir, "r e p.html"));
|
|
49
|
+
expect(out[1]).toContain("file://");
|
|
50
|
+
expect(out[1]).toContain("r%20e%20p.html");
|
|
51
|
+
expect(state.open).not.toHaveBeenCalled();
|
|
52
|
+
expect(process.exitCode).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
it("emits exactly one JSON object on stdout", async () => {
|
|
55
|
+
await run("view", structure(), "--json", "--output", path.join(dir, "j.html"));
|
|
56
|
+
expect(out).toHaveLength(1);
|
|
57
|
+
const parsed = JSON.parse(out[0]);
|
|
58
|
+
expect(parsed).toMatchObject({ status: "created", format: "pdb", warnings: [] });
|
|
59
|
+
expect(parsed.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
60
|
+
expect(err).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
it("passes the report path to the opener and succeeds", async () => {
|
|
63
|
+
state.open.mockResolvedValue({ opened: true });
|
|
64
|
+
const target = path.join(dir, "o.html");
|
|
65
|
+
await run("view", structure(), "--open", "--output", target, "--json");
|
|
66
|
+
expect(state.open).toHaveBeenCalledWith(target);
|
|
67
|
+
expect(JSON.parse(out[0]).opened).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it("keeps the report and exit 0 when opening fails", async () => {
|
|
70
|
+
state.open.mockResolvedValue({ opened: false, error: "no display" });
|
|
71
|
+
const target = path.join(dir, "w.html");
|
|
72
|
+
await run("view", structure(), "--open", "--output", target);
|
|
73
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
74
|
+
expect(err.join("\n")).toContain("no display");
|
|
75
|
+
expect(process.exitCode).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
it.each([
|
|
78
|
+
["bad extension", () => structure("a.txt"), 2],
|
|
79
|
+
["missing file", () => path.join(dir, "missing.pdb"), 2],
|
|
80
|
+
["URL", () => "https://x.test/a.pdb", 2],
|
|
81
|
+
])("exits 2 for %s", async (_n, make, code) => {
|
|
82
|
+
await run("view", make(), "--json");
|
|
83
|
+
expect(JSON.parse(out[0])).toMatchObject({ status: "error", code: "INVALID_VIEW_INPUT" });
|
|
84
|
+
expect(process.exitCode).toBe(code);
|
|
85
|
+
});
|
|
86
|
+
it("refuses to overwrite --output with exit 2", async () => {
|
|
87
|
+
const target = path.join(dir, "exists.html");
|
|
88
|
+
fs.writeFileSync(target, "keep");
|
|
89
|
+
await run("view", structure(), "--output", target);
|
|
90
|
+
expect(fs.readFileSync(target, "utf8")).toBe("keep");
|
|
91
|
+
expect(process.exitCode).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { applyReportUpdate, openInBrowser, prepareReportUpdate } from "@moldesk/core";
|
|
4
|
+
import { reportFailure } from "../utils/report-errors.js";
|
|
5
|
+
async function confirmUpdate(lines) {
|
|
6
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
7
|
+
try {
|
|
8
|
+
const answer = await rl.question(`${lines.join("\n")}\n\nContinue? [y/N] `);
|
|
9
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
rl.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function registerVisualizerCommand(program) {
|
|
16
|
+
const visualizer = program.command("visualizer").description("Manage offline molecular reports");
|
|
17
|
+
visualizer
|
|
18
|
+
.command("update <report>")
|
|
19
|
+
.description("Replace a report's viewer with the one installed with MoleculeDesk")
|
|
20
|
+
.option("--output <file>", "save the updated report as a separate file instead of replacing the original")
|
|
21
|
+
.option("--open", "open the report in your default browser afterwards")
|
|
22
|
+
.option("--yes", "confirm without prompting")
|
|
23
|
+
.option("--json", "print the result as JSON")
|
|
24
|
+
.action(async (report, options) => {
|
|
25
|
+
try {
|
|
26
|
+
await update(report, options);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
reportFailure(error, options.json);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function update(report, options) {
|
|
34
|
+
const emit = (value, text, errText = []) => {
|
|
35
|
+
if (options.json)
|
|
36
|
+
console.log(JSON.stringify(value, null, 2));
|
|
37
|
+
else {
|
|
38
|
+
for (const line of text)
|
|
39
|
+
console.log(line);
|
|
40
|
+
for (const line of errText)
|
|
41
|
+
console.error(line);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const openIfRequested = async (file) => {
|
|
45
|
+
if (!options.open)
|
|
46
|
+
return { warnings: [] };
|
|
47
|
+
const outcome = await openInBrowser(file);
|
|
48
|
+
return outcome.opened
|
|
49
|
+
? { opened: true, warnings: [] }
|
|
50
|
+
: {
|
|
51
|
+
opened: false,
|
|
52
|
+
warnings: [{ code: "OPEN_FAILED", message: `Could not open a browser: ${outcome.error ?? "unknown error"}. The report is at ${file}.` }],
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
const plan = await prepareReportUpdate(report, { outputPath: options.output });
|
|
56
|
+
const target = plan.targetPath;
|
|
57
|
+
if (plan.status === "unsupported") {
|
|
58
|
+
emit({ status: "unsupported", path: target, reason: plan.reason }, [], ["Your installed MoleculeDesk cannot update this report. Your report has not been changed."]);
|
|
59
|
+
process.exitCode = 2;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (plan.status === "noop" && options.output === undefined) {
|
|
63
|
+
const { opened, warnings } = await openIfRequested(target);
|
|
64
|
+
emit({ status: "up-to-date", path: target, viewerVersion: plan.toViewerVersion, bundleSha256: plan.toBundleSha256, ...(opened === undefined ? {} : { opened }), warnings }, ["This report already uses your current viewer."], warnings.map((w) => `Warning: ${w.message}`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!options.yes) {
|
|
68
|
+
if (options.json || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
69
|
+
emit({ status: "confirmation-required", path: target, message: "Re-run with --yes to confirm." }, [], ["Confirmation is required to update this report. Re-run with --yes to confirm."]);
|
|
70
|
+
process.exitCode = 2;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const lines = [
|
|
74
|
+
"Update this report’s viewer?",
|
|
75
|
+
"",
|
|
76
|
+
`Report: ${target}`,
|
|
77
|
+
...(plan.outputPath === undefined ? [] : [`Save as: ${plan.outputPath}`]),
|
|
78
|
+
"",
|
|
79
|
+
"Your structures and results will stay unchanged.",
|
|
80
|
+
plan.outputPath === undefined
|
|
81
|
+
? "A backup of your current report will be saved."
|
|
82
|
+
: "Your updated report will be saved as a separate file.",
|
|
83
|
+
];
|
|
84
|
+
if (!(await confirmUpdate(lines))) {
|
|
85
|
+
emit({ status: "cancelled", path: target }, ["Cancelled. Your report has not been changed."]);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const result = await applyReportUpdate(plan);
|
|
90
|
+
const { opened, warnings } = await openIfRequested(result.path);
|
|
91
|
+
const text = result.backupPath !== undefined
|
|
92
|
+
? ["Viewer updated. Refresh your report to see the changes.", `Backup saved: ${result.backupPath}`]
|
|
93
|
+
: [
|
|
94
|
+
result.status === "updated" ? "Viewer updated." : "Report saved.",
|
|
95
|
+
`Saved: ${path.resolve(result.path)}`,
|
|
96
|
+
];
|
|
97
|
+
emit({
|
|
98
|
+
status: result.status,
|
|
99
|
+
path: result.path,
|
|
100
|
+
...(result.backupPath === undefined ? {} : { backupPath: result.backupPath }),
|
|
101
|
+
sha256: result.sha256,
|
|
102
|
+
sizeBytes: result.sizeBytes,
|
|
103
|
+
payloadSha256: result.payloadSha256,
|
|
104
|
+
viewerVersion: result.viewerVersion,
|
|
105
|
+
bundleSha256: result.bundleSha256,
|
|
106
|
+
...(opened === undefined ? {} : { opened }),
|
|
107
|
+
warnings,
|
|
108
|
+
}, text, warnings.map((w) => `Warning: ${w.message}`));
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { registerViewCommand } from "./view.js";
|
|
7
|
+
import { registerVisualizerCommand } from "./visualizer.js";
|
|
8
|
+
const state = vi.hoisted(() => ({ answer: "n", questions: [], open: vi.fn() }));
|
|
9
|
+
vi.mock("node:readline/promises", () => ({
|
|
10
|
+
createInterface: () => ({
|
|
11
|
+
question: async (q) => {
|
|
12
|
+
state.questions.push(q);
|
|
13
|
+
return state.answer;
|
|
14
|
+
},
|
|
15
|
+
close: () => undefined,
|
|
16
|
+
}),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("@moldesk/core", async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal();
|
|
20
|
+
return { ...actual, openInBrowser: state.open };
|
|
21
|
+
});
|
|
22
|
+
const PDB = "ATOM 1 N ALA A 1 11.104 13.207 2.100 1.00 20.00 N\n";
|
|
23
|
+
let dir;
|
|
24
|
+
let out;
|
|
25
|
+
let err;
|
|
26
|
+
let report;
|
|
27
|
+
function setTty(value) {
|
|
28
|
+
Object.defineProperty(process.stdin, "isTTY", { value, configurable: true });
|
|
29
|
+
Object.defineProperty(process.stdout, "isTTY", { value, configurable: true });
|
|
30
|
+
}
|
|
31
|
+
async function run(...args) {
|
|
32
|
+
const program = new Command();
|
|
33
|
+
program.exitOverride();
|
|
34
|
+
registerViewCommand(program);
|
|
35
|
+
registerVisualizerCommand(program);
|
|
36
|
+
await program.parseAsync(args, { from: "user" });
|
|
37
|
+
}
|
|
38
|
+
/** A report whose HTML no longer equals the trusted reconstruction (as an older/other build would). */
|
|
39
|
+
function makeStale(file) {
|
|
40
|
+
fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace("<body>", "<body><!-- older viewer -->"));
|
|
41
|
+
}
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-cli-update-"));
|
|
44
|
+
process.env.MOLDESK_HOME = path.join(dir, "home");
|
|
45
|
+
fs.writeFileSync(path.join(dir, "p.pdb"), PDB);
|
|
46
|
+
report = path.join(dir, "report.html");
|
|
47
|
+
out = [];
|
|
48
|
+
err = [];
|
|
49
|
+
vi.spyOn(console, "log").mockImplementation((...a) => void out.push(a.join(" ")));
|
|
50
|
+
vi.spyOn(console, "error").mockImplementation((...a) => void err.push(a.join(" ")));
|
|
51
|
+
await run("view", path.join(dir, "p.pdb"), "--output", report);
|
|
52
|
+
out.length = 0;
|
|
53
|
+
err.length = 0;
|
|
54
|
+
process.exitCode = undefined;
|
|
55
|
+
state.answer = "n";
|
|
56
|
+
state.questions = [];
|
|
57
|
+
state.open.mockReset();
|
|
58
|
+
setTty(false);
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
delete process.env.MOLDESK_HOME;
|
|
63
|
+
process.exitCode = undefined;
|
|
64
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
const backups = () => fs.readdirSync(dir).filter((n) => n.includes("backup"));
|
|
67
|
+
describe("visualizer update", () => {
|
|
68
|
+
it("is a successful no-op with no prompt when the viewer already matches", async () => {
|
|
69
|
+
const before = fs.readFileSync(report);
|
|
70
|
+
await run("visualizer", "update", report);
|
|
71
|
+
expect(out).toEqual(["This report already uses your current viewer."]);
|
|
72
|
+
expect(state.questions).toEqual([]);
|
|
73
|
+
expect(fs.readFileSync(report).equals(before)).toBe(true);
|
|
74
|
+
expect(backups()).toEqual([]);
|
|
75
|
+
expect(process.exitCode).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
it.each([
|
|
78
|
+
["no TTY", false, []],
|
|
79
|
+
["--json even with a TTY", true, ["--json"]],
|
|
80
|
+
])("requires --yes: %s", async (_n, tty, extra) => {
|
|
81
|
+
makeStale(report);
|
|
82
|
+
const before = fs.readFileSync(report);
|
|
83
|
+
setTty(tty);
|
|
84
|
+
await run("visualizer", "update", report, ...extra);
|
|
85
|
+
expect(process.exitCode).toBe(2);
|
|
86
|
+
expect(state.questions).toEqual([]);
|
|
87
|
+
expect(fs.readFileSync(report).equals(before)).toBe(true);
|
|
88
|
+
expect(backups()).toEqual([]);
|
|
89
|
+
if (extra.length)
|
|
90
|
+
expect(JSON.parse(out[0]).status).toBe("confirmation-required");
|
|
91
|
+
});
|
|
92
|
+
it("updates with --yes, keeps a backup of the original bytes, and prints the success text", async () => {
|
|
93
|
+
makeStale(report);
|
|
94
|
+
const before = fs.readFileSync(report);
|
|
95
|
+
await run("visualizer", "update", report, "--yes");
|
|
96
|
+
expect(out[0]).toBe("Viewer updated. Refresh your report to see the changes.");
|
|
97
|
+
expect(out[1]).toMatch(/^Backup saved: .*report\.backup\.html$/);
|
|
98
|
+
expect(fs.readFileSync(path.join(dir, "report.backup.html")).equals(before)).toBe(true);
|
|
99
|
+
expect(fs.readFileSync(report, "utf8")).not.toContain("older viewer");
|
|
100
|
+
expect(process.exitCode).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
it("asks the documented question and proceeds on y", async () => {
|
|
103
|
+
makeStale(report);
|
|
104
|
+
setTty(true);
|
|
105
|
+
state.answer = "y";
|
|
106
|
+
await run("visualizer", "update", report);
|
|
107
|
+
const q = state.questions[0];
|
|
108
|
+
expect(q).toContain("Update this report’s viewer?");
|
|
109
|
+
expect(q).toContain(`Report: ${report}`);
|
|
110
|
+
expect(q).toContain("Your structures and results will stay unchanged.");
|
|
111
|
+
expect(q).toContain("A backup of your current report will be saved.");
|
|
112
|
+
expect(q).toContain("Continue? [y/N]");
|
|
113
|
+
expect(backups()).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
it("treats a declined confirmation as a successful no-op", async () => {
|
|
116
|
+
makeStale(report);
|
|
117
|
+
const before = fs.readFileSync(report);
|
|
118
|
+
setTty(true);
|
|
119
|
+
state.answer = "n";
|
|
120
|
+
await run("visualizer", "update", report);
|
|
121
|
+
expect(out.join("\n")).toContain("Cancelled");
|
|
122
|
+
expect(fs.readFileSync(report).equals(before)).toBe(true);
|
|
123
|
+
expect(backups()).toEqual([]);
|
|
124
|
+
expect(process.exitCode).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
it("--output saves a separate file, leaves the original alone, and changes the prompt wording", async () => {
|
|
127
|
+
makeStale(report);
|
|
128
|
+
const before = fs.readFileSync(report);
|
|
129
|
+
const target = path.join(dir, "updated.html");
|
|
130
|
+
setTty(true);
|
|
131
|
+
state.answer = "yes";
|
|
132
|
+
await run("visualizer", "update", report, "--output", target);
|
|
133
|
+
expect(state.questions[0]).toContain("Your updated report will be saved as a separate file.");
|
|
134
|
+
expect(state.questions[0]).toContain(`Save as: ${target}`);
|
|
135
|
+
expect(state.questions[0]).not.toContain("backup");
|
|
136
|
+
expect(fs.readFileSync(report).equals(before)).toBe(true);
|
|
137
|
+
expect(fs.readFileSync(target, "utf8")).not.toContain("older viewer");
|
|
138
|
+
expect(backups()).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
it("copies on no-op with --output after confirmation, and never overwrites an existing --output", async () => {
|
|
141
|
+
const target = path.join(dir, "copy.html");
|
|
142
|
+
await run("visualizer", "update", report, "--output", target, "--yes");
|
|
143
|
+
expect(fs.readFileSync(target).equals(fs.readFileSync(report))).toBe(true);
|
|
144
|
+
process.exitCode = undefined;
|
|
145
|
+
await run("visualizer", "update", report, "--output", target, "--yes");
|
|
146
|
+
expect(process.exitCode).toBe(2);
|
|
147
|
+
});
|
|
148
|
+
it("rejects ordinary HTML and unsupported input with exit 2, unchanged", async () => {
|
|
149
|
+
const plain = path.join(dir, "plain.html");
|
|
150
|
+
fs.writeFileSync(plain, "<html><script>alert(1)</script></html>");
|
|
151
|
+
await run("visualizer", "update", plain, "--yes", "--json");
|
|
152
|
+
expect(JSON.parse(out[0])).toMatchObject({ status: "error", code: "INVALID_REPORT" });
|
|
153
|
+
expect(process.exitCode).toBe(2);
|
|
154
|
+
expect(fs.readFileSync(plain, "utf8")).toBe("<html><script>alert(1)</script></html>");
|
|
155
|
+
});
|
|
156
|
+
it("opens only after a successful update; open failure is a warning, not a failure", async () => {
|
|
157
|
+
makeStale(report);
|
|
158
|
+
state.open.mockResolvedValue({ opened: false, error: "no display" });
|
|
159
|
+
await run("visualizer", "update", report, "--yes", "--open", "--json");
|
|
160
|
+
const result = JSON.parse(out[0]);
|
|
161
|
+
expect(state.open).toHaveBeenCalledWith(report);
|
|
162
|
+
expect(result).toMatchObject({ status: "updated", opened: false });
|
|
163
|
+
expect(result.warnings[0].code).toBe("OPEN_FAILED");
|
|
164
|
+
expect(process.exitCode).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
it("does not open when the update is refused", async () => {
|
|
167
|
+
makeStale(report);
|
|
168
|
+
await run("visualizer", "update", report, "--open");
|
|
169
|
+
expect(state.open).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { registerListCommand } from "./commands/list.js";
|
|
|
6
6
|
import { registerInstallCommand } from "./commands/install.js";
|
|
7
7
|
import { registerRunCommand } from "./commands/run.js";
|
|
8
8
|
import { registerUninstallCommand } from "./commands/uninstall.js";
|
|
9
|
+
import { registerViewCommand } from "./commands/view.js";
|
|
10
|
+
import { registerVisualizerCommand } from "./commands/visualizer.js";
|
|
9
11
|
const program = new Command();
|
|
10
12
|
program
|
|
11
13
|
.name("moldesk")
|
|
@@ -16,4 +18,6 @@ registerListCommand(program);
|
|
|
16
18
|
registerInstallCommand(program);
|
|
17
19
|
registerUninstallCommand(program);
|
|
18
20
|
registerRunCommand(program);
|
|
21
|
+
registerViewCommand(program);
|
|
22
|
+
registerVisualizerCommand(program);
|
|
19
23
|
program.parse();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { asMoldeskError } from "@moldesk/core";
|
|
2
|
+
/** Exit codes for report commands: 2 = usage/invalid report or input, 8 = cancelled, 10 = internal. */
|
|
3
|
+
const EXIT_CODES = {
|
|
4
|
+
INVALID_VIEW_INPUT: 2,
|
|
5
|
+
INVALID_REPORT: 2,
|
|
6
|
+
OUTPUT_EXISTS: 2,
|
|
7
|
+
UNSUPPORTED_REPORT: 2,
|
|
8
|
+
REPORT_CONFLICT: 2,
|
|
9
|
+
VIEW_CANCELLED: 8,
|
|
10
|
+
};
|
|
11
|
+
/** Prints an error as JSON (stdout, one object) or text (stderr) and sets the exit code. */
|
|
12
|
+
export function reportFailure(error, json) {
|
|
13
|
+
const err = asMoldeskError(error);
|
|
14
|
+
if (json) {
|
|
15
|
+
console.log(JSON.stringify({ status: "error", code: err.code, message: err.message, remediation: err.remediation }, null, 2));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.error(err.message);
|
|
19
|
+
if (err.remediation)
|
|
20
|
+
console.error(err.remediation);
|
|
21
|
+
}
|
|
22
|
+
process.exitCode = EXIT_CODES[err.code] ?? 10;
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moldesk/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "MoleculeDesk CLI — install and run computational biology models",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"commander": "^12.1.0",
|
|
24
|
-
"@moldesk/core": "0.0.
|
|
24
|
+
"@moldesk/core": "0.0.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"typescript": "^5.6.3",
|