smolmachines 1.25.1 → 1.26.1

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,7 +118,17 @@ 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
  }
125
+ /** The largest file the cloud's file routes accept. */
126
+ export declare const CLOUD_MAX_FILE_BYTES: number;
127
+ /** How long moving `bytes` may take (ms): the request timeout plus the time
128
+ * `bytes` needs at the floor rate. */
129
+ export declare function transferTimeoutMs(bytes: number): number;
130
+ /** Refuse a file the cloud would refuse, before sending any of it. */
131
+ export declare function checkCloudFileSize(path: string, bytes: number): void;
114
132
  /** The sentence a person should read from a failed response's body. The
115
133
  * control plane answers errors as RFC 9457 problem+json, whose `detail` is the
116
134
  * sentence; older builds sent `{"error": …}` or plain text. A JSON object
package/dist/transport.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * Cloud-only/local-only capability gaps surface as `NotSupportedError`.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.CLOUD_MAX_FILE_BYTES = void 0;
11
12
  exports.cliConfigApiKey = cliConfigApiKey;
12
13
  exports.resolveNetwork = resolveNetwork;
13
14
  exports.networkMode = networkMode;
@@ -15,6 +16,8 @@ exports.cloudNetwork = cloudNetwork;
15
16
  exports.egressPolicy = egressPolicy;
16
17
  exports.resolveBranchable = resolveBranchable;
17
18
  exports.toNativeConfig = toNativeConfig;
19
+ exports.transferTimeoutMs = transferTimeoutMs;
20
+ exports.checkCloudFileSize = checkCloudFileSize;
18
21
  exports.errorSentence = errorSentence;
19
22
  exports.encodePath = encodePath;
20
23
  exports.selectsCloud = selectsCloud;
@@ -526,7 +529,7 @@ class LocalTransport {
526
529
  }
527
530
  liveLocal.delete(this);
528
531
  }
529
- async resume() {
532
+ async resume(waitUntilReady = true) {
530
533
  try {
531
534
  await this.inner.resume();
532
535
  }
@@ -535,9 +538,10 @@ class LocalTransport {
535
538
  }
536
539
  if (this.cleanupOnExit)
537
540
  liveLocal.add(this);
538
- await this.waitUntilReady();
541
+ if (waitUntilReady)
542
+ await this.waitUntilReady();
539
543
  }
540
- async start(interceptor) {
544
+ async start(interceptor, waitUntilReady = true) {
541
545
  try {
542
546
  const binding = interceptor ?? this.interceptor;
543
547
  if (binding)
@@ -552,7 +556,8 @@ class LocalTransport {
552
556
  }
553
557
  if (this.cleanupOnExit)
554
558
  liveLocal.add(this);
555
- await this.waitUntilReady();
559
+ if (waitUntilReady)
560
+ await this.waitUntilReady();
556
561
  }
557
562
  async delete() {
558
563
  liveLocal.delete(this);
@@ -685,9 +690,35 @@ class LocalTransport {
685
690
  throw new errors_1.NotSupportedError("getLease() is a cloud-only lease feature.");
686
691
  }
687
692
  }
693
+ /** A cloud connection carrying the caller's transport hooks. */
694
+ function cloudConnOf(conn, baseUrl, apiKey) {
695
+ return {
696
+ baseUrl,
697
+ apiKey,
698
+ ...(conn.fetch ? { fetch: conn.fetch } : {}),
699
+ ...(conn.readResponseBytes ? { readResponseBytes: conn.readResponseBytes } : {}),
700
+ };
701
+ }
688
702
  const DEFAULT_CLOUD_URL = "https://api.smolmachines.com";
689
703
  /** Default per-request timeout for cloud calls (ms). Override via opts.timeoutMs. */
690
704
  const CLOUD_TIMEOUT_MS = 30_000;
705
+ // A file transfer is given time for the slowest link it should survive: a
706
+ // large file over a slow uplink must not fail at CLOUD_TIMEOUT_MS while it is
707
+ // still moving. Same floor as the Rust and Python SDKs.
708
+ const CLOUD_TRANSFER_FLOOR_BYTES_PER_S = 256 * 1024;
709
+ /** The largest file the cloud's file routes accept. */
710
+ exports.CLOUD_MAX_FILE_BYTES = 100 * 1024 * 1024;
711
+ /** How long moving `bytes` may take (ms): the request timeout plus the time
712
+ * `bytes` needs at the floor rate. */
713
+ function transferTimeoutMs(bytes) {
714
+ return CLOUD_TIMEOUT_MS + Math.ceil(bytes / CLOUD_TRANSFER_FLOOR_BYTES_PER_S) * 1_000;
715
+ }
716
+ /** Refuse a file the cloud would refuse, before sending any of it. */
717
+ function checkCloudFileSize(path, bytes) {
718
+ if (bytes > exports.CLOUD_MAX_FILE_BYTES) {
719
+ throw new errors_1.SmolError("SMOLVM_ERROR", `${path} is ${(bytes / (1024 * 1024)).toFixed(1)} MiB; cloud machines accept files up to ${exports.CLOUD_MAX_FILE_BYTES / (1024 * 1024)} MiB`);
720
+ }
721
+ }
691
722
  // Starting can include a cold image pull. Keep ordinary API calls short, but
692
723
  // give this explicitly long-running operation the same bounded window already
693
724
  // used by checkpoint restore.
@@ -750,7 +781,7 @@ async function cloudFetch(conn, method, path, opts = {}) {
750
781
  opts.signal?.addEventListener("abort", onAbort, { once: true });
751
782
  let res;
752
783
  try {
753
- res = await fetch(`${conn.baseUrl}${path}`, {
784
+ res = await (conn.fetch ?? fetch)(`${conn.baseUrl}${path}`, {
754
785
  method,
755
786
  headers,
756
787
  body: body ?? null,
@@ -763,6 +794,9 @@ async function cloudFetch(conn, method, path, opts = {}) {
763
794
  if (e.name === "AbortError") {
764
795
  throw new errors_1.SmolError("TIMEOUT", `cloud ${method} ${path} timed out after ${opts.timeoutMs ?? CLOUD_TIMEOUT_MS}ms`);
765
796
  }
797
+ // A caller's own fetch owns its failure modes (retries, typed errors).
798
+ if (conn.fetch)
799
+ throw e;
766
800
  throw new errors_1.SmolError("CONNECTION", `cloud request failed: ${e.message}`);
767
801
  }
768
802
  finally {
@@ -781,8 +815,15 @@ async function cloudFetch(conn, method, path, opts = {}) {
781
815
  ? "UNAUTHORIZED"
782
816
  : "SMOLVM_ERROR", `cloud ${method} ${path} → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
783
817
  }
784
- if (opts.accept === "bytes")
818
+ if (opts.accept === "text")
819
+ return (await res.text());
820
+ if (opts.accept === "bytes") {
821
+ if (conn.readResponseBytes) {
822
+ const bytes = await conn.readResponseBytes(res);
823
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
824
+ }
785
825
  return Buffer.from(await res.arrayBuffer());
826
+ }
786
827
  if (res.status === 204)
787
828
  return undefined;
788
829
  const ct = res.headers.get("content-type") ?? "";
@@ -947,6 +988,7 @@ class CloudTransport {
947
988
  cwd: opts?.workdir ?? null,
948
989
  timeoutSeconds: opts?.timeout ?? null,
949
990
  ...(opts?.user !== undefined ? { user: opts.user } : {}),
991
+ ...(opts?.background ? { background: true } : {}),
950
992
  };
951
993
  // The command may legitimately run far longer than the default cloud
952
994
  // timeout, so size the request abort timeout off the request's own timeout
@@ -971,7 +1013,10 @@ class CloudTransport {
971
1013
  // `stdoutB64`/`stderrB64` are byte-exact and untruncated; the generated
972
1014
  // schema type may lag the server, so read them off the raw object.
973
1015
  const raw = r;
1016
+ // A detached command reports its pid as `pid=<n>` on stdout.
1017
+ const pidMatch = opts?.background ? /^pid=(\d+)/m.exec(stdout) : null;
974
1018
  return {
1019
+ ...(pidMatch ? { pid: Number(pidMatch[1]) } : {}),
975
1020
  exitCode: r.exitCode ?? 0,
976
1021
  stdout,
977
1022
  stderr,
@@ -1002,7 +1047,7 @@ class CloudTransport {
1002
1047
  };
1003
1048
  let res;
1004
1049
  try {
1005
- res = await fetch(`${this.conn.baseUrl}/v1/machines/${this.id}/exec/stream`, {
1050
+ res = await (this.conn.fetch ?? fetch)(`${this.conn.baseUrl}/v1/machines/${this.id}/exec/stream`, {
1006
1051
  method: "POST",
1007
1052
  headers: {
1008
1053
  authorization: `Bearer ${this.conn.apiKey}`,
@@ -1107,11 +1152,14 @@ class CloudTransport {
1107
1152
  async readFile(path) {
1108
1153
  return cloudFetch(this.conn, "GET", `/v1/machines/${this.id}/files/${encodePath(path)}`, {
1109
1154
  accept: "bytes",
1155
+ timeoutMs: transferTimeoutMs(exports.CLOUD_MAX_FILE_BYTES),
1110
1156
  });
1111
1157
  }
1112
1158
  async writeFile(path, data, mode) {
1159
+ checkCloudFileSize(path, data.length);
1113
1160
  await cloudFetch(this.conn, "PUT", `/v1/machines/${this.id}/files/${encodePath(path)}`, {
1114
1161
  body: data,
1162
+ timeoutMs: transferTimeoutMs(data.length),
1115
1163
  });
1116
1164
  // The cloud /files PUT carries no file mode, so apply it with chmod when
1117
1165
  // requested — e.g. writing an executable script the caller then runs.
@@ -1137,18 +1185,34 @@ class CloudTransport {
1137
1185
  async pause() {
1138
1186
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/pause`, { timeoutMs: CLOUD_START_TIMEOUT_MS });
1139
1187
  }
1140
- async resume() {
1188
+ async resume(waitUntilReady = true) {
1141
1189
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/resume`, { timeoutMs: CLOUD_START_TIMEOUT_MS });
1142
- await waitForReady(this.conn, this.id);
1190
+ if (waitUntilReady)
1191
+ await waitForReady(this.conn, this.id);
1143
1192
  }
1144
- async start(interceptor) {
1193
+ async start(interceptor, waitUntilReady = true) {
1145
1194
  if (interceptor)
1146
1195
  throw new errors_1.NotSupportedError("egressInterceptor is local-only.");
1147
1196
  // Resume a stopped machine, then wait for its agent so the handle is usable.
1148
1197
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/start`, {
1149
1198
  timeoutMs: CLOUD_START_TIMEOUT_MS,
1150
1199
  });
1151
- await waitForReady(this.conn, this.id);
1200
+ if (waitUntilReady)
1201
+ await waitForReady(this.conn, this.id);
1202
+ }
1203
+ async info() {
1204
+ return cloudFetch(this.conn, "GET", `/v1/machines/${this.id}`);
1205
+ }
1206
+ async logs(tail) {
1207
+ return cloudFetch(this.conn, "GET", `/v1/machines/${this.id}/logs?tail=${tail}`, {
1208
+ accept: "text",
1209
+ });
1210
+ }
1211
+ async exportArtifact() {
1212
+ return cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/export`, {
1213
+ json: {},
1214
+ timeoutMs: CLOUD_START_TIMEOUT_MS,
1215
+ });
1152
1216
  }
1153
1217
  async delete() {
1154
1218
  await cloudFetch(this.conn, "DELETE", `/v1/machines/${this.id}`);
@@ -1443,7 +1507,7 @@ async function makeTransport(config, conn) {
1443
1507
  process.env.SMOL_CLOUD_URL ??
1444
1508
  cliUrl ??
1445
1509
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1446
- const cloudConn = { baseUrl, apiKey: key };
1510
+ const cloudConn = cloudConnOf(conn, baseUrl, key);
1447
1511
  await storeCloudCredentials(cloudConn, config.credentials);
1448
1512
  // smolfleet CreateMachineRequest (camelCase): source (tagged), nested
1449
1513
  // resources, network {mode}, autoStopSeconds, ttlSeconds. Optional numeric
@@ -1451,6 +1515,7 @@ async function makeTransport(config, conn) {
1451
1515
  // (exactOptionalPropertyTypes forbids passing `undefined` explicitly).
1452
1516
  const createBody = {
1453
1517
  name: config.name ?? null,
1518
+ ...(config.labels && Object.keys(config.labels).length ? { labels: config.labels } : {}),
1454
1519
  source: { type: "image", reference: config.image },
1455
1520
  resources: {
1456
1521
  ...(config.resources?.cpus !== undefined
@@ -1487,15 +1552,7 @@ async function makeTransport(config, conn) {
1487
1552
  ...(config.credentials?.length
1488
1553
  ? { credentials: config.credentials.map((c) => c.name) }
1489
1554
  : {}),
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
- : {}),
1555
+ ...(config.cacheDisk ? { cacheDisk: cacheDiskBody(config.cacheDisk) } : {}),
1499
1556
  autoStopSeconds: config.autoStopSeconds ?? null,
1500
1557
  ttlSeconds: config.ttlSeconds ?? null,
1501
1558
  // Forkable is a CREATE-time property: the control plane persists it and the
@@ -1624,15 +1681,28 @@ async function listMachines(conn, options = {}) {
1624
1681
  process.env.SMOL_CLOUD_URL ??
1625
1682
  cliUrl ??
1626
1683
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1627
- const listed = await cloudFetch({ baseUrl, apiKey: key }, "GET", "/v1/machines");
1628
- const rows = Array.isArray(listed) ? listed : (listed.machines ?? []);
1684
+ // The control plane filters by label itself; the client-side filter below
1685
+ // stays as the backstop for an older control plane that ignores it.
1686
+ const labelQuery = Object.entries(options.labels ?? {})
1687
+ .map(([k, v]) => `label=${encodeURIComponent(`${k}=${v}`)}`)
1688
+ .join("&");
1689
+ const listed = await cloudFetch(cloudConnOf(conn, baseUrl, key), "GET", `/v1/machines${labelQuery ? `?${labelQuery}` : ""}`);
1690
+ // Anything but a machine list (a proxy's page, a wrong base URL) is an
1691
+ // error, not an account with no machines.
1692
+ const rows = Array.isArray(listed)
1693
+ ? listed
1694
+ : Array.isArray(listed?.machines)
1695
+ ? listed.machines
1696
+ : undefined;
1697
+ if (!rows) {
1698
+ throw new errors_1.SmolError("SMOLVM_ERROR", `cloud GET /v1/machines at ${baseUrl} did not return a machine list`);
1699
+ }
1629
1700
  all = rows.map((m) => ({
1630
1701
  name: m.name ?? m.id,
1631
1702
  id: m.id,
1632
1703
  state: m.state,
1633
1704
  ...(m.source?.type === "image" && { image: m.source.reference }),
1634
- // The cloud API carries no caller labels yet.
1635
- labels: {},
1705
+ labels: m.labels ?? {},
1636
1706
  persistent: !m.ephemeral,
1637
1707
  // Remote machines never depend on a client process.
1638
1708
  detached: true,
@@ -1694,7 +1764,7 @@ async function connectTransport(id, conn) {
1694
1764
  process.env.SMOL_CLOUD_URL ??
1695
1765
  cliUrl ??
1696
1766
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1697
- const cloudConn = { baseUrl, apiKey: key };
1767
+ const cloudConn = cloudConnOf(conn, baseUrl, key);
1698
1768
  // Resolve like the CLI does: try the id path first, and when that 404s,
1699
1769
  // list machines and match by NAME. `machine.name` returns the human name,
1700
1770
  // so `Machine.connect(other.name)` — the natural composition of this API —
@@ -1743,6 +1813,9 @@ async function restoreCheckpointTransport(checkpointId, name, conn, options) {
1743
1813
  }
1744
1814
  conn ??= { target: looksLikeLocalCheckpoint(checkpointId) ? "local" : "cloud" };
1745
1815
  if (!selectsCloud(conn)) {
1816
+ if (options?.cacheDisk) {
1817
+ throw new errors_1.InvalidConfigError("cacheDisk is cloud-only: a local restore has no cache slot.");
1818
+ }
1746
1819
  const path = (0, node_path_1.resolve)(checkpointId);
1747
1820
  let restored;
1748
1821
  try {
@@ -1775,6 +1848,7 @@ async function restoreCheckpointTransport(checkpointId, name, conn, options) {
1775
1848
  ...(options?.networkPolicy !== undefined
1776
1849
  ? { network: cloudNetwork(options.networkPolicy) }
1777
1850
  : {}),
1851
+ ...(options?.cacheDisk ? { cacheDisk: cacheDiskBody(options.cacheDisk) } : {}),
1778
1852
  },
1779
1853
  });
1780
1854
  const id = created.id;
@@ -1790,6 +1864,14 @@ async function restoreCheckpointTransport(checkpointId, name, conn, options) {
1790
1864
  }
1791
1865
  return new CloudTransport(cloudConn, created.name ?? name, id);
1792
1866
  }
1867
+ function cacheDiskBody(ref) {
1868
+ return {
1869
+ cache: ref.cache,
1870
+ ...(ref.version !== undefined ? { version: ref.version } : {}),
1871
+ ...(ref.mountPath !== undefined ? { mountPath: ref.mountPath } : {}),
1872
+ ...(ref.slot ? { slot: true } : {}),
1873
+ };
1874
+ }
1793
1875
  /** The control plane and key a cloud operation uses: explicit options, then
1794
1876
  * the environment, then the CLI session. */
1795
1877
  function cloudConnFor(conn, operation) {
@@ -1803,7 +1885,7 @@ function cloudConnFor(conn, operation) {
1803
1885
  process.env.SMOL_CLOUD_URL ??
1804
1886
  cliUrl ??
1805
1887
  DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1806
- return { baseUrl, apiKey: key };
1888
+ return cloudConnOf(conn, baseUrl, key);
1807
1889
  }
1808
1890
  /** Create a cache disk; its version 0 is an empty filesystem. Cloud only. */
1809
1891
  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.26.1",
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.26.1",
67
+ "smolmachines-linux-x64-gnu": "1.26.1",
68
+ "smolmachines-linux-arm64-gnu": "1.26.1"
69
69
  }
70
70
  }