withruntime 0.4.0 → 0.5.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 (87) hide show
  1. package/DAYTONA.md +129 -0
  2. package/E2B.md +2 -2
  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 +939 -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 +26 -0
  18. package/dist/daytona/context.js +22 -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 +339 -0
  33. package/dist/daytona/sandbox.d.ts +196 -0
  34. package/dist/daytona/sandbox.js +448 -0
  35. package/dist/e2b/sandbox.d.ts +22 -2
  36. package/dist/e2b/sandbox.js +39 -7
  37. package/dist/errors.d.ts +10 -4
  38. package/dist/errors.js +26 -6
  39. package/dist/image-context.d.ts +39 -0
  40. package/dist/image-context.js +176 -0
  41. package/dist/index.d.ts +19 -2
  42. package/dist/index.js +17 -2
  43. package/dist/keys.d.ts +43 -0
  44. package/dist/keys.js +196 -0
  45. package/dist/login.js +14 -2
  46. package/dist/openai-agents/index.d.ts +132 -0
  47. package/dist/openai-agents/index.js +791 -0
  48. package/dist/products/audit.d.ts +48 -0
  49. package/dist/products/audit.js +10 -0
  50. package/dist/products/images.d.ts +113 -7
  51. package/dist/products/images.js +186 -27
  52. package/dist/products/index.d.ts +11 -0
  53. package/dist/products/index.js +17 -1
  54. package/dist/products/observability.d.ts +186 -0
  55. package/dist/products/observability.js +117 -0
  56. package/dist/products/secrets.d.ts +40 -0
  57. package/dist/products/secrets.js +24 -0
  58. package/dist/products/switching.d.ts +89 -0
  59. package/dist/products/switching.js +25 -0
  60. package/dist/sandbox.d.ts +26 -1
  61. package/dist/sandbox.js +101 -1
  62. package/dist/ssh.d.ts +21 -0
  63. package/dist/ssh.js +312 -0
  64. package/dist/tar.d.ts +1 -0
  65. package/dist/tar.js +4 -4
  66. package/dist/tools/index.d.ts +62 -0
  67. package/dist/tools/index.js +180 -0
  68. package/dist/transport.d.ts +12 -2
  69. package/dist/transport.js +31 -4
  70. package/dist/tunnel.d.ts +67 -0
  71. package/dist/tunnel.js +311 -0
  72. package/dist/types.d.ts +57 -1
  73. package/dist/vercel/client.d.ts +40 -0
  74. package/dist/vercel/client.js +39 -0
  75. package/dist/vercel/command.d.ts +70 -0
  76. package/dist/vercel/command.js +148 -0
  77. package/dist/vercel/errors.d.ts +53 -0
  78. package/dist/vercel/errors.js +113 -0
  79. package/dist/vercel/filesystem.d.ts +150 -0
  80. package/dist/vercel/filesystem.js +248 -0
  81. package/dist/vercel/index.d.ts +30 -0
  82. package/dist/vercel/index.js +38 -0
  83. package/dist/vercel/sandbox.d.ts +317 -0
  84. package/dist/vercel/sandbox.js +726 -0
  85. package/dist/vercel/snapshot.d.ts +93 -0
  86. package/dist/vercel/snapshot.js +123 -0
  87. 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,121 @@ 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
+ /** One line on the switching credit: how to claim it, or what it did. */
688
+ function switchingLine(summary, provider) {
689
+ const cap = dollars(summary.maxMicros);
690
+ const sw = summary.switch;
691
+ if (!sw) {
692
+ if (!summary.eligible || !provider)
693
+ return undefined;
694
+ 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}.`;
695
+ }
696
+ const from = RIVAL_NAMES[sw.provider];
697
+ switch (sw.status) {
698
+ case "pending":
699
+ return `Your switch from ${from} is recorded: your first top-up is matched, up to ${cap}, once the payment settles.`;
700
+ case "paid":
701
+ return `Your switch from ${from} matched your first top-up: ${dollars(sw.creditMicros)} of credit.`;
702
+ case "superseded":
703
+ return `Your first top-up was matched by your referral instead, which is never less than the switching credit.`;
704
+ case "reversed":
705
+ return `The top-up your switching credit matched was refunded, so the credit was taken back.`;
706
+ case "refused":
707
+ return sw.refusedReason === "program_off"
708
+ ? "The switching credit was paused when your first top-up arrived."
709
+ : "Your first top-up was not matched. Ask support if you think that is wrong.";
710
+ }
711
+ }
712
+ async function switching(command, argv, env, out) {
713
+ const args = parse(argv);
714
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
715
+ const known = new Set(command === "compare" ? ["from", "days"] : ["from"]);
716
+ for (const name of args.flags.keys())
717
+ if (!known.has(name))
718
+ throw usage(`Unknown option --${name}.`);
719
+ if (args.positional.length || args.rest)
720
+ throw usage(`Unexpected ${args.positional[0] ?? "--"}.`);
721
+ const from = flag(args, "from")?.trim().toLowerCase();
722
+ const api = (await client(env)).switching;
723
+ if (command === "switch") {
724
+ if (!from) {
725
+ const summary = await api.get();
726
+ print(switchingLine(summary) ??
727
+ (summary.eligible
728
+ ? `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)}.`
729
+ : "The switching credit matches a first top-up, and this organization has made one already."), summary);
730
+ return 0;
731
+ }
732
+ const provider = SWITCH_FROM[from];
733
+ if (!provider || from === "fly-machines")
734
+ throw unknown("provider", from, Object.keys(RIVAL_NAMES));
735
+ const summary = await api.record({ provider });
736
+ print(`Recorded: switching from ${RIVAL_NAMES[provider]}. ${switchingLine(summary) ?? ""}`.trim(), summary);
737
+ return 0;
738
+ }
739
+ if (!from)
740
+ throw usage(`Name the rival: ${me} compare --from e2b (or daytona, vercel, modal, cloudflare, fly, fly-machines...).`);
741
+ /* The server holds the list of rivals it has rates for, and names them all
742
+ when it refuses one, so a rival added there needs no new CLI. */
743
+ const days = integer(args, "days");
744
+ const c = await api.compare({ provider: from, days });
745
+ const saving = BigInt(c.savingMicros);
746
+ const percent = c.savingPercent === null ? "" : ` (${Math.abs(c.savingPercent)}%)`;
747
+ const verdict = saving >= 0n
748
+ ? `you save ${dollars(saving)}${percent}`
749
+ : `${c.rival.name} would cost ${dollars(-saving)} less${percent}`;
750
+ const figures = `${dollars(c.runtimeMicros)} on Runtime; the same sandboxes on ${c.rival.name}: ${dollars(c.rivalMicros)}; ${verdict}`;
751
+ const month = c.perMonth ? BigInt(c.perMonth.savingMicros) : undefined;
752
+ const lines = c.basis === "usage"
753
+ ? [
754
+ `Your last ${c.window.days} days: ${figures}${month === undefined
755
+ ? "."
756
+ : month >= 0n
757
+ ? `, about ${dollars(month)} a month.`
758
+ : `, about ${dollars(-month)} a month more on Runtime.`}`,
759
+ `Priced: ${c.usage.sandboxes.toLocaleString("en-US")} sandbox${c.usage.sandboxes === 1 ? "" : "es"}, ${(c.usage.runSeconds / 3600).toLocaleString("en-US", { maximumFractionDigits: 2 })} hours running.${c.usage.unpricedSandboxes ? ` ${c.usage.unpricedSandboxes} left out: ${c.rival.name} publishes no price for their size.` : ""}`,
760
+ ]
761
+ : [
762
+ `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.`,
763
+ `The example: ${figures}.`,
764
+ ];
765
+ lines.push(`${c.rival.name}'s published rates, checked ${new Date(`${c.rival.checked}T12:00:00Z`).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" })}: ${c.rival.rates.join("; ")}. Compute only: storage, network, plan fees and free allowances are left out.`);
766
+ const line = switchingLine(c.switching, Object.hasOwn(SWITCH_FROM, from) ? SWITCH_FROM[from] : undefined);
767
+ if (line)
768
+ lines.push("", line);
769
+ print(lines.join("\n"), c);
770
+ return 0;
771
+ }
517
772
  async function support(argv, env, out) {
518
773
  const args = parse(argv);
519
774
  const api = (await client(env)).support;
@@ -639,6 +894,40 @@ async function sandbox(argv, env, out) {
639
894
  print(forks.map((f) => f.id).join("\n"), forks.map((f) => f.info));
640
895
  return 0;
641
896
  }
897
+ case "update": {
898
+ const args = parse(rest);
899
+ const sbx = await get(args.positional[0]);
900
+ const onOff = (name) => {
901
+ const value = flag(args, name);
902
+ if (value === undefined)
903
+ return undefined;
904
+ if (value !== "on" && value !== "off")
905
+ throw usage(`--${name} takes on or off.`);
906
+ return value === "on";
907
+ };
908
+ const cost = flag(args, "max-total-cost");
909
+ if (cost !== undefined && cost !== "none" && !/^\d+(\.\d{1,6})?$/.test(cost))
910
+ throw usage("--max-total-cost takes dollars, such as 25 or 2.50, or none.");
911
+ const settings = {
912
+ ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
913
+ ...(args.flags.has("label") ? { labels: pairs(args, "label") } : {}),
914
+ ...(integer(args, "idle-pause") !== undefined
915
+ ? { idlePauseSeconds: integer(args, "idle-pause") }
916
+ : {}),
917
+ ...(onOff("auto-wake") === undefined ? {} : { autoWake: onOff("auto-wake") }),
918
+ ...(onOff("persistent") === undefined ? {} : { persistent: onOff("persistent") }),
919
+ ...(cost === undefined
920
+ ? {}
921
+ : {
922
+ maxTotalCostMicros: cost === "none" ? null : Math.round(Number(cost) * 1e6),
923
+ }),
924
+ };
925
+ if (!Object.keys(settings).length)
926
+ throw usage("Say what to change: --name, --label, --idle-pause, --auto-wake, --persistent or --max-total-cost.");
927
+ await sbx.update(settings);
928
+ print(describe(sbx.info), sbx.info);
929
+ return 0;
930
+ }
642
931
  case "extend": {
643
932
  const sbx = await get(rest[0]);
644
933
  const seconds = Number(need(rest[1], "seconds, e.g. runtime sandbox extend <id> 600"));
@@ -792,6 +1081,16 @@ async function sandbox(argv, env, out) {
792
1081
  }
793
1082
  case "desktop":
794
1083
  return desktop(await get(rest[0]), rest.slice(1), out);
1084
+ case "metrics": {
1085
+ const args = parse(rest);
1086
+ const sbx = await get(args.positional[0]);
1087
+ const range = (flag(args, "range") ?? "1h");
1088
+ if (!["15m", "1h", "6h", "24h", "7d", "30d"].includes(range))
1089
+ throw usage("--range is one of 15m, 1h, 6h, 24h, 7d, 30d.");
1090
+ const m = await sbx.metrics({ range });
1091
+ print(metricsText(m), m);
1092
+ return 0;
1093
+ }
795
1094
  }
796
1095
  throw unknown("sandbox command", verb, SANDBOX_VERBS);
797
1096
  }
@@ -802,6 +1101,109 @@ function attachment(spec) {
802
1101
  throw usage("--volume takes <volumeId>:/path, or <volumeId>:/path:snapshot for a copy.");
803
1102
  return { volumeId, path, mode };
804
1103
  }
1104
+ /** A secret's value, from standard input only, so it never sits in shell
1105
+ * history or a process list. Piped: everything, less one trailing newline.
1106
+ * A terminal: one line, not shown. */
1107
+ async function readSecret() {
1108
+ const stdin = process.stdin;
1109
+ if (!stdin.isTTY) {
1110
+ const chunks = [];
1111
+ for await (const chunk of stdin)
1112
+ chunks.push(chunk);
1113
+ return Buffer.concat(chunks)
1114
+ .toString("utf8")
1115
+ .replace(/\r?\n$/, "");
1116
+ }
1117
+ process.stderr.write("Value (not shown), then Enter: ");
1118
+ stdin.setRawMode(true);
1119
+ stdin.resume();
1120
+ try {
1121
+ return await new Promise((done, fail) => {
1122
+ let value = "";
1123
+ const onData = (chunk) => {
1124
+ for (const char of chunk.toString("utf8")) {
1125
+ if (char === "\r" || char === "\n") {
1126
+ stdin.off("data", onData);
1127
+ process.stderr.write("\n");
1128
+ return done(value);
1129
+ }
1130
+ if (char === "\u0003") {
1131
+ stdin.off("data", onData);
1132
+ return fail(usage("Cancelled."));
1133
+ }
1134
+ if (char === "\u007f" || char === "\b")
1135
+ value = value.slice(0, -1);
1136
+ else
1137
+ value += char;
1138
+ }
1139
+ };
1140
+ stdin.on("data", onData);
1141
+ });
1142
+ }
1143
+ finally {
1144
+ stdin.setRawMode(false);
1145
+ stdin.pause();
1146
+ }
1147
+ }
1148
+ export async function secretsCommand(argv, env, out,
1149
+ /** Tests give the value here; the CLI reads standard input. */
1150
+ read = readSecret) {
1151
+ const [verb, ...rest] = argv;
1152
+ const args = parse(rest);
1153
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
1154
+ const rows = (list) => list.length
1155
+ ? table([
1156
+ ["name", "hosts", "header", "placeholder"],
1157
+ ...list.map((secret) => [
1158
+ secret.name,
1159
+ secret.hosts.join(","),
1160
+ secret.header ? `${secret.header}: ${secret.format ?? "{value}"}` : "-",
1161
+ secret.placeholder,
1162
+ ]),
1163
+ ])
1164
+ : "No secrets.";
1165
+ if (verb === "ls" || verb === "list" || verb === undefined) {
1166
+ const list = await (await client(env)).secrets.list();
1167
+ print(rows(list), list);
1168
+ return 0;
1169
+ }
1170
+ if (verb === "set") {
1171
+ const name = need(args.positional[0], "the secret's name, such as OPENAI_API_KEY");
1172
+ const hosts = args.flags.get("host") ?? [];
1173
+ if (!hosts.length)
1174
+ throw usage("Give --host once for each host the value may go to, such as --host api.openai.com.");
1175
+ if (args.flags.has("value"))
1176
+ throw usage('The value is read from standard input, never an argument: printf %s "$KEY" | runtime secrets set NAME --host ...');
1177
+ const value = await read();
1178
+ if (!value)
1179
+ throw usage('The value is empty. Pipe it in: printf %s "$KEY" | runtime secrets set NAME --host ...');
1180
+ const header = flag(args, "header");
1181
+ const format = flag(args, "format");
1182
+ const saved = await (await client(env)).secrets.set(name, {
1183
+ value,
1184
+ hosts,
1185
+ ...(header ? { header } : {}),
1186
+ ...(format ? { format } : {}),
1187
+ });
1188
+ print([
1189
+ `Stored ${saved.name}. Sandboxes see ${saved.name}=${saved.placeholder}.`,
1190
+ saved.header
1191
+ ? `Requests to ${saved.hosts.join(", ")} get ${saved.header}: ${saved.format ?? "{value}"} over HTTPS.`
1192
+ : `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the URL and headers.`,
1193
+ ...(saved.enforced
1194
+ ? []
1195
+ : ["A host is catching up; running sandboxes have it within a minute."]),
1196
+ ].join("\n"), saved);
1197
+ return 0;
1198
+ }
1199
+ if (verb === "rm" || verb === "delete") {
1200
+ const name = need(args.positional[0], "the secret's name");
1201
+ const removed = await (await client(env)).secrets.delete(name);
1202
+ print(`Deleted ${name}.`, removed);
1203
+ return 0;
1204
+ }
1205
+ throw usage("secrets takes set, ls or rm. Run `runtime help`.");
1206
+ }
805
1207
  async function readInput(source) {
806
1208
  if (source !== "-")
807
1209
  return readFile(source, "utf8");
@@ -904,6 +1306,141 @@ async function desktop(sbx, argv, out) {
904
1306
  }
905
1307
  throw usage(`${me} sandbox desktop <id> start|stop|screenshot|open|click|type|press|windows`);
906
1308
  }
1309
+ const KEYS_HELP = `runtime keys <command>
1310
+
1311
+ create [--name <name>] [--read-only] [--daily-limit <usd>]
1312
+ A new API key, for a CI runner or any place with no browser. Prints a link
1313
+ and a code; an account owner approves the key in the browser, seeing its
1314
+ name, access and limit. The key is printed once, alone on stdout, and
1315
+ lasts until revoked. --json prints it as JSON, still alone on stdout.
1316
+
1317
+ runtime keys create --name ci --daily-limit 25 | gh secret set RUNTIME_API_KEY
1318
+
1319
+ List, limit and revoke keys at https://withruntime.com/account/keys.
1320
+ `;
1321
+ /* `runtime keys create`: a key made from the terminal and approved in the
1322
+ browser. Marc, 23 September 2026; ARCHITECTURE.md section 10, "Keys from the
1323
+ terminal". Only the options the keys page offers. No key can list, create or
1324
+ revoke keys through the API, so `ls` and `revoke` send you to the page. */
1325
+ /* `runtime account`: the accounts this machine is connected to, and which one
1326
+ every command uses (ARCHITECTURE.md section 3.9). Each is its own saved
1327
+ connection with its own key; the SDK itself stays key-based. */
1328
+ async function accountCommand(argv, env, out) {
1329
+ product = "account";
1330
+ const [verb = "ls", ...rest] = argv;
1331
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
1332
+ const store = connectionStore(env);
1333
+ if (verb === "add") {
1334
+ const result = await authenticationCommand(["login", "--another-account", ...rest], env);
1335
+ if (result.pending)
1336
+ throw notApprovedYet(result);
1337
+ await nameSaved(env);
1338
+ print(`Connected${"orgId" in result && result.orgId ? ` to ${String(result.orgId)}` : ""}.`, result);
1339
+ return 0;
1340
+ }
1341
+ if (verb === "switch" || verb === "use") {
1342
+ const which = rest.join(" ").trim();
1343
+ if (!which)
1344
+ throw usage(`Name the account: ${me} account switch <name|id>.`);
1345
+ if (env.RUNTIME_API_KEY)
1346
+ throw usage("RUNTIME_API_KEY is set, and it names one account. Unset it to switch.");
1347
+ const chosen = await store.use(which);
1348
+ if (!chosen)
1349
+ throw usage(`No saved connection for "${which}". Run \`${me} account\` to list them, or \`${me} account add\` to connect it.`);
1350
+ print(`Now using ${chosen.orgName ?? chosen.orgId}.`, {
1351
+ orgId: chosen.orgId,
1352
+ orgName: chosen.orgName ?? null,
1353
+ });
1354
+ return 0;
1355
+ }
1356
+ if (verb !== "ls" && verb !== "list")
1357
+ throw unknown("account command", verb, ["ls", "switch", "add"]);
1358
+ await nameSaved(env).catch(() => undefined);
1359
+ const current = await store.read().catch(() => null);
1360
+ const saved = [
1361
+ ...(current ? [{ ...current, current: true }] : []),
1362
+ ...(await store.others()).map((one) => ({ ...one, current: false })),
1363
+ ];
1364
+ const rows = saved.map((one) => ({
1365
+ orgId: one.orgId,
1366
+ orgName: one.orgName ?? null,
1367
+ agentName: one.agentName,
1368
+ current: one.current,
1369
+ }));
1370
+ print(rows.length
1371
+ ? table([
1372
+ ["", "account", "id", "agent"],
1373
+ ...rows.map((row) => [
1374
+ row.current ? "*" : "",
1375
+ row.orgName ?? "-",
1376
+ row.orgId,
1377
+ row.agentName,
1378
+ ]),
1379
+ ])
1380
+ : `Not connected. Run \`${me} login\`.`, { accounts: rows, environmentKey: Boolean(env.RUNTIME_API_KEY) });
1381
+ return 0;
1382
+ }
1383
+ /** Fill in the saved connection's account name from the API, once. */
1384
+ async function nameSaved(env) {
1385
+ if (env.RUNTIME_API_KEY)
1386
+ return;
1387
+ const store = connectionStore(env);
1388
+ const saved = await store.read();
1389
+ if (!saved || saved.orgName)
1390
+ return;
1391
+ const who = await new Runtime({
1392
+ apiKey: saved.key,
1393
+ baseUrl: env.RUNTIME_API_URL,
1394
+ maxRetries: 1,
1395
+ }).me();
1396
+ if (who.orgName && who.orgId === saved.orgId) {
1397
+ const again = await store.read();
1398
+ if (again && again.key === saved.key)
1399
+ await store.save({ ...again, orgName: who.orgName });
1400
+ }
1401
+ }
1402
+ async function keys(argv, env, out) {
1403
+ product = "keys";
1404
+ const [verb, ...rest] = argv;
1405
+ if (!verb || verb === "help" || verb === "--help" || verb === "-h") {
1406
+ out.write(out.json ? JSON.stringify({ usage: named(KEYS_HELP) }) : named(KEYS_HELP));
1407
+ return 0;
1408
+ }
1409
+ if (verb === "ls" || verb === "list" || verb === "revoke")
1410
+ throw usage("List and revoke keys at https://withruntime.com/account/keys. No key can list or revoke keys.");
1411
+ if (verb !== "create")
1412
+ throw unknown("keys command", verb, ["create"]);
1413
+ const args = parse(rest, ["read-only"]);
1414
+ const known = new Set(["name", "read-only", "daily-limit"]);
1415
+ for (const name of args.flags.keys())
1416
+ if (!known.has(name))
1417
+ throw usage(`Unknown option --${name}.`);
1418
+ if (args.positional.length || args.rest)
1419
+ throw usage(`Unexpected ${args.positional[0] ?? "--"}.`);
1420
+ const readOnly = has(args, "read-only");
1421
+ const limit = flag(args, "daily-limit");
1422
+ if (readOnly && limit !== undefined)
1423
+ throw usage("A read-only key cannot spend, so it takes no --daily-limit.");
1424
+ const created = await createKey({
1425
+ name: flag(args, "name") ?? (readOnly ? "Read-only key" : "My API key"),
1426
+ access: readOnly ? "read" : "full",
1427
+ dailyLimitMicros: limit === undefined ? null : dailyLimitMicros(limit),
1428
+ }, env, { notify: out.error });
1429
+ const { confirm, ...key } = created;
1430
+ out.write(out.json ? JSON.stringify(key) : key.key);
1431
+ const terms = [
1432
+ key.access === "read" ? "read only" : "full access",
1433
+ key.access === "read"
1434
+ ? undefined
1435
+ : key.dailyLimitMicros === null
1436
+ ? "no daily limit"
1437
+ : `daily limit $${(Number(BigInt(key.dailyLimitMicros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
1438
+ "until revoked",
1439
+ ].filter(Boolean);
1440
+ 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.`);
1441
+ await confirm();
1442
+ return 0;
1443
+ }
907
1444
  const SNAPSHOT_HELP = `runtime snapshot <command>
908
1445
 
909
1446
  ls [--sandbox <id>] [--name n] Your snapshots
@@ -960,18 +1497,34 @@ async function snapshot(argv, env, out) {
960
1497
  }
961
1498
  const IMAGE_HELP = `runtime image <command>
962
1499
 
963
- build (--dockerfile <path> | --from <public image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...)
964
- [--name n] [--max-image-mib 4096] [--timeout <seconds>]
1500
+ build [<folder>] [-f <Dockerfile>] | --from <image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...
1501
+ [-t name[:tag]]... [--target <stage>] [--build-arg K=V]... [--no-cache]
1502
+ [--start <command>] [--ready-port <port> | --ready-command <command>]
1503
+ [--max-image-mib 8192] [--timeout <seconds>]
965
1504
  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).
1505
+ A Dockerfile's folder is the context: everything its
1506
+ .dockerignore leaves in (100 MiB compressed at most) is
1507
+ sent, and a rebuild sends only what changed.
968
1508
  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>
1509
+ versions <name> Every version of a name, newest first
1510
+ get <image> One image: an id, name, name:tag or name@version
1511
+ logs <image> [--follow]
1512
+ Its build log
1513
+ tag <image> <tag> Point a tag of the image's name at this version
1514
+ untag <image> <tag> Take a tag off
1515
+ rm <image> Delete that version and its tags
1516
+ registry ls Registries you keep credentials for
1517
+ registry set <registry> --username <user>
1518
+ Store credentials to pull private images; the token or
1519
+ password is read from standard input and never shown again
1520
+ registry set <ecr registry> --access-key-id <id>
1521
+ Amazon ECR: the secret access key is read from standard input
1522
+ registry rm <registry>
1523
+ Use one: runtime sandbox create --image app:latest
973
1524
  `;
974
- async function image(argv, env, out) {
1525
+ export async function imageCommand(argv, env, out,
1526
+ /** Tests give the registry secret here; the CLI reads standard input. */
1527
+ read = readSecret) {
975
1528
  const [verb, ...rest] = argv;
976
1529
  const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
977
1530
  if (!verb || verb === "help" || verb === "--help") {
@@ -981,30 +1534,72 @@ async function image(argv, env, out) {
981
1534
  const api = (await client(env)).images;
982
1535
  switch (verb) {
983
1536
  case "build": {
984
- const args = parse(rest);
1537
+ const args = parse(rest, ["no-cache"]);
985
1538
  const list = (name) => (args.flags.get(name) ?? []).flatMap((value) => value.split(/[\s,]+/)).filter(Boolean);
986
1539
  const recipe = { apt: list("apt"), pip: list("pip"), npm: list("npm") };
987
1540
  const hasRecipe = recipe.apt.length + recipe.pip.length + recipe.npm.length > 0;
988
- const sources = [flag(args, "dockerfile"), flag(args, "from"), hasRecipe || undefined];
1541
+ const dockerfile = flag(args, "dockerfile") ?? flag(args, "file") ?? flag(args, "f");
1542
+ const folder = args.positional[0];
1543
+ const fromDockerfile = dockerfile !== undefined || folder !== undefined;
1544
+ const sources = [fromDockerfile || undefined, flag(args, "from"), hasRecipe || undefined];
989
1545
  if (sources.filter((s) => s !== undefined).length !== 1)
990
- throw usage("Give exactly one of --dockerfile <path>, --from <image>, or --pip/--apt/--npm.");
1546
+ throw usage("Give exactly one of a folder with a Dockerfile (or -f <Dockerfile>), --from <image>, or --pip/--apt/--npm.");
991
1547
  const build = {
992
1548
  ...(integer(args, "max-image-mib") ? { maxImageMiB: integer(args, "max-image-mib") } : {}),
993
1549
  ...(integer(args, "timeout") ? { timeoutSeconds: integer(args, "timeout") } : {}),
994
1550
  };
1551
+ // -t app, -t app:v2 -t app:latest: one name, its tags.
1552
+ let name = flag(args, "name");
1553
+ const tags = [];
1554
+ for (const tagged of [...(args.flags.get("t") ?? []), ...(args.flags.get("tag") ?? [])]) {
1555
+ const [repository, tag] = tagged.split(":");
1556
+ if (!repository)
1557
+ throw usage("-t takes name or name:tag.");
1558
+ if (name !== undefined && name !== repository)
1559
+ throw usage("Every -t names the same image; give one name and several tags.");
1560
+ name = repository;
1561
+ if (tag)
1562
+ tags.push(tag);
1563
+ }
1564
+ const start = {
1565
+ ...(flag(args, "start") ? { command: flag(args, "start") } : {}),
1566
+ ...(integer(args, "ready-port") ? { readyPort: integer(args, "ready-port") } : {}),
1567
+ ...(flag(args, "ready-command") ? { readyCommand: flag(args, "ready-command") } : {}),
1568
+ ...(integer(args, "ready-timeout")
1569
+ ? { readyTimeoutSeconds: integer(args, "ready-timeout") }
1570
+ : {}),
1571
+ };
995
1572
  const common = {
996
- ...(flag(args, "name") ? { name: flag(args, "name") } : {}),
1573
+ ...(name ? { name } : {}),
1574
+ ...(tags.length ? { tags } : {}),
997
1575
  ...(Object.keys(build).length ? { build } : {}),
1576
+ ...(Object.keys(start).length ? { start } : {}),
1577
+ ...(has(args, "no-cache") ? { cache: false } : {}),
998
1578
  };
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
- };
1579
+ let input;
1580
+ if (fromDockerfile) {
1581
+ const context = resolve(folder ?? dirname(dockerfile));
1582
+ const file = resolve(context, dockerfile ?? "Dockerfile");
1583
+ const text = await readFile(file, "utf8").catch(() => {
1584
+ throw usage(`No Dockerfile at ${file}.`);
1585
+ });
1586
+ input = {
1587
+ ...common,
1588
+ dockerfile: text,
1589
+ contextDir: context,
1590
+ ...(flag(args, "target") ? { target: flag(args, "target") } : {}),
1591
+ ...(args.flags.get("build-arg") ? { buildArgs: pairs(args, "build-arg") } : {}),
1592
+ };
1593
+ if (!out.json)
1594
+ out.error(`Sending the build context from ${context}`);
1595
+ }
1596
+ else if (flag(args, "from"))
1597
+ input = { ...common, image: flag(args, "from") };
1598
+ else
1599
+ input = {
1600
+ ...common,
1601
+ recipe: Object.fromEntries(Object.entries(recipe).filter(([, v]) => v.length)),
1602
+ };
1008
1603
  const built = await api.build(input, {
1009
1604
  ...(out.json ? {} : { onLog: (line) => out.error(line.text) }),
1010
1605
  });
@@ -1012,29 +1607,43 @@ async function image(argv, env, out) {
1012
1607
  return 0;
1013
1608
  }
1014
1609
  case "ls":
1015
- case "list": {
1016
- const all = await (await api.list()).toArray(1000);
1610
+ case "list":
1611
+ case "versions": {
1612
+ const name = verb === "versions" ? need(rest[0], "a name") : undefined;
1613
+ const all = await (await api.list(name ? { name } : {})).toArray(1000);
1017
1614
  print(all.length
1018
1615
  ? table([
1019
- ["ID", "NAME", "STATE", "MIB", "CREATED"],
1616
+ ["ID", "NAME", "VERSION", "TAGS", "STATE", "MIB", "CREATED"],
1020
1617
  ...all.map((i) => [
1021
1618
  i.id,
1022
1619
  i.name ?? "-",
1620
+ i.version === null || i.version === undefined ? "-" : String(i.version),
1621
+ i.tags?.length ? i.tags.join(",") : "-",
1023
1622
  i.state,
1024
1623
  String(i.sizeMiB ?? "-"),
1025
1624
  i.createdAt,
1026
1625
  ]),
1027
1626
  ])
1028
- : "No images. Build one: runtime image build --pip pandas", all);
1627
+ : name
1628
+ ? `No versions of ${name}.`
1629
+ : "No images. Build one: runtime image build --pip pandas", all);
1029
1630
  return 0;
1030
1631
  }
1031
1632
  case "get": {
1032
- const found = await api.get(need(rest[0], "an image id"));
1633
+ const found = await api.resolve(need(rest[0], "an image"));
1033
1634
  print(JSON.stringify(found, null, 2), found);
1034
1635
  return 0;
1035
1636
  }
1036
1637
  case "logs": {
1037
- const id = need(rest[0], "an image id");
1638
+ const args = parse(rest, ["follow", "f"]);
1639
+ const id = (await api.resolve(need(args.positional[0], "an image"))).id;
1640
+ if (has(args, "follow") || has(args, "f")) {
1641
+ const lines = [];
1642
+ const done = await api.followLogs(id, (line) => out.json ? lines.push(line.text) : out.write(line.text));
1643
+ if (out.json)
1644
+ print("", { lines, state: done.state });
1645
+ return done.state === "failed" ? 1 : 0;
1646
+ }
1038
1647
  let after = 0;
1039
1648
  const lines = [];
1040
1649
  for (;;) {
@@ -1047,76 +1656,69 @@ async function image(argv, env, out) {
1047
1656
  print(lines.join("\n") || "(no log yet)", lines);
1048
1657
  return 0;
1049
1658
  }
1659
+ case "tag":
1660
+ case "untag": {
1661
+ const ref = need(rest[0], "an image");
1662
+ const tag = need(rest[1], "a tag");
1663
+ const found = verb === "tag" ? await api.tag(ref, tag) : await api.untag(ref, tag);
1664
+ print(`${found.name ?? found.id}@${found.version ?? "-"} tags: ${found.tags.join(", ") || "none"}`, found);
1665
+ return 0;
1666
+ }
1050
1667
  case "rm":
1051
1668
  case "delete": {
1052
- const found = await api.delete(need(rest[0], "an image id"));
1669
+ const found = await api.delete(need(rest[0], "an image"));
1053
1670
  print(`${found.id} ${found.state}`, found);
1054
1671
  return 0;
1055
1672
  }
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));
1673
+ case "registry":
1674
+ case "registries": {
1675
+ const [action, ...more] = rest;
1676
+ if (action === "ls" || action === "list" || action === undefined) {
1677
+ const all = await api.registries.list();
1678
+ print(all.length
1679
+ ? table([
1680
+ ["REGISTRY", "KIND", "USER", "SINCE"],
1681
+ ...all.map((r) => [r.registry, r.kind, r.username, r.createdAt]),
1682
+ ])
1683
+ : "No registry credentials. Store some: runtime image registry set ghcr.io --username you", all);
1684
+ return 0;
1685
+ }
1686
+ if (action === "set") {
1687
+ const args = parse(more);
1688
+ const registry = need(args.positional[0], "a registry, such as ghcr.io");
1689
+ const accessKeyId = flag(args, "access-key-id");
1690
+ if (accessKeyId === undefined && flag(args, "username") === undefined)
1691
+ throw usage("Give --username (or --access-key-id for Amazon ECR).");
1692
+ if (!out.json && process.stdin.isTTY)
1693
+ out.error(accessKeyId ? "The secret access key:" : "The token or password:");
1694
+ const secret = await read();
1695
+ if (!secret)
1696
+ throw usage("Nothing on standard input.");
1697
+ const saved = await api.registries.set(accessKeyId
1698
+ ? { registry, accessKeyId, secretAccessKey: secret }
1699
+ : { registry, username: flag(args, "username"), password: secret });
1700
+ print(`Stored credentials for ${saved.registry} (${saved.username}).`, saved);
1701
+ return 0;
1702
+ }
1703
+ if (action === "rm" || action === "delete") {
1704
+ const gone = await api.registries.delete(need(more[0], "a registry"));
1705
+ print(gone.deleted ? `Forgot ${gone.registry}.` : `Nothing stored for ${gone.registry}.`, gone);
1706
+ return 0;
1707
+ }
1708
+ throw unknown("registry command", action, ["ls", "set", "rm"]);
1115
1709
  }
1116
- else
1117
- await add(absolute);
1118
1710
  }
1119
- return { dockerfile, files };
1711
+ throw unknown("image command", verb, [
1712
+ "build",
1713
+ "ls",
1714
+ "versions",
1715
+ "get",
1716
+ "logs",
1717
+ "tag",
1718
+ "untag",
1719
+ "rm",
1720
+ "registry",
1721
+ ]);
1120
1722
  }
1121
1723
  const VOLUME_HELP = `runtime volume <command>
1122
1724
 
@@ -1179,7 +1781,15 @@ async function volume(argv, env, out) {
1179
1781
  throw unknown("volume command", verb, ["create", "ls", "get", "rm"]);
1180
1782
  }
1181
1783
  /** Flags `sandbox create` and `run` take without a value. */
1182
- const CREATE_FLAGS = ["trial", "paid", "no-wait", "no-internet"];
1784
+ const CREATE_FLAGS = [
1785
+ "trial",
1786
+ "paid",
1787
+ "no-wait",
1788
+ "no-internet",
1789
+ "no-auto-wake",
1790
+ "persistent",
1791
+ "get-or-create",
1792
+ ];
1183
1793
  /** A create body from the command line's flags. */
1184
1794
  function createInput(args) {
1185
1795
  const rules = ["allow", "deny", "connect"].filter((name) => args.flags.has(name));
@@ -1204,6 +1814,12 @@ function createInput(args) {
1204
1814
  ...(flag(args, "on-timeout")
1205
1815
  ? { onLeaseEnd: flag(args, "on-timeout") }
1206
1816
  : {}),
1817
+ ...(integer(args, "idle-pause") !== undefined
1818
+ ? { idlePauseSeconds: integer(args, "idle-pause") }
1819
+ : {}),
1820
+ ...(has(args, "no-auto-wake") ? { autoWake: false } : {}),
1821
+ ...(has(args, "persistent") ? { persistent: true } : {}),
1822
+ ...(has(args, "get-or-create") ? { getOrCreate: true } : {}),
1207
1823
  ...(has(args, "trial")
1208
1824
  ? { funding: "trial" }
1209
1825
  : has(args, "paid")
@@ -1296,3 +1912,224 @@ if (import.meta.main ||
1296
1912
  process.exitCode = 1;
1297
1913
  });
1298
1914
  }
1915
+ // ------------------------------------------------------------------ observability
1916
+ /** A sparkline of up to 60 points, oldest first. */
1917
+ function spark(values) {
1918
+ const bars = "▁▂▃▄▅▆▇█";
1919
+ const known = values.filter((v) => v !== null);
1920
+ const top = Math.max(1, ...known);
1921
+ const step = Math.max(1, Math.ceil(values.length / 60));
1922
+ const out = [];
1923
+ for (let i = 0; i < values.length; i += step) {
1924
+ const slice = values.slice(i, i + step).filter((v) => v !== null);
1925
+ out.push(slice.length ? bars[Math.min(7, Math.round((Math.max(...slice) / top) * 7))] : " ");
1926
+ }
1927
+ return out.join("");
1928
+ }
1929
+ const mib = (bytes) => `${Math.round(bytes / 1_048_576).toLocaleString("en-US")} MiB`;
1930
+ function metricsText(m) {
1931
+ const cpu = m.points.map((p) => p.cpuPercent);
1932
+ const memory = m.points.map((p) => p.memoryBytes);
1933
+ const peak = Math.max(0, ...m.points.map((p) => p.cpuPeakPercent ?? p.cpuPercent ?? 0));
1934
+ const latest = m.latest;
1935
+ return [
1936
+ table([
1937
+ ["state", m.state],
1938
+ [
1939
+ "cpu now",
1940
+ latest?.cpuPercent == null
1941
+ ? "no reading in the last 15 minutes"
1942
+ : `${latest.cpuPercent}% of ${m.vcpu} vCPU (${latest.cpuCores} cores)`,
1943
+ ],
1944
+ ["memory now", latest ? `${mib(latest.memoryBytes)} of ${mib(m.memoryLimitBytes)}` : "-"],
1945
+ [`cpu, ${m.range}`, m.points.length ? `${spark(cpu)} peak ${peak}%` : "no readings"],
1946
+ [
1947
+ `memory, ${m.range}`,
1948
+ m.points.length
1949
+ ? `${spark(memory)} peak ${mib(Math.max(...m.points.map((p) => p.memoryPeakBytes)))}`
1950
+ : "no readings",
1951
+ ],
1952
+ ]),
1953
+ ].join("\n");
1954
+ }
1955
+ const WEBHOOK_VERBS = [
1956
+ "ls",
1957
+ "create",
1958
+ "get",
1959
+ "update",
1960
+ "test",
1961
+ "deliveries",
1962
+ "retry",
1963
+ "rotate-secret",
1964
+ "rm",
1965
+ ];
1966
+ async function webhooksCommand(argv, env, out) {
1967
+ const [verb = "ls", ...rest] = argv;
1968
+ product = "webhooks";
1969
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
1970
+ if (verb === "help" || verb === "--help") {
1971
+ out.write(HELP.split("\n")
1972
+ .filter((line) => /webhooks|^\s{11}\|/.test(line))
1973
+ .join("\n"));
1974
+ return 0;
1975
+ }
1976
+ const hooks = (await client(env)).webhooks;
1977
+ const args = parse(rest, ["enable", "disable"]);
1978
+ const id = () => need(args.positional[0], "a webhook id");
1979
+ const events = () => flag(args, "events")
1980
+ ?.split(",")
1981
+ .map((e) => e.trim())
1982
+ .filter(Boolean);
1983
+ const row = (h) => [
1984
+ h.id,
1985
+ h.url,
1986
+ h.enabled ? (h.failingSince ? `failing since ${h.failingSince}` : "on") : "off",
1987
+ h.events.join(","),
1988
+ ];
1989
+ switch (verb) {
1990
+ case "ls":
1991
+ case "list": {
1992
+ const page = await hooks.list();
1993
+ print(page.data.length
1994
+ ? table(page.data.map(row))
1995
+ : "No webhooks. Add one: runtime webhooks create <url>", page);
1996
+ return 0;
1997
+ }
1998
+ case "get": {
1999
+ const h = await hooks.get(id());
2000
+ print(table([row(h)]), h);
2001
+ return 0;
2002
+ }
2003
+ case "create":
2004
+ case "add": {
2005
+ const h = await hooks.create({
2006
+ url: need(args.positional[0], "the https URL to send to"),
2007
+ ...(events() ? { events: events() } : {}),
2008
+ ...(flag(args, "description") ? { description: flag(args, "description") } : {}),
2009
+ });
2010
+ print(`${h.id}\nSigning secret (shown once, keep it): ${h.secret}\nVerify deliveries with verifyWebhook from the withruntime SDK.`, h);
2011
+ return 0;
2012
+ }
2013
+ case "update": {
2014
+ const h = await hooks.update(id(), {
2015
+ ...(flag(args, "url") ? { url: flag(args, "url") } : {}),
2016
+ ...(events() ? { events: events() } : {}),
2017
+ ...(has(args, "enable") ? { enabled: true } : {}),
2018
+ ...(has(args, "disable") ? { enabled: false } : {}),
2019
+ });
2020
+ print(table([row(h)]), h);
2021
+ return 0;
2022
+ }
2023
+ case "rotate-secret": {
2024
+ const keep = integer(args, "keep");
2025
+ const h = await hooks.rotateSecret(id(), keep === undefined ? {} : { keepPreviousSeconds: keep });
2026
+ print(`New signing secret (shown once): ${h.secret}${h.previousSecretExpiresAt ? `\nThe old one also signs until ${h.previousSecretExpiresAt}.` : ""}`, h);
2027
+ return 0;
2028
+ }
2029
+ case "test": {
2030
+ const d = await hooks.test(id());
2031
+ print(d.state === "succeeded"
2032
+ ? `Delivered: your endpoint answered ${d.lastStatus} in ${d.lastDurationMs} ms.`
2033
+ : d.state === "pending"
2034
+ ? "Sent; no answer yet. See it with: runtime webhooks deliveries " + id()
2035
+ : `Not delivered: ${d.lastError ?? d.state}${d.lastStatus ? ` (HTTP ${d.lastStatus})` : ""}.`, d);
2036
+ return d.state === "failed" ? 1 : 0;
2037
+ }
2038
+ case "deliveries": {
2039
+ const page = await hooks.deliveries(id(), {
2040
+ ...(integer(args, "limit") ? { limit: integer(args, "limit") } : {}),
2041
+ });
2042
+ print(page.data.length
2043
+ ? table(page.data.map((d) => [
2044
+ d.id,
2045
+ d.createdAt,
2046
+ d.eventType,
2047
+ d.state,
2048
+ `${d.attempts}/${d.maxAttempts}`,
2049
+ d.lastStatus ? String(d.lastStatus) : (d.lastError ?? "-"),
2050
+ ]))
2051
+ : "No deliveries yet.", page);
2052
+ return 0;
2053
+ }
2054
+ case "retry": {
2055
+ const d = await hooks.retry(need(args.positional[0], "a delivery id"));
2056
+ print(`Queued ${d.id} to send again.`, d);
2057
+ return 0;
2058
+ }
2059
+ case "rm":
2060
+ case "delete": {
2061
+ print("Deleted.", await hooks.delete(id()));
2062
+ return 0;
2063
+ }
2064
+ }
2065
+ throw unknown("webhooks command", verb, WEBHOOK_VERBS);
2066
+ }
2067
+ const OTEL_VERBS = ["ls", "create", "get", "update", "flush", "rm"];
2068
+ async function otelCommand(argv, env, out) {
2069
+ const [verb = "ls", ...rest] = argv;
2070
+ product = "otel";
2071
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
2072
+ const otel = (await client(env)).otel;
2073
+ const args = parse(rest, ["enable", "disable"]);
2074
+ const id = () => need(args.positional[0], "an export id");
2075
+ const signals = () => flag(args, "signals")
2076
+ ?.split(",")
2077
+ .map((s) => s.trim());
2078
+ const row = (x) => [
2079
+ x.id,
2080
+ x.endpoint,
2081
+ x.enabled ? "on" : "off",
2082
+ x.signals.join(","),
2083
+ x.lastError
2084
+ ? `last error: ${x.lastError}`
2085
+ : x.lastSuccessAt
2086
+ ? `sent ${x.lastSuccessAt}`
2087
+ : "not sent yet",
2088
+ ];
2089
+ switch (verb) {
2090
+ case "ls":
2091
+ case "list": {
2092
+ const page = await otel.list();
2093
+ print(page.data.length
2094
+ ? table(page.data.map(row))
2095
+ : "No exports. Add one: runtime otel create <endpoint>", page);
2096
+ return 0;
2097
+ }
2098
+ case "get": {
2099
+ const x = await otel.get(id());
2100
+ print(table([row(x)]), x);
2101
+ return 0;
2102
+ }
2103
+ case "create":
2104
+ case "add": {
2105
+ const x = await otel.create({
2106
+ endpoint: need(args.positional[0], "the OTLP/HTTP endpoint"),
2107
+ ...(args.flags.has("header") ? { headers: pairs(args, "header") } : {}),
2108
+ ...(signals() ? { signals: signals() } : {}),
2109
+ });
2110
+ print(`${x.id}\nPushing ${x.signals.join(" and ")} to ${x.endpoint} about every 30 seconds.`, x);
2111
+ return 0;
2112
+ }
2113
+ case "update": {
2114
+ const x = await otel.update(id(), {
2115
+ ...(flag(args, "endpoint") ? { endpoint: flag(args, "endpoint") } : {}),
2116
+ ...(args.flags.has("header") ? { headers: pairs(args, "header") } : {}),
2117
+ ...(signals() ? { signals: signals() } : {}),
2118
+ ...(has(args, "enable") ? { enabled: true } : {}),
2119
+ ...(has(args, "disable") ? { enabled: false } : {}),
2120
+ });
2121
+ print(table([row(x)]), x);
2122
+ return 0;
2123
+ }
2124
+ case "flush": {
2125
+ print("Pushing now; `runtime otel get <id>` shows the answer in a few seconds.", await otel.flush(id()));
2126
+ return 0;
2127
+ }
2128
+ case "rm":
2129
+ case "delete": {
2130
+ print("Deleted.", await otel.delete(id()));
2131
+ return 0;
2132
+ }
2133
+ }
2134
+ throw unknown("otel command", verb, OTEL_VERBS);
2135
+ }