smolmachines 1.25.1 → 1.25.2

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/README.md CHANGED
@@ -321,6 +321,17 @@ await m.publishCacheDisk(); // v1
321
321
  // Every machine created with cacheDisk: { cache: "deps" } now starts from v1.
322
322
  ```
323
323
 
324
+ One checkpoint can resume with many caches. Create the base machine with an
325
+ empty cache as a slot, which attaches it unmounted, and checkpoint it warm.
326
+ Each restore then mounts its own cache there, no larger than the slot and at
327
+ the slot's mount path:
328
+
329
+ ```ts
330
+ const base = await Machine.create({ image: "node:22", cacheDisk: { cache: "slot-20g", slot: true } });
331
+ const { id } = await base.checkpoint();
332
+ const a = await Machine.restoreCheckpoint(id, "project-a", undefined, { cacheDisk: { cache: "project-a" } });
333
+ ```
334
+
324
335
  ## Building from source
325
336
 
326
337
  This package's native core lives alongside it (Rust, `src/*.rs`) and links the
@@ -143,6 +143,26 @@ export interface paths {
143
143
  patch?: never;
144
144
  trace?: never;
145
145
  };
146
+ "/v1/account/plan/checkout": {
147
+ parameters: {
148
+ query?: never;
149
+ header?: never;
150
+ path?: never;
151
+ cookie?: never;
152
+ };
153
+ get?: never;
154
+ put?: never;
155
+ /**
156
+ * Subscribe to a plan through Stripe Checkout.
157
+ * @description Returns a Stripe-hosted page that shows the plan and its monthly price, takes a card and collects the first month. Once Stripe confirms the payment the account moves onto the plan and keeps the card for later charges. For an account with no card on file; with one, change plan directly. Requires the `billing:write` scope.
158
+ */
159
+ post: operations["account_plan_checkout"];
160
+ delete?: never;
161
+ options?: never;
162
+ head?: never;
163
+ patch?: never;
164
+ trace?: never;
165
+ };
146
166
  "/v1/account/plans": {
147
167
  parameters: {
148
168
  query?: never;
@@ -439,6 +459,26 @@ export interface paths {
439
459
  patch?: never;
440
460
  trace?: never;
441
461
  };
462
+ "/v1/billing/card": {
463
+ parameters: {
464
+ query?: never;
465
+ header?: never;
466
+ path?: never;
467
+ cookie?: never;
468
+ };
469
+ /**
470
+ * Get the card on file.
471
+ * @description Returns the brand, last four digits and expiry of the card the account's plan fee and auto top-ups are charged to. Requires the `usage:read` or `machine:read` scope.
472
+ */
473
+ get: operations["billing_card_get"];
474
+ put?: never;
475
+ post?: never;
476
+ delete?: never;
477
+ options?: never;
478
+ head?: never;
479
+ patch?: never;
480
+ trace?: never;
481
+ };
442
482
  "/v1/billing/checkout-session": {
443
483
  parameters: {
444
484
  query?: never;
@@ -2323,8 +2363,7 @@ export interface components {
2323
2363
  scopes: string[];
2324
2364
  };
2325
2365
  /**
2326
- * @description Opt-in saved-card auto-recharge settings (`GET`/`POST /v1/billing/auto-recharge`).
2327
- * Threshold/amount are the RESOLVED effective values (per-tenant override, else
2366
+ * @description Threshold/amount are the RESOLVED effective values (per-tenant override, else
2328
2367
  * fleet default), so the UI can render exactly what will happen.
2329
2368
  */
2330
2369
  AutoRechargeSettings: {
@@ -2388,6 +2427,21 @@ export interface components {
2388
2427
  */
2389
2428
  version: number;
2390
2429
  };
2430
+ /**
2431
+ * @description Opt-in saved-card auto-recharge settings (`GET`/`POST /v1/billing/auto-recharge`).
2432
+ * The card on file: what the account's plan fee and auto top-ups are charged
2433
+ * to, as Stripe describes it. Never the number itself.
2434
+ */
2435
+ CardOnFile: {
2436
+ /** @description Card network, e.g. `visa`, `mastercard`, `amex`. */
2437
+ brand: string;
2438
+ /** Format: int32 */
2439
+ expMonth: number;
2440
+ /** Format: int32 */
2441
+ expYear: number;
2442
+ /** @description Last four digits. */
2443
+ last4: string;
2444
+ };
2391
2445
  /**
2392
2446
  * @description Ask to move this tenant onto a different plan. Only plans whose
2393
2447
  * `self_serve` flag is set may be chosen; operator tiers are assigned by an
@@ -2982,7 +3036,11 @@ export interface components {
2982
3036
  /** @description The cache disk a machine runs with. */
2983
3037
  MachineCacheDisk: {
2984
3038
  cacheDiskId: string;
3039
+ /** @description Put in a cache slot by a restore, which needs an engine that serves slots. */
3040
+ intoSlot?: boolean;
2985
3041
  mountPath: string;
3042
+ /** @description Attached unmounted, as a slot a restore can put another cache in. */
3043
+ slot?: boolean;
2986
3044
  /** Format: int32 */
2987
3045
  version: number;
2988
3046
  };
@@ -2998,6 +3056,12 @@ export interface components {
2998
3056
  * left out.
2999
3057
  */
3000
3058
  mountPath?: string | null;
3059
+ /**
3060
+ * @description Attach it as a slot: left unmounted, so a checkpoint of the machine can
3061
+ * be restored with a different cache mounted here (`cacheDisk` on restore).
3062
+ * The slot's size is this cache disk's size; its contents are never read.
3063
+ */
3064
+ slot?: boolean;
3001
3065
  /**
3002
3066
  * Format: int32
3003
3067
  * @description Version to start from; the latest when left out.
@@ -3729,6 +3793,7 @@ export interface components {
3729
3793
  };
3730
3794
  /** @description Create a stopped machine from a durable portable checkpoint. */
3731
3795
  RestorePortableCheckpointRequest: {
3796
+ cacheDisk?: null | components["schemas"]["MachineCacheDiskSpec"];
3732
3797
  name: string;
3733
3798
  };
3734
3799
  ScaleRequest: {
@@ -4268,7 +4333,7 @@ export interface operations {
4268
4333
  "application/json": components["schemas"]["ErrorResponse"];
4269
4334
  };
4270
4335
  };
4271
- /** @description The plan's monthly fee has no saved card to bill, or the card was declined; the plan does not move. */
4336
+ /** @description The plan's monthly fee has no saved card to bill (subscribe through /v1/account/plan/checkout instead), or the card was declined; the plan does not move. */
4272
4337
  402: {
4273
4338
  headers: {
4274
4339
  [name: string]: unknown;
@@ -4333,6 +4398,91 @@ export interface operations {
4333
4398
  };
4334
4399
  };
4335
4400
  };
4401
+ account_plan_checkout: {
4402
+ parameters: {
4403
+ query?: never;
4404
+ header?: never;
4405
+ path?: never;
4406
+ cookie?: never;
4407
+ };
4408
+ requestBody: {
4409
+ content: {
4410
+ "application/json": components["schemas"]["ChangePlanRequest"];
4411
+ };
4412
+ };
4413
+ responses: {
4414
+ /** @description Returns {"url": <stripe-hosted url>} for a Stripe Checkout in subscription mode; the browser comes back to the console's billing page. */
4415
+ 200: {
4416
+ headers: {
4417
+ [name: string]: unknown;
4418
+ };
4419
+ content?: never;
4420
+ };
4421
+ /** @description Missing or invalid API key */
4422
+ 401: {
4423
+ headers: {
4424
+ [name: string]: unknown;
4425
+ };
4426
+ content: {
4427
+ "application/json": components["schemas"]["ErrorResponse"];
4428
+ };
4429
+ };
4430
+ /** @description Key lacks the required scope */
4431
+ 403: {
4432
+ headers: {
4433
+ [name: string]: unknown;
4434
+ };
4435
+ content: {
4436
+ "application/json": components["schemas"]["ErrorResponse"];
4437
+ };
4438
+ };
4439
+ /** @description The account is already on that plan, or already pays a plan fee */
4440
+ 409: {
4441
+ headers: {
4442
+ [name: string]: unknown;
4443
+ };
4444
+ content: {
4445
+ "application/json": components["schemas"]["ErrorResponse"];
4446
+ };
4447
+ };
4448
+ /** @description That plan is not self-serve, or has no monthly fee */
4449
+ 422: {
4450
+ headers: {
4451
+ [name: string]: unknown;
4452
+ };
4453
+ content: {
4454
+ "application/json": components["schemas"]["ErrorResponse"];
4455
+ };
4456
+ };
4457
+ /** @description Rate limited; retry after the Retry-After header */
4458
+ 429: {
4459
+ headers: {
4460
+ [name: string]: unknown;
4461
+ };
4462
+ content: {
4463
+ "application/json": components["schemas"]["ErrorResponse"];
4464
+ };
4465
+ };
4466
+ /** @description Internal error */
4467
+ 500: {
4468
+ headers: {
4469
+ [name: string]: unknown;
4470
+ };
4471
+ content: {
4472
+ "application/json": components["schemas"]["ErrorResponse"];
4473
+ };
4474
+ };
4475
+ /** @description Paid plans are not enabled on this deployment */
4476
+ 503: {
4477
+ headers: {
4478
+ [name: string]: unknown;
4479
+ };
4480
+ content: {
4481
+ "application/json": components["schemas"]["ErrorResponse"];
4482
+ };
4483
+ };
4484
+ };
4485
+ };
4336
4486
  account_plans_list: {
4337
4487
  parameters: {
4338
4488
  query?: never;
@@ -5549,6 +5699,80 @@ export interface operations {
5549
5699
  };
5550
5700
  };
5551
5701
  };
5702
+ billing_card_get: {
5703
+ parameters: {
5704
+ query?: never;
5705
+ header?: never;
5706
+ path?: never;
5707
+ cookie?: never;
5708
+ };
5709
+ requestBody?: never;
5710
+ responses: {
5711
+ /** @description The card on file */
5712
+ 200: {
5713
+ headers: {
5714
+ [name: string]: unknown;
5715
+ };
5716
+ content: {
5717
+ "application/json": components["schemas"]["CardOnFile"];
5718
+ };
5719
+ };
5720
+ /** @description Missing or invalid API key */
5721
+ 401: {
5722
+ headers: {
5723
+ [name: string]: unknown;
5724
+ };
5725
+ content: {
5726
+ "application/json": components["schemas"]["ErrorResponse"];
5727
+ };
5728
+ };
5729
+ /** @description Key lacks the required scope */
5730
+ 403: {
5731
+ headers: {
5732
+ [name: string]: unknown;
5733
+ };
5734
+ content: {
5735
+ "application/json": components["schemas"]["ErrorResponse"];
5736
+ };
5737
+ };
5738
+ /** @description No card on file, or card payments are not enabled */
5739
+ 404: {
5740
+ headers: {
5741
+ [name: string]: unknown;
5742
+ };
5743
+ content: {
5744
+ "application/json": components["schemas"]["ErrorResponse"];
5745
+ };
5746
+ };
5747
+ /** @description Rate limited; retry after the Retry-After header */
5748
+ 429: {
5749
+ headers: {
5750
+ [name: string]: unknown;
5751
+ };
5752
+ content: {
5753
+ "application/json": components["schemas"]["ErrorResponse"];
5754
+ };
5755
+ };
5756
+ /** @description Internal error */
5757
+ 500: {
5758
+ headers: {
5759
+ [name: string]: unknown;
5760
+ };
5761
+ content: {
5762
+ "application/json": components["schemas"]["ErrorResponse"];
5763
+ };
5764
+ };
5765
+ /** @description The payment provider did not answer */
5766
+ 502: {
5767
+ headers: {
5768
+ [name: string]: unknown;
5769
+ };
5770
+ content: {
5771
+ "application/json": components["schemas"]["ErrorResponse"];
5772
+ };
5773
+ };
5774
+ };
5775
+ };
5552
5776
  billing_checkout_session: {
5553
5777
  parameters: {
5554
5778
  query?: never;
package/dist/index.d.ts CHANGED
@@ -22,4 +22,4 @@ export type { BuildRolloutJobOptions, EnsureRolloutExecutorOptions, RolloutBatch
22
22
  export { SmolError, NotSupportedError, InvalidConfigError, ExecutionError } from './errors';
23
23
  export type { CloudCheckpointInfo } from './transport';
24
24
  export type { LocalAvailability, LocalUnavailableCode } from './availability';
25
- export type { MachineConfig, CredentialSpec, CacheDiskRef, CacheDiskInfo, CacheDiskVersion, CreateCacheDiskOptions, PublishedCacheDisk, NetworkPolicy, RestoreCheckpointOptions, MachineState, ResourceSpec, MountSpec, PortSpec, BranchOptions, BranchBatchOptions, CheckpointOptions, ResizeOptions, MachineResources, ForkOptions, ForkBatchOptions, AssignOptions, ExecOptions, ExecResult, MachineUsageReport, ShareLink, PortableCheckpointInfo, MachineUsageTotals, MachineCostBreakdown, ImageInfo, MachineSummary, ListOptions, ExecEvent, ConnectOptions, EgressInterceptor, StartOptions, WaitReadyOptions, PortEndpoint, } from './types';
25
+ export type { MachineConfig, CredentialSpec, CacheDiskRef, CacheDiskInfo, CacheDiskVersion, CreateCacheDiskOptions, PublishedCacheDisk, NetworkPolicy, RestoreCheckpointOptions, MachineState, ResourceSpec, MountSpec, PortSpec, BranchOptions, BranchBatchOptions, CheckpointOptions, ResizeOptions, MachineResources, ForkOptions, ForkBatchOptions, AssignOptions, ExecOptions, ExecResult, MachineUsageReport, ShareLink, PortableCheckpointInfo, MachineUsageTotals, MachineCostBreakdown, ImageInfo, MachineSummary, ListOptions, ExecEvent, ConnectOptions, EgressInterceptor, StartOptions, ResumeOptions, WaitReadyOptions, PortEndpoint, } from './types';
package/dist/machine.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { type LocalAvailability } from "./availability";
11
11
  import { type CloudCheckpointInfo, type Transport } from "./transport";
12
- import type { CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, AssignOptions, BranchBatchOptions, BranchOptions, CheckpointOptions, ConnectOptions, ExecEvent, ExecOptions, ExecResult, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, NetworkPolicy, RestoreCheckpointOptions, StartOptions, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
12
+ import type { CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, AssignOptions, BranchBatchOptions, BranchOptions, CheckpointOptions, ConnectOptions, ExecEvent, ExecOptions, ExecResult, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, NetworkPolicy, RestoreCheckpointOptions, StartOptions, ResumeOptions, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
13
13
  export declare class Machine {
14
14
  private readonly transport;
15
15
  private constructor();
@@ -50,6 +50,10 @@ export declare class Machine {
50
50
  * @param options `labels`: keep machines carrying every one of these
51
51
  */
52
52
  static list(conn?: ConnectOptions, options?: ListOptions): Promise<MachineSummary[]>;
53
+ /** Check that a cloud connection works: the key is accepted and the base URL
54
+ * answers with a machine list. Throws otherwise, including when a proxy or
55
+ * a wrong URL answers with something that is not a machine list. */
56
+ static probe(conn?: ConnectOptions): Promise<void>;
53
57
  /** Upload a `.checkpoint` file taken on this computer to the cloud, so it
54
58
  * can be restored there by the returned id. Restoring the file with a cloud
55
59
  * target uploads it for you. */
@@ -85,6 +89,15 @@ export declare class Machine {
85
89
  * process launched; it does not mean the guest or workload is ready. Use
86
90
  * `ready()` or `waitUntilReady()` before doing work. */
87
91
  state(): Promise<string>;
92
+ /** The machine's full cloud record, every field the control plane returns
93
+ * (env, readiness, ports, …). Cloud only. */
94
+ info(): Promise<Record<string, unknown>>;
95
+ /** The last `tail` lines of the machine's console log. Cloud only. */
96
+ logs(tail?: number): Promise<string>;
97
+ /** Export this stopped machine as a `.smolmachine` in your registry
98
+ * namespace, re-deployable anywhere. Distinct from a portable checkpoint,
99
+ * which also keeps live memory. Cloud only. */
100
+ exportArtifact(): Promise<Record<string, unknown>>;
88
101
  /** Whether the machine is READY to do work. `state()` becoming "started"
89
102
  * means only that the VM process launched — the guest is still booting and
90
103
  * is NOT yet usable. `ready` becomes true once the in-VM agent is reachable
@@ -140,7 +153,7 @@ export declare class Machine {
140
153
  /** Save RAM and disk durably, then stop at that execution boundary. */
141
154
  pause(): Promise<void>;
142
155
  /** Resume saved execution in this machine. */
143
- resume(): Promise<void>;
156
+ resume(options?: ResumeOptions): Promise<void>;
144
157
  /** Boot a stopped machine with its disk state, not its previous RAM.
145
158
  * The local handle reuses its interceptor binding or accepts a fresh one. */
146
159
  start(options?: StartOptions): Promise<void>;
package/dist/machine.js CHANGED
@@ -31,6 +31,7 @@ const transport_1 = require("./transport");
31
31
  function makeExecResult(r) {
32
32
  const success = r.exitCode === 0;
33
33
  return {
34
+ ...(r.pid !== undefined ? { pid: r.pid } : {}),
34
35
  exitCode: r.exitCode,
35
36
  stdout: r.stdout,
36
37
  stderr: r.stderr,
@@ -102,6 +103,14 @@ class Machine {
102
103
  static async list(conn = {}, options = {}) {
103
104
  return (0, transport_1.listMachines)(conn, options);
104
105
  }
106
+ /** Check that a cloud connection works: the key is accepted and the base URL
107
+ * answers with a machine list. Throws otherwise, including when a proxy or
108
+ * a wrong URL answers with something that is not a machine list. */
109
+ static async probe(conn = {}) {
110
+ if (conn.target === "local")
111
+ throw new errors_1.NotSupportedError("probe is cloud-only.");
112
+ await (0, transport_1.listMachines)({ ...conn, target: "cloud" });
113
+ }
105
114
  /** Upload a `.checkpoint` file taken on this computer to the cloud, so it
106
115
  * can be restored there by the returned id. Restoring the file with a cloud
107
116
  * target uploads it for you. */
@@ -167,6 +176,27 @@ class Machine {
167
176
  state() {
168
177
  return this.transport.state();
169
178
  }
179
+ /** The machine's full cloud record, every field the control plane returns
180
+ * (env, readiness, ports, …). Cloud only. */
181
+ info() {
182
+ if (!this.transport.info)
183
+ throw new errors_1.NotSupportedError("info is cloud-only.");
184
+ return this.transport.info();
185
+ }
186
+ /** The last `tail` lines of the machine's console log. Cloud only. */
187
+ logs(tail = 100) {
188
+ if (!this.transport.logs)
189
+ throw new errors_1.NotSupportedError("logs is cloud-only.");
190
+ return this.transport.logs(tail);
191
+ }
192
+ /** Export this stopped machine as a `.smolmachine` in your registry
193
+ * namespace, re-deployable anywhere. Distinct from a portable checkpoint,
194
+ * which also keeps live memory. Cloud only. */
195
+ exportArtifact() {
196
+ if (!this.transport.exportArtifact)
197
+ throw new errors_1.NotSupportedError("exportArtifact is cloud-only.");
198
+ return this.transport.exportArtifact();
199
+ }
170
200
  /** Whether the machine is READY to do work. `state()` becoming "started"
171
201
  * means only that the VM process launched — the guest is still booting and
172
202
  * is NOT yet usable. `ready` becomes true once the in-VM agent is reachable
@@ -262,11 +292,13 @@ class Machine {
262
292
  /** Save RAM and disk durably, then stop at that execution boundary. */
263
293
  pause() { return this.transport.pause(); }
264
294
  /** Resume saved execution in this machine. */
265
- resume() { return this.transport.resume(); }
295
+ resume(options = {}) {
296
+ return this.transport.resume(options.waitUntilReady ?? true);
297
+ }
266
298
  /** Boot a stopped machine with its disk state, not its previous RAM.
267
299
  * The local handle reuses its interceptor binding or accepts a fresh one. */
268
300
  start(options = {}) {
269
- return this.transport.start(options.egressInterceptor);
301
+ return this.transport.start(options.egressInterceptor, options.waitUntilReady ?? true);
270
302
  }
271
303
  delete(opts) {
272
304
  if (opts?.includeUsage)
@@ -10,6 +10,8 @@ import { type NativeMachineConfig } from "./native";
10
10
  import type { CheckpointOptions, CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, ConnectOptions, NetworkPolicy, RestoreCheckpointOptions, ExecEvent, ExecOptions, EgressInterceptor, AssignOptions, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
11
11
  /** Raw exec result (the ergonomic wrapper is added in machine.ts). */
12
12
  export interface RawExec {
13
+ /** The detached process's pid, for a background exec. */
14
+ pid?: number;
13
15
  exitCode: number;
14
16
  stdout: string;
15
17
  stderr: string;
@@ -23,6 +25,12 @@ export interface RawExec {
23
25
  }
24
26
  export interface Transport {
25
27
  readonly name: string;
28
+ /** Cloud only: the machine's full record as the control plane returns it. */
29
+ info?(): Promise<Record<string, unknown>>;
30
+ /** Cloud only: the last `tail` lines of the machine's console log. */
31
+ logs?(tail: number): Promise<string>;
32
+ /** Cloud only: export the stopped machine as a `.smolmachine` in the registry. */
33
+ exportArtifact?(): Promise<Record<string, unknown>>;
26
34
  state(): Promise<string>;
27
35
  ready(): Promise<boolean>;
28
36
  readyAt(): Promise<string | null>;
@@ -41,8 +49,8 @@ export interface Transport {
41
49
  setNetworkPolicy(policy: NetworkPolicy): Promise<void>;
42
50
  stop(): Promise<void>;
43
51
  pause(): Promise<void>;
44
- resume(): Promise<void>;
45
- start(interceptor?: EgressInterceptor): Promise<void>;
52
+ resume(waitUntilReady?: boolean): Promise<void>;
53
+ start(interceptor?: EgressInterceptor, waitUntilReady?: boolean): Promise<void>;
46
54
  delete(): Promise<void>;
47
55
  /** Cloud only: delete and return the settled usage + cost in one call. */
48
56
  deleteWithUsage(): Promise<MachineUsageReport>;
@@ -110,6 +118,9 @@ export declare function toNativeConfig(name: string, config: MachineConfig): Nat
110
118
  interface CloudConn {
111
119
  baseUrl: string;
112
120
  apiKey: string;
121
+ /** The caller's `fetch` (ConnectOptions.fetch), when it supplied one. */
122
+ fetch?: typeof fetch;
123
+ readResponseBytes?: (response: Response) => Promise<Uint8Array>;
113
124
  }
114
125
  /** The sentence a person should read from a failed response's body. The
115
126
  * control plane answers errors as RFC 9457 problem+json, whose `detail` is the
package/dist/transport.js CHANGED
@@ -526,7 +526,7 @@ class LocalTransport {
526
526
  }
527
527
  liveLocal.delete(this);
528
528
  }
529
- async resume() {
529
+ async resume(waitUntilReady = true) {
530
530
  try {
531
531
  await this.inner.resume();
532
532
  }
@@ -535,9 +535,10 @@ class LocalTransport {
535
535
  }
536
536
  if (this.cleanupOnExit)
537
537
  liveLocal.add(this);
538
- await this.waitUntilReady();
538
+ if (waitUntilReady)
539
+ await this.waitUntilReady();
539
540
  }
540
- async start(interceptor) {
541
+ async start(interceptor, waitUntilReady = true) {
541
542
  try {
542
543
  const binding = interceptor ?? this.interceptor;
543
544
  if (binding)
@@ -552,7 +553,8 @@ class LocalTransport {
552
553
  }
553
554
  if (this.cleanupOnExit)
554
555
  liveLocal.add(this);
555
- await this.waitUntilReady();
556
+ if (waitUntilReady)
557
+ await this.waitUntilReady();
556
558
  }
557
559
  async delete() {
558
560
  liveLocal.delete(this);
@@ -685,6 +687,15 @@ class LocalTransport {
685
687
  throw new errors_1.NotSupportedError("getLease() is a cloud-only lease feature.");
686
688
  }
687
689
  }
690
+ /** A cloud connection carrying the caller's transport hooks. */
691
+ function cloudConnOf(conn, baseUrl, apiKey) {
692
+ return {
693
+ baseUrl,
694
+ apiKey,
695
+ ...(conn.fetch ? { fetch: conn.fetch } : {}),
696
+ ...(conn.readResponseBytes ? { readResponseBytes: conn.readResponseBytes } : {}),
697
+ };
698
+ }
688
699
  const DEFAULT_CLOUD_URL = "https://api.smolmachines.com";
689
700
  /** Default per-request timeout for cloud calls (ms). Override via opts.timeoutMs. */
690
701
  const CLOUD_TIMEOUT_MS = 30_000;
@@ -750,7 +761,7 @@ async function cloudFetch(conn, method, path, opts = {}) {
750
761
  opts.signal?.addEventListener("abort", onAbort, { once: true });
751
762
  let res;
752
763
  try {
753
- res = await fetch(`${conn.baseUrl}${path}`, {
764
+ res = await (conn.fetch ?? fetch)(`${conn.baseUrl}${path}`, {
754
765
  method,
755
766
  headers,
756
767
  body: body ?? null,
@@ -763,6 +774,9 @@ async function cloudFetch(conn, method, path, opts = {}) {
763
774
  if (e.name === "AbortError") {
764
775
  throw new errors_1.SmolError("TIMEOUT", `cloud ${method} ${path} timed out after ${opts.timeoutMs ?? CLOUD_TIMEOUT_MS}ms`);
765
776
  }
777
+ // A caller's own fetch owns its failure modes (retries, typed errors).
778
+ if (conn.fetch)
779
+ throw e;
766
780
  throw new errors_1.SmolError("CONNECTION", `cloud request failed: ${e.message}`);
767
781
  }
768
782
  finally {
@@ -781,8 +795,15 @@ async function cloudFetch(conn, method, path, opts = {}) {
781
795
  ? "UNAUTHORIZED"
782
796
  : "SMOLVM_ERROR", `cloud ${method} ${path} → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
783
797
  }
784
- if (opts.accept === "bytes")
798
+ if (opts.accept === "text")
799
+ return (await res.text());
800
+ if (opts.accept === "bytes") {
801
+ if (conn.readResponseBytes) {
802
+ const bytes = await conn.readResponseBytes(res);
803
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
804
+ }
785
805
  return Buffer.from(await res.arrayBuffer());
806
+ }
786
807
  if (res.status === 204)
787
808
  return undefined;
788
809
  const ct = res.headers.get("content-type") ?? "";
@@ -947,6 +968,7 @@ class CloudTransport {
947
968
  cwd: opts?.workdir ?? null,
948
969
  timeoutSeconds: opts?.timeout ?? null,
949
970
  ...(opts?.user !== undefined ? { user: opts.user } : {}),
971
+ ...(opts?.background ? { background: true } : {}),
950
972
  };
951
973
  // The command may legitimately run far longer than the default cloud
952
974
  // timeout, so size the request abort timeout off the request's own timeout
@@ -971,7 +993,10 @@ class CloudTransport {
971
993
  // `stdoutB64`/`stderrB64` are byte-exact and untruncated; the generated
972
994
  // schema type may lag the server, so read them off the raw object.
973
995
  const raw = r;
996
+ // A detached command reports its pid as `pid=<n>` on stdout.
997
+ const pidMatch = opts?.background ? /^pid=(\d+)/m.exec(stdout) : null;
974
998
  return {
999
+ ...(pidMatch ? { pid: Number(pidMatch[1]) } : {}),
975
1000
  exitCode: r.exitCode ?? 0,
976
1001
  stdout,
977
1002
  stderr,
@@ -1002,7 +1027,7 @@ class CloudTransport {
1002
1027
  };
1003
1028
  let res;
1004
1029
  try {
1005
- res = await fetch(`${this.conn.baseUrl}/v1/machines/${this.id}/exec/stream`, {
1030
+ res = await (this.conn.fetch ?? fetch)(`${this.conn.baseUrl}/v1/machines/${this.id}/exec/stream`, {
1006
1031
  method: "POST",
1007
1032
  headers: {
1008
1033
  authorization: `Bearer ${this.conn.apiKey}`,
@@ -1137,18 +1162,34 @@ class CloudTransport {
1137
1162
  async pause() {
1138
1163
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/pause`, { timeoutMs: CLOUD_START_TIMEOUT_MS });
1139
1164
  }
1140
- async resume() {
1165
+ async resume(waitUntilReady = true) {
1141
1166
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/resume`, { timeoutMs: CLOUD_START_TIMEOUT_MS });
1142
- await waitForReady(this.conn, this.id);
1167
+ if (waitUntilReady)
1168
+ await waitForReady(this.conn, this.id);
1143
1169
  }
1144
- async start(interceptor) {
1170
+ async start(interceptor, waitUntilReady = true) {
1145
1171
  if (interceptor)
1146
1172
  throw new errors_1.NotSupportedError("egressInterceptor is local-only.");
1147
1173
  // Resume a stopped machine, then wait for its agent so the handle is usable.
1148
1174
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/start`, {
1149
1175
  timeoutMs: CLOUD_START_TIMEOUT_MS,
1150
1176
  });
1151
- await waitForReady(this.conn, this.id);
1177
+ if (waitUntilReady)
1178
+ await waitForReady(this.conn, this.id);
1179
+ }
1180
+ async info() {
1181
+ return cloudFetch(this.conn, "GET", `/v1/machines/${this.id}`);
1182
+ }
1183
+ async logs(tail) {
1184
+ return cloudFetch(this.conn, "GET", `/v1/machines/${this.id}/logs?tail=${tail}`, {
1185
+ accept: "text",
1186
+ });
1187
+ }
1188
+ async exportArtifact() {
1189
+ return cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/export`, {
1190
+ json: {},
1191
+ timeoutMs: CLOUD_START_TIMEOUT_MS,
1192
+ });
1152
1193
  }
1153
1194
  async delete() {
1154
1195
  await cloudFetch(this.conn, "DELETE", `/v1/machines/${this.id}`);
@@ -1443,7 +1484,7 @@ async function makeTransport(config, conn) {
1443
1484
  process.env.SMOL_CLOUD_URL ??
1444
1485
  cliUrl ??
1445
1486
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1446
- const cloudConn = { baseUrl, apiKey: key };
1487
+ const cloudConn = cloudConnOf(conn, baseUrl, key);
1447
1488
  await storeCloudCredentials(cloudConn, config.credentials);
1448
1489
  // smolfleet CreateMachineRequest (camelCase): source (tagged), nested
1449
1490
  // resources, network {mode}, autoStopSeconds, ttlSeconds. Optional numeric
@@ -1451,6 +1492,7 @@ async function makeTransport(config, conn) {
1451
1492
  // (exactOptionalPropertyTypes forbids passing `undefined` explicitly).
1452
1493
  const createBody = {
1453
1494
  name: config.name ?? null,
1495
+ ...(config.labels && Object.keys(config.labels).length ? { labels: config.labels } : {}),
1454
1496
  source: { type: "image", reference: config.image },
1455
1497
  resources: {
1456
1498
  ...(config.resources?.cpus !== undefined
@@ -1487,15 +1529,7 @@ async function makeTransport(config, conn) {
1487
1529
  ...(config.credentials?.length
1488
1530
  ? { credentials: config.credentials.map((c) => c.name) }
1489
1531
  : {}),
1490
- ...(config.cacheDisk
1491
- ? {
1492
- cacheDisk: {
1493
- cache: config.cacheDisk.cache,
1494
- ...(config.cacheDisk.version !== undefined ? { version: config.cacheDisk.version } : {}),
1495
- ...(config.cacheDisk.mountPath !== undefined ? { mountPath: config.cacheDisk.mountPath } : {}),
1496
- },
1497
- }
1498
- : {}),
1532
+ ...(config.cacheDisk ? { cacheDisk: cacheDiskBody(config.cacheDisk) } : {}),
1499
1533
  autoStopSeconds: config.autoStopSeconds ?? null,
1500
1534
  ttlSeconds: config.ttlSeconds ?? null,
1501
1535
  // Forkable is a CREATE-time property: the control plane persists it and the
@@ -1624,15 +1658,28 @@ async function listMachines(conn, options = {}) {
1624
1658
  process.env.SMOL_CLOUD_URL ??
1625
1659
  cliUrl ??
1626
1660
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1627
- const listed = await cloudFetch({ baseUrl, apiKey: key }, "GET", "/v1/machines");
1628
- const rows = Array.isArray(listed) ? listed : (listed.machines ?? []);
1661
+ // The control plane filters by label itself; the client-side filter below
1662
+ // stays as the backstop for an older control plane that ignores it.
1663
+ const labelQuery = Object.entries(options.labels ?? {})
1664
+ .map(([k, v]) => `label=${encodeURIComponent(`${k}=${v}`)}`)
1665
+ .join("&");
1666
+ const listed = await cloudFetch(cloudConnOf(conn, baseUrl, key), "GET", `/v1/machines${labelQuery ? `?${labelQuery}` : ""}`);
1667
+ // Anything but a machine list (a proxy's page, a wrong base URL) is an
1668
+ // error, not an account with no machines.
1669
+ const rows = Array.isArray(listed)
1670
+ ? listed
1671
+ : Array.isArray(listed?.machines)
1672
+ ? listed.machines
1673
+ : undefined;
1674
+ if (!rows) {
1675
+ throw new errors_1.SmolError("SMOLVM_ERROR", `cloud GET /v1/machines at ${baseUrl} did not return a machine list`);
1676
+ }
1629
1677
  all = rows.map((m) => ({
1630
1678
  name: m.name ?? m.id,
1631
1679
  id: m.id,
1632
1680
  state: m.state,
1633
1681
  ...(m.source?.type === "image" && { image: m.source.reference }),
1634
- // The cloud API carries no caller labels yet.
1635
- labels: {},
1682
+ labels: m.labels ?? {},
1636
1683
  persistent: !m.ephemeral,
1637
1684
  // Remote machines never depend on a client process.
1638
1685
  detached: true,
@@ -1694,7 +1741,7 @@ async function connectTransport(id, conn) {
1694
1741
  process.env.SMOL_CLOUD_URL ??
1695
1742
  cliUrl ??
1696
1743
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1697
- const cloudConn = { baseUrl, apiKey: key };
1744
+ const cloudConn = cloudConnOf(conn, baseUrl, key);
1698
1745
  // Resolve like the CLI does: try the id path first, and when that 404s,
1699
1746
  // list machines and match by NAME. `machine.name` returns the human name,
1700
1747
  // so `Machine.connect(other.name)` — the natural composition of this API —
@@ -1743,6 +1790,9 @@ async function restoreCheckpointTransport(checkpointId, name, conn, options) {
1743
1790
  }
1744
1791
  conn ??= { target: looksLikeLocalCheckpoint(checkpointId) ? "local" : "cloud" };
1745
1792
  if (!selectsCloud(conn)) {
1793
+ if (options?.cacheDisk) {
1794
+ throw new errors_1.InvalidConfigError("cacheDisk is cloud-only: a local restore has no cache slot.");
1795
+ }
1746
1796
  const path = (0, node_path_1.resolve)(checkpointId);
1747
1797
  let restored;
1748
1798
  try {
@@ -1775,6 +1825,7 @@ async function restoreCheckpointTransport(checkpointId, name, conn, options) {
1775
1825
  ...(options?.networkPolicy !== undefined
1776
1826
  ? { network: cloudNetwork(options.networkPolicy) }
1777
1827
  : {}),
1828
+ ...(options?.cacheDisk ? { cacheDisk: cacheDiskBody(options.cacheDisk) } : {}),
1778
1829
  },
1779
1830
  });
1780
1831
  const id = created.id;
@@ -1790,6 +1841,14 @@ async function restoreCheckpointTransport(checkpointId, name, conn, options) {
1790
1841
  }
1791
1842
  return new CloudTransport(cloudConn, created.name ?? name, id);
1792
1843
  }
1844
+ function cacheDiskBody(ref) {
1845
+ return {
1846
+ cache: ref.cache,
1847
+ ...(ref.version !== undefined ? { version: ref.version } : {}),
1848
+ ...(ref.mountPath !== undefined ? { mountPath: ref.mountPath } : {}),
1849
+ ...(ref.slot ? { slot: true } : {}),
1850
+ };
1851
+ }
1793
1852
  /** The control plane and key a cloud operation uses: explicit options, then
1794
1853
  * the environment, then the CLI session. */
1795
1854
  function cloudConnFor(conn, operation) {
@@ -1803,7 +1862,7 @@ function cloudConnFor(conn, operation) {
1803
1862
  process.env.SMOL_CLOUD_URL ??
1804
1863
  cliUrl ??
1805
1864
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1806
- return { baseUrl, apiKey: key };
1865
+ return cloudConnOf(conn, baseUrl, key);
1807
1866
  }
1808
1867
  /** Create a cache disk; its version 0 is an empty filesystem. Cloud only. */
1809
1868
  function createCacheDisk(options, conn = {}) {
package/dist/types.d.ts CHANGED
@@ -324,6 +324,12 @@ export interface RestoreCheckpointOptions {
324
324
  * an earlier save point; do not run two machines from one checkpoint with
325
325
  * it. Local target only. */
326
326
  keepIdentity?: boolean;
327
+ /** Mount this cache in the slot the checkpoint's machine was created with
328
+ * (`cacheDisk: { cache, slot: true }`), in place of the cache it was
329
+ * captured with. It must be no larger than the slot and mount at the
330
+ * slot's path, so one base checkpoint resumes with each project's own
331
+ * cache. Cloud only. */
332
+ cacheDisk?: CacheDiskRef;
327
333
  }
328
334
  /** Per-call execution options. */
329
335
  export interface ExecOptions {
@@ -354,6 +360,9 @@ export interface ExecOptions {
354
360
  * Ignored (both families, as before) on older control planes and on the
355
361
  * local target. */
356
362
  output?: "text" | "b64" | "both";
363
+ /** Cloud only: start the command detached and return at once with its
364
+ * `pid`, leaving it running (a dev server, an agent) after the call ends. */
365
+ background?: boolean;
357
366
  }
358
367
  /** Result of a command execution. */
359
368
  export interface ExecResult {
@@ -377,6 +386,8 @@ export interface ExecResult {
377
386
  * the lossy `stdout`); on the local target it is the UTF-8 encoding of `stdout`.
378
387
  * Prefer this over `stdout` for binary or >1 MiB output. */
379
388
  stdoutBytes: Uint8Array;
389
+ /** The detached process's pid, for an exec run with `background: true`. */
390
+ pid?: number;
380
391
  /** Byte-exact, untruncated stderr; see `stdoutBytes`. */
381
392
  stderrBytes: Uint8Array;
382
393
  /** True when exitCode === 0. */
@@ -509,6 +520,14 @@ export interface ConnectOptions {
509
520
  baseUrl?: string;
510
521
  /** Cloud API key, `smk_…` (cloud target only). */
511
522
  apiKey?: string;
523
+ /** Cloud only: the `fetch` every cloud request goes through, for a proxy,
524
+ * retries, tracing, or a runtime without a global `fetch`. Its own errors
525
+ * reach the caller unchanged; timeouts and aborts are still reported as
526
+ * `TIMEOUT` and the abort reason. Default: the global `fetch`. */
527
+ fetch?: typeof fetch;
528
+ /** Cloud only: read a binary response body, for a `fetch` whose responses
529
+ * lack a working `arrayBuffer()`. */
530
+ readResponseBytes?: (response: Response) => Promise<Uint8Array>;
512
531
  }
513
532
  /** Per-launch trusted host service. Keep the token out of logs and persistent config. */
514
533
  export interface EgressInterceptor {
@@ -519,6 +538,15 @@ export interface EgressInterceptor {
519
538
  }
520
539
  export interface StartOptions {
521
540
  egressInterceptor?: EgressInterceptor;
541
+ /** Wait for the guest to be ready before returning (default true). Pass
542
+ * false to return once the start is accepted, e.g. when starting many
543
+ * machines and polling `ready()` yourself. */
544
+ waitUntilReady?: boolean;
545
+ }
546
+ /** Options for `Machine.resume`. */
547
+ export interface ResumeOptions {
548
+ /** Wait for the guest to be ready before returning (default true). */
549
+ waitUntilReady?: boolean;
522
550
  }
523
551
  /** Which cache disk a machine starts from. */
524
552
  export interface CacheDiskRef {
@@ -528,6 +556,10 @@ export interface CacheDiskRef {
528
556
  version?: number;
529
557
  /** Absolute guest path to mount it at; the cache disk's own when omitted. */
530
558
  mountPath?: string;
559
+ /** On create, attach the cache unmounted as a slot: restoring a checkpoint
560
+ * of the machine with `restoreCheckpoint(..., { cacheDisk })` mounts any
561
+ * cache no larger than this one there, at this one's mount path. */
562
+ slot?: boolean;
531
563
  }
532
564
  /** Options for creating a cache disk. */
533
565
  export interface CreateCacheDiskOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smolmachines",
3
- "version": "1.25.1",
3
+ "version": "1.25.2",
4
4
  "description": "Embed isolated microVM sandboxes directly in your code — no server to run.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "smol-machines",
@@ -63,8 +63,8 @@
63
63
  "typescript": "^5.6.0"
64
64
  },
65
65
  "optionalDependencies": {
66
- "smolmachines-darwin-arm64": "1.25.1",
67
- "smolmachines-linux-x64-gnu": "1.25.1",
68
- "smolmachines-linux-arm64-gnu": "1.25.1"
66
+ "smolmachines-darwin-arm64": "1.25.2",
67
+ "smolmachines-linux-x64-gnu": "1.25.2",
68
+ "smolmachines-linux-arm64-gnu": "1.25.2"
69
69
  }
70
70
  }