withruntime 0.21.0 → 0.33.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 (79) hide show
  1. package/COMPATIBILITY.md +64 -3
  2. package/E2B.md +1 -1
  3. package/README.md +36 -2
  4. package/README.src.md +35 -1
  5. package/compatibility-lock.json +3 -3
  6. package/dist/agents/browser.d.ts +12 -0
  7. package/dist/agents/browser.js +12 -0
  8. package/dist/agents/index.d.ts +7 -7
  9. package/dist/agents/index.js +7 -8
  10. package/dist/agents-cli.d.ts +1 -1
  11. package/dist/agents-cli.js +250 -3
  12. package/dist/agents-deploy.d.ts +186 -0
  13. package/dist/agents-deploy.js +752 -0
  14. package/dist/agents-file.d.ts +19 -0
  15. package/dist/agents-file.js +135 -0
  16. package/dist/apps/index.d.ts +10 -0
  17. package/dist/apps/index.js +11 -0
  18. package/dist/apps-cli.d.ts +3 -1
  19. package/dist/apps-cli.js +285 -38
  20. package/dist/blaxel/errors.js +2 -1
  21. package/dist/ci/index.d.ts +10 -0
  22. package/dist/ci/index.js +11 -0
  23. package/dist/ci-cli.d.ts +10 -0
  24. package/dist/ci-cli.js +343 -0
  25. package/dist/cli.d.ts +18 -7
  26. package/dist/cli.js +862 -99
  27. package/dist/daytona/errors.js +2 -1
  28. package/dist/e2b/errors.js +2 -1
  29. package/dist/e2b/filesystem.d.ts +1 -0
  30. package/dist/e2b/filesystem.js +1 -1
  31. package/dist/e2b/sandbox.d.ts +11 -2
  32. package/dist/e2b/sandbox.js +22 -6
  33. package/dist/errors.d.ts +11 -4
  34. package/dist/errors.js +27 -4
  35. package/dist/fly/index.d.ts +102 -0
  36. package/dist/fly/index.js +930 -0
  37. package/dist/index.d.ts +7 -3
  38. package/dist/keys.d.ts +6 -0
  39. package/dist/keys.js +10 -1
  40. package/dist/machine.d.ts +10 -6
  41. package/dist/machine.js +14 -6
  42. package/dist/modal/index.d.ts +34 -0
  43. package/dist/modal/index.js +128 -22
  44. package/dist/product-names.js +7 -0
  45. package/dist/products/agents.d.ts +101 -3
  46. package/dist/products/agents.js +49 -0
  47. package/dist/products/apps.d.ts +206 -10
  48. package/dist/products/apps.js +172 -1
  49. package/dist/products/ci.d.ts +332 -0
  50. package/dist/products/ci.js +101 -0
  51. package/dist/products/index.d.ts +8 -0
  52. package/dist/products/index.js +15 -0
  53. package/dist/products/jobs.d.ts +105 -5
  54. package/dist/products/jobs.js +47 -3
  55. package/dist/products/network.d.ts +13 -0
  56. package/dist/products/observability.d.ts +1 -1
  57. package/dist/products/plans.d.ts +131 -0
  58. package/dist/products/plans.js +24 -0
  59. package/dist/products/reservations.d.ts +108 -0
  60. package/dist/products/reservations.js +41 -0
  61. package/dist/products/resources.d.ts +2 -1
  62. package/dist/products/secrets.d.ts +109 -6
  63. package/dist/products/secrets.js +38 -2
  64. package/dist/proxy.d.ts +3 -3
  65. package/dist/proxy.js +20 -0
  66. package/dist/sandbox.d.ts +29 -1
  67. package/dist/sandbox.js +56 -3
  68. package/dist/snapshots.d.ts +1 -1
  69. package/dist/switched-off.d.ts +5 -0
  70. package/dist/switched-off.js +8 -0
  71. package/dist/tar.d.ts +1 -1
  72. package/dist/tar.js +19 -1
  73. package/dist/transport.d.ts +1 -1
  74. package/dist/transport.js +48 -9
  75. package/dist/types.d.ts +35 -4
  76. package/dist/vercel/errors.d.ts +1 -1
  77. package/dist/vercel/errors.js +2 -1
  78. package/dist/vercel/snapshot.js +4 -2
  79. package/package.json +23 -6
package/dist/cli.js CHANGED
@@ -47,27 +47,47 @@ Account
47
47
  usage export --since <UTC time> --until <UTC time>
48
48
  Every settlement in the range as CSV; --json, each page
49
49
  limits Whether this key is read-only, and its daily spending limit
50
+ reservations book --sandboxes <n> --from <time> --to <time> [--vcpu <n>] [--memory <MiB>]
51
+ [--disk <MiB>] [--region <r>] Hold room for a burst: confirmed or declined at once
52
+ reservations ls | get <id> | cancel <id> Your bookings; cancel gives the room back
50
53
  secrets set <NAME> --host <host>... [--header <name> [--format '<text {value}>']]
51
54
  [--allow '[GET,HEAD] /path/*']... Store a secret sandboxes use without seeing it; the
52
55
  [--only-label key=value[,k=v]]... value is read from standard input, never an argument.
53
56
  [--only-sandbox <id>]... --allow (paid): only these methods and paths get it.
54
- [--all-sandboxes] --only-label: only sandboxes with those labels (each
55
- flag one selector, any may match); --only-sandbox:
56
- only that sandbox. A new value keeps the scope;
57
- --all-sandboxes gives it to every sandbox again
57
+ [--only-app <app id>]... --only-label: only sandboxes with those labels (each
58
+ [--all-sandboxes] [--env <VAR>] flag one selector, any may match); --only-sandbox:
59
+ only that sandbox; --only-app: every copy of that App,
60
+ any version. A new value keeps the scope;
61
+ --all-sandboxes gives it to every sandbox again.
62
+ --env: the variable sandboxes see it in, if not NAME;
63
+ secrets scoped to different sandboxes may share one
64
+ secrets set <NAME> --keep-value [--host <host>]... [--allow '[GET] /path']...
65
+ Narrow where it goes without its value: a subset of
66
+ its hosts, only requests its rules allow now.
67
+ Anything wider needs the value
58
68
  secrets set <NAME> --jobs [--host ...] A copy scheduled jobs put in their environment
59
69
  (job create --secret NAME); with --host, both
60
- secrets scope <NAME> --only-label k=v... | --only-sandbox <id>... | --all-sandboxes
70
+ secrets scope <NAME> --only-label k=v... | --only-sandbox <id>... | --only-app <id>... | --all-sandboxes
61
71
  Change which sandboxes get it; no value needed
62
72
  secrets ls | get <NAME> Names, hosts, placeholders and jobs versions, never values
63
73
  secrets rotate <NAME> A new version of the jobs copy, from standard input
64
74
  secrets reveal <NAME> [--version N] Print the jobs copy's value. The sandboxes' copy is
65
75
  never readable, by anyone
76
+ secrets share <NAME> --agent <id>... | --all | --none
77
+ Which of your keys may use the jobs copy besides the one
78
+ that stored it (ids: from 'me'); --all: every key
66
79
  secrets rm <NAME> Delete a secret, every copy
67
80
  keys create [--name <name>] [--read-only] [--daily-limit <usd>] [--account-wide]
68
81
  A new API key for CI, approved by an owner in the browser
69
82
  referrals Your referral link: you both get up to $1,000, then
70
83
  you earn 10% of what they spend for 12 months
84
+ plans The plans you can choose and yours: a monthly fee
85
+ that comes back as usage, spent first (beta)
86
+ plans choose <plan> [--yearly] | cancel | resume
87
+ Start or change a plan (a first one opens a payment
88
+ page), end it at the period's end, or keep it (beta)
89
+ plans add-on <id> | remove-add-on <id> Add an add-on to the plan, or stop one (beta)
90
+ plans auto-upgrade on|off Move up a plan by itself when a limit refuses (beta)
71
91
  compare --from <provider> [--days 30] What your usage would cost at e2b, daytona, vercel,
72
92
  modal, cloudflare, fly, blaxel, lambda-microvms,
73
93
  freestyle, prime and others, and what you save
@@ -85,6 +105,7 @@ Account
85
105
  support deny <actionId> --conversation <id>
86
106
  docs [topic] Print a docs page (start, cli, api, python...)
87
107
  mcp Serve MCP on stdio for agents that need it
108
+ fly-api [--port 4280] Answer Fly Machines API calls here, with Runtime sandboxes
88
109
 
89
110
  Connect
90
111
  sandboxes ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandboxes ssh help\` for editors
@@ -111,7 +132,9 @@ Products
111
132
  volumes Persistent disks to attach to sandboxes
112
133
  jobs A command in a fresh sandbox, once or on a cron schedule, with its logs
113
134
  apps Your web server from an image or a build, copies started by requests (beta)
135
+ functions Your HTTP handler from an image or a build, asleep until calls come (beta)
114
136
  builds A connected repository's commits built into what an App deploys (beta)
137
+ ci CI/CD: each push and pull request of a repository checked in sandboxes (beta)
115
138
  containers One OCI image's process on its own microVM, at the sandbox rates (beta)
116
139
  agents Claude Code, Codex, Gemini CLI or your own agent in a sandbox per session (beta)
117
140
  domains Your own hostname for a sandbox's port, with HTTPS
@@ -144,7 +167,8 @@ const SANDBOX_HELP = `runtime sandboxes <command>
144
167
  Create one; prints its id
145
168
  ls [--all] [--label k=v]... [--state running] List (live ones unless --all)
146
169
  get <id> One sandbox, in full
147
- exec <id> [--cwd /workspace] [--env K[=V]]... [--timeout <s>] [--root] -- <command...>
170
+ exec <id> [--cwd /workspace] [--env K[=V]]... [--timeout <s>] [--root]
171
+ [--trace-files <file.json>] -- <command...>
148
172
  Run and stream output; exits with its code.
149
173
  --root runs it as root: no sudo needed
150
174
  spawn <id> [--pty] [--root] -- <command...> Start in the background; prints a process id
@@ -171,6 +195,7 @@ const SANDBOX_HELP = `runtime sandboxes <command>
171
195
  Change its size by a restart: the whole disk stays and
172
196
  --disk only grows it; programs stop (snapshot it first
173
197
  to keep memory)
198
+ resize <id> --disk 20480 Grow a running sandbox's disk live, programs running
174
199
  extend <id> <seconds> Move a time limit on (one with none needs nothing)
175
200
  update <id> [--name n] [--label k=v]... [--env K[=V]]... [--unset-env K]... [--idle-pause <seconds>] [--auto-wake on|off]
176
201
  [--persistent on|off] [--max-total-cost <usd>|none]
@@ -202,6 +227,11 @@ const SANDBOX_HELP = `runtime sandboxes <command>
202
227
  preview rotate <id> <port> Refuse every token given out for a private port so
203
228
  far; prints the new token
204
229
  previews <id> Every shared port
230
+ link <id> <path> [--put] [--expires 1h] [--filename n] [--max-bytes n] [--archive] [--no-wake]
231
+ A link anyone can open with no key: GET downloads the
232
+ file (--archive: a folder as .tar.gz), --put uploads to it;
233
+ --expires 60s to 7d, 1h by default
234
+ link revoke <id> End every link made for the sandbox so far
205
235
  unshare <id> <port> Stop sharing a port
206
236
  session create <id> [--origin <url>]... [--ttl <s>] [--name <n>]
207
237
  A token a web page uses for this sandbox's commands,
@@ -296,7 +326,7 @@ commandAfter) {
296
326
  return { positional, flags, rest };
297
327
  }
298
328
  /** Options given once per item, which a plural takes as a comma list. */
299
- const LISTS = new Set(["host", "allow", "deny", "connect"]);
329
+ const LISTS = new Set(["host", "allow", "deny", "connect", "snapshot"]);
300
330
  /** The declared option a mistyped one most likely meant. --memory-mib for
301
331
  * --memory, --timeout-seconds for --timeout: the SDK's spelling of an option is
302
332
  * the CLI's with its unit added. */
@@ -367,9 +397,19 @@ const kit = {
367
397
  table: (rows) => table(rows),
368
398
  usage: (message) => usage(message),
369
399
  unknown: (what, word, choices) => unknown(what, word, choices),
400
+ /** `sandboxes exec`'s run and output, for any machine that execs. */
401
+ execute: (machine, command, args, out, env) => execute(machine, command, args, out, integer(args, "timeout"), env),
402
+ copyFile: (from, to, use, side) => copyFile(from, to, use, side),
370
403
  };
371
404
  /** The product being run, so a mistake points at its own help. */
372
405
  let product = "";
406
+ /** `90`, `90s`, `30m`, `1h` or `7d`, in seconds. */
407
+ function durationSeconds(text) {
408
+ const found = /^(\d{1,7})([smhd]?)$/.exec(text.trim());
409
+ if (!found)
410
+ throw usage(`--expires takes a number of seconds or 30m, 1h, 7d; not ${text}.`);
411
+ return Number(found[1]) * { "": 1, s: 1, m: 60, h: 3600, d: 86_400 }[found[2]];
412
+ }
373
413
  function usage(message) {
374
414
  return new RuntimeError({
375
415
  message,
@@ -415,7 +455,9 @@ const COMMANDS = [
415
455
  "sso",
416
456
  "secrets",
417
457
  "keys",
458
+ "reservations",
418
459
  "referrals",
460
+ "plans",
419
461
  "compare",
420
462
  "switch",
421
463
  "ls",
@@ -423,13 +465,16 @@ const COMMANDS = [
423
465
  "support",
424
466
  "docs",
425
467
  "mcp",
468
+ "fly-api",
426
469
  "sandboxes",
427
470
  "snapshots",
428
471
  "images",
429
472
  "volumes",
430
473
  "jobs",
431
474
  "apps",
475
+ "functions",
432
476
  "builds",
477
+ "ci",
433
478
  "containers",
434
479
  "agents",
435
480
  "domains",
@@ -451,10 +496,13 @@ const OWN_HELP = [
451
496
  "volumes",
452
497
  "jobs",
453
498
  "apps",
499
+ "functions",
454
500
  "builds",
501
+ "ci",
455
502
  "containers",
456
503
  "agents",
457
504
  "keys",
505
+ "reservations",
458
506
  "webhooks",
459
507
  "webhook",
460
508
  "billing",
@@ -501,6 +549,7 @@ const SANDBOX_VERBS = [
501
549
  "run-code",
502
550
  "preview",
503
551
  "previews",
552
+ "link",
504
553
  "unshare",
505
554
  "session",
506
555
  "network",
@@ -624,6 +673,22 @@ function describe(info) {
624
673
  ["charged", dollars(BigInt(Math.round(Number(info.chargedMicros) || 0)))],
625
674
  ]);
626
675
  }
676
+ /** `cp` between this machine and a sandbox or an App's copy: exactly one
677
+ * side is `<id>:/path`. A destination ending in / (or a local directory)
678
+ * takes the source's own name inside it, as cp does. */
679
+ async function copyFile(from, to, use, side) {
680
+ const source = remote(from);
681
+ const target = remote(to);
682
+ const inside = (dir, path) => `${dir.replace(/\/+$/, "")}/${path.replace(/\/+$/, "").split("/").pop()}`;
683
+ if (source && !target) {
684
+ const local = await import("node:fs/promises").then((fs) => fs.stat(to).catch(() => null));
685
+ await (await use(source.id)).files.download(source.path, to.endsWith("/") || local?.isDirectory() ? inside(to, source.path) : to);
686
+ }
687
+ else if (!source && target)
688
+ await (await use(target.id)).files.upload(from, target.path.endsWith("/") ? inside(target.path, from) : target.path);
689
+ else
690
+ throw usage(`Name exactly one side as ${side}:/path.`);
691
+ }
627
692
  /** Resolves `<id>:/path` into its parts, or undefined for a local path. */
628
693
  function remote(spec) {
629
694
  const match = /^([A-Za-z0-9][A-Za-z0-9_.-]{0,127}):(\/.*)$/.exec(spec);
@@ -694,7 +759,9 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
694
759
  return 0;
695
760
  }
696
761
  if (command === "run") {
697
- const args = parse(rest, [...CREATE_FLAGS, "keep", "root"], [...CREATE_VALUES, "cwd", "env"], `${me} sandboxes run -- python3 < invoice.py`);
762
+ const args = parse(rest, [...CREATE_FLAGS, "keep", "root"],
763
+ // execute() reads --trace-files for run as for exec.
764
+ [...CREATE_VALUES, "cwd", "env", "trace-files"], `${me} sandboxes run -- python3 < invoice.py`);
698
765
  const commandLine = args.rest ?? args.positional;
699
766
  if (!commandLine.length)
700
767
  throw usage(`Give the command after --: ${me} sandboxes run -- python3 < invoice.py`);
@@ -707,8 +774,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
707
774
  // SIGINT and normal completion share one delete, with an absolute
708
775
  // deadline across its retries, so cleanup cannot hang. A delete, not a
709
776
  // stop: a stopped sandbox keeps its disk, billed, and an account without
710
- // credit keeps at most 10, so ten runs that only stopped refused the
711
- // eleventh (no_credit_kept_limit, 0685). --keep is how to keep one.
777
+ // credit keeps at most 5, so five runs that only stopped refused the
778
+ // sixth (no_credit_kept_limit). --keep is how to keep one.
712
779
  const stop = () => (cleanup ??= (async () => {
713
780
  const signal = AbortSignal.timeout(30_000);
714
781
  try {
@@ -904,6 +971,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
904
971
  }
905
972
  if (command === "keys")
906
973
  return keys(rest, env, out);
974
+ if (command === "reservations")
975
+ return reservationsCommand(rest, env, out);
907
976
  if (command === "sso") {
908
977
  const status = await (await client(env)).sso.get();
909
978
  print([
@@ -1013,6 +1082,128 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1013
1082
  ].join("\n"), r);
1014
1083
  return 0;
1015
1084
  }
1085
+ if (command === "plans") {
1086
+ product = "plans";
1087
+ const args = parse(rest, ["yearly"], []);
1088
+ const [verb, id, extra] = args.positional;
1089
+ const runtime = await client(env);
1090
+ const usd = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
1091
+ const day = (iso) => (iso ? iso.slice(0, 10) : "-");
1092
+ const yearly = args.flags.get("yearly")?.at(-1) === "true";
1093
+ if (yearly && verb !== "choose")
1094
+ throw usage("--yearly goes with plans choose <plan>.");
1095
+ const now = (current) => !current
1096
+ ? "No plan: you pay for what runs from your credit."
1097
+ : [
1098
+ `Your plan: ${current.name} (${current.planId}), ${current.status.replace("_", " ")}, ${current.interval === "year" ? "yearly" : "monthly"}.`,
1099
+ current.status === "trialing" && current.trialEndsAt
1100
+ ? `The trial ends on ${day(current.trialEndsAt)}.`
1101
+ : undefined,
1102
+ current.cancelAtPeriodEnd
1103
+ ? `It ends on ${day(current.periodEnd)}; \`${me} plans resume\` keeps it.`
1104
+ : current.nextPlanId
1105
+ ? `It moves to ${current.nextPlanId} on ${day(current.periodEnd)}.`
1106
+ : `Next fee on ${day(current.periodEnd)}.`,
1107
+ current.planUsage
1108
+ ? `Plan usage left: ${usd(current.planUsage.leftMicros)} of ${usd(current.planUsage.grantedMicros)}, until ${day(current.planUsage.expiresAt)}.`
1109
+ : undefined,
1110
+ current.addOns.length
1111
+ ? `Add-ons: ${current.addOns
1112
+ .map((a) => `${a.id}${a.quantity > 1 ? ` x${a.quantity}` : ""}${a.endsAt ? ` (ends ${day(a.endsAt)})` : ""}`)
1113
+ .join(", ")}.`
1114
+ : undefined,
1115
+ current.autoUpgrade
1116
+ ? `Auto-upgrade: on; a create a limit refuses moves you up a plan. \`${me} plans auto-upgrade off\` stops it.`
1117
+ : "Auto-upgrade: off.",
1118
+ ]
1119
+ .filter((line) => line !== undefined)
1120
+ .join("\n");
1121
+ if (verb === undefined || verb === "ls") {
1122
+ if (id !== undefined)
1123
+ throw usage(`Unexpected ${id}.`);
1124
+ const r = await runtime.plans.list();
1125
+ const amount = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
1126
+ print([
1127
+ now(r.current),
1128
+ "",
1129
+ r.plans.length
1130
+ ? table([
1131
+ ["plan", "name", "fee a month", "a year", "bonus", "trial", "support"],
1132
+ ...r.plans.map((p) => [
1133
+ p.id,
1134
+ p.name,
1135
+ p.collection === "invoice" ? "invoiced" : usd(p.feeMicros),
1136
+ p.yearlyFeeMicros === null ? "-" : usd(p.yearlyFeeMicros),
1137
+ BigInt(p.bonusMicros) > 0n ? usd(p.bonusMicros) : "-",
1138
+ p.trialDays > 0 ? `${p.trialDays} days` : "-",
1139
+ p.support,
1140
+ ]),
1141
+ ])
1142
+ : "No plans are offered yet.",
1143
+ ...r.plans
1144
+ .filter((p) => p.included.length || p.limits.length || p.addOns.length)
1145
+ .flatMap((p) => [
1146
+ "",
1147
+ `${p.id}:`,
1148
+ ...(p.included.length
1149
+ ? [
1150
+ ` includes ${p.included.map((i) => `${amount(i.quantity)} ${i.unit} of ${i.product} ${i.meter}`).join(", ")} a month`,
1151
+ ]
1152
+ : []),
1153
+ ...(p.limits.length
1154
+ ? [
1155
+ ` limits ${p.limits.map((l) => `${l.limit} ${l.max === null ? "unlimited" : l.max}`).join(", ")}`,
1156
+ ]
1157
+ : []),
1158
+ ...(p.addOns.length
1159
+ ? [
1160
+ ` add-ons ${p.addOns.map((a) => `${a.id} (${usd(a.feeMicros)} a month)`).join(", ")}`,
1161
+ ]
1162
+ : []),
1163
+ ]),
1164
+ "",
1165
+ "A plan's fee comes back as plan usage, spent first each month at the same measured prices;",
1166
+ `usage left at the period's end ends with it. Choose one: ${me} plans choose <plan> [--yearly].`,
1167
+ ].join("\n"), r);
1168
+ return 0;
1169
+ }
1170
+ if (verb === "choose" || verb === "cancel" || verb === "resume") {
1171
+ if (verb === "choose" && !id)
1172
+ throw usage(`Say which plan: ${me} plans choose <plan>.`);
1173
+ if ((verb === "choose" ? extra : id) !== undefined)
1174
+ throw usage(`Unexpected ${verb === "choose" ? extra : id}.`);
1175
+ const r = verb === "choose"
1176
+ ? await runtime.plans.choose(id, yearly ? { interval: "year" } : {})
1177
+ : verb === "cancel"
1178
+ ? await runtime.plans.cancel()
1179
+ : await runtime.plans.resume();
1180
+ print(r.checkoutUrl
1181
+ ? `A person chooses it and pays the first month here, then the plan starts:\n${r.checkoutUrl}`
1182
+ : now(r.current), r);
1183
+ return 0;
1184
+ }
1185
+ if (verb === "add-on" || verb === "remove-add-on") {
1186
+ if (!id)
1187
+ throw usage(`Say which add-on: ${me} plans ${verb} <id>.`);
1188
+ if (extra !== undefined)
1189
+ throw usage(`Unexpected ${extra}.`);
1190
+ const r = verb === "add-on" ? await runtime.plans.addOn(id) : await runtime.plans.removeAddOn(id);
1191
+ print(r.checkoutUrl
1192
+ ? `A person confirms it here, then the add-on starts:\n${r.checkoutUrl}`
1193
+ : now(r.current), r);
1194
+ return 0;
1195
+ }
1196
+ if (verb === "auto-upgrade") {
1197
+ if (id !== "on" && id !== "off")
1198
+ throw usage(`Say on or off: ${me} plans auto-upgrade on|off.`);
1199
+ if (extra !== undefined)
1200
+ throw usage(`Unexpected ${extra}.`);
1201
+ const r = await runtime.plans.autoUpgrade(id === "on");
1202
+ print(now(r.current), r);
1203
+ return 0;
1204
+ }
1205
+ throw usage(`Unknown plans command ${verb}: ls, choose <plan> [--yearly], cancel, resume, add-on <id>, remove-add-on <id> or auto-upgrade on|off.`);
1206
+ }
1016
1207
  if (command === "compare" || command === "switch")
1017
1208
  return switching(command, rest, env, out);
1018
1209
  if (command === "mcp") {
@@ -1024,6 +1215,22 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1024
1215
  await new Promise((resolve) => process.stdout.write("", () => resolve()));
1025
1216
  process.exit(0);
1026
1217
  }
1218
+ if (command === "fly-api") {
1219
+ const args = parse(rest, [], ["port", "host"]);
1220
+ const { serveMachinesApi } = await import("./fly/index.js");
1221
+ const served = await serveMachinesApi({
1222
+ client: await client(env),
1223
+ port: args.flags.has("port") ? portOf(flag(args, "port"), "4280") : 4280,
1224
+ hostname: flag(args, "host") ?? "127.0.0.1",
1225
+ });
1226
+ out.write(`Fly Machines API on ${served.url}: point FLY_API_HOSTNAME (fly-admin's apiUrl, fly-go's FLY_FLAPS_BASE_URL) here.\nEach request runs as this login, or as the Runtime key (rtcloud_...) in its bearer token. Ctrl-C stops it.\n`);
1227
+ await new Promise((resolve) => {
1228
+ process.once("SIGINT", resolve);
1229
+ process.once("SIGTERM", resolve);
1230
+ });
1231
+ await served.close();
1232
+ return 0;
1233
+ }
1027
1234
  if (command === "docs") {
1028
1235
  const topic = rest[0] ?? "index";
1029
1236
  const response = await envFetch(`https://withruntime.com/docs/${topic === "index" ? "start" : encodeURIComponent(topic)}.md`, { redirect: "error" });
@@ -1128,10 +1335,18 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1128
1335
  product = "apps";
1129
1336
  return (await import("./apps-cli.js")).appsCommand(rest, env, out, () => client(env), kit);
1130
1337
  }
1338
+ if (command === "function" || command === "functions") {
1339
+ product = "functions";
1340
+ return (await import("./apps-cli.js")).functionsCommand(rest, env, out, () => client(env), kit);
1341
+ }
1131
1342
  if (command === "build" || command === "builds") {
1132
1343
  product = "builds";
1133
1344
  return (await import("./apps-cli.js")).buildsCommand(rest, out, () => client(env), kit);
1134
1345
  }
1346
+ if (command === "ci") {
1347
+ product = "ci";
1348
+ return (await import("./ci-cli.js")).ciCommand(rest, out, () => client(env), kit);
1349
+ }
1135
1350
  if (command === "container" || command === "containers") {
1136
1351
  product = "containers";
1137
1352
  return (await import("./apps-cli.js")).containersCommand(rest, env, out, () => client(env), kit);
@@ -1466,14 +1681,29 @@ const DROP_IN = {
1466
1681
  js: 'import { SandboxInstance } from "withruntime/blaxel"; // was: from "@blaxel/core"',
1467
1682
  py: "from withruntime.blaxel import SandboxInstance # was: from blaxel.core import SandboxInstance",
1468
1683
  },
1684
+ modal: {
1685
+ js: 'import { ModalClient } from "withruntime/modal"; // was: from "modal"',
1686
+ py: "from withruntime import modal # was: import modal",
1687
+ },
1469
1688
  // A Worker-only SDK: no Python. More than the import changes, so say what.
1470
1689
  cloudflare: {
1471
1690
  js: 'import { getSandbox } from "withruntime/cloudflare"; // was: from "@cloudflare/sandbox"',
1472
1691
  also: "Change the `export { Sandbox }` line the same way, delete the `containers` block from the Wrangler config and run `npx wrangler secret put RUNTIME_API_KEY`.",
1473
1692
  },
1474
1693
  };
1475
- /** How to move a rival's code across, when one import does it. */
1476
- export function dropInLines(provider) {
1694
+ /** Fly's Machines API moves by its address, not an import: the lines of the
1695
+ * Fly guide's "From Machines". */
1696
+ const FLY_MACHINES = [
1697
+ "Your Fly Machines code runs on Runtime with one address changed:",
1698
+ " npx withruntime fly-api # answers on http://127.0.0.1:4280 as your Runtime login",
1699
+ " export FLY_API_HOSTNAME=http://127.0.0.1:4280 # Fly's curl examples",
1700
+ " export FLY_FLAPS_BASE_URL=http://127.0.0.1:4280 # fly-go, Fly's Go client",
1701
+ ];
1702
+ /** How to move a rival's code across, when one import (or, for Fly's
1703
+ * Machines, one address) does it. `from` is the name the caller gave. */
1704
+ export function dropInLines(provider, from) {
1705
+ if (from === "fly-machines")
1706
+ return FLY_MACHINES;
1477
1707
  const drop = provider && DROP_IN[provider];
1478
1708
  if (!drop)
1479
1709
  return [];
@@ -1593,13 +1823,13 @@ async function switching(command, argv, env, out) {
1593
1823
  "A switch is recorded before the first top-up, and this organization has made one already.", summary);
1594
1824
  return 0;
1595
1825
  }
1596
- const provider = SWITCH_FROM[from];
1597
- if (!provider || from === "fly-machines")
1598
- throw unknown("provider", from, Object.keys(RIVAL_NAMES));
1826
+ const provider = Object.hasOwn(SWITCH_FROM, from) ? SWITCH_FROM[from] : undefined;
1827
+ if (!provider)
1828
+ throw unknown("provider", from, Object.keys(SWITCH_FROM));
1599
1829
  const summary = await api.record({ provider });
1600
1830
  print([
1601
1831
  `Recorded: switching from ${RIVAL_NAMES[provider]}. ${welcomeLine(summary) ?? ""}`.trim(),
1602
- ...dropInLines(provider),
1832
+ ...dropInLines(provider, from),
1603
1833
  ].join("\n"), summary);
1604
1834
  return 0;
1605
1835
  }
@@ -1646,7 +1876,7 @@ async function switching(command, argv, env, out) {
1646
1876
  const line = switchingLine(c.switching, rival);
1647
1877
  if (line)
1648
1878
  lines.push("", line);
1649
- const drop = dropInLines(rival);
1879
+ const drop = dropInLines(rival, from);
1650
1880
  if (drop.length)
1651
1881
  lines.push("", ...drop);
1652
1882
  print(lines.join("\n"), c);
@@ -1811,9 +2041,11 @@ async function sandbox(argv, env, out) {
1811
2041
  };
1812
2042
  if (size.vcpu === undefined && size.memoryMiB === undefined && size.diskMiB === undefined)
1813
2043
  throw usage(`Name a new size, e.g. ${me} sandboxes resize ${sbx.id} --memory 4096 --restart`);
1814
- if (!args.flags.has("restart"))
2044
+ // A disk alone grows live, or is refused saying it needs --restart.
2045
+ const live = size.vcpu === undefined && size.memoryMiB === undefined;
2046
+ if (!live && !args.flags.has("restart"))
1815
2047
  throw usage(`A resize restarts it: its whole disk stays and its programs stop. Add --restart to say so, or snapshot it first (${me} sandboxes snapshot ${sbx.id}) to keep its memory too.`);
1816
- await sbx.resize(size, { restart: true });
2048
+ await sbx.resize(size, { restart: args.flags.has("restart") });
1817
2049
  print(describe(sbx.info), sbx.info);
1818
2050
  return 0;
1819
2051
  }
@@ -2007,7 +2239,7 @@ async function sandbox(argv, env, out) {
2007
2239
  return 0;
2008
2240
  }
2009
2241
  case "exec": {
2010
- const args = parse(rest, ["root"], ["cwd", "env", "timeout"], `${me} sandboxes exec <id> -- ls -la`);
2242
+ const args = parse(rest, ["root"], ["cwd", "env", "timeout", "trace-files"], `${me} sandboxes exec <id> -- ls -la`);
2011
2243
  const command = args.rest ?? args.positional.slice(1);
2012
2244
  if (!command.length)
2013
2245
  throw usage("Give the command after --: runtime sandboxes exec <id> -- ls -la");
@@ -2133,19 +2365,7 @@ async function sandbox(argv, env, out) {
2133
2365
  }
2134
2366
  case "cp": {
2135
2367
  const [from, to] = parse(rest, ["r", "recursive"], []).positional;
2136
- const source = remote(need(from, "a source"));
2137
- const target = remote(need(to, "a destination"));
2138
- // A destination ending in / (or a local directory) takes the source's
2139
- // own name inside it, as cp does.
2140
- const inside = (dir, path) => `${dir.replace(/\/+$/, "")}/${path.replace(/\/+$/, "").split("/").pop()}`;
2141
- if (source && !target) {
2142
- const local = await import("node:fs/promises").then((fs) => fs.stat(to).catch(() => null));
2143
- await (await use(source.id)).files.download(source.path, to.endsWith("/") || local?.isDirectory() ? inside(to, source.path) : to);
2144
- }
2145
- else if (!source && target)
2146
- await (await use(target.id)).files.upload(from, target.path.endsWith("/") ? inside(target.path, from) : target.path);
2147
- else
2148
- throw usage("Name exactly one side as <sandbox-id>:/path.");
2368
+ await copyFile(need(from, "a source"), need(to, "a destination"), use, "<sandbox-id>");
2149
2369
  print(`Copied ${from} to ${to}.`, { from, to });
2150
2370
  return 0;
2151
2371
  }
@@ -2175,6 +2395,35 @@ async function sandbox(argv, env, out) {
2175
2395
  : `${preview.urlWithToken ?? preview.url}\nPrivate: send the header x-runtime-preview-token: ${preview.token} (expires ${preview.tokenExpiresAt}).`, preview);
2176
2396
  return 0;
2177
2397
  }
2398
+ case "link": {
2399
+ if (rest[0] === "revoke") {
2400
+ const sbx = await use(need(rest[1], "runtime sandboxes link revoke <id>"));
2401
+ const revoked = await sbx.files.revokeLinks();
2402
+ print(`Every link made for ${sbx.id} until now is refused.`, revoked);
2403
+ return 0;
2404
+ }
2405
+ const args = parse(rest, ["put", "archive", "no-wake"], ["expires", "filename", "max-bytes"]);
2406
+ const example = "runtime sandboxes link <id> /workspace/report.pdf";
2407
+ const path = need(args.positional[1], example);
2408
+ const sbx = await use(need(args.positional[0], example));
2409
+ const expires = flag(args, "expires");
2410
+ const expiresInSeconds = expires === undefined ? undefined : durationSeconds(expires);
2411
+ const wake = has(args, "no-wake") ? false : undefined;
2412
+ const link = has(args, "put")
2413
+ ? await sbx.files.uploadUrl(path, {
2414
+ expiresInSeconds,
2415
+ maxBytes: integer(args, "max-bytes"),
2416
+ wake,
2417
+ })
2418
+ : await sbx.files.downloadUrl(path, {
2419
+ expiresInSeconds,
2420
+ filename: flag(args, "filename"),
2421
+ archive: has(args, "archive") || undefined,
2422
+ wake,
2423
+ });
2424
+ print(`${link.url}\n${link.method === "PUT" ? `Upload: curl -T <file> '${link.url}'` : `Download: curl -fLo ${JSON.stringify(link.filename ?? link.path.split("/").pop() ?? "file")}${link.archive ? ".tar.gz" : ""} '${link.url}'`} (works until ${link.expiresAt}).`, link);
2425
+ return 0;
2426
+ }
2178
2427
  case "previews": {
2179
2428
  const list = await (await use(rest[0])).previews.list();
2180
2429
  print(list.length
@@ -2371,16 +2620,17 @@ export function parseSecretRules(values) {
2371
2620
  }
2372
2621
  return [...byMethods.values()];
2373
2622
  }
2374
- /** `--only-label role=hub,tier=x --only-sandbox <id>`: a sandbox whose labels
2375
- * hold every pair of any one --only-label gets the secret, and so does each
2376
- * --only-sandbox. `--all-sandboxes`: every sandbox again. Nothing: undefined,
2377
- * so a replacement keeps the scope it has. */
2378
- export function parseSecretScope(labels, sandboxes, all) {
2379
- if (all && (labels?.length || sandboxes?.length))
2380
- throw usage("--all-sandboxes gives the secret to every sandbox; leave out --only-label and --only-sandbox.");
2623
+ /** `--only-label role=hub,tier=x --only-sandbox <id> --only-app <id>`: a
2624
+ * sandbox whose labels hold every pair of any one --only-label gets the
2625
+ * secret, and so does each --only-sandbox and every copy of each --only-app.
2626
+ * `--all-sandboxes`: every sandbox again. Nothing: undefined, so a
2627
+ * replacement keeps the scope it has. */
2628
+ export function parseSecretScope(labels, sandboxes, all, apps) {
2629
+ if (all && (labels?.length || sandboxes?.length || apps?.length))
2630
+ throw usage("--all-sandboxes gives the secret to every sandbox; leave out --only-label, --only-sandbox and --only-app.");
2381
2631
  if (all)
2382
2632
  return {};
2383
- if (!labels?.length && !sandboxes?.length)
2633
+ if (!labels?.length && !sandboxes?.length && !apps?.length)
2384
2634
  return undefined;
2385
2635
  const selectors = (labels ?? []).map((text) => {
2386
2636
  const selector = {};
@@ -2394,23 +2644,28 @@ export function parseSecretScope(labels, sandboxes, all) {
2394
2644
  throw usage(`--only-label takes key=value pairs, such as role=hub; got "${text}".`);
2395
2645
  return selector;
2396
2646
  });
2647
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2397
2648
  for (const id of sandboxes ?? [])
2398
- if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
2649
+ if (!uuid.test(id))
2399
2650
  throw usage(`--only-sandbox takes a sandbox id; "${id}" is not one.`);
2651
+ for (const id of apps ?? [])
2652
+ if (!uuid.test(id))
2653
+ throw usage(`--only-app takes an App's id (GET /v1/apps lists them); "${id}" is not one.`);
2654
+ const ids = (list) => [...new Set(list.map((id) => id.toLowerCase()))];
2400
2655
  return {
2401
2656
  ...(selectors.length ? { labels: selectors } : {}),
2402
- ...(sandboxes?.length
2403
- ? { sandboxes: [...new Set(sandboxes.map((id) => id.toLowerCase()))] }
2404
- : {}),
2657
+ ...(sandboxes?.length ? { sandboxes: ids(sandboxes) } : {}),
2658
+ ...(apps?.length ? { apps: ids(apps) } : {}),
2405
2659
  };
2406
2660
  }
2407
2661
  /** Who gets a secret, in a few words. */
2408
- export const scopeText = (only) => only && (only.labels?.length || only.sandboxes?.length)
2662
+ export const scopeText = (only) => only && (only.labels?.length || only.sandboxes?.length || only.apps?.length)
2409
2663
  ? [
2410
2664
  ...(only.labels ?? []).map((selector) => Object.entries(selector)
2411
2665
  .map(([key, value]) => `${key}=${value}`)
2412
2666
  .join(",")),
2413
2667
  ...(only.sandboxes ?? []),
2668
+ ...(only.apps ?? []).map((id) => `app ${id}`),
2414
2669
  ].join(" | ")
2415
2670
  : "all";
2416
2671
  const ruleText = (rules) => rules?.length
@@ -2422,13 +2677,25 @@ export async function secretsCommand(argv, env, out,
2422
2677
  /** Tests give the value here; the CLI reads standard input. */
2423
2678
  read = readSecret) {
2424
2679
  const [verb, ...rest] = argv;
2425
- const args = parse(rest, ["jobs", "all-sandboxes"], ["host", "header", "format", "allow", "value", "version", "only-label", "only-sandbox"]);
2680
+ const args = parse(rest, ["jobs", "all-sandboxes", "keep-value", "all", "none"], [
2681
+ "host",
2682
+ "header",
2683
+ "format",
2684
+ "allow",
2685
+ "value",
2686
+ "version",
2687
+ "only-label",
2688
+ "only-sandbox",
2689
+ "only-app",
2690
+ "agent",
2691
+ "env",
2692
+ ]);
2426
2693
  const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
2427
2694
  const rows = (list) => list.length
2428
2695
  ? table([
2429
2696
  ["name", "hosts", "header", "placeholder", "allow", "sandboxes", "jobs"],
2430
2697
  ...list.map(({ name, sandboxes: secret, jobs }) => [
2431
- name,
2698
+ secret?.env ? `${name} (as ${secret.env})` : name,
2432
2699
  secret ? secret.hosts.join(",") : "-",
2433
2700
  secret?.header ? `${secret.header}: ${secret.format ?? "{value}"}` : "-",
2434
2701
  secret ? secret.placeholder : "-",
@@ -2462,15 +2729,44 @@ read = readSecret) {
2462
2729
  args.flags.has("allow") ||
2463
2730
  args.flags.has("only-label") ||
2464
2731
  args.flags.has("only-sandbox") ||
2465
- args.flags.has("all-sandboxes")))
2732
+ args.flags.has("only-app") ||
2733
+ args.flags.has("all-sandboxes") ||
2734
+ args.flags.has("env")))
2466
2735
  throw usage(`rotate replaces the copy for jobs. ${jobsOnly}; to change the sandboxes' copy, set it again with --host.`);
2736
+ if (verb === "set" && has(args, "keep-value")) {
2737
+ // Narrowing needs no value: it only takes places away (secrets.narrow).
2738
+ if (jobs ||
2739
+ args.flags.has("value") ||
2740
+ args.flags.has("header") ||
2741
+ args.flags.has("format") ||
2742
+ args.flags.has("only-label") ||
2743
+ args.flags.has("only-sandbox") ||
2744
+ args.flags.has("only-app") ||
2745
+ args.flags.has("all-sandboxes") ||
2746
+ args.flags.has("env"))
2747
+ throw usage(`--keep-value changes only --host and --allow, and only to narrower ones. Change who gets it with \`${me} secrets scope\`; anything else needs the value.`);
2748
+ const allow = args.flags.get("allow");
2749
+ if (!hosts.length && !allow)
2750
+ throw usage("With --keep-value, give the narrower --host and/or --allow it keeps.");
2751
+ const saved = await (await client(env)).secrets.narrow(name, {
2752
+ ...(hosts.length ? { hosts } : {}),
2753
+ ...(allow ? { rules: parseSecretRules(allow) } : {}),
2754
+ });
2755
+ print([
2756
+ `Narrowed ${saved.name}; its value is unchanged. It goes only to ${saved.hosts.join(", ")}${saved.rules?.length ? `, only in these requests: ${ruleText(saved.rules)}` : ""}.`,
2757
+ ...(saved.enforced
2758
+ ? []
2759
+ : ["A host is catching up; running sandboxes have it within a minute."]),
2760
+ ].join("\n"), saved);
2761
+ return 0;
2762
+ }
2467
2763
  if (!hosts.length && !jobs)
2468
2764
  throw usage("Give --host once for each host the value may go to, such as --host api.openai.com, and/or --jobs to let scheduled jobs use it.");
2469
2765
  if (args.flags.has("value"))
2470
2766
  throw usage('The value is read from standard input, never an argument: printf %s "$KEY" | runtime secrets set NAME --host ...');
2471
2767
  const value = await read();
2472
2768
  if (!value)
2473
- throw usage('The value is empty. Pipe it in: printf %s "$KEY" | runtime secrets set NAME --host ...');
2769
+ throw usage('The value is empty. Pipe it in: printf %s "$KEY" | runtime secrets set NAME --host ...; or, to narrow its hosts and rules without it, add --keep-value.');
2474
2770
  const runtime = await client(env);
2475
2771
  if (verb === "rotate") {
2476
2772
  const rotated = await runtime.secrets.rotate(name, value);
@@ -2478,15 +2774,18 @@ read = readSecret) {
2478
2774
  return 0;
2479
2775
  }
2480
2776
  if (!hosts.length) {
2777
+ if (args.flags.has("env"))
2778
+ throw usage("--env names the variable sandboxes see the secret in, so it needs --host; a job names its variable on jobs create (--secret NAME=VAR).");
2481
2779
  const saved = await runtime.secrets.set(name, { value, jobs: true });
2482
2780
  print(`Stored ${name} for jobs, version ${saved.jobs.version}. Bind it: ${me} jobs create ... --secret ${name}`, saved);
2483
2781
  return 0;
2484
2782
  }
2485
2783
  const header = flag(args, "header");
2486
2784
  const format = flag(args, "format");
2785
+ const variable = flag(args, "env");
2487
2786
  const allow = args.flags.get("allow");
2488
2787
  const rules = allow ? parseSecretRules(allow) : undefined;
2489
- const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"));
2788
+ const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"), args.flags.get("only-app"));
2490
2789
  const saved = await runtime.secrets.set(name, {
2491
2790
  value,
2492
2791
  hosts,
@@ -2494,12 +2793,14 @@ read = readSecret) {
2494
2793
  ...(format ? { format } : {}),
2495
2794
  ...(rules ? { rules } : {}),
2496
2795
  ...(only ? { only } : {}),
2796
+ ...(variable ? { env: variable } : {}),
2497
2797
  ...(jobs ? { jobs: true } : {}),
2498
2798
  });
2799
+ const sees = `${saved.env ?? saved.name}=${saved.placeholder}`;
2499
2800
  print([
2500
2801
  saved.only
2501
- ? `Stored ${saved.name}. Only these sandboxes get it and see ${saved.name}=${saved.placeholder}: ${scopeText(saved.only)}.`
2502
- : `Stored ${saved.name}. Every sandbox sees ${saved.name}=${saved.placeholder}.`,
2802
+ ? `Stored ${saved.name}. Only these sandboxes get it and see ${sees}: ${scopeText(saved.only)}.`
2803
+ : `Stored ${saved.name}. Every sandbox sees ${sees}.`,
2503
2804
  saved.header
2504
2805
  ? `Requests to ${saved.hosts.join(", ")} get ${saved.header}: ${saved.format ?? "{value}"} over HTTPS.`
2505
2806
  : `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the Authorization header or another credential header (x-api-key, x-goog-api-key); in the URL it stays a placeholder.`,
@@ -2517,13 +2818,24 @@ read = readSecret) {
2517
2818
  }
2518
2819
  if (verb === "scope") {
2519
2820
  const name = need(args.positional[0], "the secret's name");
2520
- const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"));
2821
+ const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"), args.flags.get("only-app"));
2521
2822
  if (!only)
2522
- throw usage("Say who gets it: --only-label key=value (once per selector), --only-sandbox <id>, or --all-sandboxes.");
2823
+ throw usage("Say who gets it: --only-label key=value (once per selector), --only-sandbox <id>, --only-app <app id>, or --all-sandboxes.");
2523
2824
  const saved = await (await client(env)).secrets.scope(name, only);
2524
2825
  print(`${saved.name}: ${saved.only ? `only ${scopeText(saved.only)}` : "every sandbox"}.${saved.enforced ? "" : " A host is catching up; running sandboxes have it within a minute."}`, saved);
2525
2826
  return 0;
2526
2827
  }
2828
+ if (verb === "share") {
2829
+ const name = need(args.positional[0], "the secret's name");
2830
+ const named = args.flags.get("agent") ?? [];
2831
+ if (Number(named.length > 0) + Number(has(args, "all")) + Number(has(args, "none")) !== 1)
2832
+ throw usage("Say who may use it: --agent <id> (once per agent), --all for every key of yours, or --none for only the key that stored it.");
2833
+ const saved = await (await client(env)).secrets.share(name, has(args, "all") ? "all" : named);
2834
+ print(`${saved.name}: ${saved.usableBy === "all"
2835
+ ? "every key of yours"
2836
+ : `only the key that stored it${saved.usableBy.length ? `, and ${saved.usableBy.length} named agent${saved.usableBy.length === 1 ? "" : "s"}` : ""}`}.`, saved);
2837
+ return 0;
2838
+ }
2527
2839
  if (verb === "reveal") {
2528
2840
  const name = need(args.positional[0], "the secret's name");
2529
2841
  const version = flag(args, "version");
@@ -2539,7 +2851,7 @@ read = readSecret) {
2539
2851
  print(`Deleted ${name}${removed.from.length > 1 ? " for sandboxes and jobs" : removed.from[0] === "jobs" ? " for jobs" : ""}.`, removed);
2540
2852
  return 0;
2541
2853
  }
2542
- throw usage("secrets takes set, scope, ls, get, rotate, reveal or rm. Run `runtime help`.");
2854
+ throw usage("secrets takes set, scope, ls, get, rotate, share, reveal or rm. Run `runtime help`.");
2543
2855
  }
2544
2856
  async function readInput(source) {
2545
2857
  if (source !== "-")
@@ -2701,9 +3013,11 @@ const KEYS_HELP = `runtime keys <command>
2701
3013
  stdout, and lasts until revoked. --json prints it as JSON, still alone on
2702
3014
  stdout. --account-wide makes a key that sees and uses every sandbox and
2703
3015
  other resource in the account; without it, a key sees only what it makes.
2704
- create --sandbox <id,...> --actions <action,...> [--name <name>] [--daily-limit <usd>]
2705
- A key limited to those sandboxes (up to 20, by id) and those actions on
2706
- them, made by the saved key: get reads them; create starts, wakes and
3016
+ create --sandbox <id,...> --actions <action,...> [--name <name>] [--daily-limit <usd>] [--browser]
3017
+ A key limited to those sandboxes (up to 20, by id, whichever key made
3018
+ them) and those actions on them, made by the saved key, or with no saved
3019
+ key (or --browser) approved by an owner or admin in the browser, as
3020
+ above: get reads them; create starts, wakes and
2707
3021
  restarts them, and needs --daily-limit when the making key has one; stop
2708
3022
  stops and pauses them; exec runs commands; read_file and write_file reach
2709
3023
  their files. It is served nothing else: no list, no other sandbox, no
@@ -2715,6 +3029,7 @@ const KEYS_HELP = `runtime keys <command>
2715
3029
  runtime keys create --name worker-1 --daily-limit 5
2716
3030
  runtime keys create --name ci --daily-limit 25 --browser | gh secret set RUNTIME_API_KEY
2717
3031
  runtime keys create --name watcher --sandbox sbx_id --actions get,create --daily-limit 2
3032
+ runtime keys create --name helper --sandbox sbx_a,sbx_b --actions get,exec,read_file,write_file --browser
2718
3033
 
2719
3034
  Making, listing and revoking keys with a key needs one an owner or admin made.
2720
3035
  Every key is also at https://withruntime.com/account/keys.
@@ -2848,6 +3163,209 @@ async function nameSaved(env) {
2848
3163
  await store.save({ ...again, orgName: who.orgName });
2849
3164
  }
2850
3165
  }
3166
+ const RESERVATIONS_HELP = `runtime reservations <command>
3167
+
3168
+ book --sandboxes <n> --from <time> --to <time> [--vcpu <n>] [--memory <MiB>]
3169
+ [--disk <MiB>] [--region <region>]
3170
+ Hold room for n sandboxes of one size for a window of up to 24 hours that
3171
+ starts within 14 days. The answer is at once: booked, or declined with
3172
+ the most that fits and the nearest window that fits all of them (exit
3173
+ code 1). In the window, creates are never refused for room or limits up
3174
+ to what you booked. Sandboxes you run bill as usual; booked room none
3175
+ of them uses costs a quarter of the memory rate, and the answer says the
3176
+ most that comes to if none run. A time is an ISO time, now, or HH:MM on this machine's clock: today, or tomorrow when it
3177
+ has passed. --to before --from means the next day.
3178
+ keep --sandboxes <n> [--snapshot <id>]... [--for-key] [--vcpu <n>]
3179
+ [--memory <MiB>] [--disk <MiB>] [--region <region>]
3180
+ Standing room: hold room for n sandboxes from now until you cancel it,
3181
+ for a pool that deletes a sandbox after each use and makes another.
3182
+ With --snapshot, on the servers holding those snapshots, at their size.
3183
+ --for-key keeps it for this key's sandboxes alone. Room none of them
3184
+ uses costs a quarter of the memory rate every hour it is held.
3185
+ change <id> [--sandboxes <n>] [--snapshot <id>]...
3186
+ Change standing room in place; it moves or grows only when all of it fits.
3187
+ ls [--limit <n>] [--before <next>]
3188
+ Your bookings, newest first
3189
+ get <id> One booking, with how much of it is in use
3190
+ cancel <id> Give the room back
3191
+
3192
+ runtime reservations book --sandboxes 300 --from 21:00 --to 23:00
3193
+ runtime reservations book --sandboxes 50 --vcpu 4 --memory 8192 --from now --to 02:00
3194
+ runtime reservations keep --sandboxes 40 --snapshot <id> --for-key
3195
+
3196
+ A booking is the whole account's: a compartment's key cannot book or read one.
3197
+ `;
3198
+ /** A booking's time on the command line: an ISO time, `now`, or HH:MM on this
3199
+ * machine's clock. HH:MM is the next such time at or after `after` (the
3200
+ * window's start for `--to`, so an end before the start is the next day, and
3201
+ * `now` for `--from`: today, or tomorrow once it has passed). */
3202
+ export function bookingTime(name, value, now, after = now) {
3203
+ if (value === "now")
3204
+ return new Date(now);
3205
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(value);
3206
+ if (clock) {
3207
+ const [hours, minutes] = [Number(clock[1]), Number(clock[2])];
3208
+ if (hours > 23 || minutes > 59)
3209
+ throw usage(`--${name} ${value} is not a time of day.`);
3210
+ const at = new Date(after);
3211
+ at.setHours(hours, minutes, 0, 0);
3212
+ // A start a minute or less ago still reads as now; the server treats it so.
3213
+ if (at.getTime() < after - (after === now ? 60_000 : 0))
3214
+ at.setDate(at.getDate() + 1);
3215
+ return at;
3216
+ }
3217
+ const at = Date.parse(value);
3218
+ if (Number.isNaN(at))
3219
+ throw usage(`--${name} takes an ISO time such as 2026-10-17T21:00:00Z, HH:MM or now; got ${value}.`);
3220
+ return new Date(at);
3221
+ }
3222
+ /** The words for a booking: the line for a confirmed one, the server's message
3223
+ * and what would fit for a declined one. */
3224
+ function bookingText(b) {
3225
+ if (b.standing) {
3226
+ const where = b.snapshots.length ? " on the servers holding its snapshots" : "";
3227
+ const whose = b.forKey ? " for this key" : "";
3228
+ if (b.status === "confirmed") {
3229
+ const unused = b.unusedRateMicros !== "0"
3230
+ ? ` Room none of your sandboxes uses costs $${Number(b.unusedRateMicros) / 1e6} per GiB-hour: at most $${(Math.ceil(Number(b.unusedPerHourMicros) / 10_000) / 100).toFixed(2)} an hour if none run.`
3231
+ : "";
3232
+ return `Holding ${b.sandboxes} sandboxes (${b.vcpu} vCPU, ${b.memoryMiB} MiB) in ${b.region}${where}${whose} until you cancel; ${b.inUse} in use. Creates up to that are never refused for room or limits.${unused} id ${b.id}`;
3233
+ }
3234
+ if (b.status === "cancelled")
3235
+ return `Cancelled the standing room for ${b.sandboxes} sandboxes in ${b.region}. id ${b.id}`;
3236
+ return [b.message, `Largest that fits: ${b.largestSandboxes} sandboxes.`].join("\n");
3237
+ }
3238
+ const window = `from ${new Date(b.startsAt).toLocaleString()} to ${new Date(b.endsAt).toLocaleString()}`;
3239
+ if (b.status === "confirmed") {
3240
+ const booked = `Booked ${b.sandboxes} sandboxes (${b.vcpu} vCPU, ${b.memoryMiB} MiB) in ${b.region} ${window}. Creates in the window are never refused for room or limits up to that.`;
3241
+ // Bookings made before unused room had a price are never charged for it.
3242
+ const unused = b.unusedRateMicros && b.unusedRateMicros !== "0"
3243
+ ? ` Room none of your sandboxes uses costs $${Number(b.unusedRateMicros) / 1e6} per GiB-hour: at most $${(Math.ceil(Number(b.unusedMaxMicros) / 10_000) / 100).toFixed(2)} if none run, nothing if cancelled at least an hour before the start.`
3244
+ : "";
3245
+ return `${booked}${unused} id ${b.id}`;
3246
+ }
3247
+ if (b.status === "cancelled")
3248
+ return `Cancelled the booking of ${b.sandboxes} sandboxes in ${b.region} ${window}. id ${b.id}`;
3249
+ return [
3250
+ b.message,
3251
+ `Largest that fits: ${b.largestSandboxes} sandboxes.`,
3252
+ b.nearestWindow
3253
+ ? `Nearest window for all ${b.sandboxes}: from ${new Date(b.nearestWindow.startsAt).toLocaleString()} to ${new Date(b.nearestWindow.endsAt).toLocaleString()}.`
3254
+ : `No window in the next 14 days holds all ${b.sandboxes}.`,
3255
+ ].join("\n");
3256
+ }
3257
+ async function reservationsCommand(argv, env, out) {
3258
+ product = "reservations";
3259
+ const [verb, ...rest] = argv;
3260
+ const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
3261
+ if (!verb || verb === "help" || verb === "--help" || verb === "-h") {
3262
+ print(named(RESERVATIONS_HELP), { usage: named(RESERVATIONS_HELP) });
3263
+ return 0;
3264
+ }
3265
+ if (verb === "book") {
3266
+ const args = parse(rest, [], ["sandboxes", "from", "to", "vcpu", "memory", "disk", "region"]);
3267
+ const count = integer(args, "sandboxes");
3268
+ const from = flag(args, "from");
3269
+ const to = flag(args, "to");
3270
+ if (count === undefined || !from || !to || args.positional.length)
3271
+ throw usage(`Usage: ${named("runtime reservations book --sandboxes <n> --from <time> --to <time>")}`);
3272
+ const now = Date.now();
3273
+ const startsAt = bookingTime("from", from, now);
3274
+ const endsAt = bookingTime("to", to, now, startsAt.getTime());
3275
+ const vcpu = integer(args, "vcpu");
3276
+ const memoryMiB = integer(args, "memory");
3277
+ const diskMiB = integer(args, "disk");
3278
+ const region = flag(args, "region");
3279
+ const booking = await (await client(env)).reservations.create({
3280
+ sandboxes: count,
3281
+ startsAt,
3282
+ endsAt,
3283
+ ...(vcpu !== undefined ? { vcpu } : {}),
3284
+ ...(memoryMiB !== undefined ? { memoryMiB } : {}),
3285
+ ...(diskMiB !== undefined ? { diskMiB } : {}),
3286
+ ...(region ? { region } : {}),
3287
+ });
3288
+ print(bookingText(booking), booking);
3289
+ return booking.status === "declined" ? 1 : 0;
3290
+ }
3291
+ if (verb === "keep") {
3292
+ const args = parse(rest, ["for-key"], ["sandboxes", "snapshot", "vcpu", "memory", "disk", "region"]);
3293
+ const count = integer(args, "sandboxes");
3294
+ if (count === undefined || args.positional.length)
3295
+ throw usage(`Usage: ${named("runtime reservations keep --sandboxes <n> [--snapshot <id>]...")}`);
3296
+ const vcpu = integer(args, "vcpu");
3297
+ const memoryMiB = integer(args, "memory");
3298
+ const diskMiB = integer(args, "disk");
3299
+ const region = flag(args, "region");
3300
+ const snapshots = args.flags.get("snapshot") ?? [];
3301
+ const booking = await (await client(env)).reservations.keep({
3302
+ sandboxes: count,
3303
+ ...(snapshots.length ? { snapshots } : {}),
3304
+ ...(has(args, "for-key") ? { forKey: true } : {}),
3305
+ ...(vcpu !== undefined ? { vcpu } : {}),
3306
+ ...(memoryMiB !== undefined ? { memoryMiB } : {}),
3307
+ ...(diskMiB !== undefined ? { diskMiB } : {}),
3308
+ ...(region ? { region } : {}),
3309
+ });
3310
+ print(bookingText(booking), booking);
3311
+ return booking.status === "declined" ? 1 : 0;
3312
+ }
3313
+ if (verb === "change") {
3314
+ const args = parse(rest, [], ["sandboxes", "snapshot"]);
3315
+ const id = args.positional[0];
3316
+ const count = integer(args, "sandboxes");
3317
+ const snapshots = args.flags.get("snapshot");
3318
+ if (!id || args.positional.length > 1 || (count === undefined && !snapshots?.length))
3319
+ throw usage(`Usage: ${named("runtime reservations change <id> [--sandboxes <n>] [--snapshot <id>]...")}`);
3320
+ const booking = await (await client(env)).reservations.update(id, {
3321
+ ...(count !== undefined ? { sandboxes: count } : {}),
3322
+ ...(snapshots?.length ? { snapshots } : {}),
3323
+ });
3324
+ print(bookingText(booking), booking);
3325
+ return 0;
3326
+ }
3327
+ if (verb === "ls" || verb === "list") {
3328
+ const args = parse(rest, [], ["limit", "before"]);
3329
+ if (args.positional.length)
3330
+ throw usage(`Usage: ${named("runtime reservations ls")}`);
3331
+ const limit = integer(args, "limit");
3332
+ const cursor = flag(args, "before");
3333
+ const page = await (await client(env)).reservations.list({
3334
+ ...(limit !== undefined ? { limit } : {}),
3335
+ ...(cursor ? { cursor } : {}),
3336
+ });
3337
+ print(page.items.length
3338
+ ? table([
3339
+ ["ID", "STATE", "SANDBOXES", "SIZE", "REGION", "FROM", "TO", "IN USE"],
3340
+ ...page.items.map((b) => [
3341
+ b.id,
3342
+ b.status === "confirmed" ? (b.phase ?? "confirmed") : b.status,
3343
+ String(b.sandboxes),
3344
+ `${b.vcpu} vCPU, ${b.memoryMiB} MiB`,
3345
+ b.region,
3346
+ new Date(b.startsAt).toLocaleString(),
3347
+ b.endsAt ? new Date(b.endsAt).toLocaleString() : "until cancelled",
3348
+ b.status === "confirmed" ? `${b.inUse} (peak ${b.peakInUse})` : "-",
3349
+ ]),
3350
+ ]) + (page.nextCursor ? `\nMore: ${me} reservations ls --before ${page.nextCursor}` : "")
3351
+ : "No bookings.", page);
3352
+ return 0;
3353
+ }
3354
+ if (verb === "get" || verb === "cancel") {
3355
+ const args = parse(rest, [], []);
3356
+ const id = args.positional[0];
3357
+ if (!id || args.positional.length > 1)
3358
+ throw usage(`Usage: ${named(`runtime reservations ${verb} <id>`)}`);
3359
+ const api = (await client(env)).reservations;
3360
+ const booking = await (verb === "get" ? api.get(id) : api.cancel(id));
3361
+ print(bookingText(booking) +
3362
+ (verb === "get" && booking.status === "confirmed"
3363
+ ? `\nIn use now: ${booking.inUse} (peak ${booking.peakInUse}). Estimate at most ${usdOf(booking.estimateMicros)}.`
3364
+ : ""), booking);
3365
+ return 0;
3366
+ }
3367
+ throw unknown("reservations command", verb, ["book", "keep", "change", "ls", "get", "cancel"]);
3368
+ }
2851
3369
  /** Dollars from integer microdollars, for a person to read: "$5.00". */
2852
3370
  const usdOf = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
2853
3371
  async function keys(argv, env, out) {
@@ -2972,12 +3490,14 @@ async function keys(argv, env, out) {
2972
3490
  return 0;
2973
3491
  }
2974
3492
  /** `runtime keys create --sandbox ... --actions ...`: a key limited to
2975
- * sandboxes, made by the saved key (never in the browser: the keys page
2976
- * makes none). */
3493
+ * sandboxes, made by the saved key, or, with no key on this machine or with
3494
+ * --browser, asked for and approved by an owner or admin in the browser
3495
+ * (0921): an agent whose person reaches Runtime only through a connector
3496
+ * holds no key to make one with. */
2977
3497
  async function limitedKey(args, env, out, limit) {
2978
- for (const refused of ["read-only", "account-wide", "browser"])
3498
+ for (const refused of ["read-only", "account-wide"])
2979
3499
  if (has(args, refused))
2980
- throw usage(`A key limited to sandboxes reaches only them and is made by this machine's key, so it takes no --${refused}.`);
3500
+ throw usage(`A key limited to sandboxes reaches only them, so it takes no --${refused}.`);
2981
3501
  const list = (name) => (flag(args, name) ?? "")
2982
3502
  .split(",")
2983
3503
  .map((each) => each.trim())
@@ -2986,15 +3506,30 @@ async function limitedKey(args, env, out, limit) {
2986
3506
  const actions = list("actions");
2987
3507
  if (!sandboxes.length || !actions.length)
2988
3508
  throw usage(`Usage: ${named("runtime keys create --sandbox <id,...> --actions <get,create,stop,exec,read_file,write_file> [--daily-limit <usd>]")}`);
2989
- const { dailyLimitMicros } = await import("./keys.js");
2990
- const made = await (await client(env)).keys.create({
3509
+ const { createKey, dailyLimitMicros } = await import("./keys.js");
3510
+ const request = {
2991
3511
  name: flag(args, "name") ?? "Sandbox key",
2992
3512
  sandboxes,
2993
3513
  actions: actions,
2994
3514
  dailyLimitMicros: limit === null ? null : dailyLimitMicros(limit),
2995
- });
2996
- out.write(out.json ? JSON.stringify(made) : made.key);
2997
- out.error(`Created key "${made.name}", limited to ${made.sandboxes?.length === 1 ? "sandbox" : "sandboxes"} ${(made.sandboxes ?? []).join(", ")} for ${(made.actions ?? []).join(", ")}${made.dailyLimitMicros === null ? "" : `, daily limit ${usdOf(made.dailyLimitMicros)}`}, made by this machine's key. Store it now: it is not shown again.\nIt calls those sandboxes by id and nothing else; revoke it with ${named("runtime keys revoke")} ${made.keyId}.`);
3515
+ };
3516
+ const saved = has(args, "browser")
3517
+ ? null
3518
+ : env.RUNTIME_API_KEY || (await connectionStore(env).read())?.key || null;
3519
+ const asked = saved
3520
+ ? null
3521
+ : await createKey({ ...request, access: "full" }, env, { notify: out.error });
3522
+ const made = asked
3523
+ ? { ...asked, sandboxes, actions }
3524
+ : await new Runtime({
3525
+ apiKey: saved,
3526
+ baseUrl: env.RUNTIME_API_URL,
3527
+ maxRetries: 4,
3528
+ }).keys.create(request);
3529
+ const { confirm, ...shown } = made;
3530
+ out.write(out.json ? JSON.stringify(shown) : shown.key);
3531
+ out.error(`Created key "${shown.name}", limited to ${shown.sandboxes?.length === 1 ? "sandbox" : "sandboxes"} ${(shown.sandboxes ?? []).join(", ")} for ${(shown.actions ?? []).join(", ")}${shown.dailyLimitMicros === null ? "" : `, daily limit ${usdOf(shown.dailyLimitMicros)}`}, ${saved ? "made by this machine's key" : "approved in the browser"}. Store it now: it is not shown again.\nIt calls those sandboxes by id and nothing else; revoke it at https://withruntime.com/account/keys${saved ? ` or with ${named("runtime keys revoke")} ${shown.keyId}` : ""}.`);
3532
+ await confirm?.();
2998
3533
  return 0;
2999
3534
  }
3000
3535
  const SNAPSHOT_HELP = `runtime snapshots <command>
@@ -3066,13 +3601,17 @@ const IMAGE_HELP = `runtime images <command>
3066
3601
  build [<folder>] [--dockerfile|-f <Dockerfile>] | --from <image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...
3067
3602
  [--name <name>] [--tag|-t name[:tag]]... [--target <stage>] [--build-arg K=V]... [--no-cache]
3068
3603
  [--start <command>] [--ready-port <port> | --ready-command <command>]
3069
- [--max-image-mib 8192] [--disk-mib <1024-32768>] [--timeout <seconds>]
3604
+ [--max-image-mib 8192] [--disk-mib <1024-32768>] [--timeout <seconds>] [--keep <n>]
3070
3605
  Build and stream the log; prints the image id when ready.
3071
3606
  --disk-mib sizes the build's scratch disk, where earlier
3072
- stages and the context live.
3073
- A Dockerfile's folder is the context: everything its
3074
- .dockerignore leaves in (100 MiB compressed at most) is
3075
- sent, and a rebuild sends only what changed.
3607
+ stages and the context live. --keep, once it is ready,
3608
+ deletes the name's older versions but the newest n
3609
+ (this one among them) and any an App still needs.
3610
+ The folder is the context (without one, the
3611
+ Dockerfile's own folder): everything its .dockerignore
3612
+ leaves in (100 MiB compressed at most) is sent, and a
3613
+ rebuild sends only what changed. -f is read from where
3614
+ you run the command, as docker build reads it.
3076
3615
  ls Your images
3077
3616
  versions <name> Every version of a name, newest first
3078
3617
  get <image> One image: an id, name, name:tag or name@version
@@ -3112,7 +3651,42 @@ const IMAGE_BUILD_VALUES = [
3112
3651
  "max-image-mib",
3113
3652
  "disk-mib",
3114
3653
  "timeout",
3654
+ "keep",
3115
3655
  ];
3656
+ /** `images build --keep n`: the name's versions older than its newest n,
3657
+ * deleted once `built` is ready, so a name rebuilt on each merge does not keep
3658
+ * every version (21 October 2026: our website's App, rebuilt from main on
3659
+ * each merge, held 221 images on one server, 24 GiB more a day). A
3660
+ * version an App still needs is refused by the API (image_in_use) and kept; a
3661
+ * build still running is never touched; any other failure is said and kept,
3662
+ * since the build itself is done. */
3663
+ async function keepNewest(api, built, keep, out) {
3664
+ // Newest first; the build just made is never among those deleted, even when
3665
+ // a later build of the name finished first.
3666
+ const older = (await (await api.list({ name: built.name, limit: 100 })).toArray(Infinity))
3667
+ .sort((a, b) => (b.version ?? 0) - (a.version ?? 0))
3668
+ .slice(keep)
3669
+ .filter((image) => image.id !== built.id && (image.state === "ready" || image.state === "failed"));
3670
+ const deleted = [];
3671
+ const kept = [];
3672
+ for (const image of older) {
3673
+ const named = `${image.name}@${image.version}`;
3674
+ try {
3675
+ await api.delete(image.id);
3676
+ deleted.push(image.id);
3677
+ if (!out.json)
3678
+ out.error(`Deleted ${named}`);
3679
+ }
3680
+ catch (error) {
3681
+ if (!(error instanceof RuntimeError))
3682
+ throw error;
3683
+ kept.push({ id: image.id, code: error.code });
3684
+ if (!out.json)
3685
+ out.error(`Kept ${named}: ${error.message}`);
3686
+ }
3687
+ }
3688
+ return { deleted, kept };
3689
+ }
3116
3690
  export async function imageCommand(argv, env, out,
3117
3691
  /** Tests give the registry secret here; the CLI reads standard input. */
3118
3692
  read = readSecret) {
@@ -3177,10 +3751,26 @@ read = readSecret) {
3177
3751
  let input;
3178
3752
  if (fromDockerfile) {
3179
3753
  const context = resolve(folder ?? dirname(dockerfile));
3180
- const file = resolve(context, dockerfile ?? "Dockerfile");
3181
- const text = await readFile(file, "utf8").catch(() => {
3182
- throw usage(`No Dockerfile at ${file}.`);
3183
- });
3754
+ // -f is read from where the command runs, as `docker build` reads it;
3755
+ // a path inside the folder still works, as it always has. Before, a
3756
+ // relative -f was read inside the folder only, and without a folder
3757
+ // twice over: apps/web/apps/web/Dockerfile (18 October 2026).
3758
+ const files = dockerfile
3759
+ ? [...new Set([resolve(dockerfile), resolve(context, dockerfile)])]
3760
+ : [join(context, "Dockerfile")];
3761
+ let file = files[0];
3762
+ let text;
3763
+ for (const candidate of files) {
3764
+ text = await readFile(candidate, "utf8").catch(() => undefined);
3765
+ if (text !== undefined) {
3766
+ file = candidate;
3767
+ break;
3768
+ }
3769
+ }
3770
+ if (text === undefined)
3771
+ throw usage(`No Dockerfile at ${files.join(" or ")}.`);
3772
+ if (!out.json && file !== join(context, "Dockerfile"))
3773
+ out.error(`Using the Dockerfile ${file}`);
3184
3774
  input = {
3185
3775
  ...common,
3186
3776
  dockerfile: text,
@@ -3198,10 +3788,18 @@ read = readSecret) {
3198
3788
  ...common,
3199
3789
  recipe: Object.fromEntries(Object.entries(recipe).filter(([, v]) => v.length)),
3200
3790
  };
3791
+ const keep = integer(args, "keep");
3792
+ if (keep !== undefined && (keep < 1 || !name))
3793
+ throw usage("--keep takes 1 or more, and a --name or -t for the versions it keeps.");
3201
3794
  const built = await api.build(input, {
3202
3795
  ...(out.json ? {} : { onLog: (line) => out.error(line.text) }),
3203
3796
  });
3204
- print(built.id, built);
3797
+ if (keep === undefined) {
3798
+ print(built.id, built);
3799
+ return 0;
3800
+ }
3801
+ const kept = await keepNewest(api, built, keep, out);
3802
+ print(built.id, { ...built, ...kept });
3205
3803
  return 0;
3206
3804
  }
3207
3805
  case "ls":
@@ -3327,16 +3925,33 @@ const JOB_HELP = `runtime jobs <command>
3327
3925
  create <name> (--cron "<min hour day month weekday>" [--timezone Europe/Berlin] | --at <time|now>)
3328
3926
  [--vcpu 2] [--memory 4096] [--disk 4096] [--cpu shared|reserved] [--cpu-floor <thousandths>]
3329
3927
  [--timeout 1800] [--attempts 1] [--backoff 30] [--secret NAME[=ENV_NAME]]...
3330
- [--cwd /workspace/dir] [--max-cost <usd>] [--max-total-cost <usd>] [--region r] -- <command...>
3928
+ [--cwd /workspace/dir] [--max-cost <usd>] [--max-total-cost <usd>] [--region r]
3929
+ [--image <name[:tag]|name@version|id>] [--alert-webhook <https url> [--alert-secret NAME]]
3930
+ [--alert-email] [--alert-after <runs>] [--volume <volumeId>:/data[:snapshot]]...
3931
+ -- <command...>
3331
3932
  Schedule it; prints its id. Nothing runs until its time.
3332
3933
  --at takes an ISO time (2026-10-01T03:00:00Z) or now.
3333
3934
  The command runs as given, no shell: -- bash -lc '...'
3334
3935
  --attempts: tries per occurrence when it exits non-zero, 1 to 5.
3335
3936
  --secret: a secret stored with --jobs, in the run's
3336
3937
  environment as ENV_NAME (NAME by default)
3938
+ --image: an image of yours each run starts from
3939
+ --alert-*: when --alert-after runs in a row (1) fail, POST
3940
+ the webhook (signed with the jobs secret NAME) or email
3941
+ you; once more when a run succeeds again
3942
+ --volume: a volume every run attaches, read-write one
3943
+ run at a time (or :snapshot, a read-only copy), so what
3944
+ one run writes there the next one reads; the job runs
3945
+ in the volume's region unless --region says otherwise
3946
+ update <id> [--image <ref> | --standard-image] [--vcpu ..] [--memory ..] [--disk ..] [--timeout ..]
3947
+ [--attempts ..] [--backoff ..] [--secret NAME[=ENV_NAME]]... [--cwd ..] [--max-total-cost <usd>]
3948
+ [--volume <volumeId>:/data[:snapshot]]... [--no-volumes]
3949
+ [--alert-webhook <url> [--alert-secret NAME]] [--alert-email] [--alert-after <runs>] [--no-alert]
3950
+ [-- <command...>] Change it in place: the next run uses it; history stays.
3951
+ --secret and --volume replace the whole list
3337
3952
  ls Your jobs: state, schedule and next run
3338
3953
  get <id> One job, in full
3339
- runs <id> Its runs: state, exit code, when
3954
+ runs <id> [--all] Its newest 100 runs (--all: every one, oldest first)
3340
3955
  logs <runId|jobId> [-f] [--raw] A run's output (a job id: its latest run); -f follows it;
3341
3956
  on a terminal control characters show as \\uXXXX
3342
3957
  unless --raw
@@ -3350,9 +3965,51 @@ const JOB_HELP = `runtime jobs <command>
3350
3965
  Examples
3351
3966
  runtime jobs create nightly --cron "0 3 * * *" --timezone Europe/Berlin -- python3 /workspace/report.py
3352
3967
  runtime jobs create once --at now -- bash -lc 'echo hello from a job'
3968
+ runtime jobs create count --cron "0 * * * *" --volume <volumeId>:/data -- bash -lc 'date >> /data/runs'
3353
3969
  runtime jobs trigger <jobId> && runtime jobs logs <jobId> -f
3354
3970
  runtime jobs reschedule <jobId> --cron "30 4 * * 1-5" --timezone Europe/Berlin
3971
+ runtime jobs update <jobId> --image hub:latest --alert-webhook https://example.com/hook --alert-after 3
3355
3972
  `;
3973
+ /** The `--alert-*` flags of create and update, as a job's alert; undefined
3974
+ * when none is given. The secret is a jobs secret, by name. */
3975
+ async function alertFlags(args, runtime) {
3976
+ const url = flag(args, "alert-webhook");
3977
+ const secret = flag(args, "alert-secret");
3978
+ const email = has(args, "alert-email");
3979
+ const after = integer(args, "alert-after");
3980
+ if (secret !== undefined && url === undefined)
3981
+ throw usage("--alert-secret signs the --alert-webhook: give the webhook's URL too.");
3982
+ if (url === undefined && !email) {
3983
+ if (after !== undefined)
3984
+ throw usage("--alert-after says when to alert: give --alert-webhook <url> or --alert-email.");
3985
+ return undefined;
3986
+ }
3987
+ let secretId;
3988
+ if (secret !== undefined) {
3989
+ secretId = (await runtime.secrets.all()).find((s) => s.name === secret)?.jobs?.id;
3990
+ if (!secretId)
3991
+ throw usage(`${secret} has no copy for jobs. Store one: printf %s "$VALUE" | ${me} secrets set ${secret} --jobs`);
3992
+ }
3993
+ return {
3994
+ ...(after !== undefined ? { afterFailures: after } : {}),
3995
+ ...(url !== undefined ? { webhook: { url, ...(secretId ? { secretId } : {}) } } : {}),
3996
+ ...(email ? { email: true } : {}),
3997
+ };
3998
+ }
3999
+ /** `--secret NAME[=ENV_NAME]`, each a jobs secret by name. */
4000
+ async function secretFlags(args, runtime) {
4001
+ const bindings = args.flags.get("secret") ?? [];
4002
+ if (!bindings.length)
4003
+ return undefined;
4004
+ const known = await runtime.secrets.all();
4005
+ return bindings.map((binding) => {
4006
+ const [secret, env = secret] = binding.split("=", 2);
4007
+ const found = known.find((s) => s.name === secret)?.jobs;
4008
+ if (!found)
4009
+ throw usage(`${secret} has no copy for jobs. Store one: printf %s "$VALUE" | ${me} secrets set ${secret} --jobs`);
4010
+ return { name: env, secretId: found.id };
4011
+ });
4012
+ }
3356
4013
  const when = (ms) => (ms == null ? "-" : new Date(ms).toISOString());
3357
4014
  function scheduleText(job) {
3358
4015
  const s = job.definition.schedule;
@@ -3382,7 +4039,7 @@ async function jobCommand(argv, env, out) {
3382
4039
  const api = runtime.jobs;
3383
4040
  switch (verb) {
3384
4041
  case "create": {
3385
- const args = parse(rest, [], [
4042
+ const args = parse(rest, ["alert-email"], [
3386
4043
  "cron",
3387
4044
  "timezone",
3388
4045
  "at",
@@ -3399,6 +4056,11 @@ async function jobCommand(argv, env, out) {
3399
4056
  "max-cost",
3400
4057
  "max-total-cost",
3401
4058
  "region",
4059
+ "image",
4060
+ "alert-webhook",
4061
+ "alert-secret",
4062
+ "alert-after",
4063
+ "volume",
3402
4064
  ], `${me} jobs create <name> --cron "0 3 * * *" -- python3 -c '...'`);
3403
4065
  const name = need(args.positional[0], "the job's name, such as nightly-report");
3404
4066
  if (args.positional.length > 1)
@@ -3416,19 +4078,14 @@ async function jobCommand(argv, env, out) {
3416
4078
  const backoff = integer(args, "backoff");
3417
4079
  if (backoff !== undefined && attempts === undefined)
3418
4080
  throw usage("--backoff goes with --attempts: the seconds between tries.");
3419
- const bindings = args.flags.get("secret") ?? [];
3420
- let secrets;
3421
- if (bindings.length) {
3422
- const known = await runtime.secrets.all();
3423
- secrets = bindings.map((binding) => {
3424
- const [secret, env = secret] = binding.split("=", 2);
3425
- const found = known.find((s) => s.name === secret)?.jobs;
3426
- if (!found)
3427
- throw usage(`${secret} has no copy for jobs. Store one: printf %s "$VALUE" | ${me} secrets set ${secret} --jobs`);
3428
- return { name: env, secretId: found.id };
3429
- });
3430
- }
4081
+ const secrets = await secretFlags(args, runtime);
4082
+ const alert = await alertFlags(args, runtime);
3431
4083
  const cwd = flag(args, "cwd");
4084
+ const volumes = (args.flags.get("volume") ?? []).map(attachment);
4085
+ // A run starts on its volumes' server, so the job is in their region
4086
+ // unless --region says otherwise (and the API names the mismatch).
4087
+ const region = flag(args, "region") ??
4088
+ (volumes.length ? (await runtime.volumes.get(volumes[0].volumeId)).region : undefined);
3432
4089
  const job = await api.create({
3433
4090
  name,
3434
4091
  schedule: cron !== undefined
@@ -3448,10 +4105,11 @@ async function jobCommand(argv, env, out) {
3448
4105
  ...(integer(args, "cpu-floor") !== undefined
3449
4106
  ? { cpuFloorMillis: integer(args, "cpu-floor") }
3450
4107
  : {}),
3451
- ...(flag(args, "region") ? { region: flag(args, "region") } : {}),
4108
+ ...(region ? { region } : {}),
3452
4109
  ...(usdMicros(args, "max-cost") !== undefined
3453
4110
  ? { maxCostMicros: usdMicros(args, "max-cost") }
3454
4111
  : {}),
4112
+ ...(volumes.length ? { volumes } : {}),
3455
4113
  },
3456
4114
  ...(attempts !== undefined
3457
4115
  ? { retry: { maxAttempts: attempts, backoffSeconds: backoff ?? 30 } }
@@ -3460,6 +4118,8 @@ async function jobCommand(argv, env, out) {
3460
4118
  ...(usdMicros(args, "max-total-cost") !== undefined
3461
4119
  ? { maxTotalCostMicros: usdMicros(args, "max-total-cost") }
3462
4120
  : {}),
4121
+ ...(flag(args, "image") ? { image: flag(args, "image") } : {}),
4122
+ ...(alert ? { alert } : {}),
3463
4123
  });
3464
4124
  if (!out.json)
3465
4125
  out.error(`Scheduled ${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}. Follow it: ${me} jobs logs ${job.id} -f`);
@@ -3491,8 +4151,13 @@ async function jobCommand(argv, env, out) {
3491
4151
  return 0;
3492
4152
  }
3493
4153
  case "runs": {
3494
- const jobId = need(rest[0], "a job id");
3495
- const all = await (await api.runs(jobId, { limit: 100 })).toArray(Infinity);
4154
+ // The newest hundred, newest first: a job every minute holds thousands.
4155
+ // --all: every run, oldest first.
4156
+ const args = parse(rest, ["all"], []);
4157
+ const jobId = need(args.positional[0], "a job id");
4158
+ const all = has(args, "all")
4159
+ ? await (await api.runs(jobId, { limit: 100 })).toArray(Infinity)
4160
+ : (await api.runs(jobId, { limit: 100, order: "newest" })).data;
3496
4161
  print(all.length
3497
4162
  ? table([
3498
4163
  ["RUN", "ATTEMPT", "STATE", "EXIT", "SCHEDULED", "STARTED", "ENDED", "REASON"],
@@ -3526,8 +4191,7 @@ async function jobCommand(argv, env, out) {
3526
4191
  if (!(error instanceof NotFoundError))
3527
4192
  throw error;
3528
4193
  // A job's id: its latest run.
3529
- const runs = await (await api.runs(id, { limit: 100 })).toArray(Infinity);
3530
- const latest = runs.at(-1);
4194
+ const [latest] = (await api.runs(id, { limit: 1, order: "newest" })).data;
3531
4195
  if (!latest) {
3532
4196
  const job = await api.get(id);
3533
4197
  print(`${job.name} has not run yet. Next run ${when(job.nextRunAt)}.`, {
@@ -3585,6 +4249,86 @@ async function jobCommand(argv, env, out) {
3585
4249
  print(`${job.name} runs now${job.state === "paused" ? ", once; it stays paused" : ""}. Follow it: ${me} jobs logs ${job.id} -f`, job);
3586
4250
  return 0;
3587
4251
  }
4252
+ case "update": {
4253
+ const args = parse(rest, ["alert-email", "no-alert", "standard-image", "no-volumes"], [
4254
+ "image",
4255
+ "vcpu",
4256
+ "memory",
4257
+ "disk",
4258
+ "cpu",
4259
+ "cpu-floor",
4260
+ "timeout",
4261
+ "attempts",
4262
+ "backoff",
4263
+ "secret",
4264
+ "cwd",
4265
+ "max-cost",
4266
+ "max-total-cost",
4267
+ "alert-webhook",
4268
+ "alert-secret",
4269
+ "alert-after",
4270
+ "volume",
4271
+ ], `${me} jobs update <id> --image hub:latest`);
4272
+ const id = need(args.positional[0], "a job id");
4273
+ if (flag(args, "image") !== undefined && has(args, "standard-image"))
4274
+ throw usage("Give --image <ref> or --standard-image, not both.");
4275
+ const volumes = (args.flags.get("volume") ?? []).map(attachment);
4276
+ if (volumes.length && has(args, "no-volumes"))
4277
+ throw usage("Give --volume to set the volumes or --no-volumes to remove them, not both.");
4278
+ const alert = await alertFlags(args, runtime);
4279
+ if (alert && has(args, "no-alert"))
4280
+ throw usage("Give --alert-* to set the alert or --no-alert to remove it, not both.");
4281
+ const argv = args.rest ?? [];
4282
+ const cwd = flag(args, "cwd");
4283
+ if (cwd !== undefined && !argv.length)
4284
+ throw usage(`--cwd goes with the command: ${me} jobs update ${id} --cwd ${cwd} -- <command...>`);
4285
+ const compute = {
4286
+ ...(integer(args, "vcpu") !== undefined ? { vcpu: integer(args, "vcpu") } : {}),
4287
+ ...(integer(args, "memory") !== undefined ? { memoryMiB: integer(args, "memory") } : {}),
4288
+ ...(integer(args, "disk") !== undefined ? { diskMiB: integer(args, "disk") } : {}),
4289
+ ...(flag(args, "cpu")
4290
+ ? { cpuMode: oneOf(args, "cpu", ["shared", "reserved"]) }
4291
+ : {}),
4292
+ ...(integer(args, "cpu-floor") !== undefined
4293
+ ? { cpuFloorMillis: integer(args, "cpu-floor") }
4294
+ : {}),
4295
+ ...(usdMicros(args, "max-cost") !== undefined
4296
+ ? { maxCostMicros: usdMicros(args, "max-cost") }
4297
+ : {}),
4298
+ ...(volumes.length ? { volumes } : {}),
4299
+ ...(has(args, "no-volumes") ? { volumes: [] } : {}),
4300
+ };
4301
+ const retry = {
4302
+ ...(integer(args, "attempts") !== undefined
4303
+ ? { maxAttempts: integer(args, "attempts") }
4304
+ : {}),
4305
+ ...(integer(args, "backoff") !== undefined
4306
+ ? { backoffSeconds: integer(args, "backoff") }
4307
+ : {}),
4308
+ };
4309
+ const secrets = await secretFlags(args, runtime);
4310
+ const changes = {
4311
+ ...(argv.length ? { command: cwd ? { argv, cwd } : argv } : {}),
4312
+ ...(flag(args, "image") ? { image: flag(args, "image") } : {}),
4313
+ ...(has(args, "standard-image") ? { image: null } : {}),
4314
+ ...(Object.keys(compute).length ? { compute } : {}),
4315
+ ...(Object.keys(retry).length ? { retry } : {}),
4316
+ ...(integer(args, "timeout") !== undefined
4317
+ ? { timeoutSeconds: integer(args, "timeout") }
4318
+ : {}),
4319
+ ...(secrets ? { secrets } : {}),
4320
+ ...(usdMicros(args, "max-total-cost") !== undefined
4321
+ ? { maxTotalCostMicros: usdMicros(args, "max-total-cost") }
4322
+ : {}),
4323
+ ...(alert ? { alert } : {}),
4324
+ ...(has(args, "no-alert") ? { alert: null } : {}),
4325
+ };
4326
+ if (!Object.keys(changes).length)
4327
+ throw usage(`Say what changes: ${me} jobs update ${id} --image hub:latest, --timeout 600, --alert-email, or -- <new command...>`);
4328
+ const job = await api.update(id, changes);
4329
+ print(`${job.name} updated (version ${job.version}); its next run uses it.${job.alert ? ` Alerts after ${job.alert.afterFailures} failing run${job.alert.afterFailures === 1 ? "" : "s"}.` : ""}`, job);
4330
+ return 0;
4331
+ }
3588
4332
  case "reschedule": {
3589
4333
  const args = parse(rest, [], ["cron", "timezone", "at"], `${me} jobs reschedule <id> --cron "0 3 * * *" [--timezone Europe/Berlin]`);
3590
4334
  const id = need(args.positional[0], "a job id");
@@ -3611,6 +4355,7 @@ async function jobCommand(argv, env, out) {
3611
4355
  "resume",
3612
4356
  "trigger",
3613
4357
  "reschedule",
4358
+ "update",
3614
4359
  "cancel",
3615
4360
  ]);
3616
4361
  }
@@ -3991,6 +4736,11 @@ async function execute(sbx, command, args, out, timeout, env,
3991
4736
  /** `run` stops its whole sandbox on Ctrl-C, so it handles the signal itself. */
3992
4737
  ownInterrupt = true) {
3993
4738
  const target = command.length === 1 ? command[0] : command;
4739
+ // Only `sandboxes exec` takes --trace-files; `run`, `apps exec` and the
4740
+ // drop-in machines' exec share this runner without it.
4741
+ const traced = !(args.flags instanceof Flags) || args.flags.known.has("trace-files")
4742
+ ? flag(args, "trace-files")
4743
+ : undefined;
3994
4744
  const extras = await import("./cli-extras.js");
3995
4745
  const stdin = await extras.pipedStdin();
3996
4746
  const options = {
@@ -3999,6 +4749,7 @@ ownInterrupt = true) {
3999
4749
  ...(args.flags.has("env") ? { env: envPairs(args, env) } : {}),
4000
4750
  ...(has(args, "root") ? { user: "root" } : {}),
4001
4751
  ...(timeout !== undefined ? { timeoutMs: timeout * 1000 } : {}),
4752
+ ...(traced ? { traceFiles: true } : {}),
4002
4753
  };
4003
4754
  // Ctrl-C stops the command in the sandbox too, and says so; without this
4004
4755
  // the CLI exited and the command ran on unseen.
@@ -4026,6 +4777,9 @@ ownInterrupt = true) {
4026
4777
  finally {
4027
4778
  process.off("SIGINT", onInterrupt);
4028
4779
  }
4780
+ // What it read and wrote, as JSON, for a cache keyed by what a test read.
4781
+ if (traced)
4782
+ await writeFile(traced, `${JSON.stringify(result.files ?? null)}\n`);
4029
4783
  if (out.json)
4030
4784
  out.write(JSON.stringify(result));
4031
4785
  // Here the output streamed, whose timeout is 24 hours unless one was given
@@ -4094,11 +4848,20 @@ export function describeError(error, json) {
4094
4848
  requestId: error.requestId,
4095
4849
  idempotencyKey: error.idempotencyKey,
4096
4850
  details: error.details,
4851
+ retryable: error.retryable,
4852
+ ...(error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
4097
4853
  },
4098
4854
  });
4099
4855
  // In the command line's words: the option, not the request's field.
4100
4856
  const { message, hint } = cliRefusal(error);
4101
- return `Error [${error.code}]: ${message}${hint ? `\nHint: ${hint}` : ""}${error.requestId
4857
+ // Said only when the same command, run again, may work: and when, if the
4858
+ // API named it (a daily cap's hour).
4859
+ const again = error.retryable
4860
+ ? `\nRetryable: yes${error.retryAfterMs !== undefined
4861
+ ? `, after ${Math.max(1, Math.ceil(error.retryAfterMs / 1000))} s`
4862
+ : ""}`
4863
+ : "";
4864
+ return `Error [${error.code}]: ${message}${hint ? `\nHint: ${hint}` : ""}${again}${error.requestId
4102
4865
  ? // A refusal of what was sent is the caller's to fix; only a failure
4103
4866
  // of ours is worth a report.
4104
4867
  error.status >= 400 && error.status < 500