withruntime 0.23.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 (76) 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 +775 -85
  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/machine.d.ts +10 -6
  39. package/dist/machine.js +14 -6
  40. package/dist/modal/index.d.ts +34 -0
  41. package/dist/modal/index.js +128 -22
  42. package/dist/products/agents.d.ts +101 -3
  43. package/dist/products/agents.js +49 -0
  44. package/dist/products/apps.d.ts +206 -10
  45. package/dist/products/apps.js +172 -1
  46. package/dist/products/ci.d.ts +332 -0
  47. package/dist/products/ci.js +101 -0
  48. package/dist/products/index.d.ts +8 -0
  49. package/dist/products/index.js +15 -0
  50. package/dist/products/jobs.d.ts +105 -5
  51. package/dist/products/jobs.js +47 -3
  52. package/dist/products/network.d.ts +13 -0
  53. package/dist/products/observability.d.ts +1 -1
  54. package/dist/products/plans.d.ts +131 -0
  55. package/dist/products/plans.js +24 -0
  56. package/dist/products/reservations.d.ts +108 -0
  57. package/dist/products/reservations.js +41 -0
  58. package/dist/products/resources.d.ts +2 -1
  59. package/dist/products/secrets.d.ts +90 -6
  60. package/dist/products/secrets.js +8 -2
  61. package/dist/proxy.d.ts +3 -3
  62. package/dist/proxy.js +20 -0
  63. package/dist/sandbox.d.ts +29 -1
  64. package/dist/sandbox.js +56 -3
  65. package/dist/snapshots.d.ts +1 -1
  66. package/dist/switched-off.d.ts +5 -0
  67. package/dist/switched-off.js +8 -0
  68. package/dist/tar.d.ts +1 -1
  69. package/dist/tar.js +19 -1
  70. package/dist/transport.d.ts +1 -1
  71. package/dist/transport.js +48 -9
  72. package/dist/types.d.ts +35 -4
  73. package/dist/vercel/errors.d.ts +1 -1
  74. package/dist/vercel/errors.js +2 -1
  75. package/dist/vercel/snapshot.js +4 -2
  76. package/package.json +23 -6
package/dist/cli.js CHANGED
@@ -47,21 +47,27 @@ 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
58
64
  secrets set <NAME> --keep-value [--host <host>]... [--allow '[GET] /path']...
59
65
  Narrow where it goes without its value: a subset of
60
66
  its hosts, only requests its rules allow now.
61
67
  Anything wider needs the value
62
68
  secrets set <NAME> --jobs [--host ...] A copy scheduled jobs put in their environment
63
69
  (job create --secret NAME); with --host, both
64
- 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
65
71
  Change which sandboxes get it; no value needed
66
72
  secrets ls | get <NAME> Names, hosts, placeholders and jobs versions, never values
67
73
  secrets rotate <NAME> A new version of the jobs copy, from standard input
@@ -75,6 +81,13 @@ Account
75
81
  A new API key for CI, approved by an owner in the browser
76
82
  referrals Your referral link: you both get up to $1,000, then
77
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)
78
91
  compare --from <provider> [--days 30] What your usage would cost at e2b, daytona, vercel,
79
92
  modal, cloudflare, fly, blaxel, lambda-microvms,
80
93
  freestyle, prime and others, and what you save
@@ -92,6 +105,7 @@ Account
92
105
  support deny <actionId> --conversation <id>
93
106
  docs [topic] Print a docs page (start, cli, api, python...)
94
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
95
109
 
96
110
  Connect
97
111
  sandboxes ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandboxes ssh help\` for editors
@@ -118,7 +132,9 @@ Products
118
132
  volumes Persistent disks to attach to sandboxes
119
133
  jobs A command in a fresh sandbox, once or on a cron schedule, with its logs
120
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)
121
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)
122
138
  containers One OCI image's process on its own microVM, at the sandbox rates (beta)
123
139
  agents Claude Code, Codex, Gemini CLI or your own agent in a sandbox per session (beta)
124
140
  domains Your own hostname for a sandbox's port, with HTTPS
@@ -151,7 +167,8 @@ const SANDBOX_HELP = `runtime sandboxes <command>
151
167
  Create one; prints its id
152
168
  ls [--all] [--label k=v]... [--state running] List (live ones unless --all)
153
169
  get <id> One sandbox, in full
154
- 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...>
155
172
  Run and stream output; exits with its code.
156
173
  --root runs it as root: no sudo needed
157
174
  spawn <id> [--pty] [--root] -- <command...> Start in the background; prints a process id
@@ -178,6 +195,7 @@ const SANDBOX_HELP = `runtime sandboxes <command>
178
195
  Change its size by a restart: the whole disk stays and
179
196
  --disk only grows it; programs stop (snapshot it first
180
197
  to keep memory)
198
+ resize <id> --disk 20480 Grow a running sandbox's disk live, programs running
181
199
  extend <id> <seconds> Move a time limit on (one with none needs nothing)
182
200
  update <id> [--name n] [--label k=v]... [--env K[=V]]... [--unset-env K]... [--idle-pause <seconds>] [--auto-wake on|off]
183
201
  [--persistent on|off] [--max-total-cost <usd>|none]
@@ -209,6 +227,11 @@ const SANDBOX_HELP = `runtime sandboxes <command>
209
227
  preview rotate <id> <port> Refuse every token given out for a private port so
210
228
  far; prints the new token
211
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
212
235
  unshare <id> <port> Stop sharing a port
213
236
  session create <id> [--origin <url>]... [--ttl <s>] [--name <n>]
214
237
  A token a web page uses for this sandbox's commands,
@@ -303,7 +326,7 @@ commandAfter) {
303
326
  return { positional, flags, rest };
304
327
  }
305
328
  /** Options given once per item, which a plural takes as a comma list. */
306
- const LISTS = new Set(["host", "allow", "deny", "connect"]);
329
+ const LISTS = new Set(["host", "allow", "deny", "connect", "snapshot"]);
307
330
  /** The declared option a mistyped one most likely meant. --memory-mib for
308
331
  * --memory, --timeout-seconds for --timeout: the SDK's spelling of an option is
309
332
  * the CLI's with its unit added. */
@@ -374,9 +397,19 @@ const kit = {
374
397
  table: (rows) => table(rows),
375
398
  usage: (message) => usage(message),
376
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),
377
403
  };
378
404
  /** The product being run, so a mistake points at its own help. */
379
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
+ }
380
413
  function usage(message) {
381
414
  return new RuntimeError({
382
415
  message,
@@ -422,7 +455,9 @@ const COMMANDS = [
422
455
  "sso",
423
456
  "secrets",
424
457
  "keys",
458
+ "reservations",
425
459
  "referrals",
460
+ "plans",
426
461
  "compare",
427
462
  "switch",
428
463
  "ls",
@@ -430,13 +465,16 @@ const COMMANDS = [
430
465
  "support",
431
466
  "docs",
432
467
  "mcp",
468
+ "fly-api",
433
469
  "sandboxes",
434
470
  "snapshots",
435
471
  "images",
436
472
  "volumes",
437
473
  "jobs",
438
474
  "apps",
475
+ "functions",
439
476
  "builds",
477
+ "ci",
440
478
  "containers",
441
479
  "agents",
442
480
  "domains",
@@ -458,10 +496,13 @@ const OWN_HELP = [
458
496
  "volumes",
459
497
  "jobs",
460
498
  "apps",
499
+ "functions",
461
500
  "builds",
501
+ "ci",
462
502
  "containers",
463
503
  "agents",
464
504
  "keys",
505
+ "reservations",
465
506
  "webhooks",
466
507
  "webhook",
467
508
  "billing",
@@ -508,6 +549,7 @@ const SANDBOX_VERBS = [
508
549
  "run-code",
509
550
  "preview",
510
551
  "previews",
552
+ "link",
511
553
  "unshare",
512
554
  "session",
513
555
  "network",
@@ -631,6 +673,22 @@ function describe(info) {
631
673
  ["charged", dollars(BigInt(Math.round(Number(info.chargedMicros) || 0)))],
632
674
  ]);
633
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
+ }
634
692
  /** Resolves `<id>:/path` into its parts, or undefined for a local path. */
635
693
  function remote(spec) {
636
694
  const match = /^([A-Za-z0-9][A-Za-z0-9_.-]{0,127}):(\/.*)$/.exec(spec);
@@ -701,7 +759,9 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
701
759
  return 0;
702
760
  }
703
761
  if (command === "run") {
704
- 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`);
705
765
  const commandLine = args.rest ?? args.positional;
706
766
  if (!commandLine.length)
707
767
  throw usage(`Give the command after --: ${me} sandboxes run -- python3 < invoice.py`);
@@ -714,8 +774,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
714
774
  // SIGINT and normal completion share one delete, with an absolute
715
775
  // deadline across its retries, so cleanup cannot hang. A delete, not a
716
776
  // stop: a stopped sandbox keeps its disk, billed, and an account without
717
- // credit keeps at most 10, so ten runs that only stopped refused the
718
- // 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.
719
779
  const stop = () => (cleanup ??= (async () => {
720
780
  const signal = AbortSignal.timeout(30_000);
721
781
  try {
@@ -911,6 +971,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
911
971
  }
912
972
  if (command === "keys")
913
973
  return keys(rest, env, out);
974
+ if (command === "reservations")
975
+ return reservationsCommand(rest, env, out);
914
976
  if (command === "sso") {
915
977
  const status = await (await client(env)).sso.get();
916
978
  print([
@@ -1020,6 +1082,128 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1020
1082
  ].join("\n"), r);
1021
1083
  return 0;
1022
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
+ }
1023
1207
  if (command === "compare" || command === "switch")
1024
1208
  return switching(command, rest, env, out);
1025
1209
  if (command === "mcp") {
@@ -1031,6 +1215,22 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1031
1215
  await new Promise((resolve) => process.stdout.write("", () => resolve()));
1032
1216
  process.exit(0);
1033
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
+ }
1034
1234
  if (command === "docs") {
1035
1235
  const topic = rest[0] ?? "index";
1036
1236
  const response = await envFetch(`https://withruntime.com/docs/${topic === "index" ? "start" : encodeURIComponent(topic)}.md`, { redirect: "error" });
@@ -1135,10 +1335,18 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
1135
1335
  product = "apps";
1136
1336
  return (await import("./apps-cli.js")).appsCommand(rest, env, out, () => client(env), kit);
1137
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
+ }
1138
1342
  if (command === "build" || command === "builds") {
1139
1343
  product = "builds";
1140
1344
  return (await import("./apps-cli.js")).buildsCommand(rest, out, () => client(env), kit);
1141
1345
  }
1346
+ if (command === "ci") {
1347
+ product = "ci";
1348
+ return (await import("./ci-cli.js")).ciCommand(rest, out, () => client(env), kit);
1349
+ }
1142
1350
  if (command === "container" || command === "containers") {
1143
1351
  product = "containers";
1144
1352
  return (await import("./apps-cli.js")).containersCommand(rest, env, out, () => client(env), kit);
@@ -1473,14 +1681,29 @@ const DROP_IN = {
1473
1681
  js: 'import { SandboxInstance } from "withruntime/blaxel"; // was: from "@blaxel/core"',
1474
1682
  py: "from withruntime.blaxel import SandboxInstance # was: from blaxel.core import SandboxInstance",
1475
1683
  },
1684
+ modal: {
1685
+ js: 'import { ModalClient } from "withruntime/modal"; // was: from "modal"',
1686
+ py: "from withruntime import modal # was: import modal",
1687
+ },
1476
1688
  // A Worker-only SDK: no Python. More than the import changes, so say what.
1477
1689
  cloudflare: {
1478
1690
  js: 'import { getSandbox } from "withruntime/cloudflare"; // was: from "@cloudflare/sandbox"',
1479
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`.",
1480
1692
  },
1481
1693
  };
1482
- /** How to move a rival's code across, when one import does it. */
1483
- 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;
1484
1707
  const drop = provider && DROP_IN[provider];
1485
1708
  if (!drop)
1486
1709
  return [];
@@ -1600,13 +1823,13 @@ async function switching(command, argv, env, out) {
1600
1823
  "A switch is recorded before the first top-up, and this organization has made one already.", summary);
1601
1824
  return 0;
1602
1825
  }
1603
- const provider = SWITCH_FROM[from];
1604
- if (!provider || from === "fly-machines")
1605
- 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));
1606
1829
  const summary = await api.record({ provider });
1607
1830
  print([
1608
1831
  `Recorded: switching from ${RIVAL_NAMES[provider]}. ${welcomeLine(summary) ?? ""}`.trim(),
1609
- ...dropInLines(provider),
1832
+ ...dropInLines(provider, from),
1610
1833
  ].join("\n"), summary);
1611
1834
  return 0;
1612
1835
  }
@@ -1653,7 +1876,7 @@ async function switching(command, argv, env, out) {
1653
1876
  const line = switchingLine(c.switching, rival);
1654
1877
  if (line)
1655
1878
  lines.push("", line);
1656
- const drop = dropInLines(rival);
1879
+ const drop = dropInLines(rival, from);
1657
1880
  if (drop.length)
1658
1881
  lines.push("", ...drop);
1659
1882
  print(lines.join("\n"), c);
@@ -1818,9 +2041,11 @@ async function sandbox(argv, env, out) {
1818
2041
  };
1819
2042
  if (size.vcpu === undefined && size.memoryMiB === undefined && size.diskMiB === undefined)
1820
2043
  throw usage(`Name a new size, e.g. ${me} sandboxes resize ${sbx.id} --memory 4096 --restart`);
1821
- 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"))
1822
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.`);
1823
- await sbx.resize(size, { restart: true });
2048
+ await sbx.resize(size, { restart: args.flags.has("restart") });
1824
2049
  print(describe(sbx.info), sbx.info);
1825
2050
  return 0;
1826
2051
  }
@@ -2014,7 +2239,7 @@ async function sandbox(argv, env, out) {
2014
2239
  return 0;
2015
2240
  }
2016
2241
  case "exec": {
2017
- 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`);
2018
2243
  const command = args.rest ?? args.positional.slice(1);
2019
2244
  if (!command.length)
2020
2245
  throw usage("Give the command after --: runtime sandboxes exec <id> -- ls -la");
@@ -2140,19 +2365,7 @@ async function sandbox(argv, env, out) {
2140
2365
  }
2141
2366
  case "cp": {
2142
2367
  const [from, to] = parse(rest, ["r", "recursive"], []).positional;
2143
- const source = remote(need(from, "a source"));
2144
- const target = remote(need(to, "a destination"));
2145
- // A destination ending in / (or a local directory) takes the source's
2146
- // own name inside it, as cp does.
2147
- const inside = (dir, path) => `${dir.replace(/\/+$/, "")}/${path.replace(/\/+$/, "").split("/").pop()}`;
2148
- if (source && !target) {
2149
- const local = await import("node:fs/promises").then((fs) => fs.stat(to).catch(() => null));
2150
- await (await use(source.id)).files.download(source.path, to.endsWith("/") || local?.isDirectory() ? inside(to, source.path) : to);
2151
- }
2152
- else if (!source && target)
2153
- await (await use(target.id)).files.upload(from, target.path.endsWith("/") ? inside(target.path, from) : target.path);
2154
- else
2155
- throw usage("Name exactly one side as <sandbox-id>:/path.");
2368
+ await copyFile(need(from, "a source"), need(to, "a destination"), use, "<sandbox-id>");
2156
2369
  print(`Copied ${from} to ${to}.`, { from, to });
2157
2370
  return 0;
2158
2371
  }
@@ -2182,6 +2395,35 @@ async function sandbox(argv, env, out) {
2182
2395
  : `${preview.urlWithToken ?? preview.url}\nPrivate: send the header x-runtime-preview-token: ${preview.token} (expires ${preview.tokenExpiresAt}).`, preview);
2183
2396
  return 0;
2184
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
+ }
2185
2427
  case "previews": {
2186
2428
  const list = await (await use(rest[0])).previews.list();
2187
2429
  print(list.length
@@ -2378,16 +2620,17 @@ export function parseSecretRules(values) {
2378
2620
  }
2379
2621
  return [...byMethods.values()];
2380
2622
  }
2381
- /** `--only-label role=hub,tier=x --only-sandbox <id>`: a sandbox whose labels
2382
- * hold every pair of any one --only-label gets the secret, and so does each
2383
- * --only-sandbox. `--all-sandboxes`: every sandbox again. Nothing: undefined,
2384
- * so a replacement keeps the scope it has. */
2385
- export function parseSecretScope(labels, sandboxes, all) {
2386
- if (all && (labels?.length || sandboxes?.length))
2387
- 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.");
2388
2631
  if (all)
2389
2632
  return {};
2390
- if (!labels?.length && !sandboxes?.length)
2633
+ if (!labels?.length && !sandboxes?.length && !apps?.length)
2391
2634
  return undefined;
2392
2635
  const selectors = (labels ?? []).map((text) => {
2393
2636
  const selector = {};
@@ -2401,23 +2644,28 @@ export function parseSecretScope(labels, sandboxes, all) {
2401
2644
  throw usage(`--only-label takes key=value pairs, such as role=hub; got "${text}".`);
2402
2645
  return selector;
2403
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;
2404
2648
  for (const id of sandboxes ?? [])
2405
- 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))
2406
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()))];
2407
2655
  return {
2408
2656
  ...(selectors.length ? { labels: selectors } : {}),
2409
- ...(sandboxes?.length
2410
- ? { sandboxes: [...new Set(sandboxes.map((id) => id.toLowerCase()))] }
2411
- : {}),
2657
+ ...(sandboxes?.length ? { sandboxes: ids(sandboxes) } : {}),
2658
+ ...(apps?.length ? { apps: ids(apps) } : {}),
2412
2659
  };
2413
2660
  }
2414
2661
  /** Who gets a secret, in a few words. */
2415
- 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)
2416
2663
  ? [
2417
2664
  ...(only.labels ?? []).map((selector) => Object.entries(selector)
2418
2665
  .map(([key, value]) => `${key}=${value}`)
2419
2666
  .join(",")),
2420
2667
  ...(only.sandboxes ?? []),
2668
+ ...(only.apps ?? []).map((id) => `app ${id}`),
2421
2669
  ].join(" | ")
2422
2670
  : "all";
2423
2671
  const ruleText = (rules) => rules?.length
@@ -2438,14 +2686,16 @@ read = readSecret) {
2438
2686
  "version",
2439
2687
  "only-label",
2440
2688
  "only-sandbox",
2689
+ "only-app",
2441
2690
  "agent",
2691
+ "env",
2442
2692
  ]);
2443
2693
  const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
2444
2694
  const rows = (list) => list.length
2445
2695
  ? table([
2446
2696
  ["name", "hosts", "header", "placeholder", "allow", "sandboxes", "jobs"],
2447
2697
  ...list.map(({ name, sandboxes: secret, jobs }) => [
2448
- name,
2698
+ secret?.env ? `${name} (as ${secret.env})` : name,
2449
2699
  secret ? secret.hosts.join(",") : "-",
2450
2700
  secret?.header ? `${secret.header}: ${secret.format ?? "{value}"}` : "-",
2451
2701
  secret ? secret.placeholder : "-",
@@ -2479,7 +2729,9 @@ read = readSecret) {
2479
2729
  args.flags.has("allow") ||
2480
2730
  args.flags.has("only-label") ||
2481
2731
  args.flags.has("only-sandbox") ||
2482
- args.flags.has("all-sandboxes")))
2732
+ args.flags.has("only-app") ||
2733
+ args.flags.has("all-sandboxes") ||
2734
+ args.flags.has("env")))
2483
2735
  throw usage(`rotate replaces the copy for jobs. ${jobsOnly}; to change the sandboxes' copy, set it again with --host.`);
2484
2736
  if (verb === "set" && has(args, "keep-value")) {
2485
2737
  // Narrowing needs no value: it only takes places away (secrets.narrow).
@@ -2489,7 +2741,9 @@ read = readSecret) {
2489
2741
  args.flags.has("format") ||
2490
2742
  args.flags.has("only-label") ||
2491
2743
  args.flags.has("only-sandbox") ||
2492
- args.flags.has("all-sandboxes"))
2744
+ args.flags.has("only-app") ||
2745
+ args.flags.has("all-sandboxes") ||
2746
+ args.flags.has("env"))
2493
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.`);
2494
2748
  const allow = args.flags.get("allow");
2495
2749
  if (!hosts.length && !allow)
@@ -2520,15 +2774,18 @@ read = readSecret) {
2520
2774
  return 0;
2521
2775
  }
2522
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).");
2523
2779
  const saved = await runtime.secrets.set(name, { value, jobs: true });
2524
2780
  print(`Stored ${name} for jobs, version ${saved.jobs.version}. Bind it: ${me} jobs create ... --secret ${name}`, saved);
2525
2781
  return 0;
2526
2782
  }
2527
2783
  const header = flag(args, "header");
2528
2784
  const format = flag(args, "format");
2785
+ const variable = flag(args, "env");
2529
2786
  const allow = args.flags.get("allow");
2530
2787
  const rules = allow ? parseSecretRules(allow) : undefined;
2531
- 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"));
2532
2789
  const saved = await runtime.secrets.set(name, {
2533
2790
  value,
2534
2791
  hosts,
@@ -2536,12 +2793,14 @@ read = readSecret) {
2536
2793
  ...(format ? { format } : {}),
2537
2794
  ...(rules ? { rules } : {}),
2538
2795
  ...(only ? { only } : {}),
2796
+ ...(variable ? { env: variable } : {}),
2539
2797
  ...(jobs ? { jobs: true } : {}),
2540
2798
  });
2799
+ const sees = `${saved.env ?? saved.name}=${saved.placeholder}`;
2541
2800
  print([
2542
2801
  saved.only
2543
- ? `Stored ${saved.name}. Only these sandboxes get it and see ${saved.name}=${saved.placeholder}: ${scopeText(saved.only)}.`
2544
- : `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}.`,
2545
2804
  saved.header
2546
2805
  ? `Requests to ${saved.hosts.join(", ")} get ${saved.header}: ${saved.format ?? "{value}"} over HTTPS.`
2547
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.`,
@@ -2559,9 +2818,9 @@ read = readSecret) {
2559
2818
  }
2560
2819
  if (verb === "scope") {
2561
2820
  const name = need(args.positional[0], "the secret's name");
2562
- 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"));
2563
2822
  if (!only)
2564
- 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.");
2565
2824
  const saved = await (await client(env)).secrets.scope(name, only);
2566
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);
2567
2826
  return 0;
@@ -2904,6 +3163,209 @@ async function nameSaved(env) {
2904
3163
  await store.save({ ...again, orgName: who.orgName });
2905
3164
  }
2906
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
+ }
2907
3369
  /** Dollars from integer microdollars, for a person to read: "$5.00". */
2908
3370
  const usdOf = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
2909
3371
  async function keys(argv, env, out) {
@@ -3139,13 +3601,17 @@ const IMAGE_HELP = `runtime images <command>
3139
3601
  build [<folder>] [--dockerfile|-f <Dockerfile>] | --from <image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...
3140
3602
  [--name <name>] [--tag|-t name[:tag]]... [--target <stage>] [--build-arg K=V]... [--no-cache]
3141
3603
  [--start <command>] [--ready-port <port> | --ready-command <command>]
3142
- [--max-image-mib 8192] [--disk-mib <1024-32768>] [--timeout <seconds>]
3604
+ [--max-image-mib 8192] [--disk-mib <1024-32768>] [--timeout <seconds>] [--keep <n>]
3143
3605
  Build and stream the log; prints the image id when ready.
3144
3606
  --disk-mib sizes the build's scratch disk, where earlier
3145
- stages and the context live.
3146
- A Dockerfile's folder is the context: everything its
3147
- .dockerignore leaves in (100 MiB compressed at most) is
3148
- 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.
3149
3615
  ls Your images
3150
3616
  versions <name> Every version of a name, newest first
3151
3617
  get <image> One image: an id, name, name:tag or name@version
@@ -3185,7 +3651,42 @@ const IMAGE_BUILD_VALUES = [
3185
3651
  "max-image-mib",
3186
3652
  "disk-mib",
3187
3653
  "timeout",
3654
+ "keep",
3188
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
+ }
3189
3690
  export async function imageCommand(argv, env, out,
3190
3691
  /** Tests give the registry secret here; the CLI reads standard input. */
3191
3692
  read = readSecret) {
@@ -3250,10 +3751,26 @@ read = readSecret) {
3250
3751
  let input;
3251
3752
  if (fromDockerfile) {
3252
3753
  const context = resolve(folder ?? dirname(dockerfile));
3253
- const file = resolve(context, dockerfile ?? "Dockerfile");
3254
- const text = await readFile(file, "utf8").catch(() => {
3255
- throw usage(`No Dockerfile at ${file}.`);
3256
- });
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}`);
3257
3774
  input = {
3258
3775
  ...common,
3259
3776
  dockerfile: text,
@@ -3271,10 +3788,18 @@ read = readSecret) {
3271
3788
  ...common,
3272
3789
  recipe: Object.fromEntries(Object.entries(recipe).filter(([, v]) => v.length)),
3273
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.");
3274
3794
  const built = await api.build(input, {
3275
3795
  ...(out.json ? {} : { onLog: (line) => out.error(line.text) }),
3276
3796
  });
3277
- 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 });
3278
3803
  return 0;
3279
3804
  }
3280
3805
  case "ls":
@@ -3400,16 +3925,33 @@ const JOB_HELP = `runtime jobs <command>
3400
3925
  create <name> (--cron "<min hour day month weekday>" [--timezone Europe/Berlin] | --at <time|now>)
3401
3926
  [--vcpu 2] [--memory 4096] [--disk 4096] [--cpu shared|reserved] [--cpu-floor <thousandths>]
3402
3927
  [--timeout 1800] [--attempts 1] [--backoff 30] [--secret NAME[=ENV_NAME]]...
3403
- [--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...>
3404
3932
  Schedule it; prints its id. Nothing runs until its time.
3405
3933
  --at takes an ISO time (2026-10-01T03:00:00Z) or now.
3406
3934
  The command runs as given, no shell: -- bash -lc '...'
3407
3935
  --attempts: tries per occurrence when it exits non-zero, 1 to 5.
3408
3936
  --secret: a secret stored with --jobs, in the run's
3409
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
3410
3952
  ls Your jobs: state, schedule and next run
3411
3953
  get <id> One job, in full
3412
- runs <id> Its runs: state, exit code, when
3954
+ runs <id> [--all] Its newest 100 runs (--all: every one, oldest first)
3413
3955
  logs <runId|jobId> [-f] [--raw] A run's output (a job id: its latest run); -f follows it;
3414
3956
  on a terminal control characters show as \\uXXXX
3415
3957
  unless --raw
@@ -3423,9 +3965,51 @@ const JOB_HELP = `runtime jobs <command>
3423
3965
  Examples
3424
3966
  runtime jobs create nightly --cron "0 3 * * *" --timezone Europe/Berlin -- python3 /workspace/report.py
3425
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'
3426
3969
  runtime jobs trigger <jobId> && runtime jobs logs <jobId> -f
3427
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
3428
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
+ }
3429
4013
  const when = (ms) => (ms == null ? "-" : new Date(ms).toISOString());
3430
4014
  function scheduleText(job) {
3431
4015
  const s = job.definition.schedule;
@@ -3455,7 +4039,7 @@ async function jobCommand(argv, env, out) {
3455
4039
  const api = runtime.jobs;
3456
4040
  switch (verb) {
3457
4041
  case "create": {
3458
- const args = parse(rest, [], [
4042
+ const args = parse(rest, ["alert-email"], [
3459
4043
  "cron",
3460
4044
  "timezone",
3461
4045
  "at",
@@ -3472,6 +4056,11 @@ async function jobCommand(argv, env, out) {
3472
4056
  "max-cost",
3473
4057
  "max-total-cost",
3474
4058
  "region",
4059
+ "image",
4060
+ "alert-webhook",
4061
+ "alert-secret",
4062
+ "alert-after",
4063
+ "volume",
3475
4064
  ], `${me} jobs create <name> --cron "0 3 * * *" -- python3 -c '...'`);
3476
4065
  const name = need(args.positional[0], "the job's name, such as nightly-report");
3477
4066
  if (args.positional.length > 1)
@@ -3489,19 +4078,14 @@ async function jobCommand(argv, env, out) {
3489
4078
  const backoff = integer(args, "backoff");
3490
4079
  if (backoff !== undefined && attempts === undefined)
3491
4080
  throw usage("--backoff goes with --attempts: the seconds between tries.");
3492
- const bindings = args.flags.get("secret") ?? [];
3493
- let secrets;
3494
- if (bindings.length) {
3495
- const known = await runtime.secrets.all();
3496
- secrets = bindings.map((binding) => {
3497
- const [secret, env = secret] = binding.split("=", 2);
3498
- const found = known.find((s) => s.name === secret)?.jobs;
3499
- if (!found)
3500
- throw usage(`${secret} has no copy for jobs. Store one: printf %s "$VALUE" | ${me} secrets set ${secret} --jobs`);
3501
- return { name: env, secretId: found.id };
3502
- });
3503
- }
4081
+ const secrets = await secretFlags(args, runtime);
4082
+ const alert = await alertFlags(args, runtime);
3504
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);
3505
4089
  const job = await api.create({
3506
4090
  name,
3507
4091
  schedule: cron !== undefined
@@ -3521,10 +4105,11 @@ async function jobCommand(argv, env, out) {
3521
4105
  ...(integer(args, "cpu-floor") !== undefined
3522
4106
  ? { cpuFloorMillis: integer(args, "cpu-floor") }
3523
4107
  : {}),
3524
- ...(flag(args, "region") ? { region: flag(args, "region") } : {}),
4108
+ ...(region ? { region } : {}),
3525
4109
  ...(usdMicros(args, "max-cost") !== undefined
3526
4110
  ? { maxCostMicros: usdMicros(args, "max-cost") }
3527
4111
  : {}),
4112
+ ...(volumes.length ? { volumes } : {}),
3528
4113
  },
3529
4114
  ...(attempts !== undefined
3530
4115
  ? { retry: { maxAttempts: attempts, backoffSeconds: backoff ?? 30 } }
@@ -3533,6 +4118,8 @@ async function jobCommand(argv, env, out) {
3533
4118
  ...(usdMicros(args, "max-total-cost") !== undefined
3534
4119
  ? { maxTotalCostMicros: usdMicros(args, "max-total-cost") }
3535
4120
  : {}),
4121
+ ...(flag(args, "image") ? { image: flag(args, "image") } : {}),
4122
+ ...(alert ? { alert } : {}),
3536
4123
  });
3537
4124
  if (!out.json)
3538
4125
  out.error(`Scheduled ${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}. Follow it: ${me} jobs logs ${job.id} -f`);
@@ -3564,8 +4151,13 @@ async function jobCommand(argv, env, out) {
3564
4151
  return 0;
3565
4152
  }
3566
4153
  case "runs": {
3567
- const jobId = need(rest[0], "a job id");
3568
- 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;
3569
4161
  print(all.length
3570
4162
  ? table([
3571
4163
  ["RUN", "ATTEMPT", "STATE", "EXIT", "SCHEDULED", "STARTED", "ENDED", "REASON"],
@@ -3599,8 +4191,7 @@ async function jobCommand(argv, env, out) {
3599
4191
  if (!(error instanceof NotFoundError))
3600
4192
  throw error;
3601
4193
  // A job's id: its latest run.
3602
- const runs = await (await api.runs(id, { limit: 100 })).toArray(Infinity);
3603
- const latest = runs.at(-1);
4194
+ const [latest] = (await api.runs(id, { limit: 1, order: "newest" })).data;
3604
4195
  if (!latest) {
3605
4196
  const job = await api.get(id);
3606
4197
  print(`${job.name} has not run yet. Next run ${when(job.nextRunAt)}.`, {
@@ -3658,6 +4249,86 @@ async function jobCommand(argv, env, out) {
3658
4249
  print(`${job.name} runs now${job.state === "paused" ? ", once; it stays paused" : ""}. Follow it: ${me} jobs logs ${job.id} -f`, job);
3659
4250
  return 0;
3660
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
+ }
3661
4332
  case "reschedule": {
3662
4333
  const args = parse(rest, [], ["cron", "timezone", "at"], `${me} jobs reschedule <id> --cron "0 3 * * *" [--timezone Europe/Berlin]`);
3663
4334
  const id = need(args.positional[0], "a job id");
@@ -3684,6 +4355,7 @@ async function jobCommand(argv, env, out) {
3684
4355
  "resume",
3685
4356
  "trigger",
3686
4357
  "reschedule",
4358
+ "update",
3687
4359
  "cancel",
3688
4360
  ]);
3689
4361
  }
@@ -4064,6 +4736,11 @@ async function execute(sbx, command, args, out, timeout, env,
4064
4736
  /** `run` stops its whole sandbox on Ctrl-C, so it handles the signal itself. */
4065
4737
  ownInterrupt = true) {
4066
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;
4067
4744
  const extras = await import("./cli-extras.js");
4068
4745
  const stdin = await extras.pipedStdin();
4069
4746
  const options = {
@@ -4072,6 +4749,7 @@ ownInterrupt = true) {
4072
4749
  ...(args.flags.has("env") ? { env: envPairs(args, env) } : {}),
4073
4750
  ...(has(args, "root") ? { user: "root" } : {}),
4074
4751
  ...(timeout !== undefined ? { timeoutMs: timeout * 1000 } : {}),
4752
+ ...(traced ? { traceFiles: true } : {}),
4075
4753
  };
4076
4754
  // Ctrl-C stops the command in the sandbox too, and says so; without this
4077
4755
  // the CLI exited and the command ran on unseen.
@@ -4099,6 +4777,9 @@ ownInterrupt = true) {
4099
4777
  finally {
4100
4778
  process.off("SIGINT", onInterrupt);
4101
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`);
4102
4783
  if (out.json)
4103
4784
  out.write(JSON.stringify(result));
4104
4785
  // Here the output streamed, whose timeout is 24 hours unless one was given
@@ -4167,11 +4848,20 @@ export function describeError(error, json) {
4167
4848
  requestId: error.requestId,
4168
4849
  idempotencyKey: error.idempotencyKey,
4169
4850
  details: error.details,
4851
+ retryable: error.retryable,
4852
+ ...(error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
4170
4853
  },
4171
4854
  });
4172
4855
  // In the command line's words: the option, not the request's field.
4173
4856
  const { message, hint } = cliRefusal(error);
4174
- 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
4175
4865
  ? // A refusal of what was sent is the caller's to fix; only a failure
4176
4866
  // of ours is worth a report.
4177
4867
  error.status >= 400 && error.status < 500