withruntime 0.23.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPATIBILITY.md +66 -5
- package/E2B.md +1 -1
- package/README.md +36 -2
- package/README.src.md +35 -1
- package/compatibility-lock.json +3 -3
- package/dist/agents/browser.d.ts +12 -0
- package/dist/agents/browser.js +12 -0
- package/dist/agents/index.d.ts +7 -7
- package/dist/agents/index.js +7 -8
- package/dist/agents-cli.d.ts +1 -1
- package/dist/agents-cli.js +250 -3
- package/dist/agents-deploy.d.ts +186 -0
- package/dist/agents-deploy.js +752 -0
- package/dist/agents-file.d.ts +19 -0
- package/dist/agents-file.js +135 -0
- package/dist/apps/index.d.ts +10 -0
- package/dist/apps/index.js +11 -0
- package/dist/apps-cli.d.ts +3 -1
- package/dist/apps-cli.js +285 -38
- package/dist/blaxel/errors.js +2 -1
- package/dist/ci/index.d.ts +13 -0
- package/dist/ci/index.js +14 -0
- package/dist/ci-cli.d.ts +10 -0
- package/dist/ci-cli.js +402 -0
- package/dist/ci-import.d.ts +23 -0
- package/dist/ci-import.js +565 -0
- package/dist/cli.d.ts +18 -7
- package/dist/cli.js +775 -85
- package/dist/daytona/errors.js +2 -1
- package/dist/e2b/errors.js +2 -1
- package/dist/e2b/filesystem.d.ts +1 -0
- package/dist/e2b/filesystem.js +1 -1
- package/dist/e2b/sandbox.d.ts +11 -2
- package/dist/e2b/sandbox.js +22 -6
- package/dist/errors.d.ts +11 -4
- package/dist/errors.js +27 -4
- package/dist/fly/index.d.ts +102 -0
- package/dist/fly/index.js +930 -0
- package/dist/index.d.ts +7 -3
- package/dist/machine.d.ts +10 -6
- package/dist/machine.js +14 -6
- package/dist/modal/index.d.ts +34 -0
- package/dist/modal/index.js +128 -22
- package/dist/products/agents.d.ts +101 -3
- package/dist/products/agents.js +49 -0
- package/dist/products/apps.d.ts +206 -10
- package/dist/products/apps.js +172 -1
- package/dist/products/ci.d.ts +335 -0
- package/dist/products/ci.js +108 -0
- package/dist/products/index.d.ts +8 -0
- package/dist/products/index.js +15 -0
- package/dist/products/jobs.d.ts +105 -5
- package/dist/products/jobs.js +47 -3
- package/dist/products/network.d.ts +13 -0
- package/dist/products/observability.d.ts +1 -1
- package/dist/products/plans.d.ts +131 -0
- package/dist/products/plans.js +24 -0
- package/dist/products/reservations.d.ts +108 -0
- package/dist/products/reservations.js +41 -0
- package/dist/products/resources.d.ts +2 -1
- package/dist/products/secrets.d.ts +90 -6
- package/dist/products/secrets.js +8 -2
- package/dist/proxy.d.ts +3 -3
- package/dist/proxy.js +20 -0
- package/dist/sandbox.d.ts +29 -1
- package/dist/sandbox.js +56 -3
- package/dist/snapshots.d.ts +1 -1
- package/dist/switched-off.d.ts +5 -0
- package/dist/switched-off.js +8 -0
- package/dist/tar.d.ts +1 -1
- package/dist/tar.js +19 -1
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +48 -9
- package/dist/types.d.ts +35 -4
- package/dist/vercel/errors.d.ts +1 -1
- package/dist/vercel/errors.js +2 -1
- package/dist/vercel/snapshot.js +4 -2
- 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
|
-
[--
|
|
55
|
-
|
|
56
|
-
only that sandbox
|
|
57
|
-
|
|
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]
|
|
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"],
|
|
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
|
|
718
|
-
//
|
|
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
|
-
/**
|
|
1483
|
-
|
|
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
|
|
1605
|
-
throw unknown("provider", from, Object.keys(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
2382
|
-
* hold every pair of any one --only-label gets the
|
|
2383
|
-
* --only-sandbox
|
|
2384
|
-
*
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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 (
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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 ${
|
|
2544
|
-
: `Stored ${saved.name}. Every sandbox sees ${
|
|
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
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
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
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
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
|
-
|
|
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]
|
|
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>
|
|
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
|
|
3493
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
3568
|
-
|
|
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
|
|
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
|
-
|
|
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
|