withruntime 0.4.0 → 0.5.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.
Files changed (88) hide show
  1. package/DAYTONA.md +129 -0
  2. package/E2B.md +3 -3
  3. package/README.md +47 -9
  4. package/VERCEL.md +127 -0
  5. package/dist/ai/index.d.ts +33 -0
  6. package/dist/ai/index.js +38 -0
  7. package/dist/claude-agent-sdk/index.d.ts +7 -0
  8. package/dist/claude-agent-sdk/index.js +65 -0
  9. package/dist/cli.d.ts +6 -0
  10. package/dist/cli.js +959 -102
  11. package/dist/client.d.ts +25 -2
  12. package/dist/client.js +14 -1
  13. package/dist/credentials.d.ts +10 -0
  14. package/dist/credentials.js +56 -1
  15. package/dist/daytona/client.d.ts +44 -0
  16. package/dist/daytona/client.js +40 -0
  17. package/dist/daytona/context.d.ts +31 -0
  18. package/dist/daytona/context.js +25 -0
  19. package/dist/daytona/daytona.d.ts +185 -0
  20. package/dist/daytona/daytona.js +416 -0
  21. package/dist/daytona/errors.d.ts +106 -0
  22. package/dist/daytona/errors.js +182 -0
  23. package/dist/daytona/filesystem.d.ts +84 -0
  24. package/dist/daytona/filesystem.js +206 -0
  25. package/dist/daytona/git.d.ts +33 -0
  26. package/dist/daytona/git.js +128 -0
  27. package/dist/daytona/image.d.ts +45 -0
  28. package/dist/daytona/image.js +140 -0
  29. package/dist/daytona/index.d.ts +50 -0
  30. package/dist/daytona/index.js +50 -0
  31. package/dist/daytona/process.d.ts +86 -0
  32. package/dist/daytona/process.js +341 -0
  33. package/dist/daytona/sandbox.d.ts +196 -0
  34. package/dist/daytona/sandbox.js +448 -0
  35. package/dist/e2b/commands.js +6 -2
  36. package/dist/e2b/sandbox.d.ts +22 -2
  37. package/dist/e2b/sandbox.js +39 -7
  38. package/dist/errors.d.ts +10 -4
  39. package/dist/errors.js +26 -6
  40. package/dist/image-context.d.ts +39 -0
  41. package/dist/image-context.js +176 -0
  42. package/dist/index.d.ts +19 -2
  43. package/dist/index.js +17 -2
  44. package/dist/keys.d.ts +43 -0
  45. package/dist/keys.js +196 -0
  46. package/dist/login.js +14 -2
  47. package/dist/openai-agents/index.d.ts +132 -0
  48. package/dist/openai-agents/index.js +791 -0
  49. package/dist/products/audit.d.ts +48 -0
  50. package/dist/products/audit.js +10 -0
  51. package/dist/products/images.d.ts +113 -7
  52. package/dist/products/images.js +186 -27
  53. package/dist/products/index.d.ts +11 -0
  54. package/dist/products/index.js +17 -1
  55. package/dist/products/observability.d.ts +186 -0
  56. package/dist/products/observability.js +117 -0
  57. package/dist/products/secrets.d.ts +40 -0
  58. package/dist/products/secrets.js +24 -0
  59. package/dist/products/switching.d.ts +96 -0
  60. package/dist/products/switching.js +25 -0
  61. package/dist/sandbox.d.ts +26 -1
  62. package/dist/sandbox.js +101 -1
  63. package/dist/ssh.d.ts +21 -0
  64. package/dist/ssh.js +324 -0
  65. package/dist/tar.d.ts +1 -0
  66. package/dist/tar.js +4 -4
  67. package/dist/tools/index.d.ts +62 -0
  68. package/dist/tools/index.js +180 -0
  69. package/dist/transport.d.ts +12 -2
  70. package/dist/transport.js +31 -4
  71. package/dist/tunnel.d.ts +70 -0
  72. package/dist/tunnel.js +316 -0
  73. package/dist/types.d.ts +57 -1
  74. package/dist/vercel/client.d.ts +40 -0
  75. package/dist/vercel/client.js +39 -0
  76. package/dist/vercel/command.d.ts +70 -0
  77. package/dist/vercel/command.js +148 -0
  78. package/dist/vercel/errors.d.ts +53 -0
  79. package/dist/vercel/errors.js +113 -0
  80. package/dist/vercel/filesystem.d.ts +150 -0
  81. package/dist/vercel/filesystem.js +248 -0
  82. package/dist/vercel/index.d.ts +30 -0
  83. package/dist/vercel/index.js +38 -0
  84. package/dist/vercel/sandbox.d.ts +317 -0
  85. package/dist/vercel/sandbox.js +726 -0
  86. package/dist/vercel/snapshot.d.ts +93 -0
  87. package/dist/vercel/snapshot.js +123 -0
  88. package/package.json +56 -4
package/dist/cli.js CHANGED
@@ -1,20 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
- import { readdir, readFile, stat, writeFile } from "node:fs/promises";
4
- import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { resolveCredential } from "./credentials.js";
6
+ import { connectionStore, resolveCredential } from "./credentials.js";
7
7
  import { authenticationCommand, me, named } from "./login.js";
8
+ import { createKey, dailyLimitMicros } from "./keys.js";
8
9
  import { Runtime } from "./client.js";
9
10
  import { RuntimeError } from "./errors.js";
10
11
  import { envFetch } from "./proxy.js";
12
+ import { portForwardCommand, sshCommand } from "./ssh.js";
11
13
  import { VERSION } from "./transport.js";
12
14
  const HELP = `Runtime Cloud CLI ${VERSION}
13
15
 
14
16
  Usage: runtime <product> <command> [options] runtime <command> [options]
15
17
 
16
18
  Start
17
- run [--keep] [create options] -- <command> A fresh sandbox runs one command, then stops
19
+ sandbox run [--keep] [create options] -- <command>
20
+ A fresh sandbox runs one command, then stops
18
21
 
19
22
  Account
20
23
  login [--agent-name <name>] [--no-browser] [--wait]
@@ -22,9 +25,25 @@ Account
22
25
  login --with-key Or paste a key from withruntime.com/account/keys
23
26
  logout Revoke this machine's connection
24
27
  whoami The organization and agent you act as
28
+ account [ls] The accounts this machine is connected to
29
+ account switch <name|id> Use another of them for every command after
30
+ account add Connect another account (choose it in the browser)
31
+ audit [--action member.] [--limit 50] [--before <next>]
32
+ The account's audit log (owner or admin keys)
25
33
  usage Balance, trial time and charges
26
34
  limits Whether this key is read-only, and its daily spending limit
35
+ secrets set <NAME> --host <host>... [--header <name> [--format '<text {value}>']]
36
+ Store a secret sandboxes use without seeing it; the
37
+ value is read from standard input, never an argument
38
+ secrets ls Names, hosts and placeholders, never values
39
+ secrets rm <NAME> Delete a secret
40
+ keys create [--name <name>] [--read-only] [--daily-limit <usd>]
41
+ A new API key for CI, approved by an owner in the browser
27
42
  referrals Your referral link: you both get up to $500
43
+ compare --from <provider> [--days 30] What your usage would cost at e2b, daytona, vercel,
44
+ modal, cloudflare, fly and others, and what you save
45
+ switch [--from <provider>] Moving from a rival: record it before your first top-up,
46
+ and that top-up is matched, up to $100
28
47
  ls Everything you run, every product
29
48
  feedback "<text>" [--kind bug|missing_feature|competitor_gap|migration_blocker|docs|pricing|praise|other]
30
49
  [--detail <text>] [--competitor <name>] [--request-id <req_...>]
@@ -36,10 +55,26 @@ Account
36
55
  docs [topic] Print a docs page (start, cli, api, python...)
37
56
  mcp Serve MCP on stdio for agents that need it
38
57
 
58
+ Connect
59
+ sandbox ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandbox ssh help\` for editors
60
+ sandbox ssh config [--install] Make \`ssh <id|name>.runtime\` work (VS Code, JetBrains)
61
+ sandbox port-forward <id|name> <port|local:remote>...
62
+ Forward local ports to ports in a sandbox
63
+
64
+ Observability
65
+ sandbox metrics <id> [--range 1h] CPU and memory over time (15m 1h 6h 24h 7d 30d)
66
+ events [--sandbox <id>] [--type <type>] Lifecycle events, newest first
67
+ webhooks ls | create <url> [--events a,b] | test <id> | deliveries <id>
68
+ | update <id> [--url u] [--events a,b] [--enable|--disable]
69
+ | rotate-secret <id> [--keep <seconds>] | retry <deliveryId> | rm <id>
70
+ Signed lifecycle events POSTed to your URL
71
+ otel ls | create <endpoint> [--header K=V]... [--signals logs,metrics] | flush <id> | rm <id>
72
+ Events and metrics to an OpenTelemetry endpoint
73
+
39
74
  Products
40
75
  sandbox Linux microVMs: exec, shell, files, code, previews, network, desktop
41
76
  snapshot Saved sandboxes, disk and memory, to start or fork from
42
- image Custom images from a Dockerfile, a public image or a package list
77
+ image Custom images from a Dockerfile, any public or private image, or a package list
43
78
  volume Persistent disks to attach to sandboxes
44
79
  Each has its own help: \`runtime sandbox help\`, \`runtime image help\`...
45
80
 
@@ -48,10 +83,14 @@ Behind a proxy: HTTPS_PROXY and NO_PROXY are read, on Node and Bun alike.
48
83
  `;
49
84
  const SANDBOX_HELP = `runtime sandbox <command>
50
85
 
86
+ run [--keep] [create options] -- <command> A fresh sandbox runs one command, then stops
87
+ ssh <id|name> [-- <command...>] | ssh config [--install] Log in over SSH, also from VS Code and JetBrains
88
+ port-forward <id|name> <port|local:remote>... Forward local ports to ports in it
51
89
  create [--name n] [--label k=v]... [--vcpu 2] [--memory 4096] [--disk 4096]
52
90
  [--timeout <seconds>] [--on-timeout pause|stop] [--trial|--paid] [--no-wait]
53
91
  [--no-internet] [--allow <host>]... [--deny <host>]... [--connect <host:port>]...
54
- [--image <imageId> | --snapshot <snapshotId>] [--volume <volumeId>:/data[:snapshot]]...
92
+ [--image <name:tag|imageId> | --snapshot <snapshotId>] [--volume <volumeId>:/data[:snapshot]]...
93
+ [--idle-pause <seconds>] [--no-auto-wake] [--persistent] [--get-or-create]
55
94
  Create one; prints its id
56
95
  ls [--all] [--label k=v]... [--state running] List (live ones unless --all)
57
96
  get <id> One sandbox, in full
@@ -68,6 +107,9 @@ const SANDBOX_HELP = `runtime sandbox <command>
68
107
  files <id> [path] [--depth 2] [--glob '**/*.py']
69
108
  stop <id> | pause <id> | wake <id> | restart <id>
70
109
  extend <id> <seconds> More time before the lease ends
110
+ update <id> [--name n] [--label k=v]... [--idle-pause <seconds>] [--auto-wake on|off]
111
+ [--persistent on|off] [--max-total-cost <usd>|none]
112
+ Change its settings; persistent keeps it running
71
113
  snapshot <id> [--name n] [--retention days] Keep its whole machine; prints the snapshot id
72
114
  fork <id> [--count 3] [--name n] [--keep-snapshot] [--trial|--paid]
73
115
  Copies of it as it is now, running; prints their ids
@@ -78,6 +120,8 @@ const SANDBOX_HELP = `runtime sandbox <command>
78
120
  unshare <id> <port> Stop sharing a port
79
121
  network <id> [--no-internet|--internet] [--allow <host>]... [--deny <host>]... [--connect <host:port>]...
80
122
  Show its network rules, or replace them
123
+ metrics <id> [--range 15m|1h|6h|24h|7d|30d]
124
+ CPU and memory over time, and the latest reading
81
125
  desktop <id> start | stop | screenshot [file.png] | open <url> | click <x> <y>
82
126
  | type <text> | press <keys> | windows
83
127
 
@@ -162,7 +206,11 @@ const COMMANDS = [
162
206
  "whoami",
163
207
  "usage",
164
208
  "limits",
209
+ "secrets",
210
+ "keys",
165
211
  "referrals",
212
+ "compare",
213
+ "switch",
166
214
  "ls",
167
215
  "feedback",
168
216
  "support",
@@ -172,10 +220,18 @@ const COMMANDS = [
172
220
  "snapshot",
173
221
  "image",
174
222
  "volume",
223
+ "webhooks",
224
+ "otel",
225
+ "events",
175
226
  "help",
176
227
  "version",
177
228
  ];
229
+ /** Sandbox commands that once sat at the top level. */
230
+ const SANDBOX_MOVED = ["run", "ssh", "port-forward", "forward"];
178
231
  const SANDBOX_VERBS = [
232
+ "run",
233
+ "ssh",
234
+ "port-forward",
179
235
  "create",
180
236
  "ls",
181
237
  "get",
@@ -186,6 +242,7 @@ const SANDBOX_VERBS = [
186
242
  "snapshot",
187
243
  "fork",
188
244
  "extend",
245
+ "update",
189
246
  "exec",
190
247
  "spawn",
191
248
  "ps",
@@ -201,6 +258,7 @@ const SANDBOX_VERBS = [
201
258
  "unshare",
202
259
  "network",
203
260
  "desktop",
261
+ "metrics",
204
262
  ];
205
263
  function need(value, what) {
206
264
  if (!value)
@@ -261,6 +319,14 @@ function describe(info) {
261
319
  ["funding", info.funding],
262
320
  ["region", info.region],
263
321
  ["expires", info.expiresAt ?? "-"],
322
+ ["auto wake", info.autoWake === false ? "off" : "on"],
323
+ [
324
+ "idle pause",
325
+ info.idlePauseSeconds
326
+ ? `${info.idlePauseSeconds} s${info.idlePauseUnusedOnly ? " while unused" : " idle"}`
327
+ : "off",
328
+ ],
329
+ ["persistent", info.persistent ? "yes" : "no"],
264
330
  [
265
331
  "labels",
266
332
  Object.entries(info.labels ?? {})
@@ -276,7 +342,16 @@ function remote(spec) {
276
342
  return match ? { id: match[1], path: match[2] } : undefined;
277
343
  }
278
344
  export async function run(argv, env = process.env, out = defaultOut(argv)) {
279
- const [command, ...rest] = argv.filter((arg) => arg !== "--json" || (argv.indexOf("--") !== -1 && argv.indexOf(arg) > argv.indexOf("--")));
345
+ const [first, ...remaining] = argv.filter((arg) => arg !== "--json" || (argv.indexOf("--") !== -1 && argv.indexOf(arg) > argv.indexOf("--")));
346
+ // Sandbox is one product of several, so its commands live under it
347
+ // (AGENTS.md, 23 September 2026): `runtime sandbox run`, `sandbox ssh` and
348
+ // `sandbox port-forward`. The old top-level forms were removed, not aliased.
349
+ if (first && SANDBOX_MOVED.includes(first))
350
+ throw usage(`\`${me} ${first}\` is now \`${me} sandbox ${first === "forward" ? "port-forward" : first}\`.`);
351
+ const nested = (first === "sandbox" || first === "sandboxes" || first === "sbx") &&
352
+ SANDBOX_MOVED.includes(remaining[0] ?? "");
353
+ const command = nested ? remaining[0] : first;
354
+ const rest = nested ? remaining.slice(1) : remaining;
280
355
  const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
281
356
  product = "";
282
357
  if (!command) {
@@ -289,7 +364,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
289
364
  ? JSON.stringify({ connected, usage: HELP })
290
365
  : `Runtime Cloud ${VERSION}. ${start}
291
366
 
292
- ${me} run -- python3 -c 'print(6*7)' A fresh sandbox runs one command, then stops
367
+ ${me} sandbox run -- python3 -c 'print(6*7)'
368
+ A fresh sandbox runs one command, then stops
293
369
  ${me} sandbox create A sandbox that stays; prints its id
294
370
  ${me} sandbox exec <id> -- <command> Run in it
295
371
  ${me} help Everything else
@@ -304,7 +380,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
304
380
  const args = parse(rest, [...CREATE_FLAGS, "keep"]);
305
381
  const commandLine = args.rest ?? args.positional;
306
382
  if (!commandLine.length)
307
- throw usage(`Give the command after --: ${me} run -- python3 -c 'print(6*7)'`);
383
+ throw usage(`Give the command after --: ${me} sandbox run -- python3 -c 'print(6*7)'`);
308
384
  const runtime = await client(env);
309
385
  const timeout = integer(args, "timeout");
310
386
  const { timeoutSeconds: _lease, ...input } = createInput(args);
@@ -402,6 +478,60 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
402
478
  ]), l);
403
479
  return 0;
404
480
  }
481
+ if (command === "secrets")
482
+ return secretsCommand(rest, env, out);
483
+ if (command === "webhooks" || command === "webhook")
484
+ return webhooksCommand(rest, env, out);
485
+ if (command === "otel")
486
+ return otelCommand(rest, env, out);
487
+ if (command === "events") {
488
+ const args = parse(rest);
489
+ const page = await (await client(env)).events.list({
490
+ ...(flag(args, "sandbox") ? { resourceId: flag(args, "sandbox") } : {}),
491
+ ...(flag(args, "type") ? { type: flag(args, "type") } : {}),
492
+ ...(integer(args, "limit") ? { limit: integer(args, "limit") } : {}),
493
+ });
494
+ print(page.data.length
495
+ ? table(page.data.map((e) => {
496
+ const subject = (Object.values(e.data)[0] ?? {});
497
+ return [e.createdAt, e.type, e.resourceId ?? "-", subject.stopReason ?? ""];
498
+ }))
499
+ : "No events in the last 14 days.", page);
500
+ return 0;
501
+ }
502
+ if (command === "keys")
503
+ return keys(rest, env, out);
504
+ if (command === "account" || command === "accounts")
505
+ return accountCommand(rest, env, out);
506
+ if (command === "audit") {
507
+ const args = parse(rest);
508
+ const known = new Set(["action", "limit", "before"]);
509
+ for (const name of args.flags.keys())
510
+ if (!known.has(name))
511
+ throw usage(`Unknown option --${name}.`);
512
+ const page = await (await client(env)).audit.list({
513
+ ...(flag(args, "action") ? { action: flag(args, "action") } : {}),
514
+ ...(integer(args, "limit") ? { limit: integer(args, "limit") } : {}),
515
+ ...(flag(args, "before") ? { before: flag(args, "before") } : {}),
516
+ });
517
+ print(page.events.length
518
+ ? [
519
+ table([
520
+ ["when (UTC)", "action", "who", "from"],
521
+ ...page.events.map((event) => [
522
+ event.at.slice(0, 19).replace("T", " "),
523
+ event.action,
524
+ event.actor.kind === "runtime"
525
+ ? "Runtime"
526
+ : `${event.actor.name ?? event.actor.kind}${event.actor.person ? ` (${event.actor.person})` : ""}`,
527
+ event.ip ?? event.via,
528
+ ]),
529
+ ]),
530
+ ...(page.next ? ["", `Older: ${me} audit --before ${page.next}`] : []),
531
+ ].join("\n")
532
+ : "Nothing recorded yet.", page);
533
+ return 0;
534
+ }
405
535
  if (command === "referrals") {
406
536
  const r = await (await client(env)).referrals.get();
407
537
  const usd = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
@@ -423,6 +553,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
423
553
  ].join("\n"), r);
424
554
  return 0;
425
555
  }
556
+ if (command === "compare" || command === "switch")
557
+ return switching(command, rest, env, out);
426
558
  if (command === "mcp") {
427
559
  const { serveMcp } = await import("./mcp.js");
428
560
  await serveMcp(env);
@@ -496,6 +628,14 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
496
628
  : "Nothing yet. Start with: runtime sandbox create", items);
497
629
  return 0;
498
630
  }
631
+ if (command === "ssh") {
632
+ product = "ssh";
633
+ return sshCommand(rest, env, out, () => client(env), me);
634
+ }
635
+ if (command === "port-forward" || command === "forward") {
636
+ product = "ssh";
637
+ return portForwardCommand(rest, out, () => client(env), me);
638
+ }
499
639
  if (command === "sandbox" || command === "sandboxes" || command === "sbx") {
500
640
  product = "sandbox";
501
641
  return sandbox(rest, env, out);
@@ -506,7 +646,7 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
506
646
  }
507
647
  if (command === "image" || command === "images") {
508
648
  product = "image";
509
- return image(rest, env, out);
649
+ return imageCommand(rest, env, out);
510
650
  }
511
651
  if (command === "volume" || command === "volumes") {
512
652
  product = "volume";
@@ -514,6 +654,141 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
514
654
  }
515
655
  throw unknown("command", command, COMMANDS);
516
656
  }
657
+ /** Dollars from integer microdollars: cents from a dollar up, and four
658
+ * places below one, so a few minutes of use does not round to $0.01. */
659
+ function dollars(micros) {
660
+ const value = BigInt(micros);
661
+ const sign = value < 0n ? "-" : "";
662
+ const size = value < 0n ? -value : value;
663
+ const places = size < 1000000n ? 4 : 2;
664
+ const unit = 10n ** BigInt(6 - places);
665
+ const rounded = (size + unit / 2n) / unit;
666
+ const whole = rounded / 10n ** BigInt(places);
667
+ const part = (rounded % 10n ** BigInt(places)).toString().padStart(places, "0");
668
+ return `${sign}$${whole.toLocaleString("en-US")}.${part}`;
669
+ }
670
+ const SWITCH_FROM = {
671
+ e2b: "e2b",
672
+ daytona: "daytona",
673
+ vercel: "vercel",
674
+ modal: "modal",
675
+ cloudflare: "cloudflare",
676
+ fly: "fly",
677
+ "fly-machines": "fly",
678
+ };
679
+ const RIVAL_NAMES = {
680
+ e2b: "E2B",
681
+ daytona: "Daytona",
682
+ vercel: "Vercel Sandbox",
683
+ modal: "Modal",
684
+ cloudflare: "Cloudflare Sandbox",
685
+ fly: "Fly",
686
+ };
687
+ /** A date as the CLI writes one: 23 September 2026. */
688
+ const longDate = (iso) => new Date(iso).toLocaleDateString("en-GB", {
689
+ day: "numeric",
690
+ month: "long",
691
+ year: "numeric",
692
+ timeZone: "UTC",
693
+ });
694
+ /** One line on the switching credit: how to claim it, or what it did. A
695
+ * switch already on record is named with its provider and date, so it never
696
+ * reads as if this command recorded it, or recorded the provider it names. */
697
+ function switchingLine(summary, provider) {
698
+ const cap = dollars(summary.maxMicros);
699
+ const sw = summary.switch;
700
+ if (!sw) {
701
+ if (!summary.eligible || !provider)
702
+ return undefined;
703
+ 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}.`;
704
+ }
705
+ const from = RIVAL_NAMES[sw.provider];
706
+ const recorded = `This organization's switch from ${from} was recorded on ${longDate(sw.recordedAt)}`;
707
+ const once = provider && provider !== sw.provider
708
+ ? ` A switch is recorded once, so it stays ${from}, not ${RIVAL_NAMES[provider]}.`
709
+ : "";
710
+ switch (sw.status) {
711
+ case "pending":
712
+ return `${recorded}: its first top-up is matched, up to ${cap}, once the payment settles.${once}`;
713
+ case "paid":
714
+ return `${recorded} and matched its first top-up: ${dollars(sw.creditMicros)} of credit.${once}`;
715
+ case "superseded":
716
+ return `${recorded}; its first top-up was matched by a referral instead, which is never less than the switching credit.${once}`;
717
+ case "reversed":
718
+ return `${recorded}; the top-up it matched was refunded, so the credit was taken back.${once}`;
719
+ case "refused":
720
+ return sw.refusedReason === "program_off"
721
+ ? `${recorded}; the switching credit was paused when its first top-up arrived.${once}`
722
+ : `${recorded}; its first top-up was not matched. Ask support if you think that is wrong.${once}`;
723
+ }
724
+ }
725
+ /** The sandboxes the free trial paid for, said beside what was priced. */
726
+ function trialNote(trial, all) {
727
+ if (!trial)
728
+ return "";
729
+ const which = trial === all ? (all === 1 ? "It" : "All of them") : `${trial} of them`;
730
+ return ` ${which} ran on the free trial, which charged nothing; Runtime's side prices ${trial === 1 ? "it" : "them"} at the standard rates, what the same work costs on paid credit.`;
731
+ }
732
+ async function switching(command, argv, env, out) {
733
+ const args = parse(argv);
734
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
735
+ const known = new Set(command === "compare" ? ["from", "days"] : ["from"]);
736
+ for (const name of args.flags.keys())
737
+ if (!known.has(name))
738
+ throw usage(`Unknown option --${name}.`);
739
+ if (args.positional.length || args.rest)
740
+ throw usage(`Unexpected ${args.positional[0] ?? "--"}.`);
741
+ const from = flag(args, "from")?.trim().toLowerCase();
742
+ const api = (await client(env)).switching;
743
+ if (command === "switch") {
744
+ if (!from) {
745
+ const summary = await api.get();
746
+ print(switchingLine(summary) ??
747
+ (summary.eligible
748
+ ? `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)}.`
749
+ : "The switching credit matches a first top-up, and this organization has made one already."), summary);
750
+ return 0;
751
+ }
752
+ const provider = SWITCH_FROM[from];
753
+ if (!provider || from === "fly-machines")
754
+ throw unknown("provider", from, Object.keys(RIVAL_NAMES));
755
+ const summary = await api.record({ provider });
756
+ print(`Recorded: switching from ${RIVAL_NAMES[provider]}. ${switchingLine(summary) ?? ""}`.trim(), summary);
757
+ return 0;
758
+ }
759
+ if (!from)
760
+ throw usage(`Name the rival: ${me} compare --from e2b (or daytona, vercel, modal, cloudflare, fly, fly-machines...).`);
761
+ /* The server holds the list of rivals it has rates for, and names them all
762
+ when it refuses one, so a rival added there needs no new CLI. */
763
+ const days = integer(args, "days");
764
+ const c = await api.compare({ provider: from, days });
765
+ const saving = BigInt(c.savingMicros);
766
+ const percent = c.savingPercent === null ? "" : ` (${Math.abs(c.savingPercent)}%)`;
767
+ const verdict = saving >= 0n
768
+ ? `you save ${dollars(saving)}${percent}`
769
+ : `${c.rival.name} would cost ${dollars(-saving)} less${percent}`;
770
+ const figures = `${dollars(c.runtimeMicros)} on Runtime; the same sandboxes on ${c.rival.name}: ${dollars(c.rivalMicros)}; ${verdict}`;
771
+ const month = c.perMonth ? BigInt(c.perMonth.savingMicros) : undefined;
772
+ const lines = c.basis === "usage"
773
+ ? [
774
+ `Your last ${c.window.days} days: ${figures}${month === undefined
775
+ ? "."
776
+ : month >= 0n
777
+ ? `, about ${dollars(month)} a month.`
778
+ : `, about ${dollars(-month)} a month more on Runtime.`}`,
779
+ `Priced: ${c.usage.sandboxes.toLocaleString("en-US")} sandbox${c.usage.sandboxes === 1 ? "" : "es"}, ${(c.usage.runSeconds / 3600).toLocaleString("en-US", { maximumFractionDigits: 2 })} hours running.${trialNote(c.usage.trialSandboxes, c.usage.sandboxes)}${c.usage.unpricedSandboxes ? ` ${c.usage.unpricedSandboxes} left out: ${c.rival.name} publishes no price for their size.` : ""}`,
780
+ ]
781
+ : [
782
+ `No settled sandboxes in your last ${c.window.days} days yet, so this prices an example: ${c.usage.sandboxes.toLocaleString("en-US")} runs of ${(c.usage.runSeconds / c.usage.sandboxes).toLocaleString("en-US")} seconds using ${(c.usage.activeCpuSeconds / c.usage.sandboxes).toLocaleString("en-US")} CPU-seconds each.`,
783
+ `The example: ${figures}.`,
784
+ ];
785
+ lines.push(`${c.rival.name}'s published rates, checked ${longDate(`${c.rival.checked}T12:00:00Z`)}: ${c.rival.rates.join("; ")}. Compute only: storage, network, plan fees and free allowances are left out.`);
786
+ const line = switchingLine(c.switching, Object.hasOwn(SWITCH_FROM, from) ? SWITCH_FROM[from] : undefined);
787
+ if (line)
788
+ lines.push("", line);
789
+ print(lines.join("\n"), c);
790
+ return 0;
791
+ }
517
792
  async function support(argv, env, out) {
518
793
  const args = parse(argv);
519
794
  const api = (await client(env)).support;
@@ -639,6 +914,40 @@ async function sandbox(argv, env, out) {
639
914
  print(forks.map((f) => f.id).join("\n"), forks.map((f) => f.info));
640
915
  return 0;
641
916
  }
917
+ case "update": {
918
+ const args = parse(rest);
919
+ const sbx = await get(args.positional[0]);
920
+ const onOff = (name) => {
921
+ const value = flag(args, name);
922
+ if (value === undefined)
923
+ return undefined;
924
+ if (value !== "on" && value !== "off")
925
+ throw usage(`--${name} takes on or off.`);
926
+ return value === "on";
927
+ };
928
+ const cost = flag(args, "max-total-cost");
929
+ if (cost !== undefined && cost !== "none" && !/^\d+(\.\d{1,6})?$/.test(cost))
930
+ throw usage("--max-total-cost takes dollars, such as 25 or 2.50, or none.");
931
+ const settings = {
932
+ ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
933
+ ...(args.flags.has("label") ? { labels: pairs(args, "label") } : {}),
934
+ ...(integer(args, "idle-pause") !== undefined
935
+ ? { idlePauseSeconds: integer(args, "idle-pause") }
936
+ : {}),
937
+ ...(onOff("auto-wake") === undefined ? {} : { autoWake: onOff("auto-wake") }),
938
+ ...(onOff("persistent") === undefined ? {} : { persistent: onOff("persistent") }),
939
+ ...(cost === undefined
940
+ ? {}
941
+ : {
942
+ maxTotalCostMicros: cost === "none" ? null : Math.round(Number(cost) * 1e6),
943
+ }),
944
+ };
945
+ if (!Object.keys(settings).length)
946
+ throw usage("Say what to change: --name, --label, --idle-pause, --auto-wake, --persistent or --max-total-cost.");
947
+ await sbx.update(settings);
948
+ print(describe(sbx.info), sbx.info);
949
+ return 0;
950
+ }
642
951
  case "extend": {
643
952
  const sbx = await get(rest[0]);
644
953
  const seconds = Number(need(rest[1], "seconds, e.g. runtime sandbox extend <id> 600"));
@@ -792,6 +1101,16 @@ async function sandbox(argv, env, out) {
792
1101
  }
793
1102
  case "desktop":
794
1103
  return desktop(await get(rest[0]), rest.slice(1), out);
1104
+ case "metrics": {
1105
+ const args = parse(rest);
1106
+ const sbx = await get(args.positional[0]);
1107
+ const range = (flag(args, "range") ?? "1h");
1108
+ if (!["15m", "1h", "6h", "24h", "7d", "30d"].includes(range))
1109
+ throw usage("--range is one of 15m, 1h, 6h, 24h, 7d, 30d.");
1110
+ const m = await sbx.metrics({ range });
1111
+ print(metricsText(m), m);
1112
+ return 0;
1113
+ }
795
1114
  }
796
1115
  throw unknown("sandbox command", verb, SANDBOX_VERBS);
797
1116
  }
@@ -802,6 +1121,109 @@ function attachment(spec) {
802
1121
  throw usage("--volume takes <volumeId>:/path, or <volumeId>:/path:snapshot for a copy.");
803
1122
  return { volumeId, path, mode };
804
1123
  }
1124
+ /** A secret's value, from standard input only, so it never sits in shell
1125
+ * history or a process list. Piped: everything, less one trailing newline.
1126
+ * A terminal: one line, not shown. */
1127
+ async function readSecret() {
1128
+ const stdin = process.stdin;
1129
+ if (!stdin.isTTY) {
1130
+ const chunks = [];
1131
+ for await (const chunk of stdin)
1132
+ chunks.push(chunk);
1133
+ return Buffer.concat(chunks)
1134
+ .toString("utf8")
1135
+ .replace(/\r?\n$/, "");
1136
+ }
1137
+ process.stderr.write("Value (not shown), then Enter: ");
1138
+ stdin.setRawMode(true);
1139
+ stdin.resume();
1140
+ try {
1141
+ return await new Promise((done, fail) => {
1142
+ let value = "";
1143
+ const onData = (chunk) => {
1144
+ for (const char of chunk.toString("utf8")) {
1145
+ if (char === "\r" || char === "\n") {
1146
+ stdin.off("data", onData);
1147
+ process.stderr.write("\n");
1148
+ return done(value);
1149
+ }
1150
+ if (char === "\u0003") {
1151
+ stdin.off("data", onData);
1152
+ return fail(usage("Cancelled."));
1153
+ }
1154
+ if (char === "\u007f" || char === "\b")
1155
+ value = value.slice(0, -1);
1156
+ else
1157
+ value += char;
1158
+ }
1159
+ };
1160
+ stdin.on("data", onData);
1161
+ });
1162
+ }
1163
+ finally {
1164
+ stdin.setRawMode(false);
1165
+ stdin.pause();
1166
+ }
1167
+ }
1168
+ export async function secretsCommand(argv, env, out,
1169
+ /** Tests give the value here; the CLI reads standard input. */
1170
+ read = readSecret) {
1171
+ const [verb, ...rest] = argv;
1172
+ const args = parse(rest);
1173
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
1174
+ const rows = (list) => list.length
1175
+ ? table([
1176
+ ["name", "hosts", "header", "placeholder"],
1177
+ ...list.map((secret) => [
1178
+ secret.name,
1179
+ secret.hosts.join(","),
1180
+ secret.header ? `${secret.header}: ${secret.format ?? "{value}"}` : "-",
1181
+ secret.placeholder,
1182
+ ]),
1183
+ ])
1184
+ : "No secrets.";
1185
+ if (verb === "ls" || verb === "list" || verb === undefined) {
1186
+ const list = await (await client(env)).secrets.list();
1187
+ print(rows(list), list);
1188
+ return 0;
1189
+ }
1190
+ if (verb === "set") {
1191
+ const name = need(args.positional[0], "the secret's name, such as OPENAI_API_KEY");
1192
+ const hosts = args.flags.get("host") ?? [];
1193
+ if (!hosts.length)
1194
+ throw usage("Give --host once for each host the value may go to, such as --host api.openai.com.");
1195
+ if (args.flags.has("value"))
1196
+ throw usage('The value is read from standard input, never an argument: printf %s "$KEY" | runtime secrets set NAME --host ...');
1197
+ const value = await read();
1198
+ if (!value)
1199
+ throw usage('The value is empty. Pipe it in: printf %s "$KEY" | runtime secrets set NAME --host ...');
1200
+ const header = flag(args, "header");
1201
+ const format = flag(args, "format");
1202
+ const saved = await (await client(env)).secrets.set(name, {
1203
+ value,
1204
+ hosts,
1205
+ ...(header ? { header } : {}),
1206
+ ...(format ? { format } : {}),
1207
+ });
1208
+ print([
1209
+ `Stored ${saved.name}. Sandboxes see ${saved.name}=${saved.placeholder}.`,
1210
+ saved.header
1211
+ ? `Requests to ${saved.hosts.join(", ")} get ${saved.header}: ${saved.format ?? "{value}"} over HTTPS.`
1212
+ : `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the URL and headers.`,
1213
+ ...(saved.enforced
1214
+ ? []
1215
+ : ["A host is catching up; running sandboxes have it within a minute."]),
1216
+ ].join("\n"), saved);
1217
+ return 0;
1218
+ }
1219
+ if (verb === "rm" || verb === "delete") {
1220
+ const name = need(args.positional[0], "the secret's name");
1221
+ const removed = await (await client(env)).secrets.delete(name);
1222
+ print(`Deleted ${name}.`, removed);
1223
+ return 0;
1224
+ }
1225
+ throw usage("secrets takes set, ls or rm. Run `runtime help`.");
1226
+ }
805
1227
  async function readInput(source) {
806
1228
  if (source !== "-")
807
1229
  return readFile(source, "utf8");
@@ -904,6 +1326,141 @@ async function desktop(sbx, argv, out) {
904
1326
  }
905
1327
  throw usage(`${me} sandbox desktop <id> start|stop|screenshot|open|click|type|press|windows`);
906
1328
  }
1329
+ const KEYS_HELP = `runtime keys <command>
1330
+
1331
+ create [--name <name>] [--read-only] [--daily-limit <usd>]
1332
+ A new API key, for a CI runner or any place with no browser. Prints a link
1333
+ and a code; an account owner approves the key in the browser, seeing its
1334
+ name, access and limit. The key is printed once, alone on stdout, and
1335
+ lasts until revoked. --json prints it as JSON, still alone on stdout.
1336
+
1337
+ runtime keys create --name ci --daily-limit 25 | gh secret set RUNTIME_API_KEY
1338
+
1339
+ List, limit and revoke keys at https://withruntime.com/account/keys.
1340
+ `;
1341
+ /* `runtime keys create`: a key made from the terminal and approved in the
1342
+ browser. Marc, 23 September 2026; ARCHITECTURE.md section 10, "Keys from the
1343
+ terminal". Only the options the keys page offers. No key can list, create or
1344
+ revoke keys through the API, so `ls` and `revoke` send you to the page. */
1345
+ /* `runtime account`: the accounts this machine is connected to, and which one
1346
+ every command uses (ARCHITECTURE.md section 3.9). Each is its own saved
1347
+ connection with its own key; the SDK itself stays key-based. */
1348
+ async function accountCommand(argv, env, out) {
1349
+ product = "account";
1350
+ const [verb = "ls", ...rest] = argv;
1351
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
1352
+ const store = connectionStore(env);
1353
+ if (verb === "add") {
1354
+ const result = await authenticationCommand(["login", "--another-account", ...rest], env);
1355
+ if (result.pending)
1356
+ throw notApprovedYet(result);
1357
+ await nameSaved(env);
1358
+ print(`Connected${"orgId" in result && result.orgId ? ` to ${String(result.orgId)}` : ""}.`, result);
1359
+ return 0;
1360
+ }
1361
+ if (verb === "switch" || verb === "use") {
1362
+ const which = rest.join(" ").trim();
1363
+ if (!which)
1364
+ throw usage(`Name the account: ${me} account switch <name|id>.`);
1365
+ if (env.RUNTIME_API_KEY)
1366
+ throw usage("RUNTIME_API_KEY is set, and it names one account. Unset it to switch.");
1367
+ const chosen = await store.use(which);
1368
+ if (!chosen)
1369
+ throw usage(`No saved connection for "${which}". Run \`${me} account\` to list them, or \`${me} account add\` to connect it.`);
1370
+ print(`Now using ${chosen.orgName ?? chosen.orgId}.`, {
1371
+ orgId: chosen.orgId,
1372
+ orgName: chosen.orgName ?? null,
1373
+ });
1374
+ return 0;
1375
+ }
1376
+ if (verb !== "ls" && verb !== "list")
1377
+ throw unknown("account command", verb, ["ls", "switch", "add"]);
1378
+ await nameSaved(env).catch(() => undefined);
1379
+ const current = await store.read().catch(() => null);
1380
+ const saved = [
1381
+ ...(current ? [{ ...current, current: true }] : []),
1382
+ ...(await store.others()).map((one) => ({ ...one, current: false })),
1383
+ ];
1384
+ const rows = saved.map((one) => ({
1385
+ orgId: one.orgId,
1386
+ orgName: one.orgName ?? null,
1387
+ agentName: one.agentName,
1388
+ current: one.current,
1389
+ }));
1390
+ print(rows.length
1391
+ ? table([
1392
+ ["", "account", "id", "agent"],
1393
+ ...rows.map((row) => [
1394
+ row.current ? "*" : "",
1395
+ row.orgName ?? "-",
1396
+ row.orgId,
1397
+ row.agentName,
1398
+ ]),
1399
+ ])
1400
+ : `Not connected. Run \`${me} login\`.`, { accounts: rows, environmentKey: Boolean(env.RUNTIME_API_KEY) });
1401
+ return 0;
1402
+ }
1403
+ /** Fill in the saved connection's account name from the API, once. */
1404
+ async function nameSaved(env) {
1405
+ if (env.RUNTIME_API_KEY)
1406
+ return;
1407
+ const store = connectionStore(env);
1408
+ const saved = await store.read();
1409
+ if (!saved || saved.orgName)
1410
+ return;
1411
+ const who = await new Runtime({
1412
+ apiKey: saved.key,
1413
+ baseUrl: env.RUNTIME_API_URL,
1414
+ maxRetries: 1,
1415
+ }).me();
1416
+ if (who.orgName && who.orgId === saved.orgId) {
1417
+ const again = await store.read();
1418
+ if (again && again.key === saved.key)
1419
+ await store.save({ ...again, orgName: who.orgName });
1420
+ }
1421
+ }
1422
+ async function keys(argv, env, out) {
1423
+ product = "keys";
1424
+ const [verb, ...rest] = argv;
1425
+ if (!verb || verb === "help" || verb === "--help" || verb === "-h") {
1426
+ out.write(out.json ? JSON.stringify({ usage: named(KEYS_HELP) }) : named(KEYS_HELP));
1427
+ return 0;
1428
+ }
1429
+ if (verb === "ls" || verb === "list" || verb === "revoke")
1430
+ throw usage("List and revoke keys at https://withruntime.com/account/keys. No key can list or revoke keys.");
1431
+ if (verb !== "create")
1432
+ throw unknown("keys command", verb, ["create"]);
1433
+ const args = parse(rest, ["read-only"]);
1434
+ const known = new Set(["name", "read-only", "daily-limit"]);
1435
+ for (const name of args.flags.keys())
1436
+ if (!known.has(name))
1437
+ throw usage(`Unknown option --${name}.`);
1438
+ if (args.positional.length || args.rest)
1439
+ throw usage(`Unexpected ${args.positional[0] ?? "--"}.`);
1440
+ const readOnly = has(args, "read-only");
1441
+ const limit = flag(args, "daily-limit");
1442
+ if (readOnly && limit !== undefined)
1443
+ throw usage("A read-only key cannot spend, so it takes no --daily-limit.");
1444
+ const created = await createKey({
1445
+ name: flag(args, "name") ?? (readOnly ? "Read-only key" : "My API key"),
1446
+ access: readOnly ? "read" : "full",
1447
+ dailyLimitMicros: limit === undefined ? null : dailyLimitMicros(limit),
1448
+ }, env, { notify: out.error });
1449
+ const { confirm, ...key } = created;
1450
+ out.write(out.json ? JSON.stringify(key) : key.key);
1451
+ const terms = [
1452
+ key.access === "read" ? "read only" : "full access",
1453
+ key.access === "read"
1454
+ ? undefined
1455
+ : key.dailyLimitMicros === null
1456
+ ? "no daily limit"
1457
+ : `daily limit $${(Number(BigInt(key.dailyLimitMicros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
1458
+ "until revoked",
1459
+ ].filter(Boolean);
1460
+ out.error(`Created key "${key.name}" (${terms.join(", ")}). Store it now: it is not shown again, and Runtime keeps only a hash of it.\nIn CI, give it to your jobs as RUNTIME_API_KEY from the secret store.`);
1461
+ await confirm();
1462
+ return 0;
1463
+ }
907
1464
  const SNAPSHOT_HELP = `runtime snapshot <command>
908
1465
 
909
1466
  ls [--sandbox <id>] [--name n] Your snapshots
@@ -960,18 +1517,34 @@ async function snapshot(argv, env, out) {
960
1517
  }
961
1518
  const IMAGE_HELP = `runtime image <command>
962
1519
 
963
- build (--dockerfile <path> | --from <public image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...)
964
- [--name n] [--max-image-mib 4096] [--timeout <seconds>]
1520
+ build [<folder>] [-f <Dockerfile>] | --from <image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...
1521
+ [-t name[:tag]]... [--target <stage>] [--build-arg K=V]... [--no-cache]
1522
+ [--start <command>] [--ready-port <port> | --ready-command <command>]
1523
+ [--max-image-mib 8192] [--timeout <seconds>]
965
1524
  Build and stream the log; prints the image id when ready.
966
- A Dockerfile's COPY and ADD sources are sent from its folder
967
- (at most 256 files, 1 MiB in all).
1525
+ A Dockerfile's folder is the context: everything its
1526
+ .dockerignore leaves in (100 MiB compressed at most) is
1527
+ sent, and a rebuild sends only what changed.
968
1528
  ls Your images
969
- get <id> One image
970
- logs <id> Its build log
971
- rm <id> Delete it
972
- Use one: runtime sandbox create --image <id>
1529
+ versions <name> Every version of a name, newest first
1530
+ get <image> One image: an id, name, name:tag or name@version
1531
+ logs <image> [--follow]
1532
+ Its build log
1533
+ tag <image> <tag> Point a tag of the image's name at this version
1534
+ untag <image> <tag> Take a tag off
1535
+ rm <image> Delete that version and its tags
1536
+ registry ls Registries you keep credentials for
1537
+ registry set <registry> --username <user>
1538
+ Store credentials to pull private images; the token or
1539
+ password is read from standard input and never shown again
1540
+ registry set <ecr registry> --access-key-id <id>
1541
+ Amazon ECR: the secret access key is read from standard input
1542
+ registry rm <registry>
1543
+ Use one: runtime sandbox create --image app:latest
973
1544
  `;
974
- async function image(argv, env, out) {
1545
+ export async function imageCommand(argv, env, out,
1546
+ /** Tests give the registry secret here; the CLI reads standard input. */
1547
+ read = readSecret) {
975
1548
  const [verb, ...rest] = argv;
976
1549
  const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
977
1550
  if (!verb || verb === "help" || verb === "--help") {
@@ -981,30 +1554,72 @@ async function image(argv, env, out) {
981
1554
  const api = (await client(env)).images;
982
1555
  switch (verb) {
983
1556
  case "build": {
984
- const args = parse(rest);
1557
+ const args = parse(rest, ["no-cache"]);
985
1558
  const list = (name) => (args.flags.get(name) ?? []).flatMap((value) => value.split(/[\s,]+/)).filter(Boolean);
986
1559
  const recipe = { apt: list("apt"), pip: list("pip"), npm: list("npm") };
987
1560
  const hasRecipe = recipe.apt.length + recipe.pip.length + recipe.npm.length > 0;
988
- const sources = [flag(args, "dockerfile"), flag(args, "from"), hasRecipe || undefined];
1561
+ const dockerfile = flag(args, "dockerfile") ?? flag(args, "file") ?? flag(args, "f");
1562
+ const folder = args.positional[0];
1563
+ const fromDockerfile = dockerfile !== undefined || folder !== undefined;
1564
+ const sources = [fromDockerfile || undefined, flag(args, "from"), hasRecipe || undefined];
989
1565
  if (sources.filter((s) => s !== undefined).length !== 1)
990
- throw usage("Give exactly one of --dockerfile <path>, --from <image>, or --pip/--apt/--npm.");
1566
+ throw usage("Give exactly one of a folder with a Dockerfile (or -f <Dockerfile>), --from <image>, or --pip/--apt/--npm.");
991
1567
  const build = {
992
1568
  ...(integer(args, "max-image-mib") ? { maxImageMiB: integer(args, "max-image-mib") } : {}),
993
1569
  ...(integer(args, "timeout") ? { timeoutSeconds: integer(args, "timeout") } : {}),
994
1570
  };
1571
+ // -t app, -t app:v2 -t app:latest: one name, its tags.
1572
+ let name = flag(args, "name");
1573
+ const tags = [];
1574
+ for (const tagged of [...(args.flags.get("t") ?? []), ...(args.flags.get("tag") ?? [])]) {
1575
+ const [repository, tag] = tagged.split(":");
1576
+ if (!repository)
1577
+ throw usage("-t takes name or name:tag.");
1578
+ if (name !== undefined && name !== repository)
1579
+ throw usage("Every -t names the same image; give one name and several tags.");
1580
+ name = repository;
1581
+ if (tag)
1582
+ tags.push(tag);
1583
+ }
1584
+ const start = {
1585
+ ...(flag(args, "start") ? { command: flag(args, "start") } : {}),
1586
+ ...(integer(args, "ready-port") ? { readyPort: integer(args, "ready-port") } : {}),
1587
+ ...(flag(args, "ready-command") ? { readyCommand: flag(args, "ready-command") } : {}),
1588
+ ...(integer(args, "ready-timeout")
1589
+ ? { readyTimeoutSeconds: integer(args, "ready-timeout") }
1590
+ : {}),
1591
+ };
995
1592
  const common = {
996
- ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
1593
+ ...(name ? { name } : {}),
1594
+ ...(tags.length ? { tags } : {}),
997
1595
  ...(Object.keys(build).length ? { build } : {}),
1596
+ ...(Object.keys(start).length ? { start } : {}),
1597
+ ...(has(args, "no-cache") ? { cache: false } : {}),
998
1598
  };
999
- const dockerfile = flag(args, "dockerfile");
1000
- const input = dockerfile
1001
- ? { ...common, ...(await dockerContext(dockerfile)) }
1002
- : flag(args, "from")
1003
- ? { ...common, image: flag(args, "from") }
1004
- : {
1005
- ...common,
1006
- recipe: Object.fromEntries(Object.entries(recipe).filter(([, v]) => v.length)),
1007
- };
1599
+ let input;
1600
+ if (fromDockerfile) {
1601
+ const context = resolve(folder ?? dirname(dockerfile));
1602
+ const file = resolve(context, dockerfile ?? "Dockerfile");
1603
+ const text = await readFile(file, "utf8").catch(() => {
1604
+ throw usage(`No Dockerfile at ${file}.`);
1605
+ });
1606
+ input = {
1607
+ ...common,
1608
+ dockerfile: text,
1609
+ contextDir: context,
1610
+ ...(flag(args, "target") ? { target: flag(args, "target") } : {}),
1611
+ ...(args.flags.get("build-arg") ? { buildArgs: pairs(args, "build-arg") } : {}),
1612
+ };
1613
+ if (!out.json)
1614
+ out.error(`Sending the build context from ${context}`);
1615
+ }
1616
+ else if (flag(args, "from"))
1617
+ input = { ...common, image: flag(args, "from") };
1618
+ else
1619
+ input = {
1620
+ ...common,
1621
+ recipe: Object.fromEntries(Object.entries(recipe).filter(([, v]) => v.length)),
1622
+ };
1008
1623
  const built = await api.build(input, {
1009
1624
  ...(out.json ? {} : { onLog: (line) => out.error(line.text) }),
1010
1625
  });
@@ -1012,29 +1627,43 @@ async function image(argv, env, out) {
1012
1627
  return 0;
1013
1628
  }
1014
1629
  case "ls":
1015
- case "list": {
1016
- const all = await (await api.list()).toArray(1000);
1630
+ case "list":
1631
+ case "versions": {
1632
+ const name = verb === "versions" ? need(rest[0], "a name") : undefined;
1633
+ const all = await (await api.list(name ? { name } : {})).toArray(1000);
1017
1634
  print(all.length
1018
1635
  ? table([
1019
- ["ID", "NAME", "STATE", "MIB", "CREATED"],
1636
+ ["ID", "NAME", "VERSION", "TAGS", "STATE", "MIB", "CREATED"],
1020
1637
  ...all.map((i) => [
1021
1638
  i.id,
1022
1639
  i.name ?? "-",
1640
+ i.version === null || i.version === undefined ? "-" : String(i.version),
1641
+ i.tags?.length ? i.tags.join(",") : "-",
1023
1642
  i.state,
1024
1643
  String(i.sizeMiB ?? "-"),
1025
1644
  i.createdAt,
1026
1645
  ]),
1027
1646
  ])
1028
- : "No images. Build one: runtime image build --pip pandas", all);
1647
+ : name
1648
+ ? `No versions of ${name}.`
1649
+ : "No images. Build one: runtime image build --pip pandas", all);
1029
1650
  return 0;
1030
1651
  }
1031
1652
  case "get": {
1032
- const found = await api.get(need(rest[0], "an image id"));
1653
+ const found = await api.resolve(need(rest[0], "an image"));
1033
1654
  print(JSON.stringify(found, null, 2), found);
1034
1655
  return 0;
1035
1656
  }
1036
1657
  case "logs": {
1037
- const id = need(rest[0], "an image id");
1658
+ const args = parse(rest, ["follow", "f"]);
1659
+ const id = (await api.resolve(need(args.positional[0], "an image"))).id;
1660
+ if (has(args, "follow") || has(args, "f")) {
1661
+ const lines = [];
1662
+ const done = await api.followLogs(id, (line) => out.json ? lines.push(line.text) : out.write(line.text));
1663
+ if (out.json)
1664
+ print("", { lines, state: done.state });
1665
+ return done.state === "failed" ? 1 : 0;
1666
+ }
1038
1667
  let after = 0;
1039
1668
  const lines = [];
1040
1669
  for (;;) {
@@ -1047,76 +1676,69 @@ async function image(argv, env, out) {
1047
1676
  print(lines.join("\n") || "(no log yet)", lines);
1048
1677
  return 0;
1049
1678
  }
1679
+ case "tag":
1680
+ case "untag": {
1681
+ const ref = need(rest[0], "an image");
1682
+ const tag = need(rest[1], "a tag");
1683
+ const found = verb === "tag" ? await api.tag(ref, tag) : await api.untag(ref, tag);
1684
+ print(`${found.name ?? found.id}@${found.version ?? "-"} tags: ${found.tags.join(", ") || "none"}`, found);
1685
+ return 0;
1686
+ }
1050
1687
  case "rm":
1051
1688
  case "delete": {
1052
- const found = await api.delete(need(rest[0], "an image id"));
1689
+ const found = await api.delete(need(rest[0], "an image"));
1053
1690
  print(`${found.id} ${found.state}`, found);
1054
1691
  return 0;
1055
1692
  }
1056
- }
1057
- throw unknown("image command", verb, ["build", "ls", "get", "logs", "rm"]);
1058
- }
1059
- /** The Dockerfile and the files its COPY and ADD lines name, from its folder. */
1060
- async function dockerContext(path) {
1061
- const dockerfile = await readFile(path, "utf8");
1062
- const root = dirname(resolve(path));
1063
- const sources = new Set();
1064
- for (const line of dockerfile.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
1065
- const match = /^\s*(?:COPY|ADD)\s+(.+)$/i.exec(line);
1066
- if (!match || /--from=/i.test(match[1]))
1067
- continue;
1068
- const body = match[1].trim();
1069
- const words = body.startsWith("[")
1070
- ? JSON.parse(body)
1071
- : body.split(/\s+/).filter((word) => !word.startsWith("--"));
1072
- for (const word of words.slice(0, -1))
1073
- if (!/^https?:\/\//.test(word))
1074
- sources.add(word);
1075
- }
1076
- const files = [];
1077
- let total = 0;
1078
- const add = async (absolute) => {
1079
- const info = await stat(absolute);
1080
- if (info.isDirectory()) {
1081
- for (const entry of await readdir(absolute))
1082
- if (entry !== ".git")
1083
- await add(join(absolute, entry));
1084
- return;
1085
- }
1086
- const name = relative(root, absolute).split(sep).join("/");
1087
- if (files.some((file) => file.path === name))
1088
- return;
1089
- const bytes = await readFile(absolute);
1090
- total += bytes.length;
1091
- if (files.length >= 256 || total > 1024 * 1024)
1092
- throw usage("The Dockerfile's context is over 256 files or 1 MiB. Build from --from, or install packages with --pip/--apt/--npm.");
1093
- files.push({
1094
- path: name,
1095
- content: bytes.toString("base64"),
1096
- encoding: "base64",
1097
- mode: info.mode & 0o777,
1098
- });
1099
- };
1100
- for (const source of sources) {
1101
- const absolute = resolve(root, source);
1102
- if (absolute !== root && !absolute.startsWith(root + sep))
1103
- throw usage(`COPY ${source} is outside the Dockerfile's folder.`);
1104
- if (/[*?[]/.test(source)) {
1105
- const pattern = new RegExp(`^${source
1106
- .split("/")
1107
- .at(-1)
1108
- .replace(/[.+^${}()|\\]/g, "\\$&")
1109
- .replace(/\*/g, "[^/]*")
1110
- .replace(/\?/g, "[^/]")}$`);
1111
- const folder = dirname(absolute);
1112
- for (const entry of await readdir(folder))
1113
- if (pattern.test(entry))
1114
- await add(join(folder, entry));
1693
+ case "registry":
1694
+ case "registries": {
1695
+ const [action, ...more] = rest;
1696
+ if (action === "ls" || action === "list" || action === undefined) {
1697
+ const all = await api.registries.list();
1698
+ print(all.length
1699
+ ? table([
1700
+ ["REGISTRY", "KIND", "USER", "SINCE"],
1701
+ ...all.map((r) => [r.registry, r.kind, r.username, r.createdAt]),
1702
+ ])
1703
+ : "No registry credentials. Store some: runtime image registry set ghcr.io --username you", all);
1704
+ return 0;
1705
+ }
1706
+ if (action === "set") {
1707
+ const args = parse(more);
1708
+ const registry = need(args.positional[0], "a registry, such as ghcr.io");
1709
+ const accessKeyId = flag(args, "access-key-id");
1710
+ if (accessKeyId === undefined && flag(args, "username") === undefined)
1711
+ throw usage("Give --username (or --access-key-id for Amazon ECR).");
1712
+ if (!out.json && process.stdin.isTTY)
1713
+ out.error(accessKeyId ? "The secret access key:" : "The token or password:");
1714
+ const secret = await read();
1715
+ if (!secret)
1716
+ throw usage("Nothing on standard input.");
1717
+ const saved = await api.registries.set(accessKeyId
1718
+ ? { registry, accessKeyId, secretAccessKey: secret }
1719
+ : { registry, username: flag(args, "username"), password: secret });
1720
+ print(`Stored credentials for ${saved.registry} (${saved.username}).`, saved);
1721
+ return 0;
1722
+ }
1723
+ if (action === "rm" || action === "delete") {
1724
+ const gone = await api.registries.delete(need(more[0], "a registry"));
1725
+ print(gone.deleted ? `Forgot ${gone.registry}.` : `Nothing stored for ${gone.registry}.`, gone);
1726
+ return 0;
1727
+ }
1728
+ throw unknown("registry command", action, ["ls", "set", "rm"]);
1115
1729
  }
1116
- else
1117
- await add(absolute);
1118
1730
  }
1119
- return { dockerfile, files };
1731
+ throw unknown("image command", verb, [
1732
+ "build",
1733
+ "ls",
1734
+ "versions",
1735
+ "get",
1736
+ "logs",
1737
+ "tag",
1738
+ "untag",
1739
+ "rm",
1740
+ "registry",
1741
+ ]);
1120
1742
  }
1121
1743
  const VOLUME_HELP = `runtime volume <command>
1122
1744
 
@@ -1179,7 +1801,15 @@ async function volume(argv, env, out) {
1179
1801
  throw unknown("volume command", verb, ["create", "ls", "get", "rm"]);
1180
1802
  }
1181
1803
  /** Flags `sandbox create` and `run` take without a value. */
1182
- const CREATE_FLAGS = ["trial", "paid", "no-wait", "no-internet"];
1804
+ const CREATE_FLAGS = [
1805
+ "trial",
1806
+ "paid",
1807
+ "no-wait",
1808
+ "no-internet",
1809
+ "no-auto-wake",
1810
+ "persistent",
1811
+ "get-or-create",
1812
+ ];
1183
1813
  /** A create body from the command line's flags. */
1184
1814
  function createInput(args) {
1185
1815
  const rules = ["allow", "deny", "connect"].filter((name) => args.flags.has(name));
@@ -1204,6 +1834,12 @@ function createInput(args) {
1204
1834
  ...(flag(args, "on-timeout")
1205
1835
  ? { onLeaseEnd: flag(args, "on-timeout") }
1206
1836
  : {}),
1837
+ ...(integer(args, "idle-pause") !== undefined
1838
+ ? { idlePauseSeconds: integer(args, "idle-pause") }
1839
+ : {}),
1840
+ ...(has(args, "no-auto-wake") ? { autoWake: false } : {}),
1841
+ ...(has(args, "persistent") ? { persistent: true } : {}),
1842
+ ...(has(args, "get-or-create") ? { getOrCreate: true } : {}),
1207
1843
  ...(has(args, "trial")
1208
1844
  ? { funding: "trial" }
1209
1845
  : has(args, "paid")
@@ -1296,3 +1932,224 @@ if (import.meta.main ||
1296
1932
  process.exitCode = 1;
1297
1933
  });
1298
1934
  }
1935
+ // ------------------------------------------------------------------ observability
1936
+ /** A sparkline of up to 60 points, oldest first. */
1937
+ function spark(values) {
1938
+ const bars = "▁▂▃▄▅▆▇█";
1939
+ const known = values.filter((v) => v !== null);
1940
+ const top = Math.max(1, ...known);
1941
+ const step = Math.max(1, Math.ceil(values.length / 60));
1942
+ const out = [];
1943
+ for (let i = 0; i < values.length; i += step) {
1944
+ const slice = values.slice(i, i + step).filter((v) => v !== null);
1945
+ out.push(slice.length ? bars[Math.min(7, Math.round((Math.max(...slice) / top) * 7))] : " ");
1946
+ }
1947
+ return out.join("");
1948
+ }
1949
+ const mib = (bytes) => `${Math.round(bytes / 1_048_576).toLocaleString("en-US")} MiB`;
1950
+ function metricsText(m) {
1951
+ const cpu = m.points.map((p) => p.cpuPercent);
1952
+ const memory = m.points.map((p) => p.memoryBytes);
1953
+ const peak = Math.max(0, ...m.points.map((p) => p.cpuPeakPercent ?? p.cpuPercent ?? 0));
1954
+ const latest = m.latest;
1955
+ return [
1956
+ table([
1957
+ ["state", m.state],
1958
+ [
1959
+ "cpu now",
1960
+ latest?.cpuPercent == null
1961
+ ? "no reading in the last 15 minutes"
1962
+ : `${latest.cpuPercent}% of ${m.vcpu} vCPU (${latest.cpuCores} cores)`,
1963
+ ],
1964
+ ["memory now", latest ? `${mib(latest.memoryBytes)} of ${mib(m.memoryLimitBytes)}` : "-"],
1965
+ [`cpu, ${m.range}`, m.points.length ? `${spark(cpu)} peak ${peak}%` : "no readings"],
1966
+ [
1967
+ `memory, ${m.range}`,
1968
+ m.points.length
1969
+ ? `${spark(memory)} peak ${mib(Math.max(...m.points.map((p) => p.memoryPeakBytes)))}`
1970
+ : "no readings",
1971
+ ],
1972
+ ]),
1973
+ ].join("\n");
1974
+ }
1975
+ const WEBHOOK_VERBS = [
1976
+ "ls",
1977
+ "create",
1978
+ "get",
1979
+ "update",
1980
+ "test",
1981
+ "deliveries",
1982
+ "retry",
1983
+ "rotate-secret",
1984
+ "rm",
1985
+ ];
1986
+ async function webhooksCommand(argv, env, out) {
1987
+ const [verb = "ls", ...rest] = argv;
1988
+ product = "webhooks";
1989
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
1990
+ if (verb === "help" || verb === "--help") {
1991
+ out.write(HELP.split("\n")
1992
+ .filter((line) => /webhooks|^\s{11}\|/.test(line))
1993
+ .join("\n"));
1994
+ return 0;
1995
+ }
1996
+ const hooks = (await client(env)).webhooks;
1997
+ const args = parse(rest, ["enable", "disable"]);
1998
+ const id = () => need(args.positional[0], "a webhook id");
1999
+ const events = () => flag(args, "events")
2000
+ ?.split(",")
2001
+ .map((e) => e.trim())
2002
+ .filter(Boolean);
2003
+ const row = (h) => [
2004
+ h.id,
2005
+ h.url,
2006
+ h.enabled ? (h.failingSince ? `failing since ${h.failingSince}` : "on") : "off",
2007
+ h.events.join(","),
2008
+ ];
2009
+ switch (verb) {
2010
+ case "ls":
2011
+ case "list": {
2012
+ const page = await hooks.list();
2013
+ print(page.data.length
2014
+ ? table(page.data.map(row))
2015
+ : "No webhooks. Add one: runtime webhooks create <url>", page);
2016
+ return 0;
2017
+ }
2018
+ case "get": {
2019
+ const h = await hooks.get(id());
2020
+ print(table([row(h)]), h);
2021
+ return 0;
2022
+ }
2023
+ case "create":
2024
+ case "add": {
2025
+ const h = await hooks.create({
2026
+ url: need(args.positional[0], "the https URL to send to"),
2027
+ ...(events() ? { events: events() } : {}),
2028
+ ...(flag(args, "description") ? { description: flag(args, "description") } : {}),
2029
+ });
2030
+ print(`${h.id}\nSigning secret (shown once, keep it): ${h.secret}\nVerify deliveries with verifyWebhook from the withruntime SDK.`, h);
2031
+ return 0;
2032
+ }
2033
+ case "update": {
2034
+ const h = await hooks.update(id(), {
2035
+ ...(flag(args, "url") ? { url: flag(args, "url") } : {}),
2036
+ ...(events() ? { events: events() } : {}),
2037
+ ...(has(args, "enable") ? { enabled: true } : {}),
2038
+ ...(has(args, "disable") ? { enabled: false } : {}),
2039
+ });
2040
+ print(table([row(h)]), h);
2041
+ return 0;
2042
+ }
2043
+ case "rotate-secret": {
2044
+ const keep = integer(args, "keep");
2045
+ const h = await hooks.rotateSecret(id(), keep === undefined ? {} : { keepPreviousSeconds: keep });
2046
+ print(`New signing secret (shown once): ${h.secret}${h.previousSecretExpiresAt ? `\nThe old one also signs until ${h.previousSecretExpiresAt}.` : ""}`, h);
2047
+ return 0;
2048
+ }
2049
+ case "test": {
2050
+ const d = await hooks.test(id());
2051
+ print(d.state === "succeeded"
2052
+ ? `Delivered: your endpoint answered ${d.lastStatus} in ${d.lastDurationMs} ms.`
2053
+ : d.state === "pending"
2054
+ ? "Sent; no answer yet. See it with: runtime webhooks deliveries " + id()
2055
+ : `Not delivered: ${d.lastError ?? d.state}${d.lastStatus ? ` (HTTP ${d.lastStatus})` : ""}.`, d);
2056
+ return d.state === "failed" ? 1 : 0;
2057
+ }
2058
+ case "deliveries": {
2059
+ const page = await hooks.deliveries(id(), {
2060
+ ...(integer(args, "limit") ? { limit: integer(args, "limit") } : {}),
2061
+ });
2062
+ print(page.data.length
2063
+ ? table(page.data.map((d) => [
2064
+ d.id,
2065
+ d.createdAt,
2066
+ d.eventType,
2067
+ d.state,
2068
+ `${d.attempts}/${d.maxAttempts}`,
2069
+ d.lastStatus ? String(d.lastStatus) : (d.lastError ?? "-"),
2070
+ ]))
2071
+ : "No deliveries yet.", page);
2072
+ return 0;
2073
+ }
2074
+ case "retry": {
2075
+ const d = await hooks.retry(need(args.positional[0], "a delivery id"));
2076
+ print(`Queued ${d.id} to send again.`, d);
2077
+ return 0;
2078
+ }
2079
+ case "rm":
2080
+ case "delete": {
2081
+ print("Deleted.", await hooks.delete(id()));
2082
+ return 0;
2083
+ }
2084
+ }
2085
+ throw unknown("webhooks command", verb, WEBHOOK_VERBS);
2086
+ }
2087
+ const OTEL_VERBS = ["ls", "create", "get", "update", "flush", "rm"];
2088
+ async function otelCommand(argv, env, out) {
2089
+ const [verb = "ls", ...rest] = argv;
2090
+ product = "otel";
2091
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
2092
+ const otel = (await client(env)).otel;
2093
+ const args = parse(rest, ["enable", "disable"]);
2094
+ const id = () => need(args.positional[0], "an export id");
2095
+ const signals = () => flag(args, "signals")
2096
+ ?.split(",")
2097
+ .map((s) => s.trim());
2098
+ const row = (x) => [
2099
+ x.id,
2100
+ x.endpoint,
2101
+ x.enabled ? "on" : "off",
2102
+ x.signals.join(","),
2103
+ x.lastError
2104
+ ? `last error: ${x.lastError}`
2105
+ : x.lastSuccessAt
2106
+ ? `sent ${x.lastSuccessAt}`
2107
+ : "not sent yet",
2108
+ ];
2109
+ switch (verb) {
2110
+ case "ls":
2111
+ case "list": {
2112
+ const page = await otel.list();
2113
+ print(page.data.length
2114
+ ? table(page.data.map(row))
2115
+ : "No exports. Add one: runtime otel create <endpoint>", page);
2116
+ return 0;
2117
+ }
2118
+ case "get": {
2119
+ const x = await otel.get(id());
2120
+ print(table([row(x)]), x);
2121
+ return 0;
2122
+ }
2123
+ case "create":
2124
+ case "add": {
2125
+ const x = await otel.create({
2126
+ endpoint: need(args.positional[0], "the OTLP/HTTP endpoint"),
2127
+ ...(args.flags.has("header") ? { headers: pairs(args, "header") } : {}),
2128
+ ...(signals() ? { signals: signals() } : {}),
2129
+ });
2130
+ print(`${x.id}\nPushing ${x.signals.join(" and ")} to ${x.endpoint} about every 30 seconds.`, x);
2131
+ return 0;
2132
+ }
2133
+ case "update": {
2134
+ const x = await otel.update(id(), {
2135
+ ...(flag(args, "endpoint") ? { endpoint: flag(args, "endpoint") } : {}),
2136
+ ...(args.flags.has("header") ? { headers: pairs(args, "header") } : {}),
2137
+ ...(signals() ? { signals: signals() } : {}),
2138
+ ...(has(args, "enable") ? { enabled: true } : {}),
2139
+ ...(has(args, "disable") ? { enabled: false } : {}),
2140
+ });
2141
+ print(table([row(x)]), x);
2142
+ return 0;
2143
+ }
2144
+ case "flush": {
2145
+ print("Pushing now; `runtime otel get <id>` shows the answer in a few seconds.", await otel.flush(id()));
2146
+ return 0;
2147
+ }
2148
+ case "rm":
2149
+ case "delete": {
2150
+ print("Deleted.", await otel.delete(id()));
2151
+ return 0;
2152
+ }
2153
+ }
2154
+ throw unknown("otel command", verb, OTEL_VERBS);
2155
+ }