@moldesk/cli 0.0.5 → 0.0.6

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,11 @@
1
+ import type { Command } from "commander";
2
+ import { type CloudAccountDeps, type CloudRunDeps } from "@moldesk/core";
3
+ /** Exit code for a cloud error code that is more specific than "run failed" (7), or undefined. */
4
+ export declare function cloudExitCodeForCode(code: string | undefined): number | undefined;
5
+ export declare function cloudExitCode(error: unknown): number;
6
+ export interface CloudCommandHooks {
7
+ /** Resolves provider/backend wiring for recover and cache commands; injected for tests. */
8
+ runDeps?: () => Promise<CloudRunDeps>;
9
+ confirm?: (question: string) => Promise<boolean>;
10
+ }
11
+ export declare function registerCloudCommand(program: Command, deps?: CloudAccountDeps, hooks?: CloudCommandHooks): void;
@@ -0,0 +1,282 @@
1
+ import { MoldeskError, connectCloudProvider, createRunpodCloudDeps, disconnectCloudProvider, executeCloudCacheDeletion, getCloudStatus, listCloudCaches, listCloudResources, planCloudCacheDeletion, recoverCloudRun, } from "@moldesk/core";
2
+ import { confirm } from "../utils/confirm.js";
3
+ import { readSecret } from "../utils/secret-input.js";
4
+ /** Cloud exit codes live beside the run codes (0/7/8 outcomes, 2-5 usage). */
5
+ const CLOUD_EXIT_CODES = {
6
+ CREDENTIAL_UNSUPPORTED_PROVIDER: 2,
7
+ CREDENTIAL_INVALID: 2,
8
+ CREDENTIAL_MISSING: 11,
9
+ CLOUD_AUTH_FAILED: 11,
10
+ CLOUD_FORBIDDEN: 11,
11
+ CREDENTIAL_STORE_UNAVAILABLE: 11,
12
+ CLOUD_PAYMENT_REQUIRED: 12,
13
+ CLOUD_RATE_LIMITED: 12,
14
+ CLOUD_CAPACITY_UNAVAILABLE: 12,
15
+ CLOUD_UNAVAILABLE: 12,
16
+ CLOUD_BUDGET_REQUIRED: 2,
17
+ CLOUD_LIFETIME_REQUIRED: 2,
18
+ CLOUD_BUDGET_EXCEEDED: 2,
19
+ CLOUD_GPU_UNKNOWN: 2,
20
+ CLOUD_GPU_INELIGIBLE: 2,
21
+ CLOUD_OPTIONS_REQUIRED: 2,
22
+ CLOUD_OPTIONS_WITHOUT_CLOUD: 2,
23
+ CLOUD_RUNTIME_UNSUPPORTED: 2,
24
+ CLOUD_COMPANION_INPUTS_UNSUPPORTED: 2,
25
+ CLOUD_MODEL_UNSUPPORTED: 4,
26
+ // Consent and safety refusals: nothing was provisioned.
27
+ CLOUD_APPROVAL_MISMATCH: 14,
28
+ CLOUD_APPROVAL_EXPIRED: 14,
29
+ CLOUD_REQUOTE_REQUIRED: 14,
30
+ CLOUD_ACKNOWLEDGMENT_REQUIRED: 14,
31
+ CLOUD_WORKER_BUNDLE_UNCONFIGURED: 14,
32
+ CLOUD_WORKER_BUNDLE_INVALID: 14,
33
+ CLOUD_UNRESOLVED_RESOURCES: 13,
34
+ CLOUD_RUN_UNKNOWN: 2,
35
+ CLOUD_CACHE_IN_USE: 2,
36
+ };
37
+ /** Exit code for a cloud error code that is more specific than "run failed" (7), or undefined. */
38
+ export function cloudExitCodeForCode(code) {
39
+ return code !== undefined && code in CLOUD_EXIT_CODES && CLOUD_EXIT_CODES[code] >= 11 ? CLOUD_EXIT_CODES[code] : undefined;
40
+ }
41
+ export function cloudExitCode(error) {
42
+ return error instanceof MoldeskError && error.code in CLOUD_EXIT_CODES ? CLOUD_EXIT_CODES[error.code] : 10;
43
+ }
44
+ function report(error, json) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ const code = error instanceof MoldeskError ? error.code : undefined;
47
+ const remediation = error instanceof MoldeskError ? error.remediation : undefined;
48
+ if (json)
49
+ console.log(JSON.stringify({ status: "error", code, message, remediation }, null, 2));
50
+ else {
51
+ console.error(message);
52
+ if (remediation)
53
+ console.error(remediation);
54
+ }
55
+ process.exitCode = cloudExitCode(error);
56
+ }
57
+ async function readKeyFromStdin() {
58
+ let data = "";
59
+ process.stdin.setEncoding("utf8");
60
+ for await (const chunk of process.stdin)
61
+ data += chunk;
62
+ return data.replace(/\r?\n[\s\S]*$/, "");
63
+ }
64
+ export function registerCloudCommand(program, deps = {}, hooks = {}) {
65
+ const resolveRunDeps = hooks.runDeps ?? (() => createRunpodCloudDeps({ paths: deps.paths, store: deps.store }));
66
+ const ask = hooks.confirm ?? ((question) => confirm(question));
67
+ const cloud = program.command("cloud").description("Connect and inspect cloud compute accounts");
68
+ cloud
69
+ .command("connect <provider>")
70
+ .description("Store a provider API key in the OS credential store after validating it")
71
+ .option("--key-stdin", "read the key from standard input instead of prompting (never pass keys as arguments)")
72
+ .option("--json", "print the result as JSON")
73
+ .action(async (provider, options) => {
74
+ try {
75
+ if (!options.keyStdin && process.stdin.isTTY !== true) {
76
+ throw new MoldeskError({
77
+ code: "CREDENTIAL_INVALID",
78
+ message: "No terminal is attached to prompt for the key.",
79
+ remediation: "Run in a terminal, or pipe the key with --key-stdin.",
80
+ });
81
+ }
82
+ const key = options.keyStdin
83
+ ? await readKeyFromStdin()
84
+ : await readSecret({ input: process.stdin, output: process.stderr, prompt: `${provider} API key (input hidden): ` });
85
+ const result = await connectCloudProvider(provider, key.trim(), deps);
86
+ if (options.json)
87
+ console.log(JSON.stringify({ status: "connected", ...result }, null, 2));
88
+ else {
89
+ console.log(`Connected to ${provider}. Key stored in the OS credential store.`);
90
+ if (result.overriddenBy) {
91
+ console.log(`Note: a key from the environment still takes precedence over the stored key.`);
92
+ }
93
+ }
94
+ }
95
+ catch (error) {
96
+ report(error, options.json);
97
+ }
98
+ });
99
+ cloud
100
+ .command("disconnect <provider>")
101
+ .description("Remove the stored API key (an environment variable is left alone)")
102
+ .option("--json", "print the result as JSON")
103
+ .action(async (provider, options) => {
104
+ try {
105
+ const result = await disconnectCloudProvider(provider, deps);
106
+ if (options.json)
107
+ console.log(JSON.stringify({ status: "ok", ...result }, null, 2));
108
+ else
109
+ console.log(result.removed ? `Removed the stored ${provider} key.` : `No stored ${provider} key to remove.`);
110
+ }
111
+ catch (error) {
112
+ report(error, options.json);
113
+ }
114
+ });
115
+ cloud
116
+ .command("status")
117
+ .description("Show credential source, live connection and unfinished cloud work")
118
+ .option("--provider <name>", "cloud provider", "runpod")
119
+ .option("--json", "print the result as JSON")
120
+ .action(async (options) => {
121
+ try {
122
+ const status = await getCloudStatus(options.provider, deps);
123
+ if (options.json) {
124
+ console.log(JSON.stringify(status, null, 2));
125
+ }
126
+ else {
127
+ console.log(`Provider: ${status.provider}`);
128
+ console.log(`Credential: ${status.credential.source === "none" ? "none (run `moldesk cloud connect " + status.provider + "`)" : status.credential.source}`);
129
+ if (status.connection) {
130
+ console.log(`Connection: ${status.connection.ok ? "ok" : `failed (${status.connection.code}): ${status.connection.message}`}`);
131
+ }
132
+ console.log(`Open jobs: ${status.journal.openJobs.length}`);
133
+ console.log(`Unresolved resources: ${status.journal.unresolvedResources.length}`);
134
+ for (const job of status.journal.openJobs)
135
+ console.log(` job ${job.runId}: ${job.stage} (${job.status}), deadline ${job.deadlineAt}`);
136
+ }
137
+ if (status.connection && !status.connection.ok)
138
+ process.exitCode = cloudExitCode(new MoldeskError({ code: status.connection.code, message: status.connection.message }));
139
+ }
140
+ catch (error) {
141
+ report(error, options.json);
142
+ }
143
+ });
144
+ cloud
145
+ .command("resources")
146
+ .description("List cloud resources this installation owns, orphans and owed cleanup")
147
+ .option("--provider <name>", "cloud provider", "runpod")
148
+ .option("--json", "print the result as JSON")
149
+ .action(async (options) => {
150
+ try {
151
+ const listing = await listCloudResources(options.provider, deps);
152
+ if (options.json) {
153
+ console.log(JSON.stringify(listing, null, 2));
154
+ return;
155
+ }
156
+ console.log(`Owned by this installation (${listing.owned.length}):`);
157
+ for (const entry of listing.owned) {
158
+ const detail = entry.kind === "pod"
159
+ ? `${entry.status}${entry.costUsdPerHour != null ? `, $${entry.costUsdPerHour}/h` : ""}`
160
+ : `${entry.sizeGb} GB volume (retained storage is billed)`;
161
+ console.log(` ${entry.kind} ${entry.record.providerId} ${entry.record.name} ${detail}`);
162
+ }
163
+ if (listing.orphaned.length > 0) {
164
+ console.log(`Orphaned (named like ours, no journal owner; not deleted automatically) (${listing.orphaned.length}):`);
165
+ for (const entry of listing.orphaned)
166
+ console.log(` ${entry.providerId} ${entry.name}`);
167
+ }
168
+ if (listing.cleanupOwed.length > 0) {
169
+ console.log(`Cleanup owed (${listing.cleanupOwed.length}):`);
170
+ for (const record of listing.cleanupOwed)
171
+ console.log(` ${record.providerId} ${record.name} ${record.state}`);
172
+ }
173
+ console.log(`Other resources on the account (not managed by MoleculeDesk): ${listing.unmanagedCount}`);
174
+ const changed = listing.reconciliation;
175
+ if (changed.adopted.length + changed.abandoned.length + changed.vanished.length > 0) {
176
+ console.log(`Journal reconciled: ${changed.adopted.length} adopted, ${changed.abandoned.length} never created, ${changed.vanished.length} already gone.`);
177
+ }
178
+ if (listing.orphaned.length > 0 || listing.cleanupOwed.length > 0)
179
+ process.exitCode = 13;
180
+ }
181
+ catch (error) {
182
+ report(error, options.json);
183
+ }
184
+ });
185
+ cloud
186
+ .command("recover <run-id>")
187
+ .description("Resume transfer and cleanup for an interrupted or failed cloud run (never restarts inference)")
188
+ .option("--terminate", "also terminate a machine whose job is still running (loses unfinished results)")
189
+ .option("--json", "print the result as JSON")
190
+ .action(async (runId, options) => {
191
+ try {
192
+ const runDeps = await resolveRunDeps();
193
+ const result = await recoverCloudRun(runId, runDeps, { terminateRunning: options.terminate });
194
+ if (options.json)
195
+ console.log(JSON.stringify(result, null, 2));
196
+ else {
197
+ console.log(`Run ${result.runId}: ${result.status}${result.collected ? " (results downloaded and verified)" : ""}.`);
198
+ if (result.cleanup.confirmed)
199
+ console.log("Cloud resources: confirmed deleted.");
200
+ else if (result.status === "running")
201
+ console.log("The job is still running; its machine was left alone. Re-run with --terminate to stop it.");
202
+ else
203
+ console.error(`Cleanup still pending: ${result.cleanup.pending.map((resource) => `${resource.role} ${resource.providerId}`).join(", ")}.`);
204
+ }
205
+ if (!result.cleanup.confirmed)
206
+ process.exitCode = 13;
207
+ }
208
+ catch (error) {
209
+ report(error, options.json);
210
+ }
211
+ });
212
+ const cache = cloud.command("cache").description("Inspect and delete retained model caches (billed network volumes)");
213
+ cache
214
+ .command("list")
215
+ .description("List retained caches and whether their volumes still exist")
216
+ .option("--json", "print the result as JSON")
217
+ .action(async (options) => {
218
+ try {
219
+ const listing = await listCloudCaches(await resolveRunDeps());
220
+ if (options.json)
221
+ return void console.log(JSON.stringify(listing, null, 2));
222
+ if (listing.length === 0)
223
+ return void console.log("No retained caches.");
224
+ for (const entry of listing) {
225
+ const volume = entry.volumeExists === undefined ? "volume status unknown" : entry.volumeExists ? `volume ${entry.cache.volumeId}${entry.volumeSizeGb ? ` (${entry.volumeSizeGb} GB, billed)` : ""}` : "volume no longer exists";
226
+ console.log(`${entry.cache.model} ${entry.cache.modelVersion} ${entry.cache.state} ${entry.cache.datacenterId} ${volume}`);
227
+ }
228
+ }
229
+ catch (error) {
230
+ report(error, options.json);
231
+ }
232
+ });
233
+ cache
234
+ .command("delete <model>")
235
+ .description("Delete the retained volume(s) holding a model's cache (billing stops)")
236
+ .option("--yes", "skip the confirmation prompt")
237
+ .option("--json", "print the result as JSON")
238
+ .action(async (model, options) => {
239
+ try {
240
+ const runDeps = await resolveRunDeps();
241
+ const plan = await planCloudCacheDeletion(model, runDeps);
242
+ if (plan.volumes.length === 0) {
243
+ if (options.json)
244
+ console.log(JSON.stringify({ status: "nothing-to-delete", model }, null, 2));
245
+ else
246
+ console.log(`No retained cache for ${model}.`);
247
+ return;
248
+ }
249
+ if (!options.json) {
250
+ console.error(`This permanently deletes ${plan.volumes.length} volume(s) and everything on them:`);
251
+ for (const volume of plan.volumes)
252
+ console.error(` ${volume.volumeId} ${volume.datacenterId}${volume.sizeGb ? ` ${volume.sizeGb} GB` : ""}`);
253
+ for (const blocker of plan.blockers)
254
+ console.error(` Blocked: ${blocker}`);
255
+ }
256
+ if (plan.blockers.length === 0 && !options.yes) {
257
+ if (!(await ask("Delete these volumes?"))) {
258
+ if (options.json)
259
+ console.log(JSON.stringify({ status: "declined", plan }, null, 2));
260
+ else
261
+ console.error("Not deleted.");
262
+ process.exitCode = 2;
263
+ return;
264
+ }
265
+ }
266
+ const result = await executeCloudCacheDeletion(plan, runDeps);
267
+ if (options.json)
268
+ console.log(JSON.stringify({ status: result.failed.length > 0 ? "partial" : "deleted", ...result }, null, 2));
269
+ else {
270
+ for (const item of result.deleted)
271
+ console.log(`Deleted volume ${item.volumeId}.`);
272
+ for (const item of result.failed)
273
+ console.error(`Could not delete ${item.volumeId}: ${item.reason}`);
274
+ }
275
+ if (result.failed.length > 0)
276
+ process.exitCode = 12;
277
+ }
278
+ catch (error) {
279
+ report(error, options.json);
280
+ }
281
+ });
282
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,212 @@
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 { CloudJournal, CredentialStore, MoldeskError, getMoldeskPaths } from "@moldesk/core";
7
+ import { cloudExitCode, registerCloudCommand } from "./cloud.js";
8
+ const KEY = "rpa_TESTKEY0123456789";
9
+ let home;
10
+ let out;
11
+ let err;
12
+ let stored;
13
+ beforeEach(() => {
14
+ home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk cli cloud "));
15
+ stored = new Map();
16
+ out = [];
17
+ err = [];
18
+ vi.spyOn(console, "log").mockImplementation((...a) => void out.push(a.join(" ")));
19
+ vi.spyOn(console, "error").mockImplementation((...a) => void err.push(a.join(" ")));
20
+ process.exitCode = undefined;
21
+ });
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ process.exitCode = undefined;
25
+ fs.rmSync(home, { recursive: true, force: true });
26
+ });
27
+ function backend() {
28
+ return {
29
+ async get(service, account) {
30
+ return stored.get(`${service}/${account}`) ?? null;
31
+ },
32
+ async set(service, account, secret) {
33
+ stored.set(`${service}/${account}`, secret);
34
+ },
35
+ async delete(service, account) {
36
+ return stored.delete(`${service}/${account}`);
37
+ },
38
+ };
39
+ }
40
+ function provider(validate, pods = []) {
41
+ return {
42
+ name: "fake",
43
+ validateCredentials: validate,
44
+ getCatalog: async () => {
45
+ throw new Error("unused");
46
+ },
47
+ createPod: async () => {
48
+ throw new Error("must not create");
49
+ },
50
+ getPod: async () => null,
51
+ listPods: async () => pods,
52
+ terminatePod: async () => {
53
+ throw new Error("must not terminate");
54
+ },
55
+ createVolume: async () => {
56
+ throw new Error("must not create");
57
+ },
58
+ getVolume: async () => null,
59
+ listVolumes: async () => [],
60
+ deleteVolume: async () => {
61
+ throw new Error("must not delete");
62
+ },
63
+ };
64
+ }
65
+ async function run(options, ...args) {
66
+ const program = new Command();
67
+ program.exitOverride();
68
+ registerCloudCommand(program, {
69
+ paths: getMoldeskPaths({ MOLDESK_HOME: home }),
70
+ store: new CredentialStore({ backend: backend(), env: options.env ?? {} }),
71
+ createProvider: () => provider(options.validate ?? (async () => ({ ok: true, checkedAt: "t" })), options.pods),
72
+ });
73
+ await program.parseAsync(["cloud", ...args], { from: "user" });
74
+ }
75
+ describe("cloud commands", () => {
76
+ it("connect refuses to prompt without a terminal and without --key-stdin", async () => {
77
+ const original = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
78
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
79
+ try {
80
+ await run({}, "connect", "runpod");
81
+ }
82
+ finally {
83
+ if (original)
84
+ Object.defineProperty(process.stdin, "isTTY", original);
85
+ else
86
+ delete process.stdin.isTTY;
87
+ }
88
+ expect(err.join("\n")).toContain("No terminal is attached");
89
+ expect(process.exitCode).toBe(2);
90
+ expect(stored.size).toBe(0);
91
+ });
92
+ it("status without a credential is explicit and exits cleanly", async () => {
93
+ await run({}, "status");
94
+ expect(out.join("\n")).toContain("Credential: none");
95
+ expect(process.exitCode).toBeUndefined();
96
+ });
97
+ it("status with a working key reports ok and never prints the key", async () => {
98
+ await run({ env: { RUNPOD_API_KEY: KEY } }, "status", "--json");
99
+ const printed = out.join("\n");
100
+ expect(printed).not.toContain(KEY);
101
+ expect(JSON.parse(printed)).toMatchObject({ credential: { source: "env" }, connection: { ok: true } });
102
+ });
103
+ it("status exits 11 when the provider rejects the key", async () => {
104
+ await run({
105
+ env: { RUNPOD_API_KEY: KEY },
106
+ validate: async () => {
107
+ throw new MoldeskError({ code: "CLOUD_AUTH_FAILED", message: "Runpod rejected the API key.", remediation: "x" });
108
+ },
109
+ }, "status");
110
+ expect(out.join("\n")).toContain("failed (CLOUD_AUTH_FAILED)");
111
+ expect(process.exitCode).toBe(11);
112
+ });
113
+ it("disconnect reports whether anything was removed", async () => {
114
+ stored.set("moldesk/runpod:default", KEY);
115
+ await run({}, "disconnect", "runpod");
116
+ expect(out.join("\n")).toContain("Removed the stored runpod key.");
117
+ out.length = 0;
118
+ await run({}, "disconnect", "runpod");
119
+ expect(out.join("\n")).toContain("No stored runpod key to remove.");
120
+ });
121
+ it("resources needs a credential and reports exit 11 otherwise", async () => {
122
+ await run({}, "resources", "--json");
123
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ status: "error", code: "CREDENTIAL_MISSING" });
124
+ expect(process.exitCode).toBe(11);
125
+ });
126
+ it("resources lists orphans and exits 13 so scripts notice owed cleanup", async () => {
127
+ const journal = CloudJournal.open(getMoldeskPaths({ MOLDESK_HOME: home }));
128
+ const prefix = journal.clientId.replace(/-/g, "").slice(0, 8);
129
+ journal.close();
130
+ await run({
131
+ env: { RUNPOD_API_KEY: KEY },
132
+ pods: [{ id: "podX", name: `moldesk-${prefix}-deadbeef-gpu`, status: "RUNNING", dataCenterId: null, costUsdPerHour: 0.5, createdAt: null }],
133
+ }, "resources");
134
+ const printed = out.join("\n");
135
+ expect(printed).toContain("Orphaned");
136
+ expect(printed).toContain("podX");
137
+ expect(printed).toContain("Other resources on the account (not managed by MoleculeDesk): 0");
138
+ expect(process.exitCode).toBe(13);
139
+ });
140
+ });
141
+ describe("recover and cache commands", () => {
142
+ const volumes = new Map();
143
+ const deleted = [];
144
+ function runDeps() {
145
+ const provider = {
146
+ name: "runpod",
147
+ listVolumes: async () => [...volumes.values()],
148
+ getVolume: async (id) => volumes.get(id) ?? null,
149
+ deleteVolume: async (id) => {
150
+ volumes.delete(id);
151
+ deleted.push(id);
152
+ return { id, alreadyAbsent: false };
153
+ },
154
+ };
155
+ return { provider, paths: getMoldeskPaths({ MOLDESK_HOME: home }), accountScope: "acct" };
156
+ }
157
+ async function exec(confirmAnswer, ...args) {
158
+ const program = new Command();
159
+ program.exitOverride();
160
+ registerCloudCommand(program, {}, { runDeps: async () => runDeps(), confirm: async () => confirmAnswer });
161
+ await program.parseAsync(["cloud", ...args], { from: "user" });
162
+ }
163
+ function seedCache() {
164
+ volumes.clear();
165
+ deleted.length = 0;
166
+ volumes.set("vol1", { id: "vol1", name: "n", sizeGb: 40, dataCenterId: "DC-1", type: "STANDARD" });
167
+ const journal = CloudJournal.open(getMoldeskPaths({ MOLDESK_HOME: home }));
168
+ const intent = journal.recordResourceIntent({ role: "volume" });
169
+ journal.recordResourceCreated(intent.intentId, "vol1");
170
+ journal.upsertCache({ accountScope: "acct", datacenterId: "DC-1", volumeId: "vol1", model: "boltz", modelVersion: "2.2.1", cacheKey: "k".repeat(64), layoutRevision: "1", manifestDigest: "m", cachePath: "/workspace/x", sizeBytes: null, checksumManifestDigest: null });
171
+ journal.close();
172
+ }
173
+ it("cache list reports empty and populated states", async () => {
174
+ await exec(true, "cache", "list");
175
+ expect(out.join("\n")).toContain("No retained caches.");
176
+ out.length = 0;
177
+ seedCache();
178
+ await exec(true, "cache", "list");
179
+ expect(out.join("\n")).toMatch(/boltz 2\.2\.1\s+PREPARING\s+DC-1\s+volume vol1 \(40 GB, billed\)/);
180
+ });
181
+ it("cache delete shows exactly what will go and deletes nothing when declined", async () => {
182
+ seedCache();
183
+ await exec(false, "cache", "delete", "boltz");
184
+ expect(err.join("\n")).toContain("permanently deletes 1 volume(s)");
185
+ expect(err.join("\n")).toContain("vol1 DC-1 40 GB");
186
+ expect(deleted).toEqual([]);
187
+ expect(process.exitCode).toBe(2);
188
+ });
189
+ it("cache delete --yes deletes the owned volume and confirms", async () => {
190
+ seedCache();
191
+ await exec(false, "cache", "delete", "boltz", "--yes");
192
+ expect(out.join("\n")).toContain("Deleted volume vol1.");
193
+ expect(deleted).toEqual(["vol1"]);
194
+ expect(process.exitCode).toBeUndefined();
195
+ });
196
+ it("cache delete for a model with no cache is a no-op", async () => {
197
+ await exec(true, "cache", "delete", "unknown-model");
198
+ expect(out.join("\n")).toContain("No retained cache for unknown-model.");
199
+ });
200
+ it("recover rejects an unknown run with a clear code", async () => {
201
+ await exec(true, "recover", "2026-01-01T00-00-00-000Z-00000000-0000-0000-0000-000000000000", "--json");
202
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ status: "error", code: "CLOUD_RUN_UNKNOWN" });
203
+ expect(process.exitCode).toBe(2);
204
+ });
205
+ });
206
+ describe("cloudExitCode", () => {
207
+ it("maps cloud error codes and defaults to 10", () => {
208
+ expect(cloudExitCode(new MoldeskError({ code: "CLOUD_CAPACITY_UNAVAILABLE", message: "x" }))).toBe(12);
209
+ expect(cloudExitCode(new MoldeskError({ code: "CREDENTIAL_INVALID", message: "x" }))).toBe(2);
210
+ expect(cloudExitCode(new Error("boom"))).toBe(10);
211
+ });
212
+ });
@@ -0,0 +1,30 @@
1
+ import { type CloudQuote, type CloudRunDeps } from "@moldesk/core";
2
+ export interface CloudRunFlags {
3
+ compute: string;
4
+ cachePolicy?: string;
5
+ gpu?: string;
6
+ datacenter?: string;
7
+ maxBudget?: string;
8
+ maxLifetime?: string;
9
+ yes?: boolean;
10
+ dryRun?: boolean;
11
+ acceptLifetimeRisk?: boolean;
12
+ acceptStorageCharges?: boolean;
13
+ output?: string;
14
+ runtime?: string;
15
+ param: Record<string, string>;
16
+ json?: boolean;
17
+ visualize?: boolean;
18
+ }
19
+ export interface CloudRunHooks {
20
+ /** Injected for tests; production resolves Runpod wiring from the credential store. */
21
+ deps?: () => Promise<CloudRunDeps>;
22
+ confirm?: (question: string) => Promise<boolean>;
23
+ interactive?: boolean;
24
+ }
25
+ /** Printed first and again at the prompt: there is no independent shutdown service. */
26
+ export declare function formatWarnings(quote: CloudQuote): string;
27
+ export declare function formatQuote(quote: CloudQuote): string;
28
+ /** Rejects cloud-only flags on a local run, before anything happens. */
29
+ export declare function assertNoCloudFlagsForLocal(flags: Partial<CloudRunFlags>): void;
30
+ export declare function runCloudCommand(model: string, input: string, flags: CloudRunFlags, hooks?: CloudRunHooks): Promise<void>;
@@ -0,0 +1,201 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { MoldeskError, createRunpodCloudDeps, planCloudRun, runModel, } from "@moldesk/core";
3
+ import { confirm } from "../utils/confirm.js";
4
+ import { cloudExitCode, cloudExitCodeForCode } from "./cloud.js";
5
+ function usage(code, message, remediation) {
6
+ return new MoldeskError({ code, message, remediation });
7
+ }
8
+ /** Printed first and again at the prompt: there is no independent shutdown service. */
9
+ export function formatWarnings(quote) {
10
+ const bar = "!".repeat(72);
11
+ return [bar, "WARNING: READ BEFORE APPROVING", ...quote.warnings.flatMap((warning) => ["", warning.text]), "", bar].join("\n");
12
+ }
13
+ export function formatQuote(quote) {
14
+ const money = (value) => `$${value.toFixed(value < 1 ? 4 : 2)}`.replace(/\.?0+$/, (zeros) => (zeros.includes(".") ? "" : zeros));
15
+ const lines = [
16
+ formatWarnings(quote),
17
+ "",
18
+ `Cloud quote (${quote.provider}, prices as of ${quote.retrievedAt})`,
19
+ ` Model: ${quote.model} ${quote.modelVersion}`,
20
+ ` GPU: ${quote.gpu.name} (${quote.gpu.memoryGb} GB) in ${quote.datacenterId} at ${money(quote.gpu.priceUsdPerHour)}/h`,
21
+ ...(quote.cpu ? [` Prep CPU: ${quote.cpu.id} x${quote.cpu.vcpuCount} at ${money(quote.cpu.priceUsdPerHour)}/h (asset preparation only)`] : []),
22
+ ` Cache policy: ${quote.cachePolicy}`,
23
+ ` Limits: lifetime ${quote.maxLifetimeSeconds}s, budget ${money(quote.maxBudgetUsd)}`,
24
+ ` Compute: worst case ${money(quote.cost.worstCaseComputeUsd)} (hourly rate x lifetime limit; a ceiling for this estimate, not a provider-enforced spend cap)`,
25
+ ` Temp storage: ${quote.cost.temporaryStorage.worstCaseUsd === null ? `container disk ${quote.cost.temporaryStorage.diskGb} GB x ${quote.cost.temporaryStorage.machines} machine(s): cost unknown (rate not published)` : `container disk worst case ${money(quote.cost.temporaryStorage.worstCaseUsd)}`}`,
26
+ ` Retained: ${quote.cost.recurringStorage.recurring ? `${quote.cost.recurringStorage.volumeGb} GB network volume kept until you delete it; ${quote.cost.recurringStorage.usdPerMonth === null ? "monthly cost unknown (rate not published)" : `about ${money(quote.cost.recurringStorage.usdPerMonth)}/month`}` : "nothing is kept after the run"}`,
27
+ ` Budget: ${money(quote.maxBudgetUsd)} is checked against ${money(quote.cost.worstCaseBudgetedUsd)}`,
28
+ ` counts: ${quote.budgetCoverage.counted.join("; ")}`,
29
+ ` NOT counted: ${quote.budgetCoverage.notCounted.join("; ")}`,
30
+ ...(quote.budgetCoverage.unknown.length > 0 ? [` unknown: ${quote.budgetCoverage.unknown.join("; ")}`] : []),
31
+ ` Timing: no measured estimate available`,
32
+ ` Upload: ${quote.upload.files} file(s), ${quote.upload.bytes} bytes`,
33
+ ...quote.disclosures.map((line) => ` - ${line}`),
34
+ ];
35
+ return lines.join("\n");
36
+ }
37
+ function parseBudget(raw) {
38
+ const value = Number(raw);
39
+ if (raw === undefined || !Number.isFinite(value) || value <= 0)
40
+ throw usage("CLOUD_BUDGET_REQUIRED", "A positive --max-budget (USD) is required.", "Pass --max-budget <usd>.");
41
+ return value;
42
+ }
43
+ function parseLifetime(raw) {
44
+ const value = Number(raw);
45
+ if (raw === undefined || !Number.isInteger(value) || value <= 0)
46
+ throw usage("CLOUD_LIFETIME_REQUIRED", "A positive integer --max-lifetime (seconds) is required.", "Pass --max-lifetime <seconds>.");
47
+ return value;
48
+ }
49
+ /** Rejects cloud-only flags on a local run, before anything happens. */
50
+ export function assertNoCloudFlagsForLocal(flags) {
51
+ const used = [
52
+ ["--cache-policy", flags.cachePolicy],
53
+ ["--gpu", flags.gpu],
54
+ ["--datacenter", flags.datacenter],
55
+ ["--max-budget", flags.maxBudget],
56
+ ["--max-lifetime", flags.maxLifetime],
57
+ ].filter(([, value]) => value !== undefined);
58
+ if (flags.yes || flags.dryRun || flags.acceptLifetimeRisk || flags.acceptStorageCharges)
59
+ used.push(["--yes/--dry-run/--accept-*", "set"]);
60
+ if (used.length > 0) {
61
+ throw usage("CLOUD_OPTIONS_WITHOUT_CLOUD", `${used.map(([name]) => name).join(", ")} only apply with --compute runpod.`, "Add --compute runpod, or remove those options.");
62
+ }
63
+ }
64
+ export async function runCloudCommand(model, input, flags, hooks = {}) {
65
+ const out = (value) => console.log(JSON.stringify(value, null, 2));
66
+ try {
67
+ if (flags.compute !== "runpod")
68
+ throw usage("CLOUD_OPTIONS_REQUIRED", `Unsupported --compute "${flags.compute}".`, "Expected local|runpod.");
69
+ if (flags.runtime === "docker")
70
+ throw usage("CLOUD_RUNTIME_UNSUPPORTED", "The docker runtime is not supported for cloud execution.", "Use the python runtime.");
71
+ if (flags.cachePolicy !== "ephemeral" && flags.cachePolicy !== "persistent") {
72
+ throw usage("CLOUD_OPTIONS_REQUIRED", "--cache-policy ephemeral|persistent is required for cloud runs.", "Choose ephemeral (nothing retained) or persistent (a billed volume is kept).");
73
+ }
74
+ const cachePolicy = flags.cachePolicy;
75
+ const quoteOptions = {
76
+ cachePolicy,
77
+ ...(flags.gpu ? { gpuId: flags.gpu } : {}),
78
+ ...(flags.datacenter ? { datacenterId: flags.datacenter } : {}),
79
+ maxBudgetUsd: parseBudget(flags.maxBudget),
80
+ maxLifetimeSeconds: parseLifetime(flags.maxLifetime),
81
+ };
82
+ const deps = await (hooks.deps ?? (() => createRunpodCloudDeps()))();
83
+ const plan = await planCloudRun(model, input, { params: flags.param, cloud: quoteOptions }, deps);
84
+ if (flags.json) {
85
+ if (flags.dryRun)
86
+ return out({ status: "quote", quote: plan.quote });
87
+ }
88
+ else {
89
+ console.error(formatQuote(plan.quote));
90
+ if (flags.dryRun)
91
+ return void console.error("\nDry run: nothing was uploaded or provisioned.");
92
+ }
93
+ const interactive = hooks.interactive ?? (process.stdin.isTTY === true);
94
+ const ask = hooks.confirm ?? ((question) => confirm(question));
95
+ const canPrompt = interactive || hooks.confirm !== undefined;
96
+ // Each warning needs its own explicit acknowledgment: a flag, or an answered prompt. `--yes` alone never counts.
97
+ const acknowledged = [];
98
+ for (const warning of plan.quote.warnings) {
99
+ const flagged = warning.id === "lifetime-risk" ? flags.acceptLifetimeRisk === true : flags.acceptStorageCharges === true;
100
+ if (flagged) {
101
+ acknowledged.push(warning.id);
102
+ continue;
103
+ }
104
+ if (!canPrompt) {
105
+ throw usage("CLOUD_ACKNOWLEDGMENT_REQUIRED", `You must acknowledge: ${warning.text}`, `Re-run with ${warning.id === "lifetime-risk" ? "--accept-lifetime-risk" : "--accept-storage-charges"} (and --yes) to accept this risk without a terminal.`);
106
+ }
107
+ console.error(`\n${warning.text}`);
108
+ if (!(await ask("Do you understand and accept this risk?"))) {
109
+ if (flags.json)
110
+ out({ status: "declined", quoteId: plan.quote.id, reason: `warning not acknowledged: ${warning.id}` });
111
+ else
112
+ console.error("Not acknowledged. Nothing was uploaded or provisioned.");
113
+ process.exitCode = 2;
114
+ return;
115
+ }
116
+ acknowledged.push(warning.id);
117
+ }
118
+ let approved = flags.yes === true;
119
+ if (!approved) {
120
+ if (!canPrompt) {
121
+ throw usage("CLOUD_APPROVAL_MISMATCH", "This quote needs your approval, and no terminal is attached.", "Review it with --dry-run, then re-run with --yes to approve exactly this quote.");
122
+ }
123
+ approved = await ask("Approve this quote and start the cloud run?");
124
+ if (!approved) {
125
+ if (flags.json)
126
+ out({ status: "declined", quoteId: plan.quote.id });
127
+ else
128
+ console.error("Not approved. Nothing was uploaded or provisioned.");
129
+ process.exitCode = 2;
130
+ return;
131
+ }
132
+ }
133
+ const controller = new AbortController();
134
+ const onSignal = () => controller.abort();
135
+ process.once("SIGINT", onSignal);
136
+ process.once("SIGTERM", onSignal);
137
+ let result;
138
+ try {
139
+ result = await runModel(model, input, {
140
+ compute: "runpod",
141
+ params: flags.param,
142
+ outputDir: flags.output,
143
+ visualize: flags.visualize,
144
+ signal: controller.signal,
145
+ cloudDeps: deps,
146
+ cloud: { ...quoteOptions, quote: plan.quote, approval: { quoteId: plan.quote.id, approvedAt: new Date().toISOString(), acknowledged } },
147
+ onLog: flags.json ? undefined : (stream, chunk) => (stream === "stdout" ? process.stdout : process.stderr).write(chunk),
148
+ onProgress: flags.json ? undefined : (event) => console.error(`[${event.stage}]${event.message ? ` ${event.message}` : ""}`),
149
+ });
150
+ }
151
+ finally {
152
+ process.removeListener("SIGINT", onSignal);
153
+ process.removeListener("SIGTERM", onSignal);
154
+ }
155
+ if (flags.json) {
156
+ out({ model, ...result });
157
+ }
158
+ else {
159
+ console.log(`${result.record.model}: ${result.status}.`);
160
+ for (const output of result.record.outputs)
161
+ console.log(` ${output.id}: ${output.path}`);
162
+ for (const artifact of result.record.artifacts ?? []) {
163
+ console.log(` report: ${artifact.path}`);
164
+ console.log(` Open in your browser: ${pathToFileURL(artifact.path).href}`);
165
+ }
166
+ for (const warning of result.record.warnings ?? [])
167
+ console.error(` Warning (${warning.code}): ${warning.message}`);
168
+ if (result.record.error)
169
+ console.error(` ${result.record.error.code}: ${result.record.error.message}`);
170
+ if (result.exportError)
171
+ console.error(` --output copy failed: ${result.exportError.message}`);
172
+ if (result.record.compute?.hardware)
173
+ console.log(` Ran on: ${result.record.compute.hardware.name} (${result.record.compute.datacenterId})`);
174
+ console.log(` Run directory: ${result.record.command.cwd}`);
175
+ console.log(` Logs: ${result.record.logs.stdout} and ${result.record.logs.stderr}`);
176
+ }
177
+ if (result.cleanupPending) {
178
+ const ids = result.cleanupPending.resources.map((resource) => `${resource.role} ${resource.providerId}`).join(", ");
179
+ console.error(`CLOUD_CLEANUP_PENDING: these paid resources could not be confirmed deleted: ${ids}.`);
180
+ console.error(`Run \`moldesk cloud recover ${result.record.id}\` and check \`moldesk cloud resources\`.`);
181
+ process.exitCode = 13;
182
+ }
183
+ else if (result.status === "cancelled")
184
+ process.exitCode = 8;
185
+ else if (result.status === "failed" || result.exportError)
186
+ process.exitCode = cloudExitCodeForCode(result.record.error?.code) ?? 7;
187
+ }
188
+ catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ const code = error instanceof MoldeskError ? error.code : undefined;
191
+ const remediation = error instanceof MoldeskError ? error.remediation : undefined;
192
+ if (flags.json)
193
+ out({ model, status: "error", message, code, remediation });
194
+ else {
195
+ console.error(message);
196
+ if (remediation)
197
+ console.error(remediation);
198
+ }
199
+ process.exitCode = cloudExitCode(error);
200
+ }
201
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,261 @@
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 { getMoldeskPaths, parseManifest } from "@moldesk/core";
7
+ import { registerRunCommand } from "./run.js";
8
+ import { formatQuote } from "./run-cloud.js";
9
+ const DIGEST = "a".repeat(64);
10
+ let home;
11
+ let out;
12
+ let err;
13
+ beforeEach(() => {
14
+ home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk cli run cloud "));
15
+ out = [];
16
+ err = [];
17
+ vi.spyOn(console, "log").mockImplementation((...a) => void out.push(a.join(" ")));
18
+ vi.spyOn(console, "error").mockImplementation((...a) => void err.push(a.join(" ")));
19
+ process.exitCode = undefined;
20
+ });
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ process.exitCode = undefined;
24
+ fs.rmSync(home, { recursive: true, force: true });
25
+ });
26
+ const manifest = () => parseManifest({
27
+ schemaVersion: 1,
28
+ name: "fake-model",
29
+ displayName: "Fake",
30
+ modelVersion: "1.0.0",
31
+ adapterVersion: "0.1.0",
32
+ description: "t",
33
+ category: "other",
34
+ status: "planned",
35
+ runtimes: [{ kind: "python", python: "3.11", installer: "uv", platforms: ["linux-x64"], requirements: [{ name: "torch" }] }],
36
+ hardware: { platforms: ["linux-x64"] },
37
+ input: { formats: [".txt"], required: true },
38
+ outputs: [{ id: "result", glob: "*.out", required: true }],
39
+ cloud: { schemaVersion: 1, platform: "linux-x64", images: { gpu: `r.example/g@sha256:${DIGEST}`, cpu: `r.example/c@sha256:${DIGEST}` }, minVramGb: 24, minRamGb: 32, ephemeralDiskGb: 40, volumeGb: 40, assetLayoutRevision: "1", cpuAssetPreparation: true },
40
+ }, "x/manifest.yaml");
41
+ const adapter = {
42
+ modelName: "fake-model",
43
+ params: [],
44
+ async validateInput() { },
45
+ async installPlan() {
46
+ return { steps: [] };
47
+ },
48
+ async command() {
49
+ throw new Error("client never builds a remote command");
50
+ },
51
+ async collectOutputs(context) {
52
+ return fs.readdirSync(context.outputDir).filter((n) => n.endsWith(".out")).map((n) => ({ id: "result", path: path.join(context.outputDir, n) }));
53
+ },
54
+ async verifyInstallation() {
55
+ return { passed: true };
56
+ },
57
+ };
58
+ function fakeDeps(opts = {}) {
59
+ const pods = new Map();
60
+ const calls = { start: 0, upload: 0 };
61
+ const provider = {
62
+ name: "runpod",
63
+ getCatalog: async () => ({
64
+ retrievedAt: "2026-10-13T12:00:00.000Z",
65
+ gpus: [{ id: "a40", name: "A40", memoryGb: 48, priceUsdPerHour: 0.6, availability: "LOW", dataCenters: [{ id: "DC-1", availability: "LOW" }], cudaVersions: [] }],
66
+ cpus: [{ id: "cpu3c", name: "c", priceUsdPerVcpuHour: 0.03, ramGbPerVcpu: 2, minVcpu: 2, maxVcpu: 32 }],
67
+ dataCenters: [{ id: "DC-1", region: "EU", networkVolumeTypes: ["STANDARD"] }],
68
+ }),
69
+ listPods: async () => [...pods.values()],
70
+ getPod: async (id) => pods.get(id) ?? null,
71
+ terminatePod: async (id) => {
72
+ if (opts.terminateFails)
73
+ throw new Error("down");
74
+ pods.delete(id);
75
+ return { id, alreadyAbsent: false };
76
+ },
77
+ };
78
+ const backend = {
79
+ async start(job, context) {
80
+ calls.start += 1;
81
+ const intent = context.ledger.beginCreate("gpu");
82
+ pods.set("pod1", { id: "pod1", name: intent.name, status: "RUNNING" });
83
+ context.ledger.created(intent.intentId, "pod1");
84
+ return { version: 1, runId: job.runId, podId: "pod1", endpoint: "https://w" };
85
+ },
86
+ async observe() {
87
+ return { stage: "running", state: "succeeded", exitCode: 0, logs: { cursor: 1, stdout: "hello from the worker\n", stderr: "note\n" }, runtime: { dependencyFreeze: [], gpu: { name: "A40" } } };
88
+ },
89
+ async cancel() { },
90
+ async collect(_h, dir) {
91
+ fs.writeFileSync(path.join(dir, "answer.out"), "ok");
92
+ return { version: 1, files: [] };
93
+ },
94
+ async release() { },
95
+ };
96
+ const deps = { provider, backend, paths: getMoldeskPaths({ MOLDESK_HOME: home }), accountScope: "acct", resolveModel: () => ({ manifest: manifest(), adapter }), sleep: async () => undefined, pollIntervalMs: 1, cleanupAttempts: 1 };
97
+ return { deps, calls, pods };
98
+ }
99
+ async function run(hooks, ...args) {
100
+ const program = new Command();
101
+ program.exitOverride();
102
+ registerRunCommand(program, hooks);
103
+ await program.parseAsync(["run", "fake-model", inputPath(), ...args], { from: "user" });
104
+ }
105
+ function inputPath() {
106
+ const file = path.join(home, "in.txt");
107
+ fs.writeFileSync(file, "job");
108
+ return file;
109
+ }
110
+ const base = ["--compute", "runpod", "--cache-policy", "ephemeral", "--max-budget", "5", "--max-lifetime", "3600"];
111
+ const accept = ["--accept-lifetime-risk"];
112
+ const WARNING = "If MoleculeDesk closes or loses connection, your Runpod Pod may keep running and charging. Automatic shutdown is not guaranteed. Check your Runpod dashboard and terminate any remaining Pod.";
113
+ describe("moldesk run --compute runpod", () => {
114
+ it("rejects cloud-only flags on a local run", async () => {
115
+ await run({}, "--max-budget", "5");
116
+ expect(err.join("\n")).toMatch(/only apply with --compute runpod/);
117
+ expect(process.exitCode).toBe(2);
118
+ });
119
+ it("requires a cache policy, a budget and a lifetime before touching the provider", async () => {
120
+ const f = fakeDeps();
121
+ await run({ deps: async () => f.deps }, "--compute", "runpod", "--json");
122
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ code: "CLOUD_OPTIONS_REQUIRED" });
123
+ out.length = 0;
124
+ await run({ deps: async () => f.deps }, "--compute", "runpod", "--cache-policy", "ephemeral", "--max-lifetime", "60", "--json");
125
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ code: "CLOUD_BUDGET_REQUIRED" });
126
+ out.length = 0;
127
+ await run({ deps: async () => f.deps }, "--compute", "runpod", "--cache-policy", "ephemeral", "--max-budget", "1", "--json");
128
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ code: "CLOUD_LIFETIME_REQUIRED" });
129
+ expect(process.exitCode).toBe(2);
130
+ expect(f.calls.start).toBe(0);
131
+ });
132
+ it("rejects the docker runtime and unsupported models before anything is provisioned", async () => {
133
+ const f = fakeDeps();
134
+ await run({ deps: async () => f.deps }, ...base, "--runtime", "docker");
135
+ expect(process.exitCode).toBe(2);
136
+ process.exitCode = undefined;
137
+ const unsupported = { ...f.deps, resolveModel: () => ({ manifest: { ...manifest(), cloud: undefined }, adapter }) };
138
+ await run({ deps: async () => unsupported }, ...base, "--json");
139
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ code: "CLOUD_MODEL_UNSUPPORTED" });
140
+ expect(process.exitCode).toBe(4);
141
+ });
142
+ it("--dry-run prints the quote and provisions nothing", async () => {
143
+ const f = fakeDeps();
144
+ await run({ deps: async () => f.deps }, ...base, "--dry-run");
145
+ expect(err.join("\n")).toContain("Cloud quote (runpod");
146
+ expect(err.join("\n")).toContain("Dry run: nothing was uploaded or provisioned.");
147
+ expect(f.calls).toEqual({ start: 0, upload: 0 });
148
+ expect(process.exitCode).toBeUndefined();
149
+ expect(fs.existsSync(getMoldeskPaths({ MOLDESK_HOME: home }).runs)).toBe(false);
150
+ });
151
+ it("--dry-run --json emits a machine-readable quote on stdout only", async () => {
152
+ const f = fakeDeps();
153
+ await run({ deps: async () => f.deps }, ...base, "--dry-run", "--json");
154
+ const parsed = JSON.parse(out.join("\n"));
155
+ expect(parsed.status).toBe("quote");
156
+ expect(parsed.quote).toMatchObject({ provider: "runpod", cachePolicy: "ephemeral", gpu: { id: "a40" } });
157
+ expect(err).toEqual([]);
158
+ });
159
+ it("needs --yes without a terminal, and a declined prompt provisions nothing", async () => {
160
+ const f = fakeDeps();
161
+ await run({ deps: async () => f.deps, interactive: false }, ...base, ...accept);
162
+ expect(err.join("\n")).toMatch(/needs your approval, and no terminal is attached/);
163
+ expect(process.exitCode).toBe(14);
164
+ process.exitCode = undefined;
165
+ err.length = 0;
166
+ await run({ deps: async () => f.deps, confirm: async (q) => !q.startsWith("Approve") }, ...base, ...accept);
167
+ expect(err.join("\n")).toContain("Not approved");
168
+ expect(process.exitCode).toBe(2);
169
+ expect(f.calls.start).toBe(0);
170
+ });
171
+ it("shows the exact shutdown warning prominently, before the quote body and before any prompt", async () => {
172
+ const f = fakeDeps();
173
+ await run({ deps: async () => f.deps }, ...base, "--dry-run");
174
+ const lines = err.join("\n").split("\n");
175
+ expect(lines[0]).toMatch(/^!{40,}$/);
176
+ expect(lines[1]).toBe("WARNING: READ BEFORE APPROVING");
177
+ expect(err.join("\n")).toContain(WARNING);
178
+ expect(err.join("\n").indexOf(WARNING)).toBeLessThan(err.join("\n").indexOf("Cloud quote (runpod"));
179
+ });
180
+ it("does not accept --yes as acknowledgment of the shutdown risk", async () => {
181
+ const f = fakeDeps();
182
+ await run({ deps: async () => f.deps, interactive: false }, ...base, "--yes", "--json");
183
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ status: "error", code: "CLOUD_ACKNOWLEDGMENT_REQUIRED" });
184
+ expect(JSON.parse(out.join("\n")).message).toContain(WARNING);
185
+ expect(process.exitCode).toBe(14);
186
+ expect(f.calls.start).toBe(0);
187
+ });
188
+ it("asks for the risk acknowledgment explicitly in an interactive session, then for approval", async () => {
189
+ const f = fakeDeps();
190
+ const asked = [];
191
+ await run({ deps: async () => f.deps, confirm: async (q) => (asked.push(q), true) }, ...base);
192
+ expect(asked).toEqual(["Do you understand and accept this risk?", "Approve this quote and start the cloud run?"]);
193
+ expect(err.join("\n")).toContain(WARNING);
194
+ expect(out.join("\n")).toContain("fake-model: succeeded.");
195
+ expect(f.calls.start).toBe(1);
196
+ });
197
+ it("provisions nothing when the risk is not acknowledged", async () => {
198
+ const f = fakeDeps();
199
+ await run({ deps: async () => f.deps, confirm: async () => false }, ...base);
200
+ expect(err.join("\n")).toContain("Not acknowledged. Nothing was uploaded or provisioned.");
201
+ expect(process.exitCode).toBe(2);
202
+ expect(f.calls.start).toBe(0);
203
+ });
204
+ it("persistent caching also requires acknowledging that storage charges continue until deleted", async () => {
205
+ const f = fakeDeps();
206
+ const persistent = ["--compute", "runpod", "--cache-policy", "persistent", "--max-budget", "5", "--max-lifetime", "3600"];
207
+ await run({ deps: async () => f.deps, interactive: false }, ...persistent, "--yes", ...accept, "--json");
208
+ expect(JSON.parse(out.join("\n"))).toMatchObject({ code: "CLOUD_ACKNOWLEDGMENT_REQUIRED" });
209
+ expect(JSON.parse(out.join("\n")).message).toMatch(/Storage charges continue until you delete it/);
210
+ expect(f.calls.start).toBe(0);
211
+ out.length = 0;
212
+ err.length = 0;
213
+ process.exitCode = undefined;
214
+ const asked = [];
215
+ await run({ deps: async () => f.deps, confirm: async (q) => (asked.push(q), false) }, ...persistent, "--dry-run");
216
+ expect(err.join("\n")).toMatch(/Storage charges continue until you delete it/);
217
+ await run({ deps: async () => f.deps, confirm: async (q) => (asked.push(q), q.startsWith("Do you understand")) }, ...persistent);
218
+ expect(asked.filter((q) => q.startsWith("Do you understand"))).toHaveLength(2);
219
+ });
220
+ it("runs end to end after approval and reports where it ran", async () => {
221
+ const f = fakeDeps();
222
+ await run({ deps: async () => f.deps, confirm: async () => true }, ...base);
223
+ expect(out.join("\n")).toContain("fake-model: succeeded.");
224
+ expect(out.join("\n")).toContain("Ran on: A40 (DC-1)");
225
+ expect(process.exitCode).toBeUndefined();
226
+ expect(f.pods.size).toBe(0);
227
+ expect(f.calls.start).toBe(1);
228
+ });
229
+ it("--yes approves exactly the quote shown, with JSON on stdout", async () => {
230
+ const f = fakeDeps();
231
+ await run({ deps: async () => f.deps }, ...base, ...accept, "--yes", "--json");
232
+ const parsed = JSON.parse(out.join("\n"));
233
+ expect(parsed).toMatchObject({ status: "succeeded", record: { compute: { provider: "runpod", cleanup: "confirmed", acknowledgedRisks: ["lifetime-risk"] } } });
234
+ // Logs are persisted for --json runs too, and survive the machine being terminated.
235
+ expect(fs.readFileSync(parsed.record.logs.stdout, "utf8")).toBe("hello from the worker\n");
236
+ expect(fs.readFileSync(parsed.record.logs.stderr, "utf8")).toBe("note\n");
237
+ expect(f.pods.size).toBe(0);
238
+ });
239
+ it("exits 13 with exact resource ids and a recover hint when cleanup cannot be confirmed", async () => {
240
+ const f = fakeDeps({ terminateFails: true });
241
+ await run({ deps: async () => f.deps }, ...base, ...accept, "--yes");
242
+ expect(err.join("\n")).toMatch(/CLOUD_CLEANUP_PENDING: .*gpu pod1/);
243
+ expect(err.join("\n")).toMatch(/moldesk cloud recover /);
244
+ expect(process.exitCode).toBe(13);
245
+ });
246
+ });
247
+ describe("formatQuote", () => {
248
+ it("states ceilings, unknowns and retained storage plainly", async () => {
249
+ const f = fakeDeps();
250
+ const { planCloudRun } = await import("@moldesk/core");
251
+ const plan = await planCloudRun("fake-model", inputPath(), { cloud: { cachePolicy: "persistent", maxBudgetUsd: 5, maxLifetimeSeconds: 3600 } }, f.deps);
252
+ const text = formatQuote(plan.quote);
253
+ expect(text).toContain("not a provider-enforced spend cap");
254
+ expect(text).toContain("40 GB network volume kept until you delete it; monthly cost unknown (rate not published)");
255
+ expect(text).toContain("container disk 40 GB x 2 machine(s): cost unknown (rate not published)");
256
+ expect(text).toMatch(/NOT counted: .*container disk charges.*retained network volume storage/);
257
+ expect(text).toMatch(/unknown:\s+container disk rate is not published.*network volume rate is not published/);
258
+ expect(text).toContain("no measured estimate available");
259
+ expect(text).toContain("Prep CPU:");
260
+ });
261
+ });
@@ -1,2 +1,3 @@
1
1
  import type { Command } from "commander";
2
- export declare function registerRunCommand(program: Command): void;
2
+ import { type CloudRunHooks } from "./run-cloud.js";
3
+ export declare function registerRunCommand(program: Command, cloudHooks?: CloudRunHooks): void;
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import { MoldeskError, runModel } from "@moldesk/core";
3
+ import { assertNoCloudFlagsForLocal, runCloudCommand } from "./run-cloud.js";
3
4
  function parseRuntime(value) {
4
5
  if (value === "python" || value === "docker")
5
6
  return value;
@@ -34,7 +35,7 @@ function exitCodeFor(error) {
34
35
  return EXIT_CODES[error.code];
35
36
  return 10;
36
37
  }
37
- export function registerRunCommand(program) {
38
+ export function registerRunCommand(program, cloudHooks = {}) {
38
39
  program
39
40
  .command("run <model> <input>")
40
41
  .description("Run a model against an input file")
@@ -43,7 +44,29 @@ export function registerRunCommand(program) {
43
44
  .option("--param <key=value>", "set a model parameter (repeatable)", collectParam, {})
44
45
  .option("--no-visualize", "skip creating the offline molecular report")
45
46
  .option("--json", "print the run result as JSON")
47
+ .option("--compute <kind>", "where to run: local (default) or runpod", "local")
48
+ .option("--cache-policy <policy>", "cloud: ephemeral (nothing retained) or persistent (a billed volume is kept)")
49
+ .option("--gpu <id>", "cloud: GPU type id (default: cheapest eligible)")
50
+ .option("--datacenter <id>", "cloud: datacenter id")
51
+ .option("--max-budget <usd>", "cloud: maximum budget in USD (a guard on the quote, not a provider cap)")
52
+ .option("--max-lifetime <seconds>", "cloud: lifetime limit in seconds, enforced by MoleculeDesk only while it is running and connected (shutdown is not guaranteed)")
53
+ .option("--yes", "cloud: approve exactly the quote shown (required without a terminal)")
54
+ .option("--accept-lifetime-risk", "cloud: acknowledge that machines may keep running and charging if MoleculeDesk closes or disconnects")
55
+ .option("--accept-storage-charges", "cloud: acknowledge that a persistent cache keeps billing until you delete it")
56
+ .option("--dry-run", "cloud: print the quote and exit without uploading or provisioning")
46
57
  .action(async (model, input, options) => {
58
+ if (options.compute !== "local") {
59
+ await runCloudCommand(model, input, options, cloudHooks);
60
+ return;
61
+ }
62
+ try {
63
+ assertNoCloudFlagsForLocal(options);
64
+ }
65
+ catch (error) {
66
+ console.error(error instanceof Error ? error.message : String(error));
67
+ process.exitCode = 2;
68
+ return;
69
+ }
47
70
  const runtime = options.runtime ? parseRuntime(options.runtime) : undefined;
48
71
  const controller = new AbortController();
49
72
  const onSignal = () => controller.abort();
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { VERSION } from "@moldesk/core";
4
+ import { registerCloudCommand } from "./commands/cloud.js";
4
5
  import { registerDoctorCommand } from "./commands/doctor.js";
5
6
  import { registerListCommand } from "./commands/list.js";
6
7
  import { registerInstallCommand } from "./commands/install.js";
@@ -19,5 +20,6 @@ registerInstallCommand(program);
19
20
  registerUninstallCommand(program);
20
21
  registerRunCommand(program);
21
22
  registerViewCommand(program);
23
+ registerCloudCommand(program);
22
24
  registerVisualizerCommand(program);
23
25
  program.parse();
@@ -0,0 +1,6 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /** Asks a yes/no question on stderr (stdout stays machine-readable). Anything but y/yes declines. */
3
+ export declare function confirm(question: string, io?: {
4
+ input?: Readable;
5
+ output?: Writable;
6
+ }): Promise<boolean>;
@@ -0,0 +1,17 @@
1
+ import readline from "node:readline";
2
+ /** Asks a yes/no question on stderr (stdout stays machine-readable). Anything but y/yes declines. */
3
+ export function confirm(question, io = {}) {
4
+ const rl = readline.createInterface({ input: io.input ?? process.stdin, output: io.output ?? process.stderr, terminal: false });
5
+ return new Promise((resolve) => {
6
+ let answered = false;
7
+ rl.question(`${question} [y/N] `, (answer) => {
8
+ answered = true;
9
+ rl.close();
10
+ resolve(/^y(es)?$/i.test(answer.trim()));
11
+ });
12
+ rl.once("close", () => {
13
+ if (!answered)
14
+ resolve(false);
15
+ });
16
+ });
17
+ }
@@ -0,0 +1,17 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ interface TtyInput extends Readable {
3
+ isTTY?: boolean;
4
+ setRawMode?: (mode: boolean) => unknown;
5
+ }
6
+ export interface ReadSecretOptions {
7
+ input: TtyInput;
8
+ /** Prompt and newline go here (stderr), never the secret. */
9
+ output: Writable;
10
+ prompt: string;
11
+ }
12
+ /**
13
+ * Reads one secret without echoing it. On a terminal, characters are collected in raw mode and
14
+ * nothing is printed back. On a pipe, the first line is taken. The secret is never an argument.
15
+ */
16
+ export declare function readSecret({ input, output, prompt }: ReadSecretOptions): Promise<string>;
17
+ export {};
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reads one secret without echoing it. On a terminal, characters are collected in raw mode and
3
+ * nothing is printed back. On a pipe, the first line is taken. The secret is never an argument.
4
+ */
5
+ export function readSecret({ input, output, prompt }) {
6
+ return new Promise((resolve, reject) => {
7
+ output.write(prompt);
8
+ let buffer = "";
9
+ const interactive = input.isTTY === true && typeof input.setRawMode === "function";
10
+ if (interactive)
11
+ input.setRawMode(true);
12
+ input.resume();
13
+ input.setEncoding?.("utf8");
14
+ const finish = (action) => {
15
+ input.removeListener("data", onData);
16
+ input.removeListener("end", onEnd);
17
+ input.removeListener("error", onError);
18
+ if (interactive)
19
+ input.setRawMode(false);
20
+ input.pause();
21
+ output.write("\n");
22
+ action();
23
+ };
24
+ const onEnd = () => finish(() => resolve(buffer.replace(/\r?\n.*$/s, "")));
25
+ const onError = (error) => finish(() => reject(error));
26
+ const onData = (chunk) => {
27
+ for (const char of chunk.toString()) {
28
+ if (char === "\u0003")
29
+ return finish(() => reject(new Error("Cancelled.")));
30
+ if (char === "\r" || char === "\n")
31
+ return finish(() => resolve(buffer));
32
+ if (interactive && (char === "\u007f" || char === "\b"))
33
+ buffer = buffer.slice(0, -1);
34
+ else
35
+ buffer += char;
36
+ }
37
+ };
38
+ input.on("data", onData);
39
+ input.on("end", onEnd);
40
+ input.on("error", onError);
41
+ });
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { describe, expect, it } from "vitest";
3
+ import { readSecret } from "./secret-input.js";
4
+ function tty() {
5
+ const input = new PassThrough();
6
+ input.isTTY = true;
7
+ input.raw = [];
8
+ input.setRawMode = (mode) => void input.raw.push(mode);
9
+ return input;
10
+ }
11
+ describe("readSecret", () => {
12
+ it("never echoes typed characters and restores raw mode (interactive)", async () => {
13
+ const input = tty();
14
+ const output = new PassThrough();
15
+ const written = [];
16
+ output.on("data", (chunk) => written.push(chunk.toString()));
17
+ const pending = readSecret({ input, output, prompt: "key: " });
18
+ input.write("abc");
19
+ input.write("\u007fd12345\r");
20
+ expect(await pending).toBe("abd12345");
21
+ expect(written.join("")).toBe("key: \n");
22
+ expect(input.raw).toEqual([true, false]);
23
+ });
24
+ it("rejects on Ctrl-C", async () => {
25
+ const input = tty();
26
+ const pending = readSecret({ input, output: new PassThrough(), prompt: "k: " });
27
+ input.write("ab\u0003");
28
+ await expect(pending).rejects.toThrow("Cancelled.");
29
+ expect(input.raw).toEqual([true, false]);
30
+ });
31
+ it("takes the first line from a pipe and ignores the rest", async () => {
32
+ const input = new PassThrough();
33
+ const pending = readSecret({ input, output: new PassThrough(), prompt: "k: " });
34
+ input.end("first-line-key\nsecond\n");
35
+ expect(await pending).toBe("first-line-key");
36
+ });
37
+ it("returns what was received when a pipe ends without a newline", async () => {
38
+ const input = new PassThrough();
39
+ const pending = readSecret({ input, output: new PassThrough(), prompt: "k: " });
40
+ input.end("only-key");
41
+ expect(await pending).toBe("only-key");
42
+ });
43
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moldesk/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
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.5"
24
+ "@moldesk/core": "0.0.6"
25
25
  },
26
26
  "devDependencies": {
27
27
  "typescript": "^5.6.3",