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
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { parse, stringify } from "yaml";
|
|
2
|
+
/** The most a step's `timeout:` may say, in seconds: GitHub's own 6 hours. */
|
|
3
|
+
const MOST_TIMEOUT_S = 6 * 3600;
|
|
4
|
+
const MOST_STEPS = 64;
|
|
5
|
+
const MOST_CACHE = 32;
|
|
6
|
+
// A project's own install: run once in setup and kept in the warm machine,
|
|
7
|
+
// so a check runs only its tests.
|
|
8
|
+
const INSTALL = /^(npm (ci|install|i)|yarn( install)?|pnpm (install|i)|bun (install|i)|pip3? install|python3? -m pip install|poetry install|uv sync|pipenv install|go mod download|cargo fetch|bundle install|composer install|mix deps\.get)(?=\s|$)/;
|
|
9
|
+
// Actions whose work a Runtime machine already did: the commit is checked
|
|
10
|
+
// out, and Node, Bun, Python, git and a C compiler are on it.
|
|
11
|
+
const DONE = /^actions\/checkout@/;
|
|
12
|
+
const TOOLS = /^(actions\/setup-(node|python|go|java|dotnet)|oven-sh\/setup-bun|pnpm\/action-setup|dtolnay\/rust-toolchain|actions-rs\/toolchain|ruby\/setup-ruby|astral-sh\/setup-uv|denoland\/setup-deno)@/;
|
|
13
|
+
const CACHE = /^actions\/cache(\/restore|\/save)?@/;
|
|
14
|
+
const HAS = "a machine has Node, Bun, Python, git and a C compiler; install anything else in setup";
|
|
15
|
+
const stepName = (raw) => raw
|
|
16
|
+
.replace(/[^A-Za-z0-9 _.:-]+/g, " ")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/^[^A-Za-z0-9]+/, "")
|
|
20
|
+
.slice(0, 63)
|
|
21
|
+
.trim() || "job";
|
|
22
|
+
const envName = (k) => /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(k) && !k.startsWith("RUNTIME_");
|
|
23
|
+
const quoteSh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
24
|
+
/** A value as one shell word: its literal text single-quoted, each variable
|
|
25
|
+
* `expand` put in (`${RUNTIME_CI_COMMIT}` for `${{ github.sha }}`) in double
|
|
26
|
+
* quotes, so the shell fills it in and nothing else in the value is read. */
|
|
27
|
+
const wordSh = (parts) => parts.map((p) => (typeof p === "string" ? quoteSh(p) : `"\${${p.v}}"`)).join("") || "''";
|
|
28
|
+
const asText = (v) => v === null || v === undefined
|
|
29
|
+
? ""
|
|
30
|
+
: typeof v === "string"
|
|
31
|
+
? v
|
|
32
|
+
: typeof v === "number" || typeof v === "boolean"
|
|
33
|
+
? `${v}`
|
|
34
|
+
: JSON.stringify(v);
|
|
35
|
+
/** An action's name with its pinned ref left off: `actions/cache@<sha>` is `actions/cache`. */
|
|
36
|
+
const action = (uses) => uses.split("@")[0];
|
|
37
|
+
/** Every combination of a GitHub `strategy.matrix`, its `exclude` and `include` applied. */
|
|
38
|
+
export function matrixCells(matrix) {
|
|
39
|
+
if (!matrix || typeof matrix !== "object")
|
|
40
|
+
return [{}];
|
|
41
|
+
const keys = Object.keys(matrix).filter((k) => k !== "include" && k !== "exclude");
|
|
42
|
+
let cells = keys.length ? [{}] : [];
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const values = matrix[key];
|
|
45
|
+
if (!Array.isArray(values))
|
|
46
|
+
continue;
|
|
47
|
+
cells = cells.flatMap((c) => values.map((v) => ({ ...c, [key]: asText(v) })));
|
|
48
|
+
}
|
|
49
|
+
const fits = (cell, rule) => Object.entries(rule).every(([k, v]) => cell[k] === v);
|
|
50
|
+
const text = (rule) => Object.fromEntries(Object.entries(rule && typeof rule === "object" ? rule : {}).map(([k, v]) => [k, asText(v)]));
|
|
51
|
+
for (const rule of matrix.exclude ?? [])
|
|
52
|
+
cells = cells.filter((c) => !fits(c, text(rule)));
|
|
53
|
+
for (const rule of matrix.include ?? []) {
|
|
54
|
+
const add = text(rule);
|
|
55
|
+
const base = Object.fromEntries(Object.entries(add).filter(([k]) => keys.includes(k)));
|
|
56
|
+
const matched = Object.keys(base).length ? cells.filter((c) => fits(c, base)) : [];
|
|
57
|
+
if (matched.length)
|
|
58
|
+
for (const c of matched)
|
|
59
|
+
Object.assign(c, add);
|
|
60
|
+
else
|
|
61
|
+
cells.push(add);
|
|
62
|
+
}
|
|
63
|
+
return cells.length ? cells : [{}];
|
|
64
|
+
}
|
|
65
|
+
/** Files a cache key hashes: GitHub's `hashFiles('a', 'b')` or CircleCI's `{{ checksum "a" }}`. */
|
|
66
|
+
export function cacheFiles(key) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const m of key.matchAll(/hashFiles\(([^)]*)\)/g))
|
|
69
|
+
for (const f of m[1].matchAll(/['"]([^'"]+)['"]/g))
|
|
70
|
+
out.push(f[1]);
|
|
71
|
+
for (const m of key.matchAll(/checksum\s+"([^"]+)"/g))
|
|
72
|
+
out.push(m[1]);
|
|
73
|
+
return out.filter((f) => !f.includes("{{") && !f.startsWith("/") && !f.startsWith("~"));
|
|
74
|
+
}
|
|
75
|
+
/** Whether a line installs the project's own dependencies and nothing else:
|
|
76
|
+
* `npm ci` or `pip install -r requirements.txt`, never `npm install pino@9`,
|
|
77
|
+
* which one matrix cell asks and another doesn't. */
|
|
78
|
+
export function installsOnly(line) {
|
|
79
|
+
const t = line.trim();
|
|
80
|
+
const m = INSTALL.exec(t);
|
|
81
|
+
if (!m || /[|;&<>$`]/.test(t))
|
|
82
|
+
return false;
|
|
83
|
+
const words = t.slice(m[0].length).trim().split(/\s+/).filter(Boolean);
|
|
84
|
+
for (let i = 0; i < words.length; i++) {
|
|
85
|
+
const w = words[i];
|
|
86
|
+
if (["-r", "--requirement", "-e", "--editable", "-c"].includes(w))
|
|
87
|
+
i++;
|
|
88
|
+
else if (!w.startsWith("-") && w !== ".")
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
class Writer {
|
|
94
|
+
steps = [];
|
|
95
|
+
setup = [];
|
|
96
|
+
cache = new Set();
|
|
97
|
+
notes = [];
|
|
98
|
+
note(where, what) {
|
|
99
|
+
const line = `${where}: ${what}`.replace(/\s+/g, " ");
|
|
100
|
+
if (!this.notes.includes(line))
|
|
101
|
+
this.notes.push(line);
|
|
102
|
+
}
|
|
103
|
+
/** A step's lines, its installs moved to setup when they run there as
|
|
104
|
+
* they did in the step: `movable` (the step names no folder and no env of
|
|
105
|
+
* its own) and no line before it changes folder. Setup runs at the
|
|
106
|
+
* repository's root with no step's env, so `npm ci` under
|
|
107
|
+
* `working-directory: web`, or after `cd web`, stays in its step. */
|
|
108
|
+
lines(script, movable = true) {
|
|
109
|
+
const kept = [];
|
|
110
|
+
let moved = !movable;
|
|
111
|
+
for (const line of script.split("\n")) {
|
|
112
|
+
if (/(^|[;&|(]\s*)(cd|pushd)\b/.test(line.trim()))
|
|
113
|
+
moved = true;
|
|
114
|
+
if (!moved && installsOnly(line)) {
|
|
115
|
+
if (!this.setup.includes(line.trim()))
|
|
116
|
+
this.setup.push(line.trim());
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
kept.push(line.replace(/\s+$/, ""));
|
|
120
|
+
}
|
|
121
|
+
while (kept.length && !kept[kept.length - 1].trim())
|
|
122
|
+
kept.pop();
|
|
123
|
+
while (kept.length && !kept[0].trim())
|
|
124
|
+
kept.shift();
|
|
125
|
+
return kept;
|
|
126
|
+
}
|
|
127
|
+
unique(name) {
|
|
128
|
+
let n = stepName(name);
|
|
129
|
+
for (let i = 2; this.steps.some((s) => s.name === n); i++)
|
|
130
|
+
n = stepName(`${name} ${i}`);
|
|
131
|
+
return n;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** A job's steps as one script. Each step of GitHub's or CircleCI's ran in a
|
|
135
|
+
* shell of its own, so a `cd` or an `export` in one never reached the next:
|
|
136
|
+
* with more than one, each is a subshell. Undefined when none runs anything. */
|
|
137
|
+
function script(blocks) {
|
|
138
|
+
const runs = blocks.filter((b) => b.some((l) => l.trim() && !l.trim().startsWith("#")));
|
|
139
|
+
if (!runs.length)
|
|
140
|
+
return undefined;
|
|
141
|
+
const lines = blocks.flatMap((b) => runs.length > 1 && runs.includes(b) ? ["(", ...b.map((l) => ` ${l}`), ")"] : b);
|
|
142
|
+
return ["set -e", ...lines].join("\n");
|
|
143
|
+
}
|
|
144
|
+
/** `${{ ... }}` in a GitHub value: matrix cells, the commit and the branch
|
|
145
|
+
* filled in; a secret, or anything else only GitHub knows, leaves the value
|
|
146
|
+
* out (undefined), and `bad` is told what. */
|
|
147
|
+
function expand(value, cell, bad) {
|
|
148
|
+
const parts = expandParts(value, cell, bad);
|
|
149
|
+
return parts?.map((p) => (typeof p === "string" ? p : `\${${p.v}}`)).join("");
|
|
150
|
+
}
|
|
151
|
+
/** `expand`'s value as its literal parts and the variables put in for
|
|
152
|
+
* GitHub's values, for a value written as one shell word (`wordSh`). */
|
|
153
|
+
function expandParts(value, cell, bad) {
|
|
154
|
+
let ok = true;
|
|
155
|
+
const parts = [];
|
|
156
|
+
const text = (t) => {
|
|
157
|
+
if (!t)
|
|
158
|
+
return;
|
|
159
|
+
const last = parts[parts.length - 1];
|
|
160
|
+
if (typeof last === "string")
|
|
161
|
+
parts[parts.length - 1] = last + t;
|
|
162
|
+
else
|
|
163
|
+
parts.push(t);
|
|
164
|
+
};
|
|
165
|
+
let at = 0;
|
|
166
|
+
for (const m of value.matchAll(/\$\{\{([\s\S]*?)\}\}/g)) {
|
|
167
|
+
text(value.slice(at, m.index));
|
|
168
|
+
at = m.index + m[0].length;
|
|
169
|
+
const expr = m[1].trim();
|
|
170
|
+
const c = /^matrix\.([A-Za-z0-9_-]+)$/.exec(expr);
|
|
171
|
+
const e = /^env\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
|
|
172
|
+
if (c && cell[c[1]] !== undefined)
|
|
173
|
+
text(cell[c[1]]);
|
|
174
|
+
else if (expr === "github.sha")
|
|
175
|
+
parts.push({ v: "RUNTIME_CI_COMMIT" });
|
|
176
|
+
else if (expr === "github.ref_name" || expr === "github.head_ref")
|
|
177
|
+
parts.push({ v: "RUNTIME_CI_BRANCH" });
|
|
178
|
+
else if (expr === "github.workspace")
|
|
179
|
+
parts.push({ v: "PWD" });
|
|
180
|
+
else if (e)
|
|
181
|
+
parts.push({ v: e[1] });
|
|
182
|
+
else {
|
|
183
|
+
const s = /^secrets\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
|
|
184
|
+
bad(s ? `secret ${s[1]}` : m[0]);
|
|
185
|
+
ok = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
text(value.slice(at));
|
|
189
|
+
return ok ? parts : undefined;
|
|
190
|
+
}
|
|
191
|
+
/** A YAML mapping, or an empty one for anything else. */
|
|
192
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
193
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
194
|
+
/** A key that is one name or a list of them (`needs: a` or `needs: [a, b]`). */
|
|
195
|
+
const names = (v) => (typeof v === "string" ? [v] : list(v).map(asText));
|
|
196
|
+
const runsOnPush = (on) => {
|
|
197
|
+
const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(obj(on));
|
|
198
|
+
return events.some((e) => e === "push" || e === "pull_request" || e === "pull_request_target");
|
|
199
|
+
};
|
|
200
|
+
function read(w, file) {
|
|
201
|
+
try {
|
|
202
|
+
return obj(parse(file.text));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
w.note(file.path, `not read: ${error.message.split("\n")[0]}`);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function fromActions(w, file) {
|
|
210
|
+
const doc = read(w, file);
|
|
211
|
+
if (!doc?.jobs)
|
|
212
|
+
return;
|
|
213
|
+
if (!runsOnPush(doc.on)) {
|
|
214
|
+
w.note(file.path, "skipped: it runs on neither push nor pull_request");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const top = obj(doc.env);
|
|
218
|
+
const named = new Map();
|
|
219
|
+
const made = [];
|
|
220
|
+
for (const [id, raw] of Object.entries(obj(doc.jobs))) {
|
|
221
|
+
const job = obj(raw);
|
|
222
|
+
const where = `${file.path} ${id}`;
|
|
223
|
+
if (job.uses) {
|
|
224
|
+
w.note(where, `skipped: a reusable workflow (${asText(job.uses)}); import that file instead`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const runsOn = asText(job["runs-on"]);
|
|
228
|
+
if (job.if)
|
|
229
|
+
w.note(where, `its if (${asText(job.if)}) is not carried: it runs on every check`);
|
|
230
|
+
const timeout = jobTimeout(w, where, job["timeout-minutes"]);
|
|
231
|
+
if (job["continue-on-error"])
|
|
232
|
+
w.note(where, "continue-on-error is not carried: a failure fails the check");
|
|
233
|
+
const dir = asText(obj(obj(job.defaults).run)["working-directory"]);
|
|
234
|
+
const cells = matrixCells(obj(obj(job.strategy).matrix));
|
|
235
|
+
const jobSteps = list(job.steps).map(obj);
|
|
236
|
+
// What the kept commands read: a matrix key only a left-out setup
|
|
237
|
+
// action read (a Node version, say) names no step.
|
|
238
|
+
const reads = JSON.stringify([
|
|
239
|
+
job.env,
|
|
240
|
+
job.services,
|
|
241
|
+
jobSteps.map((s) => [s.run, s.env, s["working-directory"]]),
|
|
242
|
+
]);
|
|
243
|
+
const steps = [];
|
|
244
|
+
const bodies = new Set();
|
|
245
|
+
for (const cell of cells) {
|
|
246
|
+
const os = expand(runsOn, cell, () => { }) ?? runsOn;
|
|
247
|
+
if (/macos|windows/i.test(os)) {
|
|
248
|
+
w.note(where, `${os} is left out: Runtime machines are Linux`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const bad = (what) => w.note(where, `${what} is not carried; set it in the step's env or a Runtime secret`);
|
|
252
|
+
const env = {};
|
|
253
|
+
for (const [k, v] of Object.entries({ ...top, ...obj(job.env) })) {
|
|
254
|
+
const value = expand(asText(v), cell, bad);
|
|
255
|
+
if (value !== undefined && envName(k))
|
|
256
|
+
env[k] = value;
|
|
257
|
+
}
|
|
258
|
+
const blocks = [];
|
|
259
|
+
for (const [name, raw] of Object.entries(obj(job.services))) {
|
|
260
|
+
const svc = obj(raw);
|
|
261
|
+
const image = expand(asText(typeof raw === "string" ? raw : svc.image), cell, bad);
|
|
262
|
+
if (!image)
|
|
263
|
+
continue;
|
|
264
|
+
const flags = Object.entries(obj(svc.env)).map(([k, v]) => `-e ${k}=${wordSh(expandParts(asText(v), cell, bad) ?? [])}`);
|
|
265
|
+
const ports = list(svc.ports).map(asText);
|
|
266
|
+
const moved = ports.some((p) => {
|
|
267
|
+
const [a, b] = p.split(":");
|
|
268
|
+
return b !== undefined && a !== b;
|
|
269
|
+
});
|
|
270
|
+
const net = moved ? ports.map((p) => `-p ${p}`) : ["--network host"];
|
|
271
|
+
blocks.push([
|
|
272
|
+
["docker run -d --name", stepName(name).replace(/ /g, "-"), ...net, ...flags, image].join(" "),
|
|
273
|
+
]);
|
|
274
|
+
w.note(where, `service ${name} starts with Docker in the step; wait for it as its health check did`);
|
|
275
|
+
}
|
|
276
|
+
for (const s of jobSteps) {
|
|
277
|
+
const uses = asText(s.uses);
|
|
278
|
+
if (uses) {
|
|
279
|
+
if (DONE.test(uses))
|
|
280
|
+
continue;
|
|
281
|
+
if (TOOLS.test(uses))
|
|
282
|
+
w.note(where, `${action(uses)} is not needed: ${HAS}`);
|
|
283
|
+
else if (CACHE.test(uses))
|
|
284
|
+
for (const f of cacheFiles(asText(obj(s.with).key)))
|
|
285
|
+
w.cache.add(f);
|
|
286
|
+
else {
|
|
287
|
+
w.note(where, `action ${action(uses)} is not carried; run its commands in the step`);
|
|
288
|
+
blocks.push([`# was: uses: ${action(uses)}`]);
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!s.run)
|
|
293
|
+
continue;
|
|
294
|
+
const said = asText(s.name ?? s.run).split("\n")[0];
|
|
295
|
+
if (s.if && /failure\(\)|cancelled\(\)/.test(asText(s.if))) {
|
|
296
|
+
w.note(where, `step "${said}" ran only on failure; left out`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (s.if)
|
|
300
|
+
w.note(where, `step "${said}": its if (${asText(s.if)}) is not carried`);
|
|
301
|
+
const run = expand(asText(s.run), cell, bad);
|
|
302
|
+
if (run === undefined) {
|
|
303
|
+
blocks.push([`# left out (it needs a secret or a GitHub value): ${said}`]);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const exports = [];
|
|
307
|
+
for (const [k, v] of Object.entries(obj(s.env))) {
|
|
308
|
+
const value = expandParts(asText(v), cell, bad);
|
|
309
|
+
if (value !== undefined && envName(k))
|
|
310
|
+
exports.push(`export ${k}=${wordSh(value)}`);
|
|
311
|
+
}
|
|
312
|
+
const wd = asText(s["working-directory"]) || dir;
|
|
313
|
+
const lines = w.lines(run, !wd && !exports.length);
|
|
314
|
+
if (!lines.length)
|
|
315
|
+
continue;
|
|
316
|
+
blocks.push([...(wd ? [`cd ${quoteSh(wd)}`] : []), ...exports, ...lines]);
|
|
317
|
+
}
|
|
318
|
+
const run = script(blocks);
|
|
319
|
+
if (!run) {
|
|
320
|
+
w.note(where, "has no command left to run here; not written");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// Cells that differ only in what a left-out action read run the same
|
|
324
|
+
// commands: one step runs them.
|
|
325
|
+
const body = JSON.stringify([run, env]);
|
|
326
|
+
if (bodies.has(body)) {
|
|
327
|
+
w.note(where, "matrix cells that differ only in a tool's version run once, on the machine's own");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
bodies.add(body);
|
|
331
|
+
const label = Object.entries(cell)
|
|
332
|
+
.filter(([k]) => reads.includes(`matrix.${k}`))
|
|
333
|
+
.map(([, v]) => v)
|
|
334
|
+
.join(" ");
|
|
335
|
+
const title = asText(job.name || id).includes("${{") ? id : asText(job.name || id);
|
|
336
|
+
steps.push({
|
|
337
|
+
name: w.unique(label ? `${title} ${label}` : title),
|
|
338
|
+
run,
|
|
339
|
+
needs: [],
|
|
340
|
+
env,
|
|
341
|
+
...(timeout ? { timeout } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
made.push({ steps, needs: names(job.needs) });
|
|
345
|
+
named.set(id, steps.map((s) => s.name));
|
|
346
|
+
}
|
|
347
|
+
for (const m of made)
|
|
348
|
+
for (const s of m.steps) {
|
|
349
|
+
s.needs = m.needs.flatMap((n) => named.get(n) ?? []);
|
|
350
|
+
w.steps.push(s);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** A GitHub job's `timeout-minutes` as a step's `timeout:` in seconds;
|
|
354
|
+
* undefined (the default, 6 hours as GitHub's) when it says none, or a value
|
|
355
|
+
* that is not a number, which is noted. */
|
|
356
|
+
function jobTimeout(w, where, raw) {
|
|
357
|
+
if (raw === undefined || raw === null || raw === "")
|
|
358
|
+
return undefined;
|
|
359
|
+
const minutes = Number(raw);
|
|
360
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
361
|
+
w.note(where, `timeout-minutes ${asText(raw)} is not carried; the step has the default 6 hours`);
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const seconds = Math.ceil(minutes * 60);
|
|
365
|
+
if (seconds > MOST_TIMEOUT_S) {
|
|
366
|
+
w.note(where, `asks ${asText(raw)} minutes; a step here runs at most 360, as on GitHub`);
|
|
367
|
+
return MOST_TIMEOUT_S;
|
|
368
|
+
}
|
|
369
|
+
return seconds;
|
|
370
|
+
}
|
|
371
|
+
// CircleCI's resource classes by vCPUs: medium is 2, large 4, xlarge 8, 2xlarge 16.
|
|
372
|
+
const CIRCLE_MACHINES = {
|
|
373
|
+
small: "2cpu",
|
|
374
|
+
medium: "2cpu",
|
|
375
|
+
large: "4cpu",
|
|
376
|
+
xlarge: "8cpu",
|
|
377
|
+
"2xlarge": "16cpu",
|
|
378
|
+
"2xlarge+": "16cpu",
|
|
379
|
+
};
|
|
380
|
+
const KEPT_BY_CHECK = [
|
|
381
|
+
"store_artifacts",
|
|
382
|
+
"store_test_results",
|
|
383
|
+
"persist_to_workspace",
|
|
384
|
+
"attach_workspace",
|
|
385
|
+
];
|
|
386
|
+
function fromCircle(w, file) {
|
|
387
|
+
const doc = read(w, file);
|
|
388
|
+
if (!doc)
|
|
389
|
+
return;
|
|
390
|
+
const jobs = obj(doc.jobs);
|
|
391
|
+
const commands = obj(doc.commands);
|
|
392
|
+
if (doc.orbs)
|
|
393
|
+
w.note(file.path, `orbs (${Object.keys(obj(doc.orbs)).join(", ")}) are not carried; run their commands in the step`);
|
|
394
|
+
// The jobs each workflow runs, with what each requires; every job when no workflow says.
|
|
395
|
+
const runs = [];
|
|
396
|
+
const workflows = Object.entries(obj(doc.workflows))
|
|
397
|
+
.filter(([k, v]) => k !== "version" && obj(v).jobs)
|
|
398
|
+
.map(([k, v]) => [k, obj(v)]);
|
|
399
|
+
for (const [wf, flow] of workflows) {
|
|
400
|
+
if (flow.triggers) {
|
|
401
|
+
w.note(`${file.path} ${wf}`, "skipped: a scheduled workflow");
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
for (const entry of list(flow.jobs)) {
|
|
405
|
+
if (typeof entry === "string")
|
|
406
|
+
runs.push({ job: entry, name: entry, requires: [] });
|
|
407
|
+
else
|
|
408
|
+
for (const [job, raw] of Object.entries(obj(entry))) {
|
|
409
|
+
const opts = obj(raw);
|
|
410
|
+
if (opts.type === "approval") {
|
|
411
|
+
w.note(`${file.path} ${job}`, "an approval hold is not carried");
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (opts.matrix)
|
|
415
|
+
w.note(`${file.path} ${job}`, "its matrix is not carried; one step is written");
|
|
416
|
+
runs.push({ job, name: asText(opts.name ?? job), requires: names(opts.requires) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!workflows.length)
|
|
421
|
+
for (const job of Object.keys(jobs))
|
|
422
|
+
runs.push({ job, name: job, requires: [] });
|
|
423
|
+
const named = new Map();
|
|
424
|
+
const pending = [];
|
|
425
|
+
for (const r of runs) {
|
|
426
|
+
if (named.has(r.name))
|
|
427
|
+
continue;
|
|
428
|
+
const where = `${file.path} ${r.job}`;
|
|
429
|
+
if (!jobs[r.job]) {
|
|
430
|
+
w.note(where, "is an orb's job; not carried");
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const job = obj(jobs[r.job]);
|
|
434
|
+
if (job.macos || job.windows) {
|
|
435
|
+
w.note(where, "skipped: Runtime machines are Linux");
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const env = {};
|
|
439
|
+
for (const [k, v] of Object.entries(obj(job.environment)))
|
|
440
|
+
if (envName(k))
|
|
441
|
+
env[k] = asText(v);
|
|
442
|
+
const blocks = [];
|
|
443
|
+
const docker = list(job.docker).map(obj);
|
|
444
|
+
if (docker[0]?.image)
|
|
445
|
+
w.note(where, `ran in ${asText(docker[0].image)}; here ${HAS}`);
|
|
446
|
+
for (const svc of docker.slice(1)) {
|
|
447
|
+
const image = asText(svc.image);
|
|
448
|
+
const flags = Object.entries(obj(svc.environment)).map(([k, v]) => `-e ${k}=${quoteSh(asText(v))}`);
|
|
449
|
+
const name = stepName(asText(svc.name) || (image.split(":")[0].split("/").pop() ?? "")).replace(/ /g, "-");
|
|
450
|
+
blocks.push([["docker run -d --name", name, "--network host", ...flags, image].join(" ")]);
|
|
451
|
+
w.note(where, `service ${image} starts with Docker in the step; wait for it before the tests`);
|
|
452
|
+
}
|
|
453
|
+
const dir = asText(job.working_directory);
|
|
454
|
+
const walk = (steps, depth) => {
|
|
455
|
+
for (const raw of steps) {
|
|
456
|
+
const [kind, opts] = typeof raw === "string"
|
|
457
|
+
? [raw, undefined]
|
|
458
|
+
: (Object.entries(obj(raw))[0] ?? ["", undefined]);
|
|
459
|
+
// The checkout is made; Docker runs on the machine itself.
|
|
460
|
+
if (kind === "checkout" || kind === "setup_remote_docker")
|
|
461
|
+
continue;
|
|
462
|
+
if (kind === "run") {
|
|
463
|
+
const o = obj(opts);
|
|
464
|
+
const cmd = asText(typeof opts === "string" ? opts : o.command);
|
|
465
|
+
if (o.when === "on_fail") {
|
|
466
|
+
w.note(where, `step "${asText(o.name ?? cmd.split("\n")[0])}" ran only on failure; left out`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const exports = Object.entries(obj(o.environment))
|
|
470
|
+
.filter(([k]) => envName(k))
|
|
471
|
+
.map(([k, v]) => `export ${k}=${quoteSh(asText(v))}`);
|
|
472
|
+
// A step's working_directory is under the job's, which is where
|
|
473
|
+
// the checkout went: here, the repository's root.
|
|
474
|
+
const wd = asText(o.working_directory);
|
|
475
|
+
const cd = wd && !/^[~/$]/.test(wd) && wd !== "." ? [`cd ${quoteSh(wd)}`] : [];
|
|
476
|
+
const lines = w.lines(cmd, !cd.length && !exports.length);
|
|
477
|
+
if (!lines.length)
|
|
478
|
+
continue;
|
|
479
|
+
if (wd && !cd.length && wd !== "." && wd !== dir)
|
|
480
|
+
w.note(where, `working_directory ${wd} is not carried; the step runs at the repository's root`);
|
|
481
|
+
blocks.push([...cd, ...exports, ...lines]);
|
|
482
|
+
}
|
|
483
|
+
else if (kind === "restore_cache" || kind === "save_cache") {
|
|
484
|
+
const o = obj(opts);
|
|
485
|
+
for (const key of [o.key, ...list(o.keys)])
|
|
486
|
+
for (const f of cacheFiles(asText(key)))
|
|
487
|
+
w.cache.add(f);
|
|
488
|
+
}
|
|
489
|
+
else if (commands[kind] && depth < 4) {
|
|
490
|
+
if (opts)
|
|
491
|
+
w.note(where, `command ${kind}'s parameters are not filled in`);
|
|
492
|
+
walk(list(obj(commands[kind]).steps), depth + 1);
|
|
493
|
+
}
|
|
494
|
+
else if (KEPT_BY_CHECK.includes(kind)) {
|
|
495
|
+
w.note(where, `${kind} is not carried: a check keeps each job's log, and a failed job's sandbox`);
|
|
496
|
+
}
|
|
497
|
+
else if (kind) {
|
|
498
|
+
w.note(where, `${kind} (an orb's or unknown step) is not carried; run its commands in the step`);
|
|
499
|
+
blocks.push([`# was: ${kind}`]);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
walk(list(job.steps), 0);
|
|
504
|
+
const run = script(blocks);
|
|
505
|
+
if (!run) {
|
|
506
|
+
w.note(where, "has no command left to run here; not written");
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const machine = CIRCLE_MACHINES[asText(job.resource_class).replace(/^.*\./, "")];
|
|
510
|
+
const step = {
|
|
511
|
+
name: w.unique(r.name),
|
|
512
|
+
run,
|
|
513
|
+
needs: [],
|
|
514
|
+
env,
|
|
515
|
+
...(machine && machine !== "4cpu" ? { machine } : {}),
|
|
516
|
+
};
|
|
517
|
+
named.set(r.name, step.name);
|
|
518
|
+
pending.push({ step, requires: r.requires });
|
|
519
|
+
}
|
|
520
|
+
for (const p of pending) {
|
|
521
|
+
p.step.needs = p.requires.map((n) => named.get(n)).filter((n) => !!n);
|
|
522
|
+
w.steps.push(p.step);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/** `runtime.yml` from GitHub Actions workflows (`.github/workflows/*.yml`)
|
|
526
|
+
* and a CircleCI config (`.circleci/config.yml`), each given with its path. */
|
|
527
|
+
export function importCi(files) {
|
|
528
|
+
const w = new Writer();
|
|
529
|
+
for (const f of [...files].sort((a, b) => a.path.localeCompare(b.path)))
|
|
530
|
+
if (f.path.includes(".circleci/"))
|
|
531
|
+
fromCircle(w, f);
|
|
532
|
+
else
|
|
533
|
+
fromActions(w, f);
|
|
534
|
+
if (!w.steps.length)
|
|
535
|
+
return { yaml: null, notes: w.notes, steps: [] };
|
|
536
|
+
if (w.steps.length > MOST_STEPS) {
|
|
537
|
+
w.note("runtime.yml", `${w.steps.length} steps; a file holds ${MOST_STEPS}, the rest are left out`);
|
|
538
|
+
w.steps = w.steps.slice(0, MOST_STEPS);
|
|
539
|
+
}
|
|
540
|
+
const cache = [...w.cache].slice(0, MOST_CACHE);
|
|
541
|
+
const doc = {};
|
|
542
|
+
if (w.setup.length)
|
|
543
|
+
doc.setup = w.setup.join("\n");
|
|
544
|
+
if (cache.length)
|
|
545
|
+
doc.cache = cache;
|
|
546
|
+
doc.steps = w.steps.map((s) => ({
|
|
547
|
+
name: s.name,
|
|
548
|
+
run: s.run,
|
|
549
|
+
...(s.needs.length ? { needs: s.needs } : {}),
|
|
550
|
+
...(s.machine ? { machine: s.machine } : {}),
|
|
551
|
+
...(s.timeout ? { timeout: s.timeout } : {}),
|
|
552
|
+
...(Object.keys(s.env).length ? { env: s.env } : {}),
|
|
553
|
+
}));
|
|
554
|
+
const head = [
|
|
555
|
+
`# Written by \`runtime ci import\` from ${files.map((f) => f.path).join(", ")}.`,
|
|
556
|
+
...(w.notes.length
|
|
557
|
+
? ["# Not carried over, or carried differently:", ...w.notes.map((n) => `# - ${n}`)]
|
|
558
|
+
: []),
|
|
559
|
+
];
|
|
560
|
+
return {
|
|
561
|
+
yaml: `${head.join("\n")}\n${stringify(doc, { lineWidth: 0 })}`,
|
|
562
|
+
notes: w.notes,
|
|
563
|
+
steps: w.steps.map((s) => s.name),
|
|
564
|
+
};
|
|
565
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type Sandbox } from "./sandbox.js";
|
|
2
3
|
import { RuntimeError } from "./errors.js";
|
|
3
4
|
import type { OutOfMemory, Usage } from "./types.js";
|
|
4
5
|
import type { SwitchProvider } from "./products/switching.js";
|
|
@@ -31,6 +32,9 @@ declare const kit: {
|
|
|
31
32
|
table: (rows: string[][]) => string;
|
|
32
33
|
usage: (message: string) => RuntimeError;
|
|
33
34
|
unknown: (what: string, word: string, choices: readonly string[]) => RuntimeError;
|
|
35
|
+
/** `sandboxes exec`'s run and output, for any machine that execs. */
|
|
36
|
+
execute: (machine: Pick<Sandbox, "id" | "exec">, command: string[], args: Args, out: Out, env: NodeJS.ProcessEnv) => Promise<number>;
|
|
37
|
+
copyFile: (from: string, to: string, use: (id: string) => Promise<Pick<Sandbox, "files">>, side: string) => Promise<void>;
|
|
34
38
|
};
|
|
35
39
|
export type CliKit = typeof kit;
|
|
36
40
|
export declare function run(argv: string[], env?: NodeJS.ProcessEnv, out?: Out): Promise<number>;
|
|
@@ -46,8 +50,9 @@ export declare function usageCsv(u: Usage): string;
|
|
|
46
50
|
* command: the API's message and hint, on standard error. A bare exit -9 (or
|
|
47
51
|
* 137) said nothing of memory until 9 October 2026. */
|
|
48
52
|
export declare function outOfMemoryLines(memory: OutOfMemory | undefined): string | undefined;
|
|
49
|
-
/** How to move a rival's code across, when one import
|
|
50
|
-
|
|
53
|
+
/** How to move a rival's code across, when one import (or, for Fly's
|
|
54
|
+
* Machines, one address) does it. `from` is the name the caller gave. */
|
|
55
|
+
export declare function dropInLines(provider: SwitchProvider | undefined, from?: string): string[];
|
|
51
56
|
/** A secret's value, from standard input only, so it never sits in shell
|
|
52
57
|
* history or a process list. Piped: everything, less one trailing newline.
|
|
53
58
|
* A terminal: one line, not shown. */
|
|
@@ -62,16 +67,22 @@ export declare function cleanSecret(raw: string): string;
|
|
|
62
67
|
* (ARCHITECTURE.md section 10, "Per-path rules on an egress secret") and says
|
|
63
68
|
* which one it refused. */
|
|
64
69
|
export declare function parseSecretRules(values: string[]): SecretRule[];
|
|
65
|
-
/** `--only-label role=hub,tier=x --only-sandbox <id>`: a
|
|
66
|
-
* hold every pair of any one --only-label gets the
|
|
67
|
-
* --only-sandbox
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
+
/** `--only-label role=hub,tier=x --only-sandbox <id> --only-app <id>`: a
|
|
71
|
+
* sandbox whose labels hold every pair of any one --only-label gets the
|
|
72
|
+
* secret, and so does each --only-sandbox and every copy of each --only-app.
|
|
73
|
+
* `--all-sandboxes`: every sandbox again. Nothing: undefined, so a
|
|
74
|
+
* replacement keeps the scope it has. */
|
|
75
|
+
export declare function parseSecretScope(labels: string[] | undefined, sandboxes: string[] | undefined, all: boolean, apps?: string[]): SecretScope | undefined;
|
|
70
76
|
/** Who gets a secret, in a few words. */
|
|
71
77
|
export declare const scopeText: (only: SecretScope | undefined) => string;
|
|
72
78
|
export declare function secretsCommand(argv: string[], env: NodeJS.ProcessEnv, out: Out,
|
|
73
79
|
/** Tests give the value here; the CLI reads standard input. */
|
|
74
80
|
read?: () => Promise<string>): Promise<number>;
|
|
81
|
+
/** A booking's time on the command line: an ISO time, `now`, or HH:MM on this
|
|
82
|
+
* machine's clock. HH:MM is the next such time at or after `after` (the
|
|
83
|
+
* window's start for `--to`, so an end before the start is the next day, and
|
|
84
|
+
* `now` for `--from`: today, or tomorrow once it has passed). */
|
|
85
|
+
export declare function bookingTime(name: string, value: string, now: number, after?: number): Date;
|
|
75
86
|
export declare function imageCommand(argv: string[], env: NodeJS.ProcessEnv, out: Out,
|
|
76
87
|
/** Tests give the registry secret here; the CLI reads standard input. */
|
|
77
88
|
read?: () => Promise<string>): Promise<number>;
|