withruntime 0.14.0 → 0.16.0

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.
Files changed (62) hide show
  1. package/BLAXEL.md +97 -32
  2. package/COMPATIBILITY.md +120 -5
  3. package/DAYTONA.md +34 -3
  4. package/E2B.md +34 -5
  5. package/README.md +33 -0
  6. package/README.src.md +33 -0
  7. package/VERCEL.md +52 -5
  8. package/compatibility-lock.json +71 -111
  9. package/dist/blaxel/client.d.ts +11 -0
  10. package/dist/blaxel/client.js +28 -0
  11. package/dist/blaxel/errors.d.ts +65 -0
  12. package/dist/blaxel/errors.js +114 -0
  13. package/dist/blaxel/index.d.ts +10 -3
  14. package/dist/blaxel/index.js +23 -4
  15. package/dist/blaxel/preview.d.ts +29 -4
  16. package/dist/blaxel/preview.js +79 -5
  17. package/dist/blaxel/sandbox.js +1 -0
  18. package/dist/blaxel/session.js +4 -2
  19. package/dist/cli-extras.js +2 -1
  20. package/dist/cli-words.js +1 -0
  21. package/dist/cli.d.ts +1 -1
  22. package/dist/cli.js +54 -21
  23. package/dist/client.d.ts +1 -1
  24. package/dist/daytona/client.d.ts +4 -0
  25. package/dist/daytona/client.js +8 -3
  26. package/dist/daytona/daytona.d.ts +3 -0
  27. package/dist/daytona/daytona.js +2 -1
  28. package/dist/e2b/sandbox.d.ts +6 -5
  29. package/dist/e2b/sandbox.js +9 -12
  30. package/dist/freestyle/index.d.ts +77 -5
  31. package/dist/freestyle/index.js +380 -48
  32. package/dist/identity.js +36 -18
  33. package/dist/index.d.ts +2 -2
  34. package/dist/machine.d.ts +24 -12
  35. package/dist/machine.js +46 -14
  36. package/dist/mcp.js +16 -3
  37. package/dist/modal/index.d.ts +15 -0
  38. package/dist/modal/index.js +35 -7
  39. package/dist/morph/index.d.ts +255 -0
  40. package/dist/morph/index.js +641 -0
  41. package/dist/network-cli.js +2 -2
  42. package/dist/product-names.d.ts +2 -2
  43. package/dist/product-names.js +2 -2
  44. package/dist/products/limits.d.ts +16 -2
  45. package/dist/products/limits.js +2 -2
  46. package/dist/products/mcp.d.ts +5 -0
  47. package/dist/products/network-products.d.ts +21 -3
  48. package/dist/products/network-products.js +3 -1
  49. package/dist/products/previews.d.ts +6 -3
  50. package/dist/products/previews.js +1 -1
  51. package/dist/snapshots.d.ts +4 -0
  52. package/dist/ssh.d.ts +35 -0
  53. package/dist/ssh.js +93 -1
  54. package/dist/transport.d.ts +11 -8
  55. package/dist/transport.js +46 -12
  56. package/dist/types.d.ts +24 -0
  57. package/dist/vercel/command.d.ts +48 -1
  58. package/dist/vercel/command.js +237 -6
  59. package/dist/vercel/sandbox-user.js +2 -1
  60. package/dist/vercel/sandbox.d.ts +16 -2
  61. package/dist/vercel/sandbox.js +45 -2
  62. package/package.json +6 -1
@@ -0,0 +1,641 @@
1
+ /** `morphcloud` 0.0.20's MorphCloudClient on Runtime: change the import to
2
+ * `withruntime/morph` and Morph's instances, snapshots and branches run as
3
+ * Runtime sandboxes and snapshots. An instance is a sandbox, a snapshot a
4
+ * Runtime snapshot, metadata its labels, an HTTP service a preview. */
5
+ import { createHash } from "node:crypto";
6
+ import { RuntimeError } from "../errors.js";
7
+ import { Sandbox } from "../sandbox.js";
8
+ import { client, CompatibilityError, only, refuse, } from "../compat/core.js";
9
+ export { CompatibilityError };
10
+ const PROVIDER = "morph";
11
+ /** Morph's base image; on Runtime, the default image. */
12
+ export const MORPH_DEFAULT_IMAGE = "morphvm-minimal";
13
+ /** Labels the adapter keeps for itself; never shown as metadata. */
14
+ const IMAGE_LABEL = "morph.image";
15
+ const DIGEST_LABEL = "morph.digest";
16
+ const HTTP_LABEL = "morph.http.";
17
+ const RESERVED = (key) => key === "compat.provider" ||
18
+ key === IMAGE_LABEL ||
19
+ key === DIGEST_LABEL ||
20
+ key.startsWith(HTTP_LABEL);
21
+ const SSH = "reach a sandbox with `runtime sandboxes ssh <id>` (also VS Code and JetBrains) or sandbox.native.tunnel()";
22
+ export var SnapshotStatus;
23
+ (function (SnapshotStatus) {
24
+ SnapshotStatus["PENDING"] = "pending";
25
+ SnapshotStatus["READY"] = "ready";
26
+ SnapshotStatus["FAILED"] = "failed";
27
+ SnapshotStatus["DELETING"] = "deleting";
28
+ SnapshotStatus["DELETED"] = "deleted";
29
+ })(SnapshotStatus || (SnapshotStatus = {}));
30
+ export var InstanceStatus;
31
+ (function (InstanceStatus) {
32
+ InstanceStatus["PENDING"] = "pending";
33
+ InstanceStatus["READY"] = "ready";
34
+ InstanceStatus["PAUSED"] = "paused";
35
+ InstanceStatus["SAVING"] = "saving";
36
+ InstanceStatus["ERROR"] = "error";
37
+ })(InstanceStatus || (InstanceStatus = {}));
38
+ // Errors ----------------------------------------------------------------------
39
+ /** Morph throws a plain Error, `HTTP <status> ... Response Body: <json>`, for
40
+ * a failed call. A Runtime error becomes the same shape, with the
41
+ * RuntimeError as its `cause` and its `status` and `code` kept. */
42
+ async function api(call) {
43
+ try {
44
+ return await call();
45
+ }
46
+ catch (error) {
47
+ if (!(error instanceof RuntimeError) || error.status === 0)
48
+ throw error;
49
+ const body = { error: { code: error.code, message: error.message, hint: error.hint } };
50
+ const translated = new Error(`HTTP ${error.status} ${error.code} for Runtime\nResponse Body: ${JSON.stringify(body, null, 2)}`, { cause: error });
51
+ Object.assign(translated, { status: error.status, code: error.code });
52
+ throw translated;
53
+ }
54
+ }
55
+ // Shapes ----------------------------------------------------------------------
56
+ const seconds = (iso) => (iso ? Math.floor(Date.parse(iso) / 1000) : 0);
57
+ const labelsOf = (value) => value.labels ?? {};
58
+ const metadataOf = (labels) => Object.fromEntries(Object.entries(labels).filter(([key]) => !RESERVED(key)));
59
+ /** Metadata must not write the adapter's own labels. */
60
+ function checkMetadata(metadata) {
61
+ if (metadata === undefined)
62
+ return {};
63
+ if (metadata === null || typeof metadata !== "object")
64
+ throw new TypeError("metadata must be an object");
65
+ for (const [key, value] of Object.entries(metadata)) {
66
+ if (RESERVED(key))
67
+ throw new CompatibilityError("Morph", `metadata key ${key}, which the adapter keeps for itself`);
68
+ if (typeof value !== "string")
69
+ throw new TypeError(`metadata ${key} must be a string`);
70
+ }
71
+ return metadata;
72
+ }
73
+ const statusOf = (state) => state === "running"
74
+ ? InstanceStatus.READY
75
+ : state === "paused"
76
+ ? InstanceStatus.PAUSED
77
+ : state === "pausing"
78
+ ? InstanceStatus.SAVING
79
+ : state === "stopping" || state === "stopped"
80
+ ? InstanceStatus.ERROR
81
+ : InstanceStatus.PENDING;
82
+ const snapshotStatusOf = (state) => state === "capturing" ? SnapshotStatus.PENDING : state;
83
+ /** A Runtime snapshot name for a Morph digest: digests are any string, names are not. */
84
+ const digestName = (digest) => `morph-${createHash("sha256").update(digest).digest("hex")}`;
85
+ function ttlOf(ttlSeconds, ttlAction) {
86
+ if (ttlAction !== undefined && ttlAction !== "stop" && ttlAction !== "pause")
87
+ throw new TypeError("ttlAction must be stop or pause");
88
+ if (ttlSeconds === undefined) {
89
+ if (ttlAction !== undefined)
90
+ throw new CompatibilityError("Morph", "ttlAction without ttlSeconds");
91
+ return {};
92
+ }
93
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 86_400)
94
+ throw new CompatibilityError("Morph", "ttlSeconds outside 60 to 86400 whole seconds (Runtime's time limit)");
95
+ // Morph's stop deletes an instance: Runtime's "delete" does the same.
96
+ return {
97
+ timeoutSeconds: ttlSeconds,
98
+ onLeaseEnd: ttlAction === "pause" ? "pause" : "delete",
99
+ };
100
+ }
101
+ /** A Runtime image for a Morph image id: Morph's base image is Runtime's
102
+ * default; any other id is a Runtime image (id, name, name:tag). */
103
+ function imageOf(imageId) {
104
+ if (imageId === undefined || imageId === MORPH_DEFAULT_IMAGE)
105
+ return undefined;
106
+ if (imageId.startsWith("morphvm-"))
107
+ throw new CompatibilityError("Morph", `image ${imageId}; build it as a Runtime image (runtime.images.build) and pass its name`);
108
+ return imageId;
109
+ }
110
+ // Images ------------------------------------------------------------------------
111
+ export class Image {
112
+ id;
113
+ name;
114
+ diskSize;
115
+ created;
116
+ description;
117
+ object = "image";
118
+ constructor(id, name, diskSize, created, description) {
119
+ this.id = id;
120
+ this.name = name;
121
+ this.diskSize = diskSize;
122
+ this.created = created;
123
+ this.description = description;
124
+ }
125
+ }
126
+ // Snapshots ---------------------------------------------------------------------
127
+ export class Snapshot {
128
+ native;
129
+ id;
130
+ object = "snapshot";
131
+ created;
132
+ status;
133
+ spec;
134
+ refs;
135
+ digest;
136
+ metadata;
137
+ #client;
138
+ constructor(native, morph) {
139
+ this.native = native;
140
+ const labels = labelsOf(native);
141
+ this.id = native.id;
142
+ this.created = seconds(native.createdAt);
143
+ this.status = snapshotStatusOf(native.state);
144
+ this.spec = {
145
+ vcpus: native.shape.vcpu,
146
+ memory: native.shape.memoryMiB,
147
+ diskSize: native.shape.diskMiB,
148
+ };
149
+ this.refs = { imageId: labels[IMAGE_LABEL] ?? MORPH_DEFAULT_IMAGE };
150
+ if (labels[DIGEST_LABEL] !== undefined)
151
+ this.digest = labels[DIGEST_LABEL];
152
+ this.metadata = metadataOf(labels);
153
+ this.#client = morph;
154
+ }
155
+ async delete() {
156
+ await api(() => this.#client.runtime.snapshots.delete(this.id));
157
+ }
158
+ /** Morph's chain hash: SHA-256 of the parent's hash, a newline and the effect. */
159
+ static computeChainHash(parentChainHash, effectIdentifier) {
160
+ return createHash("sha256")
161
+ .update(parentChainHash)
162
+ .update("\n")
163
+ .update(effectIdentifier)
164
+ .digest("hex");
165
+ }
166
+ /** Runs `command` on a copy of this snapshot and snapshots the result, once:
167
+ * a later setup of the same command on the same snapshot answers the
168
+ * snapshot made the first time. A command that fails throws, and the copy
169
+ * is stopped either way. */
170
+ async setup(command) {
171
+ if (typeof command !== "string")
172
+ throw new TypeError("command must be a string");
173
+ const morph = this.#client;
174
+ // Morph's effect identifier: its anonymous effect's name ("") and arguments.
175
+ const digest = Snapshot.computeChainHash(this.digest || this.id, JSON.stringify([command, false, true]));
176
+ const cached = await morph.snapshots.list({ digest });
177
+ if (cached.length > 0) {
178
+ if (morph.verbose)
179
+ console.log(`✅ [CACHED] ${command}`);
180
+ return cached[0];
181
+ }
182
+ if (morph.verbose)
183
+ console.log(`🚀 [RUN] ${command}`);
184
+ const instance = await morph.instances.start({ snapshotId: this.id });
185
+ try {
186
+ const result = await instance.exec(command, {
187
+ onStdout: (text) => process.stdout.write(text),
188
+ onStderr: (text) => process.stderr.write(text),
189
+ });
190
+ if (result.exit_code !== 0)
191
+ throw new Error(`Command exited with code ${result.exit_code}`);
192
+ return await instance.snapshot({ digest, metadata: this.metadata });
193
+ }
194
+ finally {
195
+ await instance.stop();
196
+ }
197
+ }
198
+ /** Adds or changes these metadata keys; the others stay. */
199
+ async setMetadata(metadata) {
200
+ const changes = checkMetadata(metadata ?? {});
201
+ const updated = await api(() => this.#client.runtime.snapshots.update(this.id, {
202
+ labels: { ...labelsOf(this.native), ...changes },
203
+ }));
204
+ this.metadata = metadataOf(labelsOf(updated));
205
+ }
206
+ }
207
+ // Instances ---------------------------------------------------------------------
208
+ export class Instance {
209
+ id;
210
+ object = "instance";
211
+ created;
212
+ status;
213
+ spec;
214
+ refs;
215
+ networking;
216
+ ttl;
217
+ wakeOn;
218
+ metadata;
219
+ #client;
220
+ #native;
221
+ constructor(sandbox, morph, previews = []) {
222
+ this.#client = morph;
223
+ this.id = sandbox.id;
224
+ this.created = seconds(sandbox.info.createdAt);
225
+ this.spec = {
226
+ vcpus: sandbox.info.vcpu,
227
+ memory: sandbox.info.memoryMiB,
228
+ diskSize: sandbox.info.diskMiB,
229
+ };
230
+ const snapshotId = sandbox.info.snapshotId ?? sandbox.info.sourceSnapshotId;
231
+ this.refs = {
232
+ snapshotId: typeof snapshotId === "string" ? snapshotId : "",
233
+ imageId: labelsOf(sandbox.info)[IMAGE_LABEL] ?? MORPH_DEFAULT_IMAGE,
234
+ };
235
+ this.#update(sandbox, previews);
236
+ }
237
+ /** The Runtime sandbox behind it, for what Morph has no call for. */
238
+ get native() {
239
+ return this.#native;
240
+ }
241
+ #update(sandbox, previews) {
242
+ this.#native = sandbox;
243
+ const info = sandbox.info;
244
+ const labels = labelsOf(info);
245
+ this.status = statusOf(info.state);
246
+ this.metadata = metadataOf(labels);
247
+ const byPort = new Map(previews.map((p) => [p.port, p]));
248
+ const httpServices = [];
249
+ for (const [key, value] of Object.entries(labels)) {
250
+ if (!key.startsWith(HTTP_LABEL))
251
+ continue;
252
+ const preview = byPort.get(Number(value));
253
+ if (preview)
254
+ httpServices.push({
255
+ name: key.slice(HTTP_LABEL.length),
256
+ port: preview.port,
257
+ url: preview.url,
258
+ });
259
+ }
260
+ this.networking = { httpServices };
261
+ this.ttl = info.timeoutSeconds
262
+ ? {
263
+ ttlSeconds: info.timeoutSeconds,
264
+ ttlExpireAt: info.endsAt ? seconds(info.endsAt) : undefined,
265
+ ttlAction: info.onLeaseEnd === "pause" ? "pause" : "stop",
266
+ }
267
+ : {};
268
+ const wake = info.autoWake !== false;
269
+ this.wakeOn = { wakeOnSsh: wake, wakeOnHttp: wake };
270
+ }
271
+ async #refresh() {
272
+ const sandbox = await this.#client.runtime.sandboxes.get(this.id);
273
+ this.#update(sandbox, await previewsOf(sandbox));
274
+ }
275
+ /** Deletes it, with its disk and memory, as Morph's stop does; its snapshots stay. */
276
+ async stop() {
277
+ await this.#client.instances.stop({ instanceId: this.id });
278
+ }
279
+ /** Keeps memory and disk, and stops compute charges. */
280
+ async pause() {
281
+ await api(async () => {
282
+ await this.#native.pause();
283
+ await this.#refresh();
284
+ });
285
+ }
286
+ async resume() {
287
+ await api(async () => {
288
+ await this.#native.wake();
289
+ await this.#refresh();
290
+ });
291
+ }
292
+ /** Adds or changes these metadata keys; the others stay. */
293
+ async setMetadata(metadata) {
294
+ const changes = checkMetadata(metadata ?? {});
295
+ await api(async () => {
296
+ await this.#native.update({ labels: { ...labelsOf(this.#native.info), ...changes } });
297
+ await this.#refresh();
298
+ });
299
+ }
300
+ /** Runtime wakes a paused sandbox on any request (an API call, SSH, a
301
+ * visit to a shared port) or on none: both values must agree. */
302
+ async setWakeOn(wakeOnSsh, wakeOnHttp) {
303
+ if (wakeOnSsh === undefined && wakeOnHttp === undefined)
304
+ return;
305
+ if (wakeOnSsh !== undefined && wakeOnHttp !== undefined && wakeOnSsh !== wakeOnHttp)
306
+ throw new CompatibilityError("Morph", "different wakeOnSsh and wakeOnHttp; Runtime's autoWake covers every kind of request");
307
+ const autoWake = (wakeOnSsh ?? wakeOnHttp);
308
+ await api(async () => {
309
+ await this.#native.update({ autoWake });
310
+ await this.#refresh();
311
+ });
312
+ }
313
+ /** Files, memory and running processes; a running instance pauses for the
314
+ * moment it takes, then runs on. */
315
+ async snapshot(options = {}) {
316
+ only("Morph snapshot", options, ["digest", "metadata"]);
317
+ const metadata = checkMetadata(options.metadata);
318
+ const digest = options.digest || undefined;
319
+ return api(async () => {
320
+ const native = await this.#native.snapshot({
321
+ ...(digest === undefined ? {} : { name: digestName(digest) }),
322
+ labels: {
323
+ ...metadata,
324
+ [IMAGE_LABEL]: this.refs.imageId,
325
+ ...(digest === undefined ? {} : { [DIGEST_LABEL]: digest }),
326
+ },
327
+ });
328
+ return new Snapshot(native, this.#client);
329
+ });
330
+ }
331
+ /** `count` running copies, with its files, memory and processes, and the
332
+ * snapshot they were started from, which is kept. */
333
+ async branch(count) {
334
+ if (!Number.isInteger(count) || count < 1)
335
+ throw new TypeError("count must be a positive integer");
336
+ const morph = this.#client;
337
+ return api(async () => {
338
+ const t = morph.runtime.transport;
339
+ const reply = await t.json({
340
+ method: "POST",
341
+ path: `/v1/sandboxes/${encodeURIComponent(this.id)}:fork`,
342
+ body: { count, keepSnapshot: true },
343
+ wait: 60,
344
+ });
345
+ if (!reply.snapshot)
346
+ throw new Error("Runtime did not keep the branch's snapshot");
347
+ return {
348
+ snapshot: new Snapshot(reply.snapshot, morph),
349
+ instances: reply.sandboxes.map((info) => new Instance(new Sandbox(t, info), morph)),
350
+ };
351
+ });
352
+ }
353
+ /** Shares `port` at an HTTPS address. With no auth_mode, anyone with the
354
+ * address reaches it (Runtime's public preview, which needs a kept
355
+ * top-up); with auth_mode "api_key", a request needs the preview's token
356
+ * in `x-runtime-preview-token` (native.previews.get(port)). */
357
+ async exposeHttpService(name, port, auth_mode) {
358
+ if (typeof name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(name))
359
+ throw new TypeError("name must be 1 to 64 letters, digits, dashes or underscores");
360
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
361
+ throw new TypeError("port must be 1 to 65535");
362
+ if (auth_mode !== undefined && auth_mode !== "none" && auth_mode !== "api_key")
363
+ throw new CompatibilityError("Morph", `auth_mode ${auth_mode}`);
364
+ return api(async () => {
365
+ const preview = await this.#native.previews.create(port, {
366
+ visibility: auth_mode === "api_key" ? "private" : "public",
367
+ });
368
+ await this.#native.update({
369
+ labels: { ...labelsOf(this.#native.info), [`${HTTP_LABEL}${name}`]: String(port) },
370
+ });
371
+ await this.#refresh();
372
+ return { name, port, url: preview.url };
373
+ });
374
+ }
375
+ async hideHttpService(name) {
376
+ await api(async () => {
377
+ const labels = { ...labelsOf(this.#native.info) };
378
+ const port = labels[`${HTTP_LABEL}${name}`];
379
+ if (port === undefined)
380
+ throw new RuntimeError({
381
+ code: "not_found",
382
+ status: 404,
383
+ message: `HTTP service ${name} not found`,
384
+ });
385
+ delete labels[`${HTTP_LABEL}${name}`];
386
+ if (!Object.entries(labels).some(([k, v]) => k.startsWith(HTTP_LABEL) && v === port))
387
+ await this.#native.previews.delete(Number(port));
388
+ await this.#native.update({ labels });
389
+ await this.#refresh();
390
+ });
391
+ }
392
+ /** A string, or a one-item array, runs in a shell; a longer array runs as
393
+ * its arguments. Output streams to onStdout and onStderr as it comes. */
394
+ async exec(command, options = {}) {
395
+ only("Morph exec", options, ["timeout", "onStdout", "onStderr"]);
396
+ const argv = typeof command === "string" ? [command] : command;
397
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((a) => typeof a !== "string"))
398
+ throw new TypeError("command must be a string or a nonempty array of strings");
399
+ const { timeout, onStdout, onStderr } = options;
400
+ if (timeout !== undefined && !(timeout > 0 && timeout <= 86_400))
401
+ throw new TypeError("timeout must be more than 0 and at most 86400 seconds");
402
+ const result = await api(() => this.#native.exec(argv.length === 1 ? argv[0] : argv, {
403
+ timeoutMs: Math.ceil((timeout ?? 86_400) * 1000),
404
+ ...(onStdout ? { onStdout } : {}),
405
+ ...(onStderr ? { onStderr } : {}),
406
+ }));
407
+ if (result.timedOut)
408
+ throw new Error(`Command execution timed out after ${timeout ?? "24 hours"} ${timeout ? (timeout === 1 ? "second" : "seconds") : ""}`);
409
+ return { exit_code: result.exitCode ?? -1, stdout: result.stdout, stderr: result.stderr };
410
+ }
411
+ /** Seconds; waits until it runs, waking it if paused. */
412
+ async waitUntilReady(timeout) {
413
+ const until = timeout === undefined ? Infinity : Date.now() + timeout * 1000;
414
+ await this.#refresh();
415
+ if (this.status === InstanceStatus.PAUSED)
416
+ await this.resume();
417
+ while (this.status !== InstanceStatus.READY) {
418
+ if (this.status === InstanceStatus.ERROR)
419
+ throw new Error("Instance encountered an error");
420
+ if (Date.now() > until)
421
+ throw new Error("Instance did not become ready before timeout");
422
+ await new Promise((resolve) => setTimeout(resolve, 250));
423
+ await this.#refresh();
424
+ }
425
+ }
426
+ /** Copies a local file or folder into the instance, or out of it: the
427
+ * instance's side is written `<instance id>:<path>`. Without delete, dry
428
+ * runs or .gitignore, which refuse. */
429
+ async sync(source, dest, options = {}) {
430
+ only("Morph sync", options, ["delete", "dryRun", "verbose", "respectGitignore"]);
431
+ if (options.delete || options.dryRun || options.respectGitignore)
432
+ throw new CompatibilityError("Morph", "sync with delete, dryRun or respectGitignore; copy with sandbox.native.files.upload or download");
433
+ const side = (path) => {
434
+ const match = /^([^:]+):(.+)$/.exec(path);
435
+ if (match && match[1] !== this.id)
436
+ throw new CompatibilityError("Morph", `sync with another instance (${match[1]})`);
437
+ return match ? match[2] : undefined;
438
+ };
439
+ const [from, to] = [side(source), side(dest)];
440
+ if ((from === undefined) === (to === undefined))
441
+ throw new Error("Exactly one of source and dest must be <instance id>:<path>");
442
+ if (options.verbose)
443
+ console.log(`[INFO] Syncing ${source} to ${dest}`);
444
+ await api(() => to !== undefined
445
+ ? this.#native.files.upload(source, to)
446
+ : this.#native.files.download(from, dest));
447
+ }
448
+ }
449
+ refuse("Morph", "Instance", Instance, {
450
+ ssh: SSH,
451
+ sshKey: SSH,
452
+ sshKeyRotate: SSH,
453
+ reboot: "restart its programs with sandbox.native.resize, or start a fresh copy from a snapshot",
454
+ setTTL: "give ttlSeconds and ttlAction to instances.start or boot; sandbox.native.extend(seconds) adds time",
455
+ });
456
+ async function previewsOf(sandbox) {
457
+ if (!Object.keys(labelsOf(sandbox.info)).some((key) => key.startsWith(HTTP_LABEL)))
458
+ return [];
459
+ return sandbox.previews.list();
460
+ }
461
+ // Client --------------------------------------------------------------------------
462
+ /** Morph's client, on Runtime. `new MorphCloudClient()` reads RUNTIME_API_KEY
463
+ * (or a saved login), never MORPH_API_KEY. */
464
+ export class MorphCloudClient {
465
+ runtime;
466
+ baseUrl;
467
+ apiKey;
468
+ verbose;
469
+ constructor(options = {}) {
470
+ const { verbose, ...rest } = options;
471
+ this.runtime = client(rest);
472
+ this.baseUrl = this.runtime.transport.baseUrl;
473
+ this.apiKey = "";
474
+ this.verbose = verbose ?? false;
475
+ }
476
+ images = {
477
+ /** Morph's base image, which is Runtime's default image. Build others with runtime.images. */
478
+ list: async (_options = {}) => [
479
+ new Image(MORPH_DEFAULT_IMAGE, MORPH_DEFAULT_IMAGE, 4096, 0, "Runtime's default image"),
480
+ ],
481
+ };
482
+ snapshots = {
483
+ list: async (options = {}) => {
484
+ only("Morph snapshots.list", options, ["digest", "metadata"]);
485
+ const want = options.metadata ?? {};
486
+ const page = await api(() => this.runtime.snapshots.list(options.digest ? { name: digestName(options.digest) } : {}));
487
+ const found = [];
488
+ for await (const native of page) {
489
+ if (native.state === "deleting" || native.state === "failed")
490
+ continue;
491
+ const labels = labelsOf(native);
492
+ if (options.digest && labels[DIGEST_LABEL] !== options.digest)
493
+ continue;
494
+ if (Object.entries(want).some(([k, v]) => labels[k] !== String(v)))
495
+ continue;
496
+ found.push(new Snapshot(native, this));
497
+ }
498
+ return found;
499
+ },
500
+ /** A bootable snapshot of an image at a size: Runtime boots a sandbox from
501
+ * the image, snapshots its disk and stops it. With a digest, a snapshot
502
+ * already made with it is answered instead. */
503
+ create: async (options = {}) => {
504
+ only("Morph snapshots.create", options, [
505
+ "imageId",
506
+ "vcpus",
507
+ "memory",
508
+ "diskSize",
509
+ "digest",
510
+ "metadata",
511
+ ]);
512
+ const metadata = checkMetadata(options.metadata);
513
+ if (options.digest) {
514
+ const cached = await this.snapshots.list({ digest: options.digest });
515
+ if (cached.length > 0)
516
+ return cached[0];
517
+ }
518
+ const image = imageOf(options.imageId);
519
+ const sandbox = await api(() => this.runtime.sandboxes.create({
520
+ ...(image === undefined ? {} : { image }),
521
+ ...(options.vcpus === undefined ? {} : { vcpu: options.vcpus }),
522
+ ...(options.memory === undefined ? {} : { memoryMiB: options.memory }),
523
+ ...(options.diskSize === undefined ? {} : { diskMiB: options.diskSize }),
524
+ labels: { "compat.provider": PROVIDER },
525
+ }));
526
+ try {
527
+ const native = await api(() => sandbox.snapshot({
528
+ mode: "disk",
529
+ ...(options.digest ? { name: digestName(options.digest) } : {}),
530
+ labels: {
531
+ ...metadata,
532
+ [IMAGE_LABEL]: options.imageId ?? MORPH_DEFAULT_IMAGE,
533
+ ...(options.digest ? { [DIGEST_LABEL]: options.digest } : {}),
534
+ },
535
+ }));
536
+ return new Snapshot(native, this);
537
+ }
538
+ finally {
539
+ // The snapshot stays when its source is deleted.
540
+ await sandbox.delete();
541
+ }
542
+ },
543
+ get: async (options) => new Snapshot(await api(() => this.runtime.snapshots.get(options.snapshotId)), this),
544
+ };
545
+ instances = {
546
+ /** Running and paused instances whose metadata match. */
547
+ list: async (options = {}) => {
548
+ only("Morph instances.list", options, ["metadata"]);
549
+ const labels = Object.fromEntries(Object.entries(options.metadata ?? {}).map(([k, v]) => [k, String(v)]));
550
+ const page = await api(() => this.runtime.sandboxes.list(Object.keys(labels).length ? { labels } : {}));
551
+ const found = [];
552
+ for await (const sandbox of page)
553
+ found.push(new Instance(sandbox, this, await previewsOf(sandbox)));
554
+ return found;
555
+ },
556
+ /** A running copy of a snapshot: files, and memory and processes when the
557
+ * snapshot kept them. */
558
+ start: async (options) => {
559
+ only("Morph instances.start", options, [
560
+ "snapshotId",
561
+ "metadata",
562
+ "ttlSeconds",
563
+ "ttlAction",
564
+ "timeout",
565
+ ]);
566
+ const metadata = checkMetadata(options.metadata);
567
+ const ttl = ttlOf(options.ttlSeconds, options.ttlAction);
568
+ const snapshot = await api(() => this.runtime.snapshots.get(options.snapshotId));
569
+ const image = labelsOf(snapshot)[IMAGE_LABEL];
570
+ const sandbox = await api(() => this.runtime.sandboxes.create({
571
+ snapshot: options.snapshotId,
572
+ ...ttl,
573
+ labels: {
574
+ ...metadata,
575
+ "compat.provider": PROVIDER,
576
+ ...(image === undefined ? {} : { [IMAGE_LABEL]: image }),
577
+ },
578
+ }, options.timeout === undefined ? {} : { timeoutMs: Math.ceil(options.timeout * 1000) }));
579
+ const instance = new Instance(sandbox, this);
580
+ Object.assign(instance.refs, { snapshotId: options.snapshotId });
581
+ return instance;
582
+ },
583
+ /** As start; a different vcpus or memory restarts it at that size (its
584
+ * files kept, its programs started afresh). The disk keeps the snapshot's size. */
585
+ boot: async (options) => {
586
+ only("Morph instances.boot", options, [
587
+ "snapshotId",
588
+ "vcpus",
589
+ "memory",
590
+ "diskSize",
591
+ "metadata",
592
+ "ttlSeconds",
593
+ "ttlAction",
594
+ ]);
595
+ const instance = await this.instances.start({
596
+ snapshotId: options.snapshotId,
597
+ metadata: options.metadata,
598
+ ttlSeconds: options.ttlSeconds,
599
+ ttlAction: options.ttlAction,
600
+ });
601
+ if (options.diskSize !== undefined && options.diskSize !== instance.spec.diskSize) {
602
+ await instance.stop();
603
+ throw new CompatibilityError("Morph", "boot with another diskSize; snapshot a sandbox created with that diskMiB");
604
+ }
605
+ const size = {
606
+ ...(options.vcpus !== undefined && options.vcpus !== instance.spec.vcpus
607
+ ? { vcpu: options.vcpus }
608
+ : {}),
609
+ ...(options.memory !== undefined && options.memory !== instance.spec.memory
610
+ ? { memoryMiB: options.memory }
611
+ : {}),
612
+ };
613
+ if (Object.keys(size).length === 0)
614
+ return instance;
615
+ try {
616
+ const sandbox = await api(() => instance.native.resize(size));
617
+ const resized = new Instance(sandbox, this);
618
+ Object.assign(resized.refs, { snapshotId: options.snapshotId });
619
+ return resized;
620
+ }
621
+ catch (error) {
622
+ await instance.stop();
623
+ throw error;
624
+ }
625
+ },
626
+ get: async (options) => {
627
+ const sandbox = await api(() => this.runtime.sandboxes.get(options.instanceId));
628
+ return new Instance(sandbox, this, await previewsOf(sandbox));
629
+ },
630
+ /** Deletes it, with its disk and memory, as Morph's stop does; its snapshots stay. */
631
+ stop: async (options) => {
632
+ await api(() => this.runtime.sandboxes.delete(options.instanceId));
633
+ },
634
+ };
635
+ }
636
+ refuse("Morph", "MorphCloudClient", MorphCloudClient, {
637
+ GET: "call Runtime's API with its own client (new Runtime())",
638
+ POST: "call Runtime's API with its own client (new Runtime())",
639
+ streamPOST: "call Runtime's API with its own client (new Runtime())",
640
+ DELETE: "call Runtime's API with its own client (new Runtime())",
641
+ });
@@ -222,11 +222,11 @@ export async function networkProductCommand(product, argv, args, out, client) {
222
222
  const all = await runtime.domains.list();
223
223
  print(all.length
224
224
  ? table([
225
- ["HOSTNAME", "STATE", "SANDBOX", "PORT"],
225
+ ["HOSTNAME", "STATE", "SERVES", "PORT"],
226
226
  ...all.map((d) => [
227
227
  d.hostname,
228
228
  d.disabled ? "taken down" : d.state,
229
- d.sandboxId,
229
+ d.sandboxId ?? `app ${d.appId ?? ""}`,
230
230
  String(d.port),
231
231
  ]),
232
232
  ])
@@ -1,5 +1,5 @@
1
- /** CLOUD.md section 4: one plural word per product on every interface (Marc,
2
- * 6 October 2026). The API path `/v1/<name>`, the SDK's `runtime.<name>`, the
1
+ /** CLOUD.md section 4: one plural word per product on every interface
2
+ * (6 October 2026). The API path `/v1/<name>`, the SDK's `runtime.<name>`, the
3
3
  * CLI's `runtime <name>` and the MCP's `runtime_<name>` all use `name` below;
4
4
  * the CLI also takes `singular` as a typing shortcut, and nothing else does.
5
5
  *
@@ -1,5 +1,5 @@
1
- /** CLOUD.md section 4: one plural word per product on every interface (Marc,
2
- * 6 October 2026). The API path `/v1/<name>`, the SDK's `runtime.<name>`, the
1
+ /** CLOUD.md section 4: one plural word per product on every interface
2
+ * (6 October 2026). The API path `/v1/<name>`, the SDK's `runtime.<name>`, the
3
3
  * CLI's `runtime <name>` and the MCP's `runtime_<name>` all use `name` below;
4
4
  * the CLI also takes `singular` as a typing shortcut, and nothing else does.
5
5
  *