withruntime 0.11.2 → 0.12.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 (164) hide show
  1. package/BLAXEL.md +2 -2
  2. package/COMPATIBILITY.md +74 -9
  3. package/DAYTONA.md +33 -5
  4. package/E2B.md +85 -24
  5. package/README.md +9 -8
  6. package/README.src.md +8 -7
  7. package/VERCEL.md +43 -5
  8. package/compatibility-lock.json +58 -49
  9. package/dist/billing-cli.d.ts +1 -1
  10. package/dist/billing-cli.js +77 -1
  11. package/dist/blaxel/errors.d.ts +14 -0
  12. package/dist/blaxel/errors.js +29 -0
  13. package/dist/blaxel/index.d.ts +2 -2
  14. package/dist/blaxel/index.js +4 -4
  15. package/dist/blaxel/interpreter.d.ts +2 -5
  16. package/dist/blaxel/sandbox.d.ts +14 -5
  17. package/dist/blaxel/sandbox.js +63 -7
  18. package/dist/blaxel/snapshot.d.ts +4 -0
  19. package/dist/blaxel/snapshot.js +4 -0
  20. package/dist/cli/logs.d.ts +21 -0
  21. package/dist/cli/logs.js +188 -0
  22. package/dist/cli/logs.test.d.ts +1 -0
  23. package/dist/cli/logs.test.js +272 -0
  24. package/dist/cli-extras.d.ts +3 -3
  25. package/dist/cli-extras.js +17 -12
  26. package/dist/cli-words.d.ts +10 -1
  27. package/dist/cli-words.js +18 -0
  28. package/dist/cli.d.ts +5 -1
  29. package/dist/cli.js +405 -204
  30. package/dist/client.d.ts +2 -4
  31. package/dist/client.js +27 -11
  32. package/dist/cloudflare/errors.d.ts +143 -0
  33. package/dist/cloudflare/errors.js +167 -0
  34. package/dist/cloudflare/index.d.ts +175 -6
  35. package/dist/cloudflare/index.js +416 -26
  36. package/dist/cloudflare/interpreter.d.ts +119 -0
  37. package/dist/cloudflare/interpreter.js +223 -0
  38. package/dist/cloudflare/streams.d.ts +38 -0
  39. package/dist/cloudflare/streams.js +145 -0
  40. package/dist/cloudflare/watch.d.ts +9 -0
  41. package/dist/cloudflare/watch.js +24 -0
  42. package/dist/codesandbox/index.d.ts +216 -28
  43. package/dist/codesandbox/index.js +478 -78
  44. package/dist/compat/core.d.ts +18 -0
  45. package/dist/compat/core.js +29 -0
  46. package/dist/compat/files.d.ts +1 -0
  47. package/dist/compat/files.js +25 -5
  48. package/dist/daytona/computer-use.d.ts +13 -4
  49. package/dist/daytona/computer-use.js +66 -8
  50. package/dist/daytona/daytona.d.ts +6 -0
  51. package/dist/daytona/daytona.js +30 -3
  52. package/dist/daytona/errors.d.ts +11 -0
  53. package/dist/daytona/errors.js +18 -0
  54. package/dist/daytona/filesystem.js +2 -1
  55. package/dist/daytona/image.d.ts +13 -9
  56. package/dist/daytona/image.js +18 -12
  57. package/dist/daytona/index.d.ts +1 -1
  58. package/dist/daytona/sandbox.d.ts +8 -2
  59. package/dist/daytona/sandbox.js +16 -2
  60. package/dist/domains/index.d.ts +9 -0
  61. package/dist/domains/index.js +10 -0
  62. package/dist/e2b/commands.d.ts +1 -1
  63. package/dist/e2b/commands.js +6 -7
  64. package/dist/e2b/errors.d.ts +17 -0
  65. package/dist/e2b/errors.js +40 -1
  66. package/dist/e2b/index.d.ts +2 -2
  67. package/dist/e2b/index.js +1 -1
  68. package/dist/e2b/sandbox.d.ts +27 -21
  69. package/dist/e2b/sandbox.js +34 -8
  70. package/dist/e2b/unsupported.js +1 -1
  71. package/dist/errors.d.ts +10 -1
  72. package/dist/errors.js +16 -4
  73. package/dist/freestyle/index.d.ts +285 -37
  74. package/dist/freestyle/index.js +656 -144
  75. package/dist/identity.d.ts +3 -0
  76. package/dist/identity.js +27 -0
  77. package/dist/images/index.d.ts +9 -0
  78. package/dist/images/index.js +10 -0
  79. package/dist/index.d.ts +1 -0
  80. package/dist/ips/index.d.ts +9 -0
  81. package/dist/ips/index.js +10 -0
  82. package/dist/jobs/index.d.ts +9 -0
  83. package/dist/jobs/index.js +10 -0
  84. package/dist/keys.js +2 -0
  85. package/dist/login.js +2 -0
  86. package/dist/machine.d.ts +12 -3
  87. package/dist/machine.js +20 -3
  88. package/dist/mcp.js +1 -1
  89. package/dist/modal/index.d.ts +339 -61
  90. package/dist/modal/index.js +781 -121
  91. package/dist/network-cli.d.ts +3 -1
  92. package/dist/network-cli.js +78 -36
  93. package/dist/networks/index.d.ts +11 -0
  94. package/dist/networks/index.js +10 -0
  95. package/dist/ports/index.d.ts +9 -0
  96. package/dist/ports/index.js +10 -0
  97. package/dist/product-names.d.ts +52 -0
  98. package/dist/product-names.js +164 -0
  99. package/dist/product-renames.d.ts +103 -0
  100. package/dist/product-renames.js +189 -0
  101. package/dist/products/apps.d.ts +176 -0
  102. package/dist/products/apps.js +165 -0
  103. package/dist/products/billing.d.ts +41 -0
  104. package/dist/products/billing.js +13 -0
  105. package/dist/products/browser.d.ts +10 -0
  106. package/dist/products/browsers.d.ts +71 -0
  107. package/dist/products/browsers.js +102 -0
  108. package/dist/products/builds.d.ts +126 -0
  109. package/dist/products/builds.js +221 -0
  110. package/dist/products/index.d.ts +3 -16
  111. package/dist/products/index.js +3 -14
  112. package/dist/products/jobs.d.ts +17 -0
  113. package/dist/products/jobs.js +27 -10
  114. package/dist/products/logs.d.ts +245 -0
  115. package/dist/products/logs.js +274 -0
  116. package/dist/products/logs.test.d.ts +1 -0
  117. package/dist/products/logs.test.js +343 -0
  118. package/dist/products/network-products.d.ts +38 -7
  119. package/dist/products/network-products.js +31 -18
  120. package/dist/products/observability.d.ts +3 -2
  121. package/dist/products/private-network.js +1 -1
  122. package/dist/products/queues.d.ts +149 -0
  123. package/dist/products/queues.js +213 -0
  124. package/dist/products/referrals.d.ts +21 -7
  125. package/dist/products/referrals.js +3 -3
  126. package/dist/products/secrets.d.ts +7 -3
  127. package/dist/products/secrets.js +39 -18
  128. package/dist/products/signed-requests.d.ts +21 -0
  129. package/dist/products/signed-requests.js +11 -0
  130. package/dist/products/streams.d.ts +73 -0
  131. package/dist/products/streams.js +17 -0
  132. package/dist/products/switching.d.ts +35 -7
  133. package/dist/products/switching.js +4 -3
  134. package/dist/products/volumes.d.ts +3 -0
  135. package/dist/record-cli.d.ts +23 -0
  136. package/dist/record-cli.js +308 -0
  137. package/dist/renamed-products.d.ts +38 -0
  138. package/dist/renamed-products.js +34 -0
  139. package/dist/runloop/index.d.ts +9 -2
  140. package/dist/runloop/index.js +29 -8
  141. package/dist/runloop/sdk.d.ts +131 -0
  142. package/dist/runloop/sdk.js +167 -0
  143. package/dist/sandbox.d.ts +3 -2
  144. package/dist/sandbox.js +14 -9
  145. package/dist/sandboxes/index.d.ts +11 -0
  146. package/dist/sandboxes/index.js +12 -0
  147. package/dist/snapshots/index.d.ts +9 -0
  148. package/dist/snapshots/index.js +11 -0
  149. package/dist/snapshots.d.ts +7 -4
  150. package/dist/sprites/index.d.ts +343 -31
  151. package/dist/sprites/index.js +866 -84
  152. package/dist/ssh.d.ts +1 -1
  153. package/dist/ssh.js +11 -11
  154. package/dist/terminal.d.ts +24 -0
  155. package/dist/terminal.js +32 -0
  156. package/dist/transport.d.ts +15 -1
  157. package/dist/transport.js +56 -3
  158. package/dist/tunnel.js +1 -1
  159. package/dist/types.d.ts +63 -0
  160. package/dist/vercel/sandbox.js +5 -3
  161. package/dist/vercel/snapshot.js +10 -2
  162. package/dist/volumes/index.d.ts +9 -0
  163. package/dist/volumes/index.js +10 -0
  164. package/package.json +48 -2
package/dist/cli.js CHANGED
@@ -11,15 +11,19 @@ import { Sandbox as SandboxHandle } from "./sandbox.js";
11
11
  import { NotFoundError, RuntimeError } from "./errors.js";
12
12
  import { NETWORK_PRODUCTS, networkProductCommand } from "./network-cli.js";
13
13
  import { billingCommand } from "./billing-cli.js";
14
- import { cliRefusal, describeRecord, newestCpu, topicHelp } from "./cli-words.js";
14
+ import { cliRefusal, describeRecord, newestCpu, productWords, topicHelp } from "./cli-words.js";
15
15
  import { envFetch } from "./proxy.js";
16
+ import { logStream, terminalSafe, terminalSafeLines } from "./terminal.js";
16
17
  import { VERSION } from "./transport.js";
18
+ /** A log stream's text (a sandbox process's, an image build's, a job run's)
19
+ * as stdout shows it: escaped on a terminal unless --raw (terminal.ts). */
20
+ const logText = (args) => (text) => process.stdout.isTTY && !has(args, "raw") ? terminalSafeLines(text) : text;
17
21
  const HELP = `Runtime Cloud CLI ${VERSION}
18
22
 
19
23
  Usage: runtime <product> <command> [options] runtime <command> [options]
20
24
 
21
25
  Start
22
- sandbox run [--keep] [create options] -- <command>
26
+ sandboxes run [--keep] [create options] -- <command>
23
27
  A fresh sandbox runs one command, then stops
24
28
 
25
29
  Account
@@ -53,13 +57,15 @@ Account
53
57
  secrets rm <NAME> Delete a secret, every copy
54
58
  keys create [--name <name>] [--read-only] [--daily-limit <usd>] [--account-wide]
55
59
  A new API key for CI, approved by an owner in the browser
56
- referrals Your referral link: you both get up to $500
60
+ referrals Your referral link: you both get up to $1,000, then
61
+ you earn 10% of what they spend for 12 months
57
62
  compare --from <provider> [--days 30] What your usage would cost at e2b, daytona, vercel,
58
63
  modal, cloudflare, fly, blaxel, lambda-microvms,
59
64
  freestyle, prime and others, and what you save
60
65
  switch [--from <provider>] Moving from e2b, daytona, vercel, modal, cloudflare, fly
61
- or blaxel: record it before your first top-up, and
62
- that top-up is matched, up to $100
66
+ or blaxel: record it before your first top-up.
67
+ Credit you add within 30 days of signing up is
68
+ matched, up to $1,000 in total
63
69
  ls Everything you run, every product
64
70
  feedback "<text>" [--kind bug|missing_feature|competitor_gap|migration_blocker|docs|pricing|praise|other]
65
71
  [--detail <text>] [--competitor <name>] [--request-id <req_...>]
@@ -72,13 +78,13 @@ Account
72
78
  mcp Serve MCP on stdio for agents that need it
73
79
 
74
80
  Connect
75
- sandbox ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandbox ssh help\` for editors
76
- sandbox ssh config [--install] Make \`ssh <id|name>.runtime\` work (VS Code, JetBrains)
77
- sandbox port-forward <id|name> <port|local:remote>...
81
+ sandboxes ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandboxes ssh help\` for editors
82
+ sandboxes ssh config [--install] Make \`ssh <id|name>.runtime\` work (VS Code, JetBrains)
83
+ sandboxes port-forward <id|name> <port|local:remote>...
78
84
  Forward local ports to ports in a sandbox
79
85
 
80
86
  Observability
81
- sandbox metrics <id> [--range 1h] CPU and memory over time (15m 1h 6h 24h 7d 30d)
87
+ sandboxes metrics <id> [--range 1h] CPU and memory over time (15m 1h 6h 24h 7d 30d)
82
88
  events [--sandbox <id>] [--type <type>] Lifecycle events, newest first
83
89
  webhooks ls | create <url> [--events a,b] | test <id> | deliveries <id>
84
90
  | update <id> [--url u] [--events a,b] [--enable|--disable]
@@ -88,22 +94,23 @@ Observability
88
94
  Events and metrics to an OpenTelemetry endpoint
89
95
 
90
96
  Products
91
- sandbox Linux microVMs: exec, shell, files, code, previews, network, desktop, browser
92
- snapshot Saved sandboxes, disk and memory, to start or fork from
93
- image Custom images from a Dockerfile, any public or private image, or a package list
94
- volume Persistent disks to attach to sandboxes
95
- job A command in a fresh sandbox, once or on a cron schedule, with its logs
96
- domain Your own hostname for a sandbox's port, with HTTPS
97
- port A public TCP port to a sandbox: databases, game servers
98
- address A dedicated outbound address, for allow-lists
99
- tunnel A WireGuard tunnel from your own network into your sandboxes
100
- network Account-wide network settings: your own proxy, sandboxes reaching each other by name
101
- Each has its own help: \`runtime sandbox help\`, \`runtime image help\`...
97
+ sandboxes Linux microVMs: exec, shell, files, code, previews, network, desktop, browser
98
+ snapshots Saved sandboxes, disk and memory, to start or fork from
99
+ images Custom images from a Dockerfile, any public or private image, or a package list
100
+ volumes Persistent disks to attach to sandboxes
101
+ jobs A command in a fresh sandbox, once or on a cron schedule, with its logs
102
+ domains Your own hostname for a sandbox's port, with HTTPS
103
+ ports A public TCP port to a sandbox: databases, game servers
104
+ ips A dedicated outbound address, for allow-lists
105
+ networks Your networks: a WireGuard tunnel in, your own proxy out, sandboxes reaching
106
+ each other by name
107
+ Each has its own help: \`runtime sandboxes help\`, \`runtime images help\`...
108
+ The singular (\`runtime sandbox\`) is a shortcut for the same product.
102
109
 
103
110
  Every command takes --json. Keys: RUNTIME_API_KEY, or the connection from \`runtime login\`.
104
111
  Behind a proxy: HTTPS_PROXY and NO_PROXY are read, on Node and Bun alike.
105
112
  `;
106
- const SANDBOX_HELP = `runtime sandbox <command>
113
+ const SANDBOX_HELP = `runtime sandboxes <command>
107
114
 
108
115
  Every <id> below can also be the sandbox's name.
109
116
 
@@ -112,7 +119,7 @@ const SANDBOX_HELP = `runtime sandbox <command>
112
119
  port-forward <id|name> <port|local:remote>... Forward local ports to ports in it
113
120
  create [--name n] [--label k=v]... [--env K=V]... [--vcpu 2] [--memory 4096] [--disk 4096]
114
121
  [--cpu shared|reserved] [--cpu-floor <thousandths>] [--max-cost <usd>] [--max-total-cost <usd>]
115
- [--timeout <seconds>] [--on-timeout pause|stop] [--trial|--paid] [--no-wait]
122
+ [--timeout <60-86400>] [--on-timeout pause|stop|delete] [--no-wait]
116
123
  [--no-internet] [--allow <host>]... [--deny <host>]... [--connect <host:port>]...
117
124
  [--image <name:tag|imageId> | --snapshot <snapshotId>] [--volume <volumeId>:/data[:snapshot]]...
118
125
  [--idle-pause <seconds>] [--no-auto-wake] [--persistent] [--get-or-create]
@@ -123,7 +130,9 @@ const SANDBOX_HELP = `runtime sandbox <command>
123
130
  Run and stream output; exits with its code
124
131
  spawn <id> [--pty] -- <command...> Start in the background; prints a process id
125
132
  ps <id> Running and recent processes
126
- logs <id> <processId> [-f] A process's output so far; -f follows it to the end
133
+ logs <id> <processId> [-f] [--raw] A process's output so far; -f follows it to the end;
134
+ on a terminal control characters show as \\uXXXX
135
+ unless --raw
127
136
  kill <id> <processId> [--signal SIGTERM]
128
137
  shell <id> [--command "bash -l"] An interactive terminal
129
138
  cp <src> <dst> Copy files or directories; name a sandbox
@@ -144,7 +153,7 @@ const SANDBOX_HELP = `runtime sandbox <command>
144
153
  update <id> [--name n] [--label k=v]... [--env K=V]... [--unset-env K]... [--idle-pause <seconds>] [--auto-wake on|off]
145
154
  [--persistent on|off] [--max-total-cost <usd>|none]
146
155
  Change its settings; persistent keeps it running until stopped
147
- snapshot <id> [--name n] [--retention days] Keep its whole machine; prints the snapshot id
156
+ snapshot <id> [--name n] [--retention days|credit] Keep its whole machine; prints the snapshot id
148
157
  mount <id> <s3|r2|gcs>://<bucket>[/prefix] <path> [--secret NAME] [--region r]
149
158
  [--endpoint https://...] [--account-id id] [--read-only]
150
159
  Your bucket as a directory; the proxy signs, the sandbox never sees the key
@@ -152,7 +161,7 @@ const SANDBOX_HELP = `runtime sandbox <command>
152
161
  tailscale up <id> --auth-key-secret NAME [--hostname h] [--tag tag:x]...
153
162
  Join your tailnet (paid); the key is a secret set with --jobs
154
163
  tailscale status <id> | tailscale down <id> Its tailnet address; take it off the tailnet
155
- fork <id> [--count 3] [--name n] [--keep-snapshot] [--trial|--paid]
164
+ fork <id> [--count 3] [--name n] [--keep-snapshot]
156
165
  Copies of it as it is now, running; prints their ids
157
166
  run-code <id> <file|-> [--lang python|javascript|typescript|r|java|bash|go] [--context c] [--out-dir .]
158
167
  Run a notebook-style cell; charts saved as PNG
@@ -188,10 +197,10 @@ const SANDBOX_HELP = `runtime sandbox <command>
188
197
  Chromium for CDP agents: prints its cdpUrl
189
198
 
190
199
  Examples
191
- id=$(runtime sandbox create)
192
- runtime sandbox cp ./invoice.py $id:/workspace/invoice.py
193
- runtime sandbox exec $id -- python3 /workspace/invoice.py
194
- runtime sandbox stop $id
200
+ id=$(runtime sandboxes create)
201
+ runtime sandboxes cp ./invoice.py $id:/workspace/invoice.py
202
+ runtime sandboxes exec $id -- python3 /workspace/invoice.py
203
+ runtime sandboxes stop $id
195
204
  `;
196
205
  /** A command's options. Reading one its parse() did not declare is a bug in
197
206
  * this file, and says so, so a test that runs the command finds it. */
@@ -270,6 +279,8 @@ function unknownOption(name, known) {
270
279
  }
271
280
  const flag = (args, name) => args.flags.get(name)?.at(-1);
272
281
  const has = (args, name) => args.flags.get(name)?.at(-1) === "true";
282
+ /** Days, or the word `credit` for null: kept while the account has credit. */
283
+ const daysOrCredit = (args, name) => flag(args, name) === "credit" ? null : integer(args, name);
273
284
  const integer = (args, name) => {
274
285
  const value = flag(args, name);
275
286
  if (value === undefined)
@@ -360,16 +371,15 @@ const COMMANDS = [
360
371
  "support",
361
372
  "docs",
362
373
  "mcp",
363
- "sandbox",
364
- "snapshot",
365
- "image",
366
- "volume",
367
- "job",
368
- "domain",
369
- "port",
370
- "address",
371
- "tunnel",
372
- "network",
374
+ "sandboxes",
375
+ "snapshots",
376
+ "images",
377
+ "volumes",
378
+ "jobs",
379
+ "domains",
380
+ "ports",
381
+ "ips",
382
+ "networks",
373
383
  "webhooks",
374
384
  "otel",
375
385
  "events",
@@ -378,30 +388,20 @@ const COMMANDS = [
378
388
  ];
379
389
  /** Commands with a help of their own: `runtime <command> help`. */
380
390
  const OWN_HELP = [
381
- "sandbox",
382
391
  "sandboxes",
383
392
  "sbx",
384
- "snapshot",
385
393
  "snapshots",
386
- "image",
387
394
  "images",
388
- "volume",
389
395
  "volumes",
390
- "job",
391
396
  "jobs",
392
397
  "keys",
393
398
  "webhooks",
394
399
  "webhook",
395
400
  "billing",
396
- "domain",
397
401
  "domains",
398
- "port",
399
402
  "ports",
400
- "address",
401
- "addresses",
402
- "tunnel",
403
- "tunnels",
404
- "network",
403
+ "ips",
404
+ "networks",
405
405
  ];
406
406
  /** Sandbox commands that once sat at the top level. */
407
407
  const SANDBOX_MOVED = ["run", "ssh", "port-forward", "forward"];
@@ -506,6 +506,14 @@ function table(rows) {
506
506
  .trimEnd())
507
507
  .join("\n");
508
508
  }
509
+ /** When a sandbox ends by itself (0300, 0381). A paid-ahead lease, expiresAt,
510
+ * is not its time limit; only an older server, which sends no endsAt, gives
511
+ * the end of a limited sandbox's time as expiresAt. */
512
+ function sandboxEnd(info) {
513
+ if (info.endsAt === null || (info.endsAt === undefined && !info.timeoutSeconds))
514
+ return "no limit";
515
+ return info.endsAt ?? info.expiresAt ?? "-";
516
+ }
509
517
  function describe(info) {
510
518
  return table([
511
519
  ["id", info.id],
@@ -515,19 +523,9 @@ function describe(info) {
515
523
  "shape",
516
524
  `${info.vcpu} vCPU, ${info.memoryMiB} MiB memory, ${info.diskMiB} MiB disk (${info.cpu})`,
517
525
  ],
518
- ["funding", info.funding],
519
526
  ["region", info.region],
520
- // A sandbox with no time limit runs while it works (0300): endsAt is
521
- // null. An older server sends no endsAt; its expiresAt was the end.
522
527
  ["time limit", info.timeoutSeconds ? `${info.timeoutSeconds} s` : "none"],
523
- [
524
- "ends",
525
- info.endsAt === null
526
- ? info.state === "running"
527
- ? "when idle, stopped or out of credit"
528
- : "-"
529
- : (info.endsAt ?? info.expiresAt ?? "-"),
530
- ],
528
+ ["ends", sandboxEnd(info)],
531
529
  ["at its end", info.onLeaseEnd ?? "-"],
532
530
  // Why a sandbox is no longer running (troubleshooting.md, "A sandbox
533
531
  // stopped on its own"); `get` left both out until 30 September 2026.
@@ -560,17 +558,22 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
560
558
  // Only what comes before -- is the CLI's: a --json after it is the
561
559
  // command's, whether or not the CLI was given its own.
562
560
  const dash = argv.indexOf("--");
563
- const [first, ...remaining] = argv.filter((arg, index) => arg !== "--json" || (dash !== -1 && index > dash));
561
+ // CLOUD.md section 4: the plural names the product, the singular is a
562
+ // typing shortcut, and an old word works for 30 days with a warning.
563
+ const words = productWords(argv.filter((arg, index) => arg !== "--json" || (dash !== -1 && index > dash)));
564
+ if (words.warning)
565
+ out.error(`Warning: ${named(words.warning)}`);
566
+ const [first, ...remaining] = words.argv;
564
567
  // Sandbox is one product of several, so its commands live under it
565
- // (AGENTS.md, 23 September 2026): `runtime sandbox run`, `sandbox ssh` and
568
+ // (AGENTS.md, 23 September 2026): `runtime sandboxes run`, `sandbox ssh` and
566
569
  // `sandbox port-forward`. The old top-level forms were removed, not aliased.
567
570
  if (first && SANDBOX_MOVED.includes(first))
568
- throw usage(`\`${me} ${first}\` is now \`${me} sandbox ${first === "forward" ? "port-forward" : first}\`.`);
571
+ throw usage(`\`${me} ${first}\` is now \`${me} sandboxes ${first === "forward" ? "port-forward" : first}\`.`);
569
572
  const nested = (first === "sandbox" || first === "sandboxes" || first === "sbx") &&
570
573
  SANDBOX_MOVED.includes(remaining[0] ?? "");
571
574
  const command = nested ? remaining[0] : first;
572
575
  const rest = nested ? remaining.slice(1) : remaining;
573
- // `runtime sandbox create --help` is how an agent asks what a command takes:
576
+ // `runtime sandboxes create --help` is how an agent asks what a command takes:
574
577
  // answer with the product's help, never by running the command.
575
578
  const cut = remaining.indexOf("--");
576
579
  const asked = (cut < 0 ? remaining : remaining.slice(0, cut)).some((arg) => arg === "--help" || arg === "-h") ||
@@ -603,10 +606,10 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
603
606
  ? JSON.stringify({ connected, usage: HELP })
604
607
  : `Runtime Cloud ${VERSION}. ${start}
605
608
 
606
- ${me} sandbox run -- python3 < invoice.py
609
+ ${me} sandboxes run -- python3 < invoice.py
607
610
  Run a local invoice script, then stop the sandbox
608
- ${me} sandbox create A sandbox that stays; prints its id
609
- ${me} sandbox exec <id> -- <command> Run in it
611
+ ${me} sandboxes create A sandbox that stays; prints its id
612
+ ${me} sandboxes exec <id> -- <command> Run in it
610
613
  ${me} help Everything else
611
614
  `);
612
615
  return 0;
@@ -616,14 +619,15 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
616
619
  return 0;
617
620
  }
618
621
  if (command === "run") {
619
- const args = parse(rest, [...CREATE_FLAGS, "keep"], [...CREATE_VALUES, "cwd", "env"], `${me} sandbox run -- python3 < invoice.py`);
622
+ const args = parse(rest, [...CREATE_FLAGS, "keep"], [...CREATE_VALUES, "cwd", "env"], `${me} sandboxes run -- python3 < invoice.py`);
620
623
  const commandLine = args.rest ?? args.positional;
621
624
  if (!commandLine.length)
622
- throw usage(`Give the command after --: ${me} sandbox run -- python3 < invoice.py`);
625
+ throw usage(`Give the command after --: ${me} sandboxes run -- python3 < invoice.py`);
623
626
  const runtime = await client(env);
624
627
  const timeout = integer(args, "timeout");
625
628
  const { timeoutSeconds: _lease, ...input } = createInput(args);
626
- const sbx = await runtime.sandboxes.create(input, { onCapacityWait: waiting(out) });
629
+ const onCapacityWait = waiting(out);
630
+ const sbx = await withVolumeRelease((options) => runtime.sandboxes.create(input, { ...options, onCapacityWait }), out);
627
631
  let cleanup;
628
632
  // SIGINT and normal completion share one stop. Keep an absolute deadline
629
633
  // across the stop retries and final state check, so cleanup cannot hang.
@@ -642,7 +646,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
642
646
  code: "cleanup_failed",
643
647
  status: 0,
644
648
  message: `Could not confirm that sandbox ${sbx.id} stopped. ${error instanceof Error ? error.message : String(error)}`,
645
- hint: `Check it and retry cleanup with: ${me} sandbox stop ${sbx.id}`,
649
+ hint: `Check it and retry cleanup with: ${me} sandboxes stop ${sbx.id}`,
646
650
  cause: error,
647
651
  }), out.json));
648
652
  return false;
@@ -658,7 +662,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
658
662
  finally {
659
663
  try {
660
664
  if (has(args, "keep"))
661
- out.error(`Kept sandbox ${sbx.id}. Run more with: ${me} sandbox exec ${sbx.id} -- <command>`);
665
+ out.error(`Kept sandbox ${sbx.id}. Run more with: ${me} sandboxes exec ${sbx.id} -- <command>`);
662
666
  else if (!(await stop()) && code === 0)
663
667
  code = 1;
664
668
  }
@@ -881,7 +885,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
881
885
  product = "billing";
882
886
  const origins = connectionOrigins(env);
883
887
  const { claimWalletAccount, openWalletAccount } = await import("./wallet.js");
884
- return billingCommand(rest, parse(rest, ["wait", "new-account", "accept-terms"], []), out, () => client(env), undefined, {
888
+ return billingCommand(rest, parse(rest, ["wait", "new-account", "accept-terms"], ["below", "amount", "monthly"]), out, () => client(env), undefined, {
885
889
  open: (input) => {
886
890
  checkBrowserOrigins(origins);
887
891
  return openWalletAccount(input, { authUrl: origins.auth });
@@ -905,21 +909,24 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
905
909
  });
906
910
  }
907
911
  if (command === "referrals") {
912
+ parse(rest, [], []);
908
913
  const r = await (await client(env)).referrals.get();
909
914
  const usd = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
910
915
  print([
911
916
  `Your link: ${r.link}`,
912
917
  "",
913
- `When a company signs up with it and makes its first top-up of ${usd(r.minPurchaseMicros)} or more,`,
914
- `you each get credit equal to that top-up: at least ${usd(r.rewardMicros)}, at most ${usd(r.maxRewardMicros)}`,
915
- `(up to ${usd(r.yearlyCapMicros)} a year for you).`,
918
+ `A company that signs up with it has each payment in its first ${r.windowDays} days matched, up to ${usd(r.maxRewardMicros)},`,
919
+ `and you get the same; its first payment of ${usd(r.minPurchaseMicros)} or more gives each of you at least ${usd(r.rewardMicros)}.`,
920
+ `Then for ${r.shareMonths} months you earn ${r.shareBasisPoints / 100}% of what it spends from credit it paid for`,
921
+ `(up to ${usd(r.yearlyCapMicros)} a year for you, matches and share together).`,
916
922
  ...(r.enabled ? [] : ["The program is paused: new sign-ups are not counted right now."]),
917
923
  "",
918
924
  table([
919
925
  ["signed up", String(r.signedUp)],
920
- ["waiting for a first top-up", String(r.pending)],
921
- ["paid", String(r.paid)],
926
+ ["waiting for a first payment", String(r.pending)],
927
+ ["paying", String(r.payingCompanies)],
922
928
  ["earned", usd(r.earnedMicros)],
929
+ ["of which share", usd(r.shareEarnedMicros)],
923
930
  [`left this year (${r.year})`, usd(r.capRemainingMicros)],
924
931
  ]),
925
932
  ].join("\n"), r);
@@ -1001,7 +1008,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1001
1008
  ]);
1002
1009
  print(rows.length
1003
1010
  ? table([["ID", "PRODUCT", "NAME", "STATE", "CREATED"], ...rows])
1004
- : "Nothing yet. Start with: runtime sandbox create", items);
1011
+ : "Nothing yet. Start with: runtime sandboxes create", items);
1005
1012
  return 0;
1006
1013
  }
1007
1014
  if (command === "ssh") {
@@ -1032,11 +1039,13 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1032
1039
  product = "job";
1033
1040
  return jobCommand(rest, env, out);
1034
1041
  }
1035
- const networkProduct = { domains: "domain", ports: "port", addresses: "address", tunnels: "tunnel" }[command] ??
1036
- command;
1037
- if (NETWORK_PRODUCTS.includes(networkProduct)) {
1042
+ // WireGuard is inside networks: `runtime networks wireguard peer add`.
1043
+ const wireguard = command === "networks" && rest[0] === "wireguard";
1044
+ const networkProduct = wireguard ? "wireguard" : command;
1045
+ const networkRest = wireguard ? rest.slice(1) : rest;
1046
+ if (command !== "wireguard" && NETWORK_PRODUCTS.includes(networkProduct)) {
1038
1047
  product = networkProduct;
1039
- return networkProductCommand(networkProduct, rest, parse(rest, ["ipv6"], ["sandbox", "subnet", "public-key", "route", "out", "host", "secret"]), out, () => client(env));
1048
+ return networkProductCommand(networkProduct, networkRest, parse(networkRest, ["ipv6"], ["sandbox", "subnet", "public-key", "route", "out", "host", "secret"]), out, () => client(env));
1040
1049
  }
1041
1050
  throw unknown("command", command, COMMANDS);
1042
1051
  }
@@ -1112,6 +1121,43 @@ function allowanceRows(allowances) {
1112
1121
  `${amount(a.left)} of ${amount(a.quantity)} ${a.unit}s left`,
1113
1122
  ]);
1114
1123
  }
1124
+ /** What Runtime for Startups includes as `runtime usage` says it, in the
1125
+ * console's words: machine time left of what it gave, storage as the GB kept
1126
+ * free each month and the GB kept now, and what it runs at once. */
1127
+ function potRows(pot, limits) {
1128
+ const amount = (n) => n.toLocaleString("en-US", { maximumFractionDigits: 1 });
1129
+ const day = (iso) => {
1130
+ const at = new Date(iso);
1131
+ const month = at.toLocaleString("en-US", { month: "short", timeZone: "UTC" });
1132
+ return `${at.getUTCDate()} ${month} ${at.getUTCFullYear()}`;
1133
+ };
1134
+ const NAMES = {
1135
+ "machine-cpu": "vCPU-hours",
1136
+ "machine-memory": "memory",
1137
+ "saved-copies": "saved copies kept free",
1138
+ disks: "volumes",
1139
+ };
1140
+ const ORDER = ["machine-cpu", "machine-memory", "saved-copies", "disks"];
1141
+ const rank = (pool) => (ORDER.includes(pool) ? ORDER.indexOf(pool) : ORDER.length);
1142
+ const rows = [...pot]
1143
+ .sort((a, b) => rank(a.pool) - rank(b.pool))
1144
+ .map((p) => {
1145
+ const name = `${p.period === "month" ? "Runtime for Startups" : "startup hours"}: ${NAMES[p.pool] ?? p.pool.replace(/-/g, " ")}`;
1146
+ const until = p.expiresAt ? ` until ${day(p.expiresAt)}` : "";
1147
+ if (p.period === "month") {
1148
+ const kept = p.keptGB ?? 0;
1149
+ const over = kept > p.quantity ? `; past ${amount(p.quantity)} GB bills at the published rate` : "";
1150
+ return [
1151
+ name,
1152
+ `${amount(p.quantity)} GB kept free${until} · ${amount(kept)} GB kept now${over}`,
1153
+ ];
1154
+ }
1155
+ return [name, `${amount(p.left ?? 0)} of ${amount(p.quantity)} ${p.unit}s left${until}`];
1156
+ });
1157
+ if (limits)
1158
+ rows.push(["Runtime for Startups: sandboxes at once", amount(limits.sandboxesAtOnce)], ["Runtime for Startups: image builds at once", amount(limits.buildsAtOnce)]);
1159
+ return rows;
1160
+ }
1115
1161
  /** What a pilot gives, as `runtime whoami` and `runtime limits` say it: the
1116
1162
  * sandboxes it runs free and until when, past which credit does not run
1117
1163
  * them. */
@@ -1155,6 +1201,16 @@ function usageSummary(u) {
1155
1201
  rows.push(["free time", trialLeft(u.trial)]);
1156
1202
  if (u.allowances)
1157
1203
  rows.push(...allowanceRows(u.allowances));
1204
+ // Only a data property is an older server's answer. Do not invoke SDK
1205
+ // compatibility getters when a server sends neither generation of fields.
1206
+ const pot = "startupIncluded" in u
1207
+ ? u.startupIncluded
1208
+ : Object.getOwnPropertyDescriptor(u, "pot")?.value;
1209
+ const potLimits = "startupLimits" in u
1210
+ ? u.startupLimits
1211
+ : Object.getOwnPropertyDescriptor(u, "potLimits")?.value;
1212
+ if (pot?.length || potLimits)
1213
+ rows.push(...potRows(pot ?? [], potLimits));
1158
1214
  if (u.pilot)
1159
1215
  rows.push(["pilot sandbox hours", pilotLeft(u.pilot)]);
1160
1216
  if (u.outbound) {
@@ -1233,10 +1289,21 @@ async function outputSoFar(runtime, sandboxId, processId) {
1233
1289
  processId,
1234
1290
  state: latest?.state ?? null,
1235
1291
  exitCode: latest?.exitCode ?? null,
1292
+ ...(latest?.outOfMemory ? { outOfMemory: latest.outOfMemory } : {}),
1236
1293
  truncated,
1237
1294
  chunks,
1238
1295
  };
1239
1296
  }
1297
+ /** What a person reads when the sandbox's out-of-memory killer ended a
1298
+ * command: the API's message and hint, on standard error. A bare exit -9 (or
1299
+ * 137) said nothing of memory until 9 October 2026. */
1300
+ export function outOfMemoryLines(memory) {
1301
+ // The words come from an answer, not this CLI: escaped like a log line, so
1302
+ // nothing in them can drive the terminal (terminal.ts).
1303
+ return memory && typeof memory.message === "string" && typeof memory.hint === "string"
1304
+ ? `runtime: ${terminalSafe(memory.message)}\nHint: ${terminalSafe(memory.hint)}`
1305
+ : undefined;
1306
+ }
1240
1307
  /** Dollars from integer microdollars: cents from a dollar up, four places
1241
1308
  * below one, and every place below a hundredth of a cent, so a short trial
1242
1309
  * test never reads as $0.0000. */
@@ -1309,36 +1376,60 @@ const longDate = (iso) => new Date(iso).toLocaleDateString("en-GB", {
1309
1376
  year: "numeric",
1310
1377
  timeZone: "UTC",
1311
1378
  });
1312
- /** One line on the switching credit: how to claim it, or what it did. A
1313
- * switch already on record is named with its provider and date, so it never
1379
+ /** One line on the welcome match: while it is open, how much of it credit
1380
+ * added now can still get; after, what it got. Undefined when there is
1381
+ * nothing to say: the offer ended unused. */
1382
+ function welcomeLine(summary) {
1383
+ const w = summary.welcome;
1384
+ // A server from before the welcome match (0433) answers without it.
1385
+ if (!w)
1386
+ return undefined;
1387
+ const whole = (micros) => dollars(micros).replace(/\.00$/, "");
1388
+ if (w.eligible) {
1389
+ // A server from before 0435 answers without what is left.
1390
+ const left = w.leftMicros ?? (BigInt(w.maxMicros) - BigInt(w.creditMicros)).toString();
1391
+ return BigInt(w.creditMicros) > 0n
1392
+ ? `${whole(w.creditMicros)} of ${whole(w.maxMicros)} matched so far; credit you add by ${longDate(w.endsAt)} is matched, up to ${whole(left)} more.`
1393
+ : `Credit you add by ${longDate(w.endsAt)} is matched, up to ${whole(w.maxMicros)} in total.`;
1394
+ }
1395
+ switch (w.status) {
1396
+ case "paid":
1397
+ return `Your credit was matched: ${dollars(w.creditMicros)} of credit.`;
1398
+ case "superseded":
1399
+ return "Your payments are matched by your referral instead, which is never less.";
1400
+ case "reversed":
1401
+ return "The payments that were matched were refunded, so the match was taken back.";
1402
+ case "refused":
1403
+ return "Your credit was not matched. Ask support if you think that is wrong.";
1404
+ }
1405
+ return undefined;
1406
+ }
1407
+ /** What `switch` says: the switch on record, if any, and the welcome match.
1408
+ * A switch already on record is named with its provider and date, so it never
1314
1409
  * reads as if this command recorded it, or recorded the provider it names. */
1315
1410
  function switchingLine(summary, provider) {
1316
- const cap = dollars(summary.maxMicros);
1317
1411
  const sw = summary.switch;
1412
+ const welcome = welcomeLine(summary);
1318
1413
  if (!sw) {
1319
- if (!summary.eligible || !provider)
1320
- return undefined;
1321
- return `Switching from ${RIVAL_NAMES[provider]}? Run \`${me} switch --from ${provider}\` before your first top-up and we match that top-up, up to ${cap}.`;
1414
+ if (!provider || !summary.eligible)
1415
+ return welcome;
1416
+ return [
1417
+ `Switching from ${RIVAL_NAMES[provider]}? Run \`${me} switch --from ${provider}\` before your first top-up to record it.`,
1418
+ welcome,
1419
+ ]
1420
+ .filter(Boolean)
1421
+ .join(" ");
1322
1422
  }
1323
1423
  const from = RIVAL_NAMES[sw.provider];
1324
- const recorded = `This organization's switch from ${from} was recorded on ${longDate(sw.recordedAt)}`;
1325
1424
  const once = provider && provider !== sw.provider
1326
1425
  ? ` A switch is recorded once, so it stays ${from}, not ${RIVAL_NAMES[provider]}.`
1327
1426
  : "";
1328
- switch (sw.status) {
1329
- case "pending":
1330
- return `${recorded}: its first top-up is matched, up to ${cap}, once the payment settles.${once}`;
1331
- case "paid":
1332
- return `${recorded} and matched its first top-up: ${dollars(sw.creditMicros)} of credit.${once}`;
1333
- case "superseded":
1334
- return `${recorded}; its first top-up was matched by a referral instead, which is never less than the switching credit.${once}`;
1335
- case "reversed":
1336
- return `${recorded}; the top-up it matched was refunded, so the credit was taken back.${once}`;
1337
- case "refused":
1338
- return sw.refusedReason === "program_off"
1339
- ? `${recorded}; the switching credit was paused when its first top-up arrived.${once}`
1340
- : `${recorded}; its first top-up was not matched. Ask support if you think that is wrong.${once}`;
1341
- }
1427
+ return [
1428
+ `This organization's switch from ${from} was recorded on ${longDate(sw.recordedAt)}.${once}`,
1429
+ welcome,
1430
+ ]
1431
+ .filter(Boolean)
1432
+ .join(" ");
1342
1433
  }
1343
1434
  /** Running time in the largest unit that keeps it above zero: a short
1344
1435
  * trial test is seconds, not "0 hours". */
@@ -1378,10 +1469,13 @@ async function switching(command, argv, env, out) {
1378
1469
  if (command === "switch") {
1379
1470
  if (!from) {
1380
1471
  const summary = await api.get();
1381
- print(switchingLine(summary) ??
1382
- (summary.eligible
1383
- ? `Moving from a rival? Run \`${me} switch --from <provider>\` (${summary.providers.join(", ")}) before your first top-up, and that top-up is matched, up to ${dollars(summary.maxMicros)}.`
1384
- : "The switching credit matches a first top-up, and this organization has made one already."), summary);
1472
+ const record = summary.eligible
1473
+ ? `Moving from a rival? Run \`${me} switch --from <provider>\` (${summary.providers.join(", ")}) before your first top-up to record it.`
1474
+ : undefined;
1475
+ print((summary.switch
1476
+ ? switchingLine(summary)
1477
+ : [record, welcomeLine(summary)].filter(Boolean).join(" ")) ||
1478
+ "A switch is recorded before the first top-up, and this organization has made one already.", summary);
1385
1479
  return 0;
1386
1480
  }
1387
1481
  const provider = SWITCH_FROM[from];
@@ -1389,7 +1483,7 @@ async function switching(command, argv, env, out) {
1389
1483
  throw unknown("provider", from, Object.keys(RIVAL_NAMES));
1390
1484
  const summary = await api.record({ provider });
1391
1485
  print([
1392
- `Recorded: switching from ${RIVAL_NAMES[provider]}. ${switchingLine(summary) ?? ""}`.trim(),
1486
+ `Recorded: switching from ${RIVAL_NAMES[provider]}. ${welcomeLine(summary) ?? ""}`.trim(),
1393
1487
  ...dropInLines(provider),
1394
1488
  ].join("\n"), summary);
1395
1489
  return 0;
@@ -1507,9 +1601,9 @@ async function sandbox(argv, env, out) {
1507
1601
  switch (verb) {
1508
1602
  case "create": {
1509
1603
  const args = parse(rest, CREATE_FLAGS, CREATE_VALUES);
1510
- const sbx = await runtime.sandboxes.create(createInput(args), {
1511
- onCapacityWait: waiting(out),
1512
- });
1604
+ const input = createInput(args);
1605
+ const onCapacityWait = waiting(out);
1606
+ const sbx = await withVolumeRelease((options) => runtime.sandboxes.create(input, { ...options, onCapacityWait }), out);
1513
1607
  print(sbx.id, sbx.info);
1514
1608
  return 0;
1515
1609
  }
@@ -1534,24 +1628,21 @@ async function sandbox(argv, env, out) {
1534
1628
  ? null
1535
1629
  : await runtime.sandboxes.list({ state: ["stopped"], limit: 50 });
1536
1630
  const stopped = hidden?.data.length
1537
- ? `${hidden.data.length}${hidden.hasMore ? "+" : ""} stopped; runtime sandbox ls --all shows them.`
1631
+ ? `${hidden.data.length}${hidden.hasMore ? "+" : ""} stopped; runtime sandboxes ls --all shows them.`
1538
1632
  : "";
1539
1633
  const rows = all.map((s) => [
1540
1634
  s.id,
1541
1635
  s.info.name ?? "-",
1542
1636
  s.state,
1543
1637
  `${s.info.vcpu}/${s.info.memoryMiB}`,
1544
- s.info.funding,
1545
- // When it stops or pauses by itself (0381): its time limit's end, or
1546
- // "-" when it never will. An older server sends no endsAt.
1547
- (s.info.endsAt === undefined ? s.info.expiresAt : s.info.endsAt) ?? "-",
1638
+ sandboxEnd(s.info),
1548
1639
  ]);
1549
1640
  print([
1550
1641
  rows.length
1551
- ? table([["ID", "NAME", "STATE", "CPU/MIB", "FUNDING", "ENDS"], ...rows])
1642
+ ? table([["ID", "NAME", "STATE", "CPU/MIB", "ENDS"], ...rows])
1552
1643
  : stopped
1553
1644
  ? "No live sandboxes."
1554
- : "No sandboxes. Create one: runtime sandbox create",
1645
+ : "No sandboxes. Create one: runtime sandboxes create",
1555
1646
  stopped,
1556
1647
  ]
1557
1648
  .filter(Boolean)
@@ -1566,31 +1657,31 @@ async function sandbox(argv, env, out) {
1566
1657
  case "switch-image": {
1567
1658
  const args = parse(rest, ["keep-workspace"], []);
1568
1659
  const [id, image] = args.positional;
1569
- const sbx = await use(need(id, `a sandbox, e.g. ${me} sandbox switch-image <id> app:v2 --keep-workspace`));
1660
+ const sbx = await use(need(id, `a sandbox, e.g. ${me} sandboxes switch-image <id> app:v2 --keep-workspace`));
1570
1661
  if (!args.flags.has("keep-workspace"))
1571
- throw usage(`A switch keeps only /workspace (your home), volumes, env and previews; processes restart and everything else on its disk is lost. Add --keep-workspace to say so, or snapshot it first (${me} sandbox snapshot ${sbx.id}) to keep everything.`);
1662
+ throw usage(`A switch keeps only /workspace (your home), volumes, env and previews; processes restart and everything else on its disk is lost. Add --keep-workspace to say so, or snapshot it first (${me} sandboxes snapshot ${sbx.id}) to keep everything.`);
1572
1663
  await sbx.switchImage(need(image, "an image, e.g. app:v2"), { keep: "workspace" });
1573
1664
  print(describe(sbx.info), sbx.info);
1574
1665
  return 0;
1575
1666
  }
1576
1667
  case "resize": {
1577
1668
  const args = parse(rest, ["restart"], ["vcpu", "memory"]);
1578
- const sbx = await use(need(args.positional[0], `a sandbox, e.g. ${me} sandbox resize <id> --memory 4096 --restart`));
1669
+ const sbx = await use(need(args.positional[0], `a sandbox, e.g. ${me} sandboxes resize <id> --memory 4096 --restart`));
1579
1670
  const size = {
1580
1671
  ...(integer(args, "vcpu") !== undefined ? { vcpu: integer(args, "vcpu") } : {}),
1581
1672
  ...(integer(args, "memory") !== undefined ? { memoryMiB: integer(args, "memory") } : {}),
1582
1673
  };
1583
1674
  if (size.vcpu === undefined && size.memoryMiB === undefined)
1584
- throw usage(`Name a new size, e.g. ${me} sandbox resize ${sbx.id} --memory 4096 --restart`);
1675
+ throw usage(`Name a new size, e.g. ${me} sandboxes resize ${sbx.id} --memory 4096 --restart`);
1585
1676
  if (!args.flags.has("restart"))
1586
- throw usage(`A resize restarts it: its whole disk stays and its programs stop. Add --restart to say so, or snapshot it first (${me} sandbox snapshot ${sbx.id}) to keep its memory too.`);
1677
+ throw usage(`A resize restarts it: its whole disk stays and its programs stop. Add --restart to say so, or snapshot it first (${me} sandboxes snapshot ${sbx.id}) to keep its memory too.`);
1587
1678
  await sbx.resize(size, { restart: true });
1588
1679
  print(describe(sbx.info), sbx.info);
1589
1680
  return 0;
1590
1681
  }
1591
1682
  case "rm":
1592
1683
  case "delete": {
1593
- const sbx = await use(need(rest[0], `a sandbox, e.g. ${me} sandbox rm <id>`));
1684
+ const sbx = await use(need(rest[0], `a sandbox, e.g. ${me} sandboxes rm <id>`));
1594
1685
  const gone = await sbx.delete();
1595
1686
  print(`${gone.id} deleted`, gone);
1596
1687
  return 0;
@@ -1692,15 +1783,15 @@ async function sandbox(argv, env, out) {
1692
1783
  print(gone.left ? "Left the tailnet." : "Not on a tailnet.", gone);
1693
1784
  return 0;
1694
1785
  }
1695
- throw usage("runtime sandbox tailscale up|status|down <id>");
1786
+ throw usage("runtime sandboxes tailscale up|status|down <id>");
1696
1787
  }
1697
1788
  case "snapshot": {
1698
1789
  const args = parse(rest, [], ["name", "retention"]);
1699
1790
  const sbx = await get(args.positional[0]);
1700
1791
  const snapshot = await sbx.snapshot({
1701
1792
  ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
1702
- ...(integer(args, "retention") !== undefined
1703
- ? { retentionDays: integer(args, "retention") }
1793
+ ...(daysOrCredit(args, "retention") !== undefined
1794
+ ? { retentionDays: daysOrCredit(args, "retention") }
1704
1795
  : {}),
1705
1796
  });
1706
1797
  print(snapshot.id, snapshot);
@@ -1772,26 +1863,26 @@ async function sandbox(argv, env, out) {
1772
1863
  }
1773
1864
  case "extend": {
1774
1865
  const sbx = await get(rest[0]);
1775
- const seconds = Number(need(rest[1], "seconds, e.g. runtime sandbox extend <id> 600"));
1866
+ const seconds = Number(need(rest[1], "seconds, e.g. runtime sandboxes extend <id> 600"));
1776
1867
  await sbx.extend(seconds);
1777
- print(sbx.info.endsAt === null
1868
+ print(sandboxEnd(sbx.info) === "no limit"
1778
1869
  ? `${sbx.id} has no time limit: it runs while it works, so nothing was extended`
1779
- : `${sbx.id} now ends at ${sbx.info.endsAt ?? sbx.info.expiresAt}`, sbx.info);
1870
+ : `${sbx.id} now ends at ${sandboxEnd(sbx.info)}`, sbx.info);
1780
1871
  return 0;
1781
1872
  }
1782
1873
  case "exec": {
1783
- const args = parse(rest, [], ["cwd", "env", "timeout"], `${me} sandbox exec <id> -- ls -la`);
1874
+ const args = parse(rest, [], ["cwd", "env", "timeout"], `${me} sandboxes exec <id> -- ls -la`);
1784
1875
  const command = args.rest ?? args.positional.slice(1);
1785
1876
  if (!command.length)
1786
- throw usage("Give the command after --: runtime sandbox exec <id> -- ls -la");
1877
+ throw usage("Give the command after --: runtime sandboxes exec <id> -- ls -la");
1787
1878
  const sbx = await use(args.positional[0]);
1788
1879
  return execute(sbx, command, args, out, integer(args, "timeout"));
1789
1880
  }
1790
1881
  case "spawn": {
1791
- const args = parse(rest, ["pty"], ["cwd", "env"], `${me} sandbox spawn <id> -- npm run dev`);
1882
+ const args = parse(rest, ["pty"], ["cwd", "env"], `${me} sandboxes spawn <id> -- npm run dev`);
1792
1883
  const command = args.rest ?? [];
1793
1884
  if (!command.length)
1794
- throw usage("Give the command after --: runtime sandbox spawn <id> -- npm run dev");
1885
+ throw usage("Give the command after --: runtime sandboxes spawn <id> -- npm run dev");
1795
1886
  const sbx = await use(args.positional[0]);
1796
1887
  const proc = await sbx.spawn(command.length === 1 ? command[0] : command, {
1797
1888
  ...(has(args, "pty") ? { pty: { cols: 120, rows: 40 } } : {}),
@@ -1807,36 +1898,54 @@ async function sandbox(argv, env, out) {
1807
1898
  print(list.length
1808
1899
  ? table([
1809
1900
  ["PROCESS", "STATE", "EXIT", "COMMAND"],
1810
- ...list.map((p) => [p.id, p.state, String(p.exitCode ?? "-"), p.command]),
1901
+ // The command line is the guest's report: escaped, as a log is.
1902
+ ...list.map((p) => [
1903
+ terminalSafe(String(p.id)),
1904
+ terminalSafe(String(p.state)),
1905
+ `${typeof p.exitCode === "number" ? p.exitCode : "-"}${p.outOfMemory ? " (out of memory)" : ""}`,
1906
+ terminalSafe(String(p.command ?? "")),
1907
+ ]),
1811
1908
  ])
1812
1909
  : "No processes.", list);
1910
+ const killed = list.find((p) => p.outOfMemory)?.outOfMemory;
1911
+ if (killed && typeof killed.hint === "string" && !out.json)
1912
+ out.error(`Hint: ${terminalSafe(killed.hint)}`);
1813
1913
  return 0;
1814
1914
  }
1815
1915
  case "logs": {
1816
- const args = parse(rest, ["f", "follow"], []);
1916
+ const args = parse(rest, ["f", "follow", "raw"], []);
1817
1917
  const sbx = await use(args.positional[0]);
1818
1918
  const proc = await sbx.processes.get(need(args.positional[1], "a process id"));
1919
+ const [stdout, stderr, shown] = [
1920
+ logStream(process.stdout, has(args, "raw")),
1921
+ logStream(process.stderr, has(args, "raw")),
1922
+ logText(args),
1923
+ ];
1819
1924
  if (has(args, "f") || has(args, "follow")) {
1820
1925
  for await (const event of proc.output())
1821
1926
  if (event.type === "stdout")
1822
- process.stdout.write(event.data);
1927
+ stdout(event.data);
1823
1928
  else if (event.type === "stderr")
1824
- process.stderr.write(event.data);
1929
+ stderr(event.data);
1825
1930
  else if (out.json)
1826
- out.write(JSON.stringify(event));
1931
+ out.write(shown(JSON.stringify(event)));
1932
+ else if (event.type === "exit" && event.outOfMemory)
1933
+ out.error(outOfMemoryLines(event.outOfMemory));
1827
1934
  return 0;
1828
1935
  }
1829
1936
  /* Without -f, what it has printed so far, then back to the prompt, as
1830
1937
  `docker logs` does: a server's log is read, not followed forever. */
1831
1938
  const so = await outputSoFar(runtime, sbx.id, proc.id);
1832
1939
  if (out.json) {
1833
- out.write(JSON.stringify(so));
1940
+ out.write(shown(JSON.stringify(so)));
1834
1941
  return 0;
1835
1942
  }
1836
1943
  for (const chunk of so.chunks)
1837
- (chunk.stream === "stderr" ? process.stderr : process.stdout).write(chunk.text);
1944
+ (chunk.stream === "stderr" ? stderr : stdout)(chunk.text);
1945
+ if (so.outOfMemory)
1946
+ out.error(outOfMemoryLines(so.outOfMemory));
1838
1947
  if (so.state === "running")
1839
- process.stderr.write(`[${proc.id} is still running; follow it: ${me} sandbox logs ${sbx.id} ${proc.id} -f]\n`);
1948
+ process.stderr.write(`[${proc.id} is still running; follow it: ${me} sandboxes logs ${sbx.id} ${proc.id} -f]\n`);
1840
1949
  return 0;
1841
1950
  }
1842
1951
  case "kill": {
@@ -1906,7 +2015,7 @@ async function sandbox(argv, env, out) {
1906
2015
  return runCode(await use(rest[0]), rest.slice(1), out);
1907
2016
  case "preview": {
1908
2017
  if (rest[0] === "rotate") {
1909
- const port = portOf(rest[2], "runtime sandbox preview rotate <id> 3000");
2018
+ const port = portOf(rest[2], "runtime sandboxes preview rotate <id> 3000");
1910
2019
  const sbx = await use(rest[1]);
1911
2020
  const preview = await sbx.previews.rotate(port);
1912
2021
  print(preview.visibility === "public"
@@ -1915,7 +2024,7 @@ async function sandbox(argv, env, out) {
1915
2024
  return 0;
1916
2025
  }
1917
2026
  const args = parse(rest, ["public"], ["ttl"]);
1918
- const port = portOf(args.positional[1], "runtime sandbox preview <id> 3000");
2027
+ const port = portOf(args.positional[1], "runtime sandboxes preview <id> 3000");
1919
2028
  const sbx = await use(args.positional[0]);
1920
2029
  const preview = await sbx.previews.create(port, {
1921
2030
  ...(has(args, "public") ? { visibility: "public" } : {}),
@@ -1935,7 +2044,7 @@ async function sandbox(argv, env, out) {
1935
2044
  ["PORT", "VISIBILITY", "URL"],
1936
2045
  ...list.map((p) => [String(p.port), p.visibility, p.url]),
1937
2046
  ])
1938
- : "No shared ports. Share one: runtime sandbox preview <id> 3000", list);
2047
+ : "No shared ports. Share one: runtime sandboxes preview <id> 3000", list);
1939
2048
  return 0;
1940
2049
  }
1941
2050
  case "session": {
@@ -1952,17 +2061,17 @@ async function sandbox(argv, env, out) {
1952
2061
  one.origins.join(",") || "-",
1953
2062
  ]),
1954
2063
  ])
1955
- : "No sessions. Make one: runtime sandbox session create <id> --origin https://app.example.com", list);
2064
+ : "No sessions. Make one: runtime sandboxes session create <id> --origin https://app.example.com", list);
1956
2065
  return 0;
1957
2066
  }
1958
2067
  if (verb === "revoke") {
1959
- const sessionId = need(rest[2], "a session id: runtime sandbox session revoke <id> <session-id>");
2068
+ const sessionId = need(rest[2], "a session id: runtime sandboxes session revoke <id> <session-id>");
1960
2069
  const ended = await (await use(rest[1])).sessions.revoke(sessionId);
1961
2070
  print(`Session ${ended.id} ended.`, ended);
1962
2071
  return 0;
1963
2072
  }
1964
2073
  if (verb !== "create")
1965
- throw usage("runtime sandbox session create <id> [--origin <url>]..., session ls <id>, or session revoke <id> <session-id>");
2074
+ throw usage("runtime sandboxes session create <id> [--origin <url>]..., session ls <id>, or session revoke <id> <session-id>");
1966
2075
  const args = parse(rest.slice(1), [], ["origin", "ttl", "name"]);
1967
2076
  const origins = args.flags.get("origin") ?? [];
1968
2077
  const ttl = integer(args, "ttl");
@@ -1976,7 +2085,7 @@ async function sandbox(argv, env, out) {
1976
2085
  return 0;
1977
2086
  }
1978
2087
  case "unshare": {
1979
- const port = portOf(rest[1], "runtime sandbox unshare <id> 3000");
2088
+ const port = portOf(rest[1], "runtime sandboxes unshare <id> 3000");
1980
2089
  const sbx = await use(rest[0]);
1981
2090
  print(`Port ${port} is no longer shared.`, await sbx.previews.delete(port));
1982
2091
  return 0;
@@ -2174,7 +2283,7 @@ read = readSecret) {
2174
2283
  }
2175
2284
  if (!hosts.length) {
2176
2285
  const saved = await runtime.secrets.set(name, { value, jobs: true });
2177
- print(`Stored ${name} for jobs, version ${saved.jobs.version}. Bind it: ${me} job create ... --secret ${name}`, saved);
2286
+ print(`Stored ${name} for jobs, version ${saved.jobs.version}. Bind it: ${me} jobs create ... --secret ${name}`, saved);
2178
2287
  return 0;
2179
2288
  }
2180
2289
  const header = flag(args, "header");
@@ -2193,14 +2302,14 @@ read = readSecret) {
2193
2302
  `Stored ${saved.name}. Sandboxes see ${saved.name}=${saved.placeholder}.`,
2194
2303
  saved.header
2195
2304
  ? `Requests to ${saved.hosts.join(", ")} get ${saved.header}: ${saved.format ?? "{value}"} over HTTPS.`
2196
- : `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the URL and headers.`,
2305
+ : `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the Authorization header or another credential header (x-api-key, x-goog-api-key); in the URL it stays a placeholder.`,
2197
2306
  ...(saved.rules?.length ? [`Only these requests get it: ${ruleText(saved.rules)}.`] : []),
2198
2307
  ...(saved.enforced
2199
2308
  ? []
2200
2309
  : ["A host is catching up; running sandboxes have it within a minute."]),
2201
2310
  ...(saved.jobs
2202
2311
  ? [
2203
- `Also stored for jobs, version ${saved.jobs.version}: --secret ${saved.name} on ${me} job create.`,
2312
+ `Also stored for jobs, version ${saved.jobs.version}: --secret ${saved.name} on ${me} jobs create.`,
2204
2313
  ]
2205
2314
  : []),
2206
2315
  ].join("\n"), saved);
@@ -2339,7 +2448,7 @@ async function desktop(sbx, argv, out) {
2339
2448
  return 0;
2340
2449
  }
2341
2450
  }
2342
- throw usage(`${me} sandbox desktop <id> start|stop|screenshot|open|click|type|press|windows|record`);
2451
+ throw usage(`${me} sandboxes desktop <id> start|stop|screenshot|open|click|type|press|windows|record`);
2343
2452
  }
2344
2453
  async function browser(sbx, argv, out) {
2345
2454
  const [action, ...rest] = argv;
@@ -2353,7 +2462,8 @@ async function browser(sbx, argv, out) {
2353
2462
  ...(integer(args, "height") !== undefined ? { height: integer(args, "height") } : {}),
2354
2463
  });
2355
2464
  print(`${started.version}${started.headless ? "" : `, on the desktop: ${started.streamUrl}`}\n` +
2356
- `CDP: ${started.cdpUrl}\n(private; it expires ${started.expiresAt ?? "never"})`, started);
2465
+ `CDP: ${started.cdpUrl}\n(private; it expires ${started.expiresAt ?? "never"})` +
2466
+ (started.hint ? `\n${started.hint}` : ""), started);
2357
2467
  return 0;
2358
2468
  }
2359
2469
  case "get":
@@ -2368,7 +2478,7 @@ async function browser(sbx, argv, out) {
2368
2478
  return 0;
2369
2479
  }
2370
2480
  }
2371
- throw usage(`${me} sandbox browser <id> start [--headed]|get|stop`);
2481
+ throw usage(`${me} sandboxes browser <id> start [--headed]|get|stop`);
2372
2482
  }
2373
2483
  const KEYS_HELP = `runtime keys <command>
2374
2484
 
@@ -2541,14 +2651,14 @@ async function keys(argv, env, out) {
2541
2651
  await confirm();
2542
2652
  return 0;
2543
2653
  }
2544
- const SNAPSHOT_HELP = `runtime snapshot <command>
2654
+ const SNAPSHOT_HELP = `runtime snapshots <command>
2545
2655
 
2546
2656
  ls [--sandbox <id>] [--name n] Your snapshots
2547
2657
  get <id> One snapshot
2548
- extend <id> <days> Keep it longer
2658
+ extend <id> <days|credit> Keep it longer; credit: while you have credit
2549
2659
  rm <id> Delete it
2550
- Take one with \`runtime sandbox snapshot <sandboxId>\`; start a sandbox from it
2551
- with \`runtime sandbox create --snapshot <snapshotId>\`.
2660
+ Take one with \`runtime sandboxes snapshot <sandboxId>\`; start a sandbox from it
2661
+ with \`runtime sandboxes create --snapshot <snapshotId>\`.
2552
2662
  `;
2553
2663
  async function snapshot(argv, env, out) {
2554
2664
  const [verb, ...rest] = argv;
@@ -2571,9 +2681,15 @@ async function snapshot(argv, env, out) {
2571
2681
  print(all.length
2572
2682
  ? table([
2573
2683
  ["ID", "NAME", "STATUS", "FROM", "EXPIRES"],
2574
- ...all.map((s) => [s.id, s.name ?? "-", s.status, s.sourceSandboxId, s.expiresAt]),
2684
+ ...all.map((s) => [
2685
+ s.id,
2686
+ s.name ?? "-",
2687
+ s.status,
2688
+ s.sourceSandboxId,
2689
+ s.expiresAt ?? "with credit",
2690
+ ]),
2575
2691
  ])
2576
- : "No snapshots. Take one: runtime sandbox snapshot <sandboxId>", all);
2692
+ : "No snapshots. Take one: runtime sandboxes snapshot <sandboxId>", all);
2577
2693
  return 0;
2578
2694
  }
2579
2695
  case "get": {
@@ -2582,9 +2698,11 @@ async function snapshot(argv, env, out) {
2582
2698
  return 0;
2583
2699
  }
2584
2700
  case "extend": {
2585
- const days = integer(parse(rest.slice(1), [], ["days"]), "days") ?? Number(need(rest[1], "days"));
2701
+ const word = parse(rest.slice(1), [], ["days"]);
2702
+ const days = daysOrCredit(word, "days") ??
2703
+ (need(rest[1], "days") === "credit" ? null : Number(need(rest[1], "days")));
2586
2704
  const found = await api.extend(need(rest[0], "a snapshot id"), days);
2587
- print(`${found.id} kept until ${found.expiresAt}`, found);
2705
+ print(`${found.id} kept ${found.expiresAt === null ? "while you have credit" : `until ${found.expiresAt}`}`, found);
2588
2706
  return 0;
2589
2707
  }
2590
2708
  case "rm":
@@ -2597,7 +2715,7 @@ async function snapshot(argv, env, out) {
2597
2715
  }
2598
2716
  throw unknown("snapshot command", verb, ["ls", "get", "extend", "rm"]);
2599
2717
  }
2600
- const IMAGE_HELP = `runtime image <command>
2718
+ const IMAGE_HELP = `runtime images <command>
2601
2719
 
2602
2720
  build [<folder>] [--dockerfile|-f <Dockerfile>] | --from <image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...
2603
2721
  [--name <name>] [--tag|-t name[:tag]]... [--target <stage>] [--build-arg K=V]... [--no-cache]
@@ -2612,8 +2730,9 @@ const IMAGE_HELP = `runtime image <command>
2612
2730
  ls Your images
2613
2731
  versions <name> Every version of a name, newest first
2614
2732
  get <image> One image: an id, name, name:tag or name@version
2615
- logs <image> [--follow]
2616
- Its build log
2733
+ logs <image> [--follow] [--raw]
2734
+ Its build log; on a terminal control characters
2735
+ show as \\uXXXX unless --raw
2617
2736
  tag <image> <tag> Point a tag of the image's name at this version
2618
2737
  untag <image> <tag> Take a tag off
2619
2738
  rm <image> Delete that version and its tags
@@ -2624,7 +2743,7 @@ const IMAGE_HELP = `runtime image <command>
2624
2743
  registry set <ecr registry> --access-key-id <id>
2625
2744
  Amazon ECR: the secret access key is read from standard input
2626
2745
  registry rm <registry>
2627
- Use one: runtime sandbox create --image app:latest
2746
+ Use one: runtime sandboxes create --image app:latest
2628
2747
  `;
2629
2748
  /** Options `image build` takes with a value. */
2630
2749
  const IMAGE_BUILD_VALUES = [
@@ -2759,7 +2878,7 @@ read = readSecret) {
2759
2878
  ])
2760
2879
  : name
2761
2880
  ? `No versions of ${name}.`
2762
- : "No images. Build one: runtime image build --pip pandas", all);
2881
+ : "No images. Build one: runtime images build --pip pandas", all);
2763
2882
  return 0;
2764
2883
  }
2765
2884
  case "get": {
@@ -2768,13 +2887,14 @@ read = readSecret) {
2768
2887
  return 0;
2769
2888
  }
2770
2889
  case "logs": {
2771
- const args = parse(rest, ["follow", "f"], []);
2890
+ const args = parse(rest, ["follow", "f", "raw"], []);
2772
2891
  const id = (await api.resolve(need(args.positional[0], "an image"))).id;
2892
+ const shown = logText(args);
2773
2893
  if (has(args, "follow") || has(args, "f")) {
2774
2894
  const lines = [];
2775
- const done = await api.followLogs(id, (line) => out.json ? lines.push(line.text) : out.write(line.text));
2895
+ const done = await api.followLogs(id, (line) => out.json ? lines.push(line.text) : out.write(shown(line.text)));
2776
2896
  if (out.json)
2777
- print("", { lines, state: done.state });
2897
+ out.write(shown(JSON.stringify({ lines, state: done.state })));
2778
2898
  return done.state === "failed" ? 1 : 0;
2779
2899
  }
2780
2900
  let after = 0;
@@ -2786,7 +2906,7 @@ read = readSecret) {
2786
2906
  break;
2787
2907
  after = page.nextAfter;
2788
2908
  }
2789
- print(lines.join("\n") || "(no log yet)", lines);
2909
+ out.write(shown(out.json ? JSON.stringify(lines) : lines.join("\n") || "(no log yet)"));
2790
2910
  return 0;
2791
2911
  }
2792
2912
  case "tag":
@@ -2813,7 +2933,7 @@ read = readSecret) {
2813
2933
  ["REGISTRY", "KIND", "USER", "SINCE"],
2814
2934
  ...all.map((r) => [r.registry, r.kind, r.username, r.createdAt]),
2815
2935
  ])
2816
- : "No registry credentials. Store some: runtime image registry set ghcr.io --username you", all);
2936
+ : "No registry credentials. Store some: runtime images registry set ghcr.io --username you", all);
2817
2937
  return 0;
2818
2938
  }
2819
2939
  if (action === "set") {
@@ -2853,7 +2973,7 @@ read = readSecret) {
2853
2973
  "registry",
2854
2974
  ]);
2855
2975
  }
2856
- const JOB_HELP = `runtime job <command>
2976
+ const JOB_HELP = `runtime jobs <command>
2857
2977
 
2858
2978
  A job runs a command in a fresh sandbox, once or on a schedule. Each run spends
2859
2979
  the included usage first, then credit at the sandbox rates.
@@ -2871,14 +2991,21 @@ const JOB_HELP = `runtime job <command>
2871
2991
  ls Your jobs: state, schedule and next run
2872
2992
  get <id> One job, in full
2873
2993
  runs <id> Its runs: state, exit code, when
2874
- logs <runId|jobId> [-f] A run's output (a job id: its latest run); -f follows it
2994
+ logs <runId|jobId> [-f] [--raw] A run's output (a job id: its latest run); -f follows it;
2995
+ on a terminal control characters show as \\uXXXX
2996
+ unless --raw
2875
2997
  pause <id> | resume <id> Stop or restart scheduling; a run already going finishes
2998
+ trigger <id> Run it now, once; its schedule does not move, and a
2999
+ paused job stays paused
3000
+ reschedule <id> (--cron "<five fields>" [--timezone Europe/Berlin] | --at <time|now>)
3001
+ A new schedule; the next run is its next time
2876
3002
  cancel <id> End it for good and stop a run in progress
2877
3003
 
2878
3004
  Examples
2879
- runtime job create nightly --cron "0 3 * * *" --timezone Europe/Berlin -- python3 /workspace/report.py
2880
- runtime job create once --at now -- bash -lc 'echo hello from a job'
2881
- runtime job logs <jobId> -f
3005
+ runtime jobs create nightly --cron "0 3 * * *" --timezone Europe/Berlin -- python3 /workspace/report.py
3006
+ runtime jobs create once --at now -- bash -lc 'echo hello from a job'
3007
+ runtime jobs trigger <jobId> && runtime jobs logs <jobId> -f
3008
+ runtime jobs reschedule <jobId> --cron "30 4 * * 1-5" --timezone Europe/Berlin
2882
3009
  `;
2883
3010
  const when = (ms) => (ms == null ? "-" : new Date(ms).toISOString());
2884
3011
  function scheduleText(job) {
@@ -2926,13 +3053,13 @@ async function jobCommand(argv, env, out) {
2926
3053
  "max-cost",
2927
3054
  "max-total-cost",
2928
3055
  "region",
2929
- ], `${me} job create <name> --cron "0 3 * * *" -- python3 -c '...'`);
3056
+ ], `${me} jobs create <name> --cron "0 3 * * *" -- python3 -c '...'`);
2930
3057
  const name = need(args.positional[0], "the job's name, such as nightly-report");
2931
3058
  if (args.positional.length > 1)
2932
- throw usage(`Put the command after --: ${me} job create ${name} ... -- ${args.positional.slice(1).join(" ")}`);
3059
+ throw usage(`Put the command after --: ${me} jobs create ${name} ... -- ${args.positional.slice(1).join(" ")}`);
2933
3060
  const argv = args.rest ?? [];
2934
3061
  if (!argv.length)
2935
- throw usage(`Give the command after --: ${me} job create ${name} --at now -- echo hello`);
3062
+ throw usage(`Give the command after --: ${me} jobs create ${name} --at now -- echo hello`);
2936
3063
  const cron = flag(args, "cron");
2937
3064
  const at = flag(args, "at");
2938
3065
  if ((cron === undefined) === (at === undefined))
@@ -2989,7 +3116,7 @@ async function jobCommand(argv, env, out) {
2989
3116
  : {}),
2990
3117
  });
2991
3118
  if (!out.json)
2992
- out.error(`Scheduled ${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}. Follow it: ${me} job logs ${job.id} -f`);
3119
+ out.error(`Scheduled ${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}. Follow it: ${me} jobs logs ${job.id} -f`);
2993
3120
  print(job.id, job);
2994
3121
  return 0;
2995
3122
  }
@@ -3009,7 +3136,7 @@ async function jobCommand(argv, env, out) {
3009
3136
  j.blockedReason ?? "-",
3010
3137
  ]),
3011
3138
  ])
3012
- : `No jobs. Schedule one: ${me} job create hello --at now -- echo hello`, all);
3139
+ : `No jobs. Schedule one: ${me} jobs create hello --at now -- echo hello`, all);
3013
3140
  return 0;
3014
3141
  }
3015
3142
  case "get": {
@@ -3038,8 +3165,13 @@ async function jobCommand(argv, env, out) {
3038
3165
  return 0;
3039
3166
  }
3040
3167
  case "logs": {
3041
- const args = parse(rest, ["f", "follow"], []);
3168
+ const args = parse(rest, ["f", "follow", "raw"], []);
3042
3169
  const id = need(args.positional[0], "a run id or a job id");
3170
+ const [stdout, stderr, shown] = [
3171
+ logStream(process.stdout, has(args, "raw")),
3172
+ logStream(process.stderr, has(args, "raw")),
3173
+ logText(args),
3174
+ ];
3043
3175
  let runId = id;
3044
3176
  try {
3045
3177
  await api.run(id);
@@ -3067,25 +3199,27 @@ async function jobCommand(argv, env, out) {
3067
3199
  for (;;) {
3068
3200
  const page = await api.logs(runId, { cursor, limitBytes: 65536 });
3069
3201
  if (out.json)
3070
- out.write(JSON.stringify(page));
3202
+ out.write(shown(JSON.stringify(page)));
3071
3203
  else
3072
3204
  for (const chunk of page.chunks)
3073
- (chunk.stream === "stderr" ? process.stderr : process.stdout).write(chunk.text);
3205
+ (chunk.stream === "stderr" ? stderr : stdout)(chunk.text);
3074
3206
  cursor = page.nextCursor;
3075
3207
  if (page.complete) {
3208
+ if (!follow && out.json)
3209
+ return 0;
3210
+ const run = await api.run(runId);
3076
3211
  if (!out.json) {
3077
- const run = await api.run(runId);
3078
3212
  if (page.truncated)
3079
3213
  process.stderr.write("[the run printed more than it keeps; this is its last part]\n");
3080
3214
  process.stderr.write(`[${run.state}${run.exitCode === null ? "" : `, exit code ${run.exitCode}`}${run.reason ? `, ${run.reason}` : ""}]\n`);
3081
3215
  }
3082
- return 0;
3216
+ return follow ? (run.exitCode ?? (run.state === "succeeded" ? 0 : 1)) : 0;
3083
3217
  }
3084
3218
  if (page.chunks.length)
3085
3219
  continue;
3086
3220
  if (!follow) {
3087
3221
  if (!out.json)
3088
- process.stderr.write(`[${runId} is still going; follow it: ${me} job logs ${runId} -f]\n`);
3222
+ process.stderr.write(`[${runId} is still going; follow it: ${me} jobs logs ${runId} -f]\n`);
3089
3223
  return 0;
3090
3224
  }
3091
3225
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -3099,6 +3233,27 @@ async function jobCommand(argv, env, out) {
3099
3233
  print(`${job.name} ${job.state}.`, job);
3100
3234
  return 0;
3101
3235
  }
3236
+ case "trigger": {
3237
+ const id = need(rest[0], "a job id");
3238
+ const job = await api.trigger(id);
3239
+ print(`${job.name} runs now${job.state === "paused" ? ", once; it stays paused" : ""}. Follow it: ${me} jobs logs ${job.id} -f`, job);
3240
+ return 0;
3241
+ }
3242
+ case "reschedule": {
3243
+ const args = parse(rest, [], ["cron", "timezone", "at"], `${me} jobs reschedule <id> --cron "0 3 * * *" [--timezone Europe/Berlin]`);
3244
+ const id = need(args.positional[0], "a job id");
3245
+ const cron = flag(args, "cron");
3246
+ const at = flag(args, "at");
3247
+ if ((cron === undefined) === (at === undefined))
3248
+ throw usage('Give --cron "<five fields>" for a schedule, or --at <time|now> for one run.');
3249
+ if (at !== undefined && flag(args, "timezone") !== undefined)
3250
+ throw usage("--timezone goes with --cron; --at takes a time with its own offset.");
3251
+ const job = await api.reschedule(id, cron !== undefined
3252
+ ? { cron, ...(flag(args, "timezone") ? { timezone: flag(args, "timezone") } : {}) }
3253
+ : { at: atTime(at) });
3254
+ print(`${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}.`, job);
3255
+ return 0;
3256
+ }
3102
3257
  }
3103
3258
  throw unknown("job command", verb, [
3104
3259
  "create",
@@ -3108,12 +3263,15 @@ async function jobCommand(argv, env, out) {
3108
3263
  "logs",
3109
3264
  "pause",
3110
3265
  "resume",
3266
+ "trigger",
3267
+ "reschedule",
3111
3268
  "cancel",
3112
3269
  ]);
3113
3270
  }
3114
- const VOLUME_HELP = `runtime volume <command>
3271
+ const VOLUME_HELP = `runtime volumes <command>
3115
3272
 
3116
- create --size-mib <N> [--name n] [--shared] A persistent disk; prints its id
3273
+ create --size-mib <N> [--name n] [--label k=v]... [--shared]
3274
+ A persistent disk; prints its id
3117
3275
  ls Your volumes
3118
3276
  get <id> One volume, its backups and where it is attached
3119
3277
  resize <id> --size-mib <N> Grow a detached volume on its current host
@@ -3128,7 +3286,7 @@ const VOLUME_HELP = `runtime volume <command>
3128
3286
  backup-rm <backupId> Delete a backup
3129
3287
  backup-policy <id> [--daily on|off] [--retention-days N]
3130
3288
  Daily backups on or off, and how long each is kept
3131
- Attach one: runtime sandbox create --volume <id>:/data
3289
+ Attach one: runtime sandboxes create --volume <id>:/data
3132
3290
  Volumes are backed up off their host daily unless you turn it off.
3133
3291
  Shared commands work where shared disks are enabled.
3134
3292
  `;
@@ -3142,10 +3300,11 @@ async function volume(argv, env, out) {
3142
3300
  const api = (await client(env)).volumes;
3143
3301
  switch (verb) {
3144
3302
  case "create": {
3145
- const args = parse(rest, ["shared"], ["size-mib", "name"]);
3303
+ const args = parse(rest, ["shared"], ["size-mib", "name", "label"]);
3146
3304
  const created = await api.create({
3147
3305
  sizeMiB: integer(args, "size-mib") ?? Number(need(undefined, "--size-mib <N>")),
3148
3306
  ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
3307
+ ...(args.flags.has("label") ? { labels: pairs(args, "label") } : {}),
3149
3308
  ...(has(args, "shared") ? { shared: true } : {}),
3150
3309
  });
3151
3310
  print(created.id, created);
@@ -3166,7 +3325,7 @@ async function volume(argv, env, out) {
3166
3325
  v.attachments.map((a) => `${a.sandboxId}:${a.path}`).join(", ") || "-",
3167
3326
  ]),
3168
3327
  ])
3169
- : "No volumes. Create one: runtime volume create --size-mib 10240", all);
3328
+ : "No volumes. Create one: runtime volumes create --size-mib 10240", all);
3170
3329
  return 0;
3171
3330
  }
3172
3331
  case "get": {
@@ -3178,7 +3337,7 @@ async function volume(argv, env, out) {
3178
3337
  const args = parse(rest, [], ["size-mib"]);
3179
3338
  const id = need(args.positional[0], "a volume id");
3180
3339
  if (args.positional.length !== 1 || args.rest)
3181
- throw usage(`Use ${me} volume resize <id> --size-mib <N>.`);
3340
+ throw usage(`Use ${me} volumes resize <id> --size-mib <N>.`);
3182
3341
  const sizeMiB = integer(args, "size-mib") ?? Number(need(undefined, "--size-mib <N>"));
3183
3342
  const resized = await api.resize(id, { sizeMiB });
3184
3343
  print(describeRecord(resized, table), resized);
@@ -3190,7 +3349,7 @@ async function volume(argv, env, out) {
3190
3349
  const sandboxId = need(args.positional[1], "a sandbox id");
3191
3350
  const path = need(flag(args, "path"), "--path /data");
3192
3351
  if (args.positional.length !== 2 || args.rest)
3193
- throw usage(`Use ${me} volume attach <id> <sandboxId> --path /data.`);
3352
+ throw usage(`Use ${me} volumes attach <id> <sandboxId> --path /data.`);
3194
3353
  const attachment = await api.attach(id, { sandboxId, path });
3195
3354
  print(describeRecord(attachment, table), attachment);
3196
3355
  return attachment.error ? 1 : 0;
@@ -3201,7 +3360,7 @@ async function volume(argv, env, out) {
3201
3360
  const id = need(args.positional[0], "a volume id");
3202
3361
  const attachmentId = need(args.positional[1], "an attachment id");
3203
3362
  if (args.positional.length !== 2 || args.rest)
3204
- throw usage(`Use ${me} volume ${verb} <id> <attachmentId>.`);
3363
+ throw usage(`Use ${me} volumes ${verb} <id> <attachmentId>.`);
3205
3364
  const attachment = verb === "detach"
3206
3365
  ? await api.detach(id, attachmentId)
3207
3366
  : await api.getAttachment(id, attachmentId);
@@ -3210,7 +3369,8 @@ async function volume(argv, env, out) {
3210
3369
  }
3211
3370
  case "rm":
3212
3371
  case "delete": {
3213
- const found = await api.delete(need(rest[0], "a volume id"));
3372
+ const id = need(rest[0], "a volume id");
3373
+ const found = await withVolumeRelease((options) => api.delete(id, options), out);
3214
3374
  print(`${found.id} ${found.state}`, found);
3215
3375
  return 0;
3216
3376
  }
@@ -3220,7 +3380,7 @@ async function volume(argv, env, out) {
3220
3380
  const days = integer(args, "retention-days");
3221
3381
  const made = await api.backup(id, days === undefined ? {} : { retentionDays: days }, has(args, "no-wait") ? { wait: 0 } : {});
3222
3382
  if (!out.json && made.state === "pending")
3223
- out.error(`Copying it off the host; check with: ${me} volume backups ${id}`);
3383
+ out.error(`Copying it off the host; check with: ${me} volumes backups ${id}`);
3224
3384
  print(made.id, made);
3225
3385
  return 0;
3226
3386
  }
@@ -3248,7 +3408,7 @@ async function volume(argv, env, out) {
3248
3408
  const args = parse(rest, [], ["name"]);
3249
3409
  const created = await api.restore(need(args.positional[0], "a backup id"), flag(args, "name") ? { name: flag(args, "name") } : {});
3250
3410
  if (!out.json && created.state === "creating")
3251
- out.error(`Restoring; check with: ${me} volume get ${created.id}`);
3411
+ out.error(`Restoring; check with: ${me} volumes get ${created.id}`);
3252
3412
  print(created.id, created);
3253
3413
  return 0;
3254
3414
  }
@@ -3307,12 +3467,47 @@ const CREATE_FLAGS = [
3307
3467
  function waiting(out) {
3308
3468
  const said = new Set();
3309
3469
  return (refusal) => {
3470
+ // The CLI's volume wait owns this refusal, including its deadline.
3471
+ if (refusal.code === "volume_releasing")
3472
+ throw refusal;
3310
3473
  if (said.has(refusal.code))
3311
3474
  return;
3312
3475
  said.add(refusal.code);
3313
3476
  out.error(`${refusal.message} Waiting for room, up to 2 minutes; Ctrl-C stops.`);
3314
3477
  };
3315
3478
  }
3479
+ /** A stopping sandbox can take a minute to let go of its volume. Retry only
3480
+ * that refusal, with the same key so a create cannot make two sandboxes. */
3481
+ async function withVolumeRelease(operation, out) {
3482
+ const idempotencyKey = crypto.randomUUID();
3483
+ let until;
3484
+ for (;;) {
3485
+ try {
3486
+ return await operation({
3487
+ idempotencyKey,
3488
+ ...(until === undefined
3489
+ ? {}
3490
+ : { signal: AbortSignal.timeout(Math.max(1, Math.ceil(until - performance.now()))) }),
3491
+ });
3492
+ }
3493
+ catch (error) {
3494
+ if (!(error instanceof RuntimeError) ||
3495
+ error.code !== "volume_releasing" ||
3496
+ error.status !== 409)
3497
+ throw error;
3498
+ if (until === undefined) {
3499
+ until = performance.now() + 90_000;
3500
+ out.error("Waiting for the volume to be let go, up to 90 seconds; Ctrl-C stops.");
3501
+ }
3502
+ const left = until - performance.now();
3503
+ if (left <= 0)
3504
+ throw error;
3505
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1000, left)));
3506
+ if (performance.now() >= until)
3507
+ throw error;
3508
+ }
3509
+ }
3510
+ }
3316
3511
  /** Options `sandbox create` and `run` take with a value. */
3317
3512
  const CREATE_VALUES = [
3318
3513
  "name",
@@ -3353,6 +3548,9 @@ function usdMicros(args, name) {
3353
3548
  }
3354
3549
  /** A create body from the command line's flags. */
3355
3550
  function createInput(args) {
3551
+ for (const name of ["image", "snapshot", "volume", "name", "region", "connect"])
3552
+ if (args.flags.get(name)?.some((value) => !value.trim()))
3553
+ throw usage(`--${name} needs a non-empty value.`);
3356
3554
  const rules = ["allow", "deny", "connect"].filter((name) => args.flags.has(name));
3357
3555
  return {
3358
3556
  ...(flag(args, "image") ? { image: flag(args, "image") } : {}),
@@ -3387,7 +3585,7 @@ function createInput(args) {
3387
3585
  ? { timeoutSeconds: integer(args, "timeout") }
3388
3586
  : {}),
3389
3587
  ...(flag(args, "on-timeout")
3390
- ? { onLeaseEnd: oneOf(args, "on-timeout", ["pause", "stop"]) }
3588
+ ? { onLeaseEnd: oneOf(args, "on-timeout", ["pause", "stop", "delete"]) }
3391
3589
  : {}),
3392
3590
  ...(integer(args, "idle-pause") !== undefined
3393
3591
  ? { idlePauseSeconds: integer(args, "idle-pause") }
@@ -3449,6 +3647,9 @@ ownInterrupt = true) {
3449
3647
  // (Sandbox.exec); `--json` answers the result and prints no such line.
3450
3648
  else if (result.timedOut)
3451
3649
  out.error(`runtime: timed out after ${timeout ? `${timeout} s` : "24 h"}; the command's process group was killed.`);
3650
+ const memory = outOfMemoryLines(result.outOfMemory);
3651
+ if (memory && !out.json)
3652
+ out.error(memory);
3452
3653
  // Never drop output silently: say so, and do not exit 0.
3453
3654
  const lost = extras.lostOutput(result, sbx.id);
3454
3655
  if (lost && !out.json)