@ivan-bekker/ghav 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -2
- package/dist/server/index.js +2330 -0
- package/dist/web/assets/index-CbHqRNlT.js +73 -0
- package/dist/web/assets/index-DLQqHehA.css +2 -0
- package/dist/web/index.html +14 -0
- package/package.json +65 -4
|
@@ -0,0 +1,2330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server/index.ts
|
|
4
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
5
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync6 } from "node:fs";
|
|
6
|
+
import path11 from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { serve } from "@hono/node-server";
|
|
9
|
+
|
|
10
|
+
// src/server/app.ts
|
|
11
|
+
import { createReadStream, statSync as statSync2 } from "node:fs";
|
|
12
|
+
import path7 from "node:path";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
15
|
+
import { Hono } from "hono";
|
|
16
|
+
import { bodyLimit } from "hono/body-limit";
|
|
17
|
+
import { streamSSE } from "hono/streaming";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
// src/shared/types.ts
|
|
21
|
+
var FINAL_STATUSES = ["success", "failure", "cancelled", "error"];
|
|
22
|
+
var DEFAULT_PLATFORMS = [
|
|
23
|
+
"ubuntu-latest=catthehacker/ubuntu:act-latest",
|
|
24
|
+
"ubuntu-22.04=catthehacker/ubuntu:act-22.04",
|
|
25
|
+
"ubuntu-20.04=catthehacker/ubuntu:act-20.04"
|
|
26
|
+
];
|
|
27
|
+
var MIN_CONCURRENT_RUNS = 1;
|
|
28
|
+
var MAX_CONCURRENT_RUNS = 8;
|
|
29
|
+
var DEFAULT_CONCURRENT_RUNS = 4;
|
|
30
|
+
var DEFAULT_REPO_CONFIG = {
|
|
31
|
+
platforms: DEFAULT_PLATFORMS,
|
|
32
|
+
env: {},
|
|
33
|
+
vars: {},
|
|
34
|
+
pull: false,
|
|
35
|
+
offline: false,
|
|
36
|
+
containerArchitecture: ""
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/server/act/exec.ts
|
|
40
|
+
import { execFile } from "node:child_process";
|
|
41
|
+
|
|
42
|
+
// src/server/workflows.ts
|
|
43
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { parse } from "yaml";
|
|
46
|
+
var WORKFLOWS_DIR = ".github/workflows";
|
|
47
|
+
var isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
48
|
+
var isScalar = (x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean";
|
|
49
|
+
function toList(x) {
|
|
50
|
+
if (typeof x === "string") return [x];
|
|
51
|
+
if (Array.isArray(x)) return x.filter((e) => typeof e === "string");
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
function parseEvents(on) {
|
|
55
|
+
if (typeof on === "string") return [on];
|
|
56
|
+
if (Array.isArray(on)) return on.filter((e) => typeof e === "string");
|
|
57
|
+
if (isObj(on)) return Object.keys(on);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
function parseInputs(on) {
|
|
61
|
+
if (!isObj(on) || !isObj(on.workflow_dispatch)) return [];
|
|
62
|
+
const inputs = on.workflow_dispatch.inputs;
|
|
63
|
+
if (!isObj(inputs)) return [];
|
|
64
|
+
return Object.entries(inputs).map(([name, def]) => {
|
|
65
|
+
const d = isObj(def) ? def : {};
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
description: typeof d.description === "string" ? d.description : void 0,
|
|
69
|
+
type: typeof d.type === "string" ? d.type : void 0,
|
|
70
|
+
default: isScalar(d.default) ? String(d.default) : void 0,
|
|
71
|
+
required: d.required === true,
|
|
72
|
+
options: Array.isArray(d.options) ? d.options.filter(isScalar).map(String) : void 0
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function parseRunsOn(x) {
|
|
77
|
+
if (typeof x === "string") return x;
|
|
78
|
+
if (Array.isArray(x)) return x.filter((e) => typeof e === "string").join(",");
|
|
79
|
+
if (isObj(x)) return toList(x.labels).join(",") || (typeof x.group === "string" ? x.group : "");
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
function parseEnvironment(x) {
|
|
83
|
+
const name = typeof x === "string" ? x : isObj(x) && typeof x.name === "string" ? x.name : void 0;
|
|
84
|
+
return name && !name.includes("${{") ? name : void 0;
|
|
85
|
+
}
|
|
86
|
+
function hasContainerOptions(x) {
|
|
87
|
+
return isObj(x) && typeof x.options === "string" && x.options.trim() !== "";
|
|
88
|
+
}
|
|
89
|
+
function parseMatrix(strategy) {
|
|
90
|
+
if (!isObj(strategy) || strategy.matrix === void 0) return { matrix: null, matrixDynamic: false };
|
|
91
|
+
const m = strategy.matrix;
|
|
92
|
+
if (typeof m === "string") return { matrix: null, matrixDynamic: true };
|
|
93
|
+
if (!isObj(m)) return { matrix: null, matrixDynamic: false };
|
|
94
|
+
const axes = {};
|
|
95
|
+
let dynamic = false;
|
|
96
|
+
for (const [key, val] of Object.entries(m)) {
|
|
97
|
+
if (key === "include" || key === "exclude") {
|
|
98
|
+
if (typeof val === "string") dynamic = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(val)) axes[key] = val.filter(isScalar);
|
|
102
|
+
else if (typeof val === "string") dynamic = true;
|
|
103
|
+
}
|
|
104
|
+
return { matrix: Object.keys(axes).length ? axes : null, matrixDynamic: dynamic };
|
|
105
|
+
}
|
|
106
|
+
var HIDE_DIRECTIVE = /^#\s*gha-view:\s*hide$/i;
|
|
107
|
+
function isHidden(text) {
|
|
108
|
+
for (const raw of text.replace(/^/, "").split(/\r?\n/)) {
|
|
109
|
+
const line = raw.trim();
|
|
110
|
+
if (line === "" || line === "---") continue;
|
|
111
|
+
if (!line.startsWith("#")) return false;
|
|
112
|
+
if (HIDE_DIRECTIVE.test(line)) return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
function parseWorkflow(file, text) {
|
|
117
|
+
const hidden = isHidden(text) ? { hidden: true } : {};
|
|
118
|
+
const empty = { file, name: file, events: [], inputs: [], jobs: [], ...hidden };
|
|
119
|
+
let doc;
|
|
120
|
+
try {
|
|
121
|
+
doc = parse(text);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { ...empty, error: `Invalid YAML: ${e.message.split("\n")[0]}` };
|
|
124
|
+
}
|
|
125
|
+
if (!isObj(doc)) return { ...empty, error: "Workflow file is empty or not a mapping" };
|
|
126
|
+
const jobs = [];
|
|
127
|
+
if (isObj(doc.jobs)) {
|
|
128
|
+
for (const [id, raw] of Object.entries(doc.jobs)) {
|
|
129
|
+
const j = isObj(raw) ? raw : {};
|
|
130
|
+
jobs.push({
|
|
131
|
+
id,
|
|
132
|
+
name: typeof j.name === "string" ? j.name : id,
|
|
133
|
+
needs: toList(j.needs),
|
|
134
|
+
runsOn: parseRunsOn(j["runs-on"]),
|
|
135
|
+
uses: typeof j.uses === "string" ? j.uses : void 0,
|
|
136
|
+
environment: parseEnvironment(j.environment),
|
|
137
|
+
...hasContainerOptions(j.container) ? { containerOptions: true } : {},
|
|
138
|
+
...parseMatrix(j.strategy)
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
file,
|
|
144
|
+
name: typeof doc.name === "string" ? doc.name : file,
|
|
145
|
+
events: parseEvents(doc.on),
|
|
146
|
+
inputs: parseInputs(doc.on),
|
|
147
|
+
jobs,
|
|
148
|
+
...hidden
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function loadWorkflows(repoPath) {
|
|
152
|
+
const dir = path.join(repoPath, WORKFLOWS_DIR);
|
|
153
|
+
let names;
|
|
154
|
+
try {
|
|
155
|
+
names = (await readdir(dir)).filter((n) => /\.ya?ml$/.test(n)).sort();
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
return Promise.all(
|
|
160
|
+
names.map(async (file) => {
|
|
161
|
+
try {
|
|
162
|
+
return parseWorkflow(file, await readFile(path.join(dir, file), "utf8"));
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { file, name: file, events: [], inputs: [], jobs: [], error: e.message };
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/server/act/validate.ts
|
|
171
|
+
import path2 from "node:path";
|
|
172
|
+
var EVENT_RE = /^[a-z][a-z0-9_]*$/;
|
|
173
|
+
var JOB_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
174
|
+
var KEY_RE = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
175
|
+
var WORKFLOW_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.ya?ml$/;
|
|
176
|
+
var SECRET_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
177
|
+
var PLATFORM_RE = /^[^=\s]+=\S+$/;
|
|
178
|
+
var ENVIRONMENT_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
179
|
+
var ARCH_RE = /^linux\/(amd64|arm64|arm\/v7|386)$/;
|
|
180
|
+
var RESERVED_SECRET_NAMES = /* @__PURE__ */ new Set([
|
|
181
|
+
"PATH",
|
|
182
|
+
"HOME",
|
|
183
|
+
"USER",
|
|
184
|
+
"SHELL",
|
|
185
|
+
"TMPDIR",
|
|
186
|
+
"PWD",
|
|
187
|
+
"LANG",
|
|
188
|
+
"TERM",
|
|
189
|
+
"IFS",
|
|
190
|
+
"CI"
|
|
191
|
+
]);
|
|
192
|
+
var RESERVED_SECRET_PREFIXES = ["GITHUB_", "LD_", "DYLD_", "XDG_", "DOCKER_", "NODE_", "ACTIONS_", "RUNNER_"];
|
|
193
|
+
var ValidationError = class extends Error {
|
|
194
|
+
};
|
|
195
|
+
function fail(msg) {
|
|
196
|
+
throw new ValidationError(msg);
|
|
197
|
+
}
|
|
198
|
+
function noNul(label, v) {
|
|
199
|
+
if (v.includes("\0")) fail(`${label} contains a NUL byte`);
|
|
200
|
+
}
|
|
201
|
+
function eventName(v) {
|
|
202
|
+
if (!EVENT_RE.test(v)) fail(`invalid event name: ${v}`);
|
|
203
|
+
return v;
|
|
204
|
+
}
|
|
205
|
+
function jobId(v) {
|
|
206
|
+
if (!JOB_RE.test(v)) fail(`invalid job id: ${v}`);
|
|
207
|
+
return v;
|
|
208
|
+
}
|
|
209
|
+
function workflowFile(v) {
|
|
210
|
+
if (!WORKFLOW_FILE_RE.test(v)) fail(`invalid workflow file name: ${v}`);
|
|
211
|
+
return v;
|
|
212
|
+
}
|
|
213
|
+
function workflowPath(p) {
|
|
214
|
+
noNul("workflow path", p);
|
|
215
|
+
if (!path2.isAbsolute(p)) fail(`workflow path must be absolute: ${p}`);
|
|
216
|
+
workflowFile(path2.basename(p));
|
|
217
|
+
return p;
|
|
218
|
+
}
|
|
219
|
+
function keyValueMap(label, m) {
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const [k, v] of Object.entries(m ?? {})) {
|
|
222
|
+
if (!KEY_RE.test(k)) fail(`invalid ${label} name: ${k}`);
|
|
223
|
+
if (typeof v !== "string") fail(`${label} ${k} must be a string`);
|
|
224
|
+
noNul(`${label} ${k}`, v);
|
|
225
|
+
out[k] = v;
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
function secretName(v) {
|
|
230
|
+
if (!SECRET_NAME_RE.test(v)) fail(`invalid secret name: ${v}`);
|
|
231
|
+
const upper = v.toUpperCase();
|
|
232
|
+
if (v === "GITHUB_TOKEN") return v;
|
|
233
|
+
if (RESERVED_SECRET_NAMES.has(upper) || RESERVED_SECRET_PREFIXES.some((p) => upper.startsWith(p))) {
|
|
234
|
+
fail(`secret name is reserved: ${v}`);
|
|
235
|
+
}
|
|
236
|
+
return v;
|
|
237
|
+
}
|
|
238
|
+
function environmentName(v) {
|
|
239
|
+
if (!ENVIRONMENT_RE.test(v)) fail(`invalid environment name: "${v}" (use lowercase letters, digits, - and _, up to 32 characters)`);
|
|
240
|
+
return v;
|
|
241
|
+
}
|
|
242
|
+
function platform(v) {
|
|
243
|
+
if (!PLATFORM_RE.test(v)) fail(`invalid platform mapping (expected label=image): ${v}`);
|
|
244
|
+
return v;
|
|
245
|
+
}
|
|
246
|
+
function containerArchitecture(v) {
|
|
247
|
+
if (v !== "" && !ARCH_RE.test(v)) fail(`unsupported container architecture: ${v}`);
|
|
248
|
+
return v;
|
|
249
|
+
}
|
|
250
|
+
function port(v) {
|
|
251
|
+
if (!Number.isInteger(v) || v < 1 || v > 65535) fail("port must be an integer from 1 to 65535");
|
|
252
|
+
return v;
|
|
253
|
+
}
|
|
254
|
+
function isHostPlatform(mapping) {
|
|
255
|
+
const image = mapping.slice(mapping.indexOf("=") + 1);
|
|
256
|
+
return image.startsWith("-");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/server/act/list.ts
|
|
260
|
+
var COLUMNS = ["Stage", "Job ID", "Job name", "Workflow name", "Workflow file", "Events"];
|
|
261
|
+
function parseActList(stdout) {
|
|
262
|
+
const lines = stdout.split("\n").map((l) => l.replace(/\r$/, ""));
|
|
263
|
+
const headerIdx = lines.findIndex((l) => l.startsWith("Stage") && l.includes("Job ID"));
|
|
264
|
+
if (headerIdx < 0) return [];
|
|
265
|
+
const header = lines[headerIdx];
|
|
266
|
+
const starts = COLUMNS.map((c) => header.indexOf(c));
|
|
267
|
+
if (starts.some((s) => s < 0)) return [];
|
|
268
|
+
const jobs = [];
|
|
269
|
+
for (const line of lines.slice(headerIdx + 1)) {
|
|
270
|
+
if (line.trim() === "") continue;
|
|
271
|
+
const cell = (i) => line.slice(starts[i], i + 1 < starts.length ? starts[i + 1] : void 0).trim();
|
|
272
|
+
const stage = Number(cell(0));
|
|
273
|
+
if (!Number.isInteger(stage)) continue;
|
|
274
|
+
jobs.push({
|
|
275
|
+
stage,
|
|
276
|
+
jobId: cell(1),
|
|
277
|
+
jobName: cell(2),
|
|
278
|
+
workflowName: cell(3),
|
|
279
|
+
workflowFile: cell(4),
|
|
280
|
+
events: cell(5).split(",").map((e) => e.trim()).filter(Boolean)
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return jobs;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/server/act/exec.ts
|
|
287
|
+
function runAct(cfg, args, cwd, timeoutMs = 15e3) {
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
execFile(cfg.actBin, args, { cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
290
|
+
const exit = err?.code;
|
|
291
|
+
const code = err ? typeof exit === "number" ? exit : 1 : 0;
|
|
292
|
+
resolve({ code, stdout: String(stdout), stderr: String(stderr) });
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
var NOISE = [/Couldn't get a valid docker connection/, /Apple M-series chip/, /Using docker host/];
|
|
297
|
+
function stripActNoise(text) {
|
|
298
|
+
return text.split("\n").filter((l) => !NOISE.some((re) => re.test(l))).join("\n").trim();
|
|
299
|
+
}
|
|
300
|
+
async function enrichWithStages(cfg, repoPath, workflows) {
|
|
301
|
+
return Promise.all(
|
|
302
|
+
workflows.map(async (wf) => {
|
|
303
|
+
if (wf.error || wf.jobs.length === 0) return wf;
|
|
304
|
+
const res = await runAct(cfg, ["--list", "-W", `${WORKFLOWS_DIR}/${workflowFile(wf.file)}`], repoPath);
|
|
305
|
+
if (res.code !== 0) return wf;
|
|
306
|
+
const stages = new Map(parseActList(res.stdout).map((j) => [j.jobId, j.stage]));
|
|
307
|
+
const jobs = wf.jobs.map((j) => ({ ...j, stage: stages.get(j.id) }));
|
|
308
|
+
return { ...wf, jobs };
|
|
309
|
+
})
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
async function validateWorkflow(cfg, repoPath, file) {
|
|
313
|
+
const res = await runAct(cfg, ["--validate", "-W", `${WORKFLOWS_DIR}/${workflowFile(file)}`], repoPath);
|
|
314
|
+
const output = stripActNoise(res.stdout + "\n" + res.stderr);
|
|
315
|
+
return { ok: res.code === 0, output: output || (res.code === 0 ? "Workflow is valid." : "Validation failed.") };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/server/doctor.ts
|
|
319
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
320
|
+
import { accessSync, constants, mkdirSync } from "node:fs";
|
|
321
|
+
var REQUIRED_ACT_FLAGS = [
|
|
322
|
+
"json",
|
|
323
|
+
"workflows",
|
|
324
|
+
"platform",
|
|
325
|
+
"artifact-server-addr",
|
|
326
|
+
"artifact-server-path",
|
|
327
|
+
"artifact-server-port",
|
|
328
|
+
"cache-server-addr",
|
|
329
|
+
"rm",
|
|
330
|
+
"pull",
|
|
331
|
+
"matrix",
|
|
332
|
+
"input",
|
|
333
|
+
"secret",
|
|
334
|
+
"var",
|
|
335
|
+
"env",
|
|
336
|
+
"eventpath",
|
|
337
|
+
"job",
|
|
338
|
+
"dryrun",
|
|
339
|
+
"container-architecture",
|
|
340
|
+
"action-offline-mode",
|
|
341
|
+
"list",
|
|
342
|
+
"validate",
|
|
343
|
+
"network",
|
|
344
|
+
"container-options"
|
|
345
|
+
];
|
|
346
|
+
function run(bin, args, timeoutMs) {
|
|
347
|
+
return new Promise((resolve) => {
|
|
348
|
+
execFile2(bin, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
349
|
+
if (err) resolve({ ok: false, stdout: String(stdout ?? ""), error: err.code === "ENOENT" ? "not found" : err.message });
|
|
350
|
+
else resolve({ ok: true, stdout: String(stdout) });
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
async function actVersion(cfg) {
|
|
355
|
+
const r = await run(cfg.actBin, ["--version"], 8e3);
|
|
356
|
+
if (!r.ok) return void 0;
|
|
357
|
+
return /(\d+\.\d+\.\d+)/.exec(r.stdout)?.[1];
|
|
358
|
+
}
|
|
359
|
+
async function dockerReachable(cfg) {
|
|
360
|
+
const r = await run(cfg.dockerBin, ["info", "--format", "{{.ServerVersion}}"], 8e3);
|
|
361
|
+
return r.ok ? { ok: true, version: r.stdout.trim() } : { ok: false, error: r.error };
|
|
362
|
+
}
|
|
363
|
+
async function runDoctor(cfg) {
|
|
364
|
+
const checks = [];
|
|
365
|
+
const version = await actVersion(cfg);
|
|
366
|
+
if (!version) {
|
|
367
|
+
checks.push({
|
|
368
|
+
id: "act",
|
|
369
|
+
title: "act",
|
|
370
|
+
status: "fail",
|
|
371
|
+
detail: `Could not run "${cfg.actBin} --version".`,
|
|
372
|
+
fix: "Install act (brew install act) or set GHA_VIEW_ACT_BIN to its path."
|
|
373
|
+
});
|
|
374
|
+
} else {
|
|
375
|
+
const opts = await run(cfg.actBin, ["--list-options"], 8e3);
|
|
376
|
+
let missing = [];
|
|
377
|
+
try {
|
|
378
|
+
const names = new Set(JSON.parse(opts.stdout).map((o) => o.name));
|
|
379
|
+
missing = REQUIRED_ACT_FLAGS.filter((f) => !names.has(f));
|
|
380
|
+
} catch {
|
|
381
|
+
}
|
|
382
|
+
checks.push(
|
|
383
|
+
missing.length ? { id: "act", title: "act", status: "fail", detail: `act ${version} lacks flags the viewer needs: ${missing.join(", ")}.`, fix: "Upgrade act (brew upgrade act)." } : { id: "act", title: "act", status: "ok", detail: `act ${version}` }
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const docker = await dockerReachable(cfg);
|
|
387
|
+
checks.push(
|
|
388
|
+
docker.ok ? { id: "docker", title: "Docker", status: "ok", detail: `Docker daemon ${docker.version}` } : {
|
|
389
|
+
id: "docker",
|
|
390
|
+
title: "Docker",
|
|
391
|
+
status: "fail",
|
|
392
|
+
detail: "The Docker daemon is not reachable. Runs that use container images will fail.",
|
|
393
|
+
fix: "Start Docker Desktop and wait for it to finish starting. Dry runs and host-mode platforms still work."
|
|
394
|
+
}
|
|
395
|
+
);
|
|
396
|
+
if (process.platform === "darwin" && process.arch === "arm64") {
|
|
397
|
+
checks.push({
|
|
398
|
+
id: "arch",
|
|
399
|
+
title: "Apple Silicon",
|
|
400
|
+
status: "warn",
|
|
401
|
+
detail: "Some actions and images are amd64-only and may fail or run slowly under emulation.",
|
|
402
|
+
fix: "If a run fails with an exec format error, set the repo's container architecture to linux/amd64 in Settings."
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
mkdirSync(cfg.dataDir, { recursive: true });
|
|
407
|
+
accessSync(cfg.dataDir, constants.W_OK);
|
|
408
|
+
checks.push({ id: "data", title: "Data directory", status: "ok", detail: cfg.dataDir });
|
|
409
|
+
} catch (e) {
|
|
410
|
+
checks.push({ id: "data", title: "Data directory", status: "fail", detail: `${cfg.dataDir} is not writable: ${e.message}` });
|
|
411
|
+
}
|
|
412
|
+
return checks;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/server/repos.ts
|
|
416
|
+
import { createHash } from "node:crypto";
|
|
417
|
+
import { readdir as readdir2, realpath, stat } from "node:fs/promises";
|
|
418
|
+
import os from "node:os";
|
|
419
|
+
import path3 from "node:path";
|
|
420
|
+
var RepoError = class extends Error {
|
|
421
|
+
constructor(message, status = 400) {
|
|
422
|
+
super(message);
|
|
423
|
+
this.status = status;
|
|
424
|
+
}
|
|
425
|
+
status;
|
|
426
|
+
};
|
|
427
|
+
function repoIdFor(realPath) {
|
|
428
|
+
return createHash("sha1").update(realPath).digest("hex").slice(0, 10);
|
|
429
|
+
}
|
|
430
|
+
function normalizeConfig(input, base = DEFAULT_REPO_CONFIG) {
|
|
431
|
+
const platforms = (input.platforms ?? base.platforms).map((p) => platform(p.trim())).filter(Boolean);
|
|
432
|
+
return {
|
|
433
|
+
platforms,
|
|
434
|
+
env: keyValueMap("env", input.env ?? base.env),
|
|
435
|
+
vars: keyValueMap("var", input.vars ?? base.vars),
|
|
436
|
+
pull: input.pull ?? base.pull,
|
|
437
|
+
offline: input.offline ?? base.offline,
|
|
438
|
+
containerArchitecture: containerArchitecture(input.containerArchitecture ?? base.containerArchitecture)
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
var RepoService = class {
|
|
442
|
+
constructor(store, secrets) {
|
|
443
|
+
this.store = store;
|
|
444
|
+
this.secrets = secrets;
|
|
445
|
+
}
|
|
446
|
+
store;
|
|
447
|
+
secrets;
|
|
448
|
+
async stateOf(p) {
|
|
449
|
+
try {
|
|
450
|
+
if (!(await stat(p)).isDirectory()) return "missing";
|
|
451
|
+
} catch {
|
|
452
|
+
return "missing";
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const names = await readdir2(path3.join(p, WORKFLOWS_DIR));
|
|
456
|
+
return names.some((n) => /\.ya?ml$/.test(n)) ? "ok" : "no-workflows";
|
|
457
|
+
} catch {
|
|
458
|
+
return "no-workflows";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async toRepo(row) {
|
|
462
|
+
return {
|
|
463
|
+
id: row.id,
|
|
464
|
+
path: row.path,
|
|
465
|
+
name: row.name,
|
|
466
|
+
config: row.config,
|
|
467
|
+
secretNames: this.secrets.names(row.id),
|
|
468
|
+
state: await this.stateOf(row.path)
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
async list() {
|
|
472
|
+
return Promise.all(this.store.listRepos().map((r) => this.toRepo(r)));
|
|
473
|
+
}
|
|
474
|
+
async get(id) {
|
|
475
|
+
const row = this.store.getRepo(id);
|
|
476
|
+
if (!row) throw new RepoError("repo not found", 404);
|
|
477
|
+
return this.toRepo(row);
|
|
478
|
+
}
|
|
479
|
+
async add(inputPath) {
|
|
480
|
+
let p = inputPath.trim();
|
|
481
|
+
if (p === "~" || p.startsWith("~/")) p = path3.join(os.homedir(), p.slice(1));
|
|
482
|
+
if (!path3.isAbsolute(p)) throw new RepoError("Use an absolute path, e.g. /Users/you/code/my-repo");
|
|
483
|
+
let real;
|
|
484
|
+
try {
|
|
485
|
+
real = await realpath(p);
|
|
486
|
+
} catch {
|
|
487
|
+
throw new RepoError(`Path does not exist: ${p}`);
|
|
488
|
+
}
|
|
489
|
+
if (!(await stat(real)).isDirectory()) throw new RepoError(`Not a directory: ${real}`);
|
|
490
|
+
if (await this.stateOf(real) !== "ok") {
|
|
491
|
+
throw new RepoError(`No workflow files found in ${path3.join(real, WORKFLOWS_DIR)}`);
|
|
492
|
+
}
|
|
493
|
+
if (this.store.getRepoByPath(real)) throw new RepoError("This repo is already added", 409);
|
|
494
|
+
const row = {
|
|
495
|
+
id: repoIdFor(real),
|
|
496
|
+
path: real,
|
|
497
|
+
name: path3.basename(real),
|
|
498
|
+
config: normalizeConfig({}),
|
|
499
|
+
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
500
|
+
};
|
|
501
|
+
this.store.insertRepo(row);
|
|
502
|
+
return this.toRepo(row);
|
|
503
|
+
}
|
|
504
|
+
async update(id, fields) {
|
|
505
|
+
const row = this.store.getRepo(id);
|
|
506
|
+
if (!row) throw new RepoError("repo not found", 404);
|
|
507
|
+
const name = fields.name?.trim();
|
|
508
|
+
if (fields.name !== void 0 && !name) throw new RepoError("name cannot be empty");
|
|
509
|
+
this.store.updateRepo(id, {
|
|
510
|
+
name,
|
|
511
|
+
config: fields.config ? normalizeConfig(fields.config, row.config) : void 0
|
|
512
|
+
});
|
|
513
|
+
return this.get(id);
|
|
514
|
+
}
|
|
515
|
+
/** Removes the repo and its secrets. Run history is removed by the caller (it owns the log files). */
|
|
516
|
+
remove(id) {
|
|
517
|
+
if (!this.store.getRepo(id)) throw new RepoError("repo not found", 404);
|
|
518
|
+
this.store.deleteRepo(id);
|
|
519
|
+
this.secrets.deleteAll(id);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// src/server/runs/artifacts.ts
|
|
524
|
+
import { randomBytes } from "node:crypto";
|
|
525
|
+
import { closeSync, createWriteStream, existsSync, mkdirSync as mkdirSync2, openSync, readdirSync, readSync, renameSync, rmSync, statSync } from "node:fs";
|
|
526
|
+
import path4 from "node:path";
|
|
527
|
+
import { pipeline } from "node:stream/promises";
|
|
528
|
+
import { open as openZip } from "yauzl";
|
|
529
|
+
var DIR = "artifacts";
|
|
530
|
+
var EXTRACTED = ".extracted";
|
|
531
|
+
var EXTRACT_LIMITS = { maxEntries: 1e4, maxBytes: 2 * 1024 ** 3 };
|
|
532
|
+
var isZip = (name) => /\.zip$/i.test(name);
|
|
533
|
+
function walk(dir, rel) {
|
|
534
|
+
const out = [];
|
|
535
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
536
|
+
if (ent.name.startsWith(".")) continue;
|
|
537
|
+
const childRel = rel ? `${rel}/${ent.name}` : ent.name;
|
|
538
|
+
const abs = path4.join(dir, ent.name);
|
|
539
|
+
if (ent.isDirectory()) {
|
|
540
|
+
const children = walk(abs, childRel);
|
|
541
|
+
out.push({
|
|
542
|
+
name: ent.name,
|
|
543
|
+
path: childRel,
|
|
544
|
+
type: "dir",
|
|
545
|
+
size: children.reduce((n, c) => n + c.size, 0),
|
|
546
|
+
children
|
|
547
|
+
});
|
|
548
|
+
} else if (ent.isFile()) {
|
|
549
|
+
const node = { name: ent.name, path: childRel, type: "file", size: statSync(abs).size };
|
|
550
|
+
const extracted = isZip(ent.name) ? path4.join(dir, EXTRACTED, ent.name) : void 0;
|
|
551
|
+
if (extracted && existsSync(extracted)) {
|
|
552
|
+
node.extracted = true;
|
|
553
|
+
node.children = walk(extracted, `${rel ? `${rel}/` : ""}${EXTRACTED}/${ent.name}`);
|
|
554
|
+
}
|
|
555
|
+
out.push(node);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
559
|
+
}
|
|
560
|
+
function listArtifacts(runDir) {
|
|
561
|
+
const root = path4.join(runDir, DIR);
|
|
562
|
+
if (!existsSync(root)) return [];
|
|
563
|
+
const top = walk(root, "");
|
|
564
|
+
if (top.length > 0 && top.every((n) => n.type === "dir" && /^\d+$/.test(n.name))) {
|
|
565
|
+
return mergeByName(top.flatMap((n) => n.children ?? []));
|
|
566
|
+
}
|
|
567
|
+
return top;
|
|
568
|
+
}
|
|
569
|
+
function mergeByName(nodes) {
|
|
570
|
+
const byName = /* @__PURE__ */ new Map();
|
|
571
|
+
for (const n of nodes) {
|
|
572
|
+
const prev = byName.get(n.name);
|
|
573
|
+
if (!prev) {
|
|
574
|
+
byName.set(n.name, n);
|
|
575
|
+
} else if (prev.type === "dir" && n.type === "dir") {
|
|
576
|
+
byName.set(n.name, {
|
|
577
|
+
...prev,
|
|
578
|
+
size: prev.size + n.size,
|
|
579
|
+
children: mergeByName([...prev.children ?? [], ...n.children ?? []])
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
584
|
+
}
|
|
585
|
+
function segment(s) {
|
|
586
|
+
if (!s || s === "." || s === ".." || s.includes("\0") || /[/\\]/.test(s) || s.length > 255) {
|
|
587
|
+
throw new RepoError("file not found", 404);
|
|
588
|
+
}
|
|
589
|
+
return s;
|
|
590
|
+
}
|
|
591
|
+
function resolveArtifactFile(runDir, rel) {
|
|
592
|
+
const parts = rel.split("/").map(segment);
|
|
593
|
+
const root = path4.resolve(runDir, DIR);
|
|
594
|
+
const abs = path4.resolve(root, ...parts);
|
|
595
|
+
if (abs !== root && !abs.startsWith(root + path4.sep)) throw new RepoError("file not found", 404);
|
|
596
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) throw new RepoError("file not found", 404);
|
|
597
|
+
return { abs, downloadName: parts[parts.length - 1] };
|
|
598
|
+
}
|
|
599
|
+
var extracting = /* @__PURE__ */ new Set();
|
|
600
|
+
function entryParts(name) {
|
|
601
|
+
const parts = name.replace(/\/$/, "").split("/");
|
|
602
|
+
for (const p of parts) {
|
|
603
|
+
if (!p || p === "." || p === ".." || p.includes("\0") || p.includes("\\") || p.length > 255) {
|
|
604
|
+
throw new RepoError(`zip entry has an unsafe path: ${JSON.stringify(name)}`, 400);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return parts;
|
|
608
|
+
}
|
|
609
|
+
var formatLimit = (n) => n >= 1024 ** 3 ? `${+(n / 1024 ** 3).toFixed(1)} GiB` : `${n} bytes`;
|
|
610
|
+
var isSymlink = (e) => (e.externalFileAttributes >>> 16 & 61440) === 40960;
|
|
611
|
+
function unzipInto(zipPath, outDir, limits) {
|
|
612
|
+
return new Promise((resolve, reject) => {
|
|
613
|
+
openZip(zipPath, { lazyEntries: true, validateEntrySizes: true }, (err, zip) => {
|
|
614
|
+
if (err) return reject(new RepoError(`could not read the zip: ${err.message}`, 400));
|
|
615
|
+
let entries = 0;
|
|
616
|
+
let bytes = 0;
|
|
617
|
+
const fail2 = (e) => {
|
|
618
|
+
zip.close();
|
|
619
|
+
reject(e instanceof RepoError ? e : new RepoError(`could not extract the zip: ${e.message}`, 400));
|
|
620
|
+
};
|
|
621
|
+
const handle = async (entry) => {
|
|
622
|
+
if (++entries > limits.maxEntries) throw new RepoError(`zip has more than ${limits.maxEntries} entries; extract it by hand`, 400);
|
|
623
|
+
bytes += entry.uncompressedSize;
|
|
624
|
+
if (bytes > limits.maxBytes) throw new RepoError(`zip would unpack to more than ${formatLimit(limits.maxBytes)}; extract it by hand`, 400);
|
|
625
|
+
if (isSymlink(entry)) return;
|
|
626
|
+
const parts = entryParts(entry.fileName);
|
|
627
|
+
const dest = path4.resolve(outDir, ...parts);
|
|
628
|
+
if (!dest.startsWith(outDir + path4.sep)) throw new RepoError(`zip entry has an unsafe path: ${JSON.stringify(entry.fileName)}`, 400);
|
|
629
|
+
if (entry.fileName.endsWith("/")) {
|
|
630
|
+
mkdirSync2(dest, { recursive: true });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
mkdirSync2(path4.dirname(dest), { recursive: true });
|
|
634
|
+
const src = await new Promise(
|
|
635
|
+
(res, rej) => zip.openReadStream(entry, (e, stream) => e ? rej(e) : res(stream))
|
|
636
|
+
);
|
|
637
|
+
await pipeline(src, createWriteStream(dest, { flags: "wx", mode: 420 }));
|
|
638
|
+
};
|
|
639
|
+
zip.on("error", fail2);
|
|
640
|
+
zip.on("end", resolve);
|
|
641
|
+
zip.on("entry", (entry) => handle(entry).then(() => zip.readEntry(), fail2));
|
|
642
|
+
zip.readEntry();
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
async function extractArtifactZip(runDir, rel, limits = EXTRACT_LIMITS) {
|
|
647
|
+
const { abs, downloadName } = resolveArtifactFile(runDir, rel);
|
|
648
|
+
if (!isZip(downloadName)) throw new RepoError("only .zip artifacts can be extracted", 400);
|
|
649
|
+
const holder = path4.join(path4.dirname(abs), EXTRACTED);
|
|
650
|
+
const target = path4.join(holder, downloadName);
|
|
651
|
+
if (existsSync(target)) throw new RepoError("this zip is already extracted", 409);
|
|
652
|
+
if (extracting.has(abs)) throw new RepoError("this zip is being extracted right now", 409);
|
|
653
|
+
extracting.add(abs);
|
|
654
|
+
const tmp = path4.join(holder, `.tmp-${randomBytes(6).toString("hex")}`);
|
|
655
|
+
try {
|
|
656
|
+
mkdirSync2(tmp, { recursive: true });
|
|
657
|
+
await unzipInto(abs, tmp, limits);
|
|
658
|
+
renameSync(tmp, target);
|
|
659
|
+
} finally {
|
|
660
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
661
|
+
extracting.delete(abs);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
var TEXT = /* @__PURE__ */ new Set(["txt", "log", "md", "yml", "yaml", "csv", "xml", "toml", "ini", "env", "ts", "tsx", "sh", "bash"]);
|
|
665
|
+
var MIME = {
|
|
666
|
+
zip: "application/zip",
|
|
667
|
+
pdf: "application/pdf",
|
|
668
|
+
json: "application/json; charset=utf-8",
|
|
669
|
+
css: "text/css; charset=utf-8",
|
|
670
|
+
js: "text/javascript; charset=utf-8",
|
|
671
|
+
mjs: "text/javascript; charset=utf-8",
|
|
672
|
+
html: "text/html; charset=utf-8",
|
|
673
|
+
htm: "text/html; charset=utf-8",
|
|
674
|
+
svg: "image/svg+xml",
|
|
675
|
+
png: "image/png",
|
|
676
|
+
jpg: "image/jpeg",
|
|
677
|
+
jpeg: "image/jpeg",
|
|
678
|
+
gif: "image/gif",
|
|
679
|
+
webp: "image/webp",
|
|
680
|
+
avif: "image/avif",
|
|
681
|
+
bmp: "image/bmp",
|
|
682
|
+
ico: "image/x-icon",
|
|
683
|
+
mp4: "video/mp4",
|
|
684
|
+
webm: "video/webm",
|
|
685
|
+
mp3: "audio/mpeg",
|
|
686
|
+
wav: "audio/wav",
|
|
687
|
+
ogg: "audio/ogg"
|
|
688
|
+
};
|
|
689
|
+
var ACTIVE = /* @__PURE__ */ new Set(["html", "htm", "svg"]);
|
|
690
|
+
function looksLikeText(abs) {
|
|
691
|
+
const fd = openSync(abs, "r");
|
|
692
|
+
try {
|
|
693
|
+
const buf = Buffer.alloc(8192);
|
|
694
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
695
|
+
if (buf.subarray(0, n).includes(0)) return false;
|
|
696
|
+
try {
|
|
697
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, n), { stream: true });
|
|
698
|
+
return true;
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
} finally {
|
|
703
|
+
closeSync(fd);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function fileResponseHeaders(downloadName, inline, abs) {
|
|
707
|
+
const ext = downloadName.includes(".") ? downloadName.slice(downloadName.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
708
|
+
let type = MIME[ext] ?? (TEXT.has(ext) ? "text/plain; charset=utf-8" : void 0);
|
|
709
|
+
if (!type) type = inline && abs && looksLikeText(abs) ? "text/plain; charset=utf-8" : "application/octet-stream";
|
|
710
|
+
const showInline = inline && ext !== "zip" && type !== "application/octet-stream";
|
|
711
|
+
const safe = downloadName.replace(/["\\\r\n]/g, "_");
|
|
712
|
+
const headers = {
|
|
713
|
+
"content-type": type,
|
|
714
|
+
"content-disposition": `${showInline ? "inline" : "attachment"}; filename="${safe}"`,
|
|
715
|
+
"x-content-type-options": "nosniff"
|
|
716
|
+
};
|
|
717
|
+
if (showInline && ACTIVE.has(ext)) headers["content-security-policy"] = "sandbox allow-scripts";
|
|
718
|
+
return headers;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// src/server/runs/manager.ts
|
|
722
|
+
import { spawn } from "node:child_process";
|
|
723
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
724
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync as rmSync2, writeFileSync } from "node:fs";
|
|
725
|
+
import path6 from "node:path";
|
|
726
|
+
import net from "node:net";
|
|
727
|
+
import { StringDecoder } from "node:string_decoder";
|
|
728
|
+
|
|
729
|
+
// src/server/act/args.ts
|
|
730
|
+
function buildActArgs(inv) {
|
|
731
|
+
const args = [
|
|
732
|
+
eventName(inv.event),
|
|
733
|
+
"--json",
|
|
734
|
+
"-W",
|
|
735
|
+
inv.workflowPath ? workflowPath(inv.workflowPath) : `.github/workflows/${workflowFile(inv.workflowFile)}`
|
|
736
|
+
];
|
|
737
|
+
for (const p of inv.platforms) args.push("-P", platform(p));
|
|
738
|
+
const arch = containerArchitecture(inv.containerArchitecture);
|
|
739
|
+
if (arch) args.push("--container-architecture", arch);
|
|
740
|
+
args.push(
|
|
741
|
+
"--artifact-server-path",
|
|
742
|
+
inv.artifactDir,
|
|
743
|
+
"--artifact-server-port",
|
|
744
|
+
String(port(inv.artifactPort)),
|
|
745
|
+
"--rm",
|
|
746
|
+
`--pull=${inv.pull ? "true" : "false"}`
|
|
747
|
+
);
|
|
748
|
+
if (inv.offline) args.push("--action-offline-mode");
|
|
749
|
+
if (inv.dryRun) args.push("-n");
|
|
750
|
+
if (inv.liveView) {
|
|
751
|
+
const port2 = port(inv.liveView.port);
|
|
752
|
+
args.push("--network", "bridge", "--container-options", `-p 127.0.0.1:${port2}:${port2}`);
|
|
753
|
+
}
|
|
754
|
+
if (inv.job) args.push("-j", jobId(inv.job));
|
|
755
|
+
for (const [k, val] of Object.entries(keyValueMap("matrix", inv.matrix))) args.push("--matrix", `${k}:${val}`);
|
|
756
|
+
for (const [k, val] of Object.entries(keyValueMap("input", inv.inputs))) args.push("--input", `${k}=${val}`);
|
|
757
|
+
if (inv.eventPayloadPath) args.push("-e", inv.eventPayloadPath);
|
|
758
|
+
for (const [k, val] of Object.entries(keyValueMap("env", inv.env))) args.push("--env", `${k}=${val}`);
|
|
759
|
+
for (const [k, val] of Object.entries(keyValueMap("var", inv.vars))) args.push("--var", `${k}=${val}`);
|
|
760
|
+
for (const name of inv.secretNames) args.push("-s", secretName(name));
|
|
761
|
+
return args;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/server/act/parse.ts
|
|
765
|
+
function matrixSuffix(matrix) {
|
|
766
|
+
if (!matrix) return "";
|
|
767
|
+
const keys = Object.keys(matrix).sort();
|
|
768
|
+
if (keys.length === 0) return "";
|
|
769
|
+
return "#" + keys.map((k) => `${k}=${JSON.stringify(matrix[k])}`).join(",");
|
|
770
|
+
}
|
|
771
|
+
function mapLevel(level) {
|
|
772
|
+
switch (level) {
|
|
773
|
+
case "warning":
|
|
774
|
+
return "warning";
|
|
775
|
+
case "error":
|
|
776
|
+
case "fatal":
|
|
777
|
+
case "panic":
|
|
778
|
+
return "error";
|
|
779
|
+
case "debug":
|
|
780
|
+
case "trace":
|
|
781
|
+
return "debug";
|
|
782
|
+
default:
|
|
783
|
+
return "info";
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function stepIdOf(o) {
|
|
787
|
+
const raw = o.stepID ?? o.stepid;
|
|
788
|
+
if (Array.isArray(raw)) return raw.join(".");
|
|
789
|
+
if (typeof raw === "string") return raw;
|
|
790
|
+
return void 0;
|
|
791
|
+
}
|
|
792
|
+
function jobNameOf(job) {
|
|
793
|
+
if (!job) return void 0;
|
|
794
|
+
const i = job.indexOf("/");
|
|
795
|
+
return (i >= 0 ? job.slice(i + 1) : job).trim();
|
|
796
|
+
}
|
|
797
|
+
function parseActLine(line, stream, seq, fallbackTs) {
|
|
798
|
+
if (line.trim() === "") return null;
|
|
799
|
+
let o = null;
|
|
800
|
+
if (line.startsWith("{")) {
|
|
801
|
+
try {
|
|
802
|
+
const parsed = JSON.parse(line);
|
|
803
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) o = parsed;
|
|
804
|
+
} catch {
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (!o || typeof o.msg !== "string") {
|
|
808
|
+
if (stream === "stderr") {
|
|
809
|
+
const logfmt = /^(?:time="[^"]*"\s+)?level=(\w+)\s+msg="?(.*?)"?\s*$/.exec(line);
|
|
810
|
+
if (logfmt) {
|
|
811
|
+
return { seq, ts: fallbackTs, kind: "stderr", level: mapLevel(logfmt[1]), text: logfmt[2].trim() };
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
seq,
|
|
815
|
+
ts: fallbackTs,
|
|
816
|
+
kind: "stderr",
|
|
817
|
+
level: /^(error|fatal)\b/i.test(line) ? "error" : "warning",
|
|
818
|
+
text: line
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
return { seq, ts: fallbackTs, kind: "raw", level: "info", text: line };
|
|
822
|
+
}
|
|
823
|
+
const matrix = o.matrix && typeof o.matrix === "object" ? o.matrix : void 0;
|
|
824
|
+
const jobKey = o.jobID ? o.jobID + matrixSuffix(matrix) : void 0;
|
|
825
|
+
const stepId = stepIdOf(o);
|
|
826
|
+
const base = {
|
|
827
|
+
seq,
|
|
828
|
+
ts: o.time ?? fallbackTs,
|
|
829
|
+
kind: "log",
|
|
830
|
+
level: mapLevel(o.level),
|
|
831
|
+
text: o.msg.trim(),
|
|
832
|
+
jobKey,
|
|
833
|
+
jobId: o.jobID,
|
|
834
|
+
jobName: jobNameOf(o.job),
|
|
835
|
+
matrix,
|
|
836
|
+
stepKey: jobKey && stepId ? `${jobKey}/${stepId}` : void 0,
|
|
837
|
+
step: o.step,
|
|
838
|
+
stage: o.stage
|
|
839
|
+
};
|
|
840
|
+
if (stream === "stderr") base.kind = "stderr";
|
|
841
|
+
if (o.jobResult) {
|
|
842
|
+
return { ...base, kind: "job-end", result: o.jobResult };
|
|
843
|
+
}
|
|
844
|
+
if (o.stepResult) {
|
|
845
|
+
return {
|
|
846
|
+
...base,
|
|
847
|
+
kind: "step-end",
|
|
848
|
+
result: o.stepResult,
|
|
849
|
+
durationMs: typeof o.executionTime === "number" ? Math.round(o.executionTime / 1e6) : void 0
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
if (o.raw_output === true) {
|
|
853
|
+
return { ...base, kind: "output", text: o.msg.replace(/\r?\n$/, "") };
|
|
854
|
+
}
|
|
855
|
+
if (o.command) {
|
|
856
|
+
const arg = (o.arg ?? "").trim();
|
|
857
|
+
if (o.command === "error" || o.command === "warning" || o.command === "notice") {
|
|
858
|
+
return {
|
|
859
|
+
...base,
|
|
860
|
+
kind: "annotation",
|
|
861
|
+
level: o.command === "notice" ? "info" : o.command,
|
|
862
|
+
text: arg || base.text
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
if (o.command === "group") return { ...base, kind: "group", text: arg };
|
|
866
|
+
if (o.command === "endgroup") return { ...base, kind: "group", text: "", groupEnd: true };
|
|
867
|
+
return { ...base, kind: "command" };
|
|
868
|
+
}
|
|
869
|
+
if (o.step && o.msg.startsWith("\u2B50 Run ")) {
|
|
870
|
+
return { ...base, kind: "step-start" };
|
|
871
|
+
}
|
|
872
|
+
return base;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/server/workflows-lookup.ts
|
|
876
|
+
import { readFile as readFile2, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
877
|
+
import path5 from "node:path";
|
|
878
|
+
var MAX_SOURCE_BYTES = 1024 * 1024;
|
|
879
|
+
async function loadWorkflow(repoPath, file) {
|
|
880
|
+
const name = workflowFile(file);
|
|
881
|
+
try {
|
|
882
|
+
return parseWorkflow(name, await readFile2(path5.join(repoPath, WORKFLOWS_DIR, name), "utf8"));
|
|
883
|
+
} catch {
|
|
884
|
+
return void 0;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async function readWorkflowSource(repoPath, file) {
|
|
888
|
+
const name = workflowFile(file);
|
|
889
|
+
let real;
|
|
890
|
+
try {
|
|
891
|
+
real = await realpath2(path5.join(repoPath, WORKFLOWS_DIR, name));
|
|
892
|
+
} catch {
|
|
893
|
+
throw new RepoError(`${name} no longer exists. Refresh the workflow list.`, 404);
|
|
894
|
+
}
|
|
895
|
+
const root = await realpath2(repoPath);
|
|
896
|
+
if (!real.startsWith(root + path5.sep)) throw new RepoError(`${name} is a link to a file outside the repo, so it is not shown.`);
|
|
897
|
+
const info = await stat2(real);
|
|
898
|
+
if (!info.isFile()) throw new RepoError(`${name} is not a regular file.`);
|
|
899
|
+
if (info.size > MAX_SOURCE_BYTES) throw new RepoError(`${name} is larger than 1 MB, so it is not shown. Open it in your editor.`);
|
|
900
|
+
return { file: name, text: await readFile2(real, "utf8") };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/server/runs/env.ts
|
|
904
|
+
var REF = /\\\$\{|\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
905
|
+
function unquote(value) {
|
|
906
|
+
const q = value[0];
|
|
907
|
+
if (value.length >= 2 && (q === '"' || q === "'") && value.at(-1) === q) {
|
|
908
|
+
return { text: value.slice(1, -1), expand: q === '"' };
|
|
909
|
+
}
|
|
910
|
+
return { text: value, expand: true };
|
|
911
|
+
}
|
|
912
|
+
function resolveEnv(raw) {
|
|
913
|
+
const done = /* @__PURE__ */ new Map();
|
|
914
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
915
|
+
const resolve = (name) => {
|
|
916
|
+
const cached = done.get(name);
|
|
917
|
+
if (cached !== void 0) return cached;
|
|
918
|
+
if (!Object.hasOwn(raw, name) || visiting.has(name)) return void 0;
|
|
919
|
+
visiting.add(name);
|
|
920
|
+
const { text, expand } = unquote(raw[name]);
|
|
921
|
+
const out = expand ? text.replace(REF, (m, ref) => ref === void 0 ? "${" : resolve(ref) ?? m) : text;
|
|
922
|
+
visiting.delete(name);
|
|
923
|
+
done.set(name, out);
|
|
924
|
+
return out;
|
|
925
|
+
};
|
|
926
|
+
return Object.fromEntries(Object.keys(raw).map((name) => [name, resolve(name)]));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// src/server/runs/context.ts
|
|
930
|
+
function layer(layers, mask, resolved) {
|
|
931
|
+
const merged = /* @__PURE__ */ new Map();
|
|
932
|
+
for (const [source, values] of layers) {
|
|
933
|
+
for (const [name, value] of Object.entries(values ?? {})) merged.set(name, { name, value: mask(resolved?.[name] ?? value), source });
|
|
934
|
+
}
|
|
935
|
+
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
936
|
+
}
|
|
937
|
+
function mergedEnv(repoEnv, environmentEnv, runEnv) {
|
|
938
|
+
return resolveEnv({ ...repoEnv, ...environmentEnv, ...runEnv });
|
|
939
|
+
}
|
|
940
|
+
function buildRunContext(l) {
|
|
941
|
+
const { request } = l;
|
|
942
|
+
const secrets = /* @__PURE__ */ new Map();
|
|
943
|
+
for (const name of l.repoSecrets) secrets.set(name, { name, source: "repo" });
|
|
944
|
+
for (const name of l.environment?.secrets ?? []) secrets.set(name, { name, source: "environment" });
|
|
945
|
+
return {
|
|
946
|
+
recorded: true,
|
|
947
|
+
environment: request.environment,
|
|
948
|
+
workflowFile: l.workflowFile,
|
|
949
|
+
env: layer(
|
|
950
|
+
[["repo", l.repoEnv], ["environment", l.environment?.env], ["run", request.env], ["viewer", l.viewerEnv]],
|
|
951
|
+
l.mask,
|
|
952
|
+
{ ...mergedEnv(l.repoEnv, l.environment?.env, request.env), ...l.viewerEnv }
|
|
953
|
+
),
|
|
954
|
+
vars: layer([["repo", l.repoVars], ["run", request.vars], ["viewer", l.viewerVars]], l.mask),
|
|
955
|
+
inputs: layer([["run", request.inputs]], l.mask).map(({ name, value }) => ({ name, value })),
|
|
956
|
+
secrets: [...secrets.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
function contextFromRequest(request) {
|
|
960
|
+
return {
|
|
961
|
+
...buildRunContext({ request, repoEnv: {}, repoVars: {}, repoSecrets: [], mask: (v) => v }),
|
|
962
|
+
recorded: false
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// src/server/runs/isolate.ts
|
|
967
|
+
import { isAlias, isMap, parseDocument } from "yaml";
|
|
968
|
+
function isolateWorkflow(text, suffix) {
|
|
969
|
+
const doc = parseDocument(text);
|
|
970
|
+
if (doc.errors.length > 0) return void 0;
|
|
971
|
+
const jobs = doc.get("jobs", true);
|
|
972
|
+
if (!isMap(jobs)) return void 0;
|
|
973
|
+
for (const pair of jobs.items) {
|
|
974
|
+
const job = pair.value;
|
|
975
|
+
if (isAlias(job)) return void 0;
|
|
976
|
+
if (!isMap(job)) continue;
|
|
977
|
+
const id = String(pair.key.value ?? pair.key);
|
|
978
|
+
const name = job.get("name");
|
|
979
|
+
job.set("name", `${name === void 0 || name === null ? id : String(name)} ${suffix}`);
|
|
980
|
+
}
|
|
981
|
+
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
|
982
|
+
}
|
|
983
|
+
function isolationSuffix(runId) {
|
|
984
|
+
return `[${runId.split("-")[1] ?? runId}]`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/server/runs/manager.ts
|
|
988
|
+
var RUN_ID_RE = /^[a-z0-9]+-[a-f0-9]{6}$/;
|
|
989
|
+
var MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
990
|
+
var KILL_GRACE_MS = 1e4;
|
|
991
|
+
var LIVE_VIEW_PROBE_MS = 500;
|
|
992
|
+
var CONTEXT_FILE = "context.json";
|
|
993
|
+
var CLONE_ERROR_RE = /Unable to clone|remote not found/i;
|
|
994
|
+
var CLONE_HINT = "act could not clone an action while other runs were active. Run it again: if it works, the runs probably clashed in act's shared action cache. If it fails again, check the action's name and your network.";
|
|
995
|
+
function isRunId(id) {
|
|
996
|
+
return RUN_ID_RE.test(id);
|
|
997
|
+
}
|
|
998
|
+
function redactionPatterns(secrets) {
|
|
999
|
+
const out = /* @__PURE__ */ new Set();
|
|
1000
|
+
for (const value of Object.values(secrets)) {
|
|
1001
|
+
for (const part of [value, ...value.split(/\r?\n/)]) {
|
|
1002
|
+
if (part.length < 4) continue;
|
|
1003
|
+
out.add(part);
|
|
1004
|
+
out.add(JSON.stringify(part).slice(1, -1));
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return [...out].sort((a, b) => b.length - a.length);
|
|
1008
|
+
}
|
|
1009
|
+
function redact(line, patterns) {
|
|
1010
|
+
let result = line;
|
|
1011
|
+
for (const p of patterns) if (result.includes(p)) result = result.split(p).join("***");
|
|
1012
|
+
return result;
|
|
1013
|
+
}
|
|
1014
|
+
function scrub(value, patterns, hits) {
|
|
1015
|
+
if (typeof value === "string") {
|
|
1016
|
+
const r = redact(value, patterns);
|
|
1017
|
+
if (r !== value) hits.n++;
|
|
1018
|
+
return r;
|
|
1019
|
+
}
|
|
1020
|
+
if (Array.isArray(value)) return value.map((x) => scrub(x, patterns, hits));
|
|
1021
|
+
if (value && typeof value === "object") {
|
|
1022
|
+
return Object.fromEntries(Object.entries(value).map(([k, x]) => [k, scrub(x, patterns, hits)]));
|
|
1023
|
+
}
|
|
1024
|
+
return value;
|
|
1025
|
+
}
|
|
1026
|
+
function redactLine(line, patterns) {
|
|
1027
|
+
if (patterns.length === 0) return line;
|
|
1028
|
+
let out = line;
|
|
1029
|
+
if (line.startsWith("{")) {
|
|
1030
|
+
try {
|
|
1031
|
+
const hits = { n: 0 };
|
|
1032
|
+
const scrubbed = scrub(JSON.parse(line), patterns, hits);
|
|
1033
|
+
if (hits.n > 0) out = JSON.stringify(scrubbed);
|
|
1034
|
+
} catch {
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return redact(out, patterns);
|
|
1038
|
+
}
|
|
1039
|
+
function needsDocker(wf, jobId2, platforms) {
|
|
1040
|
+
const jobs = jobId2 ? wf.jobs.filter((j) => j.id === jobId2) : wf.jobs;
|
|
1041
|
+
return jobs.some((j) => {
|
|
1042
|
+
const label = j.runsOn.split(",")[0]?.trim().toLowerCase();
|
|
1043
|
+
if (!label || label.includes("${{")) return false;
|
|
1044
|
+
const mapping = platforms.find((p) => p.toLowerCase().startsWith(label + "="));
|
|
1045
|
+
return !!mapping && !isHostPlatform(mapping);
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
function jobsToRun(wf, jobId2) {
|
|
1049
|
+
if (!jobId2) return wf.jobs;
|
|
1050
|
+
const byId = new Map(wf.jobs.map((j) => [j.id, j]));
|
|
1051
|
+
const out = /* @__PURE__ */ new Map();
|
|
1052
|
+
const visit = (id) => {
|
|
1053
|
+
const job = byId.get(id);
|
|
1054
|
+
if (!job || out.has(id)) return;
|
|
1055
|
+
out.set(id, job);
|
|
1056
|
+
job.needs.forEach(visit);
|
|
1057
|
+
};
|
|
1058
|
+
visit(jobId2);
|
|
1059
|
+
return [...out.values()];
|
|
1060
|
+
}
|
|
1061
|
+
function platformOf(job, platforms) {
|
|
1062
|
+
const label = job.runsOn.split(",")[0]?.trim().toLowerCase();
|
|
1063
|
+
if (job.uses || !label || label.includes("${{")) return "unknown";
|
|
1064
|
+
const mapping = platforms.find((p) => p.toLowerCase().startsWith(label + "="));
|
|
1065
|
+
return mapping && isHostPlatform(mapping) ? "host" : "docker";
|
|
1066
|
+
}
|
|
1067
|
+
var NO_DOCKER_NOTE = "Live view needs a job that runs in Docker; none of this run's jobs do.";
|
|
1068
|
+
function matrixCombinations(job, pin) {
|
|
1069
|
+
if (!job.matrix) return 1;
|
|
1070
|
+
return Object.entries(job.matrix).reduce((n, [axis, values]) => n * (axis in pin ? 1 : Math.max(values.length, 1)), 1);
|
|
1071
|
+
}
|
|
1072
|
+
function planLiveView(wf, jobId2, platforms, matrixPin) {
|
|
1073
|
+
const jobs = jobsToRun(wf, jobId2);
|
|
1074
|
+
const platformById = new Map(jobs.map((j) => [j.id, platformOf(j, platforms)]));
|
|
1075
|
+
const docker = jobs.filter((j) => platformById.get(j.id) === "docker");
|
|
1076
|
+
if (!docker.length && ![...platformById.values()].includes("unknown")) return { enabled: false, notes: [NO_DOCKER_NOTE] };
|
|
1077
|
+
const byId = new Map(jobs.map((j) => [j.id, j]));
|
|
1078
|
+
const depth = /* @__PURE__ */ new Map();
|
|
1079
|
+
const stageOf = (j, seen = /* @__PURE__ */ new Set()) => {
|
|
1080
|
+
const known = depth.get(j.id);
|
|
1081
|
+
if (known !== void 0) return known;
|
|
1082
|
+
if (seen.has(j.id)) return 0;
|
|
1083
|
+
seen.add(j.id);
|
|
1084
|
+
const d = Math.max(-1, ...j.needs.flatMap((n) => byId.has(n) ? [stageOf(byId.get(n), seen)] : [])) + 1;
|
|
1085
|
+
depth.set(j.id, d);
|
|
1086
|
+
return d;
|
|
1087
|
+
};
|
|
1088
|
+
const byStage = /* @__PURE__ */ new Map();
|
|
1089
|
+
for (const j of docker) byStage.set(stageOf(j), [...byStage.get(stageOf(j)) ?? [], j]);
|
|
1090
|
+
const together = [...byStage.values()].find((g) => g.length > 1);
|
|
1091
|
+
if (together) {
|
|
1092
|
+
const names = together.map((j) => `"${j.name}"`);
|
|
1093
|
+
return {
|
|
1094
|
+
enabled: false,
|
|
1095
|
+
notes: [`Live view skipped: jobs ${names.slice(0, -1).join(", ")} and ${names.at(-1)} run at the same time, and each would publish the live-view port. Pick one job (and a matrix value) in the Run dialog to use it.`]
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
const wide = docker.find((j) => matrixCombinations(j, matrixPin) > 1);
|
|
1099
|
+
if (wide) {
|
|
1100
|
+
return {
|
|
1101
|
+
enabled: false,
|
|
1102
|
+
notes: [`Live view skipped: job "${wide.name}" runs ${matrixCombinations(wide, matrixPin)} matrix combinations at the same time, and each would publish the live-view port. Pin a matrix value in the Run dialog to use it.`]
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
const own = docker.filter((j) => j.containerOptions);
|
|
1106
|
+
return {
|
|
1107
|
+
enabled: true,
|
|
1108
|
+
notes: own.length ? [`Live view unavailable for ${own.map((j) => `"${j.name}"`).join(", ")}: ${own.length === 1 ? "it sets" : "they set"} container.options, which act uses instead of the viewer's.`] : []
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
function wantsIsolation(wf, jobId2, platforms) {
|
|
1112
|
+
return jobsToRun(wf, jobId2).some((j) => platformOf(j, platforms) !== "host");
|
|
1113
|
+
}
|
|
1114
|
+
function runLockKeys(wf, jobId2, repoId, platforms, isolated) {
|
|
1115
|
+
const anyHost = platforms.some((p) => isHostPlatform(p));
|
|
1116
|
+
const hostKey = `host:${repoId}`;
|
|
1117
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1118
|
+
for (const job of jobsToRun(wf, jobId2)) {
|
|
1119
|
+
const jobKey = `job:${wf.name}/${job.name}`;
|
|
1120
|
+
const platform2 = platformOf(job, platforms);
|
|
1121
|
+
if (platform2 === "host") {
|
|
1122
|
+
keys.add(hostKey);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
if (!isolated) keys.add(jobKey);
|
|
1126
|
+
if (platform2 === "unknown" && anyHost) keys.add(hostKey);
|
|
1127
|
+
}
|
|
1128
|
+
return [...keys];
|
|
1129
|
+
}
|
|
1130
|
+
function waitReason(lockKey, holder, running, cap) {
|
|
1131
|
+
if (lockKey?.startsWith("host:")) {
|
|
1132
|
+
return "Waiting for another host-mode run in this repo. Host-mode steps share the repo's working directory. This starts when that run ends.";
|
|
1133
|
+
}
|
|
1134
|
+
if (lockKey) {
|
|
1135
|
+
const job = lockKey.slice("job:".length);
|
|
1136
|
+
const by = holder ? ` (${holder.summary.workflow})` : "";
|
|
1137
|
+
return `Waiting for another run of "${job}"${by}. act names containers after the workflow and job, so two at once would remove each other's. This starts when that run ends.`;
|
|
1138
|
+
}
|
|
1139
|
+
return `Waiting for a free slot: ${running} running, limit ${cap}. This starts when one ends. You can raise the limit in Setup check.`;
|
|
1140
|
+
}
|
|
1141
|
+
function probeFreePort() {
|
|
1142
|
+
return new Promise((resolve, reject) => {
|
|
1143
|
+
const server = net.createServer();
|
|
1144
|
+
server.unref();
|
|
1145
|
+
server.once("error", reject);
|
|
1146
|
+
server.listen(0, () => {
|
|
1147
|
+
const { port: port2 } = server.address();
|
|
1148
|
+
server.close(() => resolve(port2));
|
|
1149
|
+
});
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
var RunManager = class {
|
|
1153
|
+
constructor(deps) {
|
|
1154
|
+
this.deps = deps;
|
|
1155
|
+
}
|
|
1156
|
+
deps;
|
|
1157
|
+
active = /* @__PURE__ */ new Map();
|
|
1158
|
+
queue = [];
|
|
1159
|
+
reservedPorts = /* @__PURE__ */ new Set();
|
|
1160
|
+
shuttingDown = false;
|
|
1161
|
+
cachedActVersion;
|
|
1162
|
+
actVersionLoaded = false;
|
|
1163
|
+
runDir(id) {
|
|
1164
|
+
if (!isRunId(id)) throw new RepoError("invalid run id");
|
|
1165
|
+
return path6.join(this.deps.cfg.dataDir, "runs", id);
|
|
1166
|
+
}
|
|
1167
|
+
/** Mark leftovers from a previous crash as failed. Call once at startup. */
|
|
1168
|
+
recover() {
|
|
1169
|
+
this.deps.store.failInterruptedRuns((/* @__PURE__ */ new Date()).toISOString());
|
|
1170
|
+
}
|
|
1171
|
+
// --- creating runs ---------------------------------------------------------
|
|
1172
|
+
async create(req) {
|
|
1173
|
+
const { store, repos } = this.deps;
|
|
1174
|
+
const repo = await repos.get(req.repoId);
|
|
1175
|
+
if (repo.state !== "ok") throw new RepoError(`Repo is not usable (${repo.state}).`);
|
|
1176
|
+
const file = workflowFile(req.workflow);
|
|
1177
|
+
const wf = await this.deps.getWorkflow(repo.path, file);
|
|
1178
|
+
if (!wf) throw new RepoError(`Workflow not found: ${file}`, 404);
|
|
1179
|
+
if (wf.error) throw new RepoError(`Workflow has errors: ${wf.error}`);
|
|
1180
|
+
const event = eventName(req.event);
|
|
1181
|
+
if (!wf.events.includes(event)) {
|
|
1182
|
+
throw new RepoError(`${file} does not listen for "${event}" (it listens for: ${wf.events.join(", ") || "nothing"}).`);
|
|
1183
|
+
}
|
|
1184
|
+
if (req.job !== void 0) {
|
|
1185
|
+
jobId(req.job);
|
|
1186
|
+
if (!wf.jobs.some((j) => j.id === req.job)) throw new RepoError(`Job not found in ${file}: ${req.job}`);
|
|
1187
|
+
}
|
|
1188
|
+
if (event === "workflow_dispatch" && req.inputs) {
|
|
1189
|
+
const known = new Set(wf.inputs.map((i) => i.name));
|
|
1190
|
+
for (const name of Object.keys(req.inputs)) {
|
|
1191
|
+
if (!known.has(name)) throw new RepoError(`Unknown workflow_dispatch input: ${name}`);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (req.eventPayload?.trim()) {
|
|
1195
|
+
if (Buffer.byteLength(req.eventPayload) > MAX_PAYLOAD_BYTES) throw new RepoError("Event payload is too large (1 MB max).");
|
|
1196
|
+
try {
|
|
1197
|
+
const parsed = JSON.parse(req.eventPayload);
|
|
1198
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
1199
|
+
} catch {
|
|
1200
|
+
throw new RepoError("Event payload must be a JSON object.");
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
keyValueMap("matrix", req.matrix);
|
|
1204
|
+
keyValueMap("input", req.inputs);
|
|
1205
|
+
keyValueMap("env", req.env);
|
|
1206
|
+
keyValueMap("var", req.vars);
|
|
1207
|
+
if (req.environment) this.deps.environments.get(req.environment);
|
|
1208
|
+
const id = `${Date.now().toString(36)}-${randomBytes2(3).toString("hex")}`;
|
|
1209
|
+
const dir = this.runDir(id);
|
|
1210
|
+
mkdirSync3(dir, { recursive: true });
|
|
1211
|
+
const isolation = await this.isolate(repo.path, file, wf, req.job, repo.config.platforms, id, dir);
|
|
1212
|
+
const summary = {
|
|
1213
|
+
id,
|
|
1214
|
+
repoId: repo.id,
|
|
1215
|
+
workflow: file,
|
|
1216
|
+
event,
|
|
1217
|
+
job: req.job,
|
|
1218
|
+
status: "queued",
|
|
1219
|
+
dryRun: req.dryRun === true,
|
|
1220
|
+
queuedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1221
|
+
request: { ...req, repoId: repo.id, workflow: file, event }
|
|
1222
|
+
};
|
|
1223
|
+
store.insertRun(summary);
|
|
1224
|
+
this.active.set(id, {
|
|
1225
|
+
summary,
|
|
1226
|
+
repoPath: repo.path,
|
|
1227
|
+
workflowInfo: wf,
|
|
1228
|
+
seq: 0,
|
|
1229
|
+
cancelRequested: false,
|
|
1230
|
+
sawJobEnd: false,
|
|
1231
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
1232
|
+
decoders: { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") },
|
|
1233
|
+
residual: { stdout: "", stderr: "" },
|
|
1234
|
+
logFile: path6.join(dir, "log.ndjson"),
|
|
1235
|
+
redactions: [],
|
|
1236
|
+
lockKeys: runLockKeys(wf, req.job, repo.id, repo.config.platforms, isolation !== void 0),
|
|
1237
|
+
isolation,
|
|
1238
|
+
overlapped: false,
|
|
1239
|
+
sawCloneError: false
|
|
1240
|
+
});
|
|
1241
|
+
this.queue.push(id);
|
|
1242
|
+
this.tick();
|
|
1243
|
+
return { ...summary };
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Write the per-run workflow copy that lets this run share a workflow with other runs (see
|
|
1247
|
+
* runs/isolate.ts), when it has a Docker job and the file can be rewritten. The copy is a snapshot taken
|
|
1248
|
+
* now and keeps the file's base name, so a workflow without a `name:` is still named after its file.
|
|
1249
|
+
* Any problem means no copy, so the run falls back to the lock it had before.
|
|
1250
|
+
*/
|
|
1251
|
+
async isolate(repoPath, file, wf, job, platforms, id, dir) {
|
|
1252
|
+
if (!wantsIsolation(wf, job, platforms)) return void 0;
|
|
1253
|
+
try {
|
|
1254
|
+
const suffix = isolationSuffix(id);
|
|
1255
|
+
const copy = isolateWorkflow((await readWorkflowSource(repoPath, file)).text, suffix);
|
|
1256
|
+
if (copy === void 0) return void 0;
|
|
1257
|
+
const target = path6.resolve(dir, "wf", file);
|
|
1258
|
+
mkdirSync3(path6.dirname(target), { recursive: true });
|
|
1259
|
+
writeFileSync(target, copy);
|
|
1260
|
+
return { suffix, file: target };
|
|
1261
|
+
} catch {
|
|
1262
|
+
return void 0;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
async rerun(id) {
|
|
1266
|
+
const run2 = this.deps.store.getRun(id);
|
|
1267
|
+
if (!run2) throw new RepoError("run not found", 404);
|
|
1268
|
+
return this.create(run2.request);
|
|
1269
|
+
}
|
|
1270
|
+
// --- scheduling ------------------------------------------------------------
|
|
1271
|
+
/** The saved limit (Setup check), else the configured default. */
|
|
1272
|
+
maxConcurrentRuns() {
|
|
1273
|
+
const saved = Number(this.deps.store.getSetting("maxConcurrentRuns"));
|
|
1274
|
+
return Number.isInteger(saved) && saved >= MIN_CONCURRENT_RUNS && saved <= MAX_CONCURRENT_RUNS ? saved : this.deps.cfg.maxConcurrentRuns;
|
|
1275
|
+
}
|
|
1276
|
+
/** Save a new limit and start whatever it lets through. Running runs are never stopped by a lower one. */
|
|
1277
|
+
setMaxConcurrentRuns(n) {
|
|
1278
|
+
if (!Number.isInteger(n) || n < MIN_CONCURRENT_RUNS || n > MAX_CONCURRENT_RUNS) {
|
|
1279
|
+
throw new RepoError(`The run limit must be a whole number from ${MIN_CONCURRENT_RUNS} to ${MAX_CONCURRENT_RUNS}.`);
|
|
1280
|
+
}
|
|
1281
|
+
this.deps.store.setSetting("maxConcurrentRuns", String(n));
|
|
1282
|
+
this.tick();
|
|
1283
|
+
return n;
|
|
1284
|
+
}
|
|
1285
|
+
/** Start queued runs, oldest first, while there is a free slot and no running run holds one of their locks. */
|
|
1286
|
+
tick() {
|
|
1287
|
+
if (this.shuttingDown) return;
|
|
1288
|
+
const cap = this.maxConcurrentRuns();
|
|
1289
|
+
const running = [...this.active.values()].filter((a) => a.summary.status === "running");
|
|
1290
|
+
let slots = cap - running.length;
|
|
1291
|
+
const holders = /* @__PURE__ */ new Map();
|
|
1292
|
+
for (const r of running) for (const k of r.lockKeys) holders.set(k, r);
|
|
1293
|
+
let runningCount = running.length;
|
|
1294
|
+
for (const id of [...this.queue]) {
|
|
1295
|
+
const a = this.active.get(id);
|
|
1296
|
+
if (!a) {
|
|
1297
|
+
this.queue = this.queue.filter((q) => q !== id);
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
const blocked = a.lockKeys.find((k) => holders.has(k));
|
|
1301
|
+
if (blocked || slots <= 0) {
|
|
1302
|
+
this.setWaiting(a, waitReason(blocked, blocked ? holders.get(blocked) : void 0, runningCount, cap));
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
this.queue = this.queue.filter((q) => q !== id);
|
|
1306
|
+
for (const k of a.lockKeys) holders.set(k, a);
|
|
1307
|
+
slots--;
|
|
1308
|
+
runningCount++;
|
|
1309
|
+
void this.start(a);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/** Record why a queued run is waiting. Stored in `note`, so every endpoint and the SSE stream carry it. */
|
|
1313
|
+
setWaiting(a, reason) {
|
|
1314
|
+
if (a.summary.note === reason) return;
|
|
1315
|
+
a.summary.note = reason;
|
|
1316
|
+
this.deps.store.updateRun(a.summary);
|
|
1317
|
+
this.notifyStatus(a);
|
|
1318
|
+
}
|
|
1319
|
+
async start(a) {
|
|
1320
|
+
a.summary.status = "running";
|
|
1321
|
+
a.summary.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1322
|
+
a.summary.note = void 0;
|
|
1323
|
+
const running = [...this.active.values()].filter((r) => r.summary.status === "running");
|
|
1324
|
+
if (running.length > 1) for (const r of running) r.overlapped = true;
|
|
1325
|
+
this.deps.store.updateRun(a.summary);
|
|
1326
|
+
this.notifyStatus(a);
|
|
1327
|
+
try {
|
|
1328
|
+
await this.execute(a);
|
|
1329
|
+
} catch (e) {
|
|
1330
|
+
this.emit(a, "stderr", [`Error: ${e.message}`]);
|
|
1331
|
+
this.finish(a, "error", void 0, `The viewer could not start this run: ${e.message}`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
async execute(a) {
|
|
1335
|
+
const { cfg, repos, secrets, environments } = this.deps;
|
|
1336
|
+
const s = a.summary;
|
|
1337
|
+
const req = s.request;
|
|
1338
|
+
const repo = await repos.get(s.repoId);
|
|
1339
|
+
const dir = this.runDir(s.id);
|
|
1340
|
+
const artifactDir = path6.join(dir, "artifacts");
|
|
1341
|
+
mkdirSync3(artifactDir, { recursive: true });
|
|
1342
|
+
let eventPayloadPath;
|
|
1343
|
+
if (req.eventPayload?.trim()) {
|
|
1344
|
+
eventPayloadPath = path6.join(dir, "event.json");
|
|
1345
|
+
writeFileSync(eventPayloadPath, req.eventPayload);
|
|
1346
|
+
}
|
|
1347
|
+
const environment = req.environment ? environments.resolve(req.environment) : void 0;
|
|
1348
|
+
const repoSecrets = secrets.values(repo.id);
|
|
1349
|
+
const secretValues = { ...repoSecrets, ...environment?.secrets };
|
|
1350
|
+
a.redactions = redactionPatterns(secretValues);
|
|
1351
|
+
const artifactPort = a.artifactPort = await this.reservePort();
|
|
1352
|
+
const layerEnv = mergedEnv(repo.config.env, environment?.env, req.env);
|
|
1353
|
+
let viewerEnv;
|
|
1354
|
+
let liveView;
|
|
1355
|
+
if (req.viewable === true && !s.dryRun) {
|
|
1356
|
+
const plan = planLiveView(a.workflowInfo, s.job, repo.config.platforms, req.matrix ?? {});
|
|
1357
|
+
if (plan.enabled) {
|
|
1358
|
+
a.viewablePort = await this.reservePort();
|
|
1359
|
+
liveView = { port: a.viewablePort };
|
|
1360
|
+
viewerEnv = { VNC_PORT: String(a.viewablePort) };
|
|
1361
|
+
}
|
|
1362
|
+
if (plan.notes.length) {
|
|
1363
|
+
s.note = plan.notes.join(" ");
|
|
1364
|
+
this.deps.store.updateRun(s);
|
|
1365
|
+
this.notifyStatus(a);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const args = buildActArgs({
|
|
1369
|
+
event: s.event,
|
|
1370
|
+
workflowFile: s.workflow,
|
|
1371
|
+
workflowPath: a.isolation?.file,
|
|
1372
|
+
job: s.job,
|
|
1373
|
+
matrix: req.matrix ?? {},
|
|
1374
|
+
inputs: req.inputs ?? {},
|
|
1375
|
+
eventPayloadPath,
|
|
1376
|
+
dryRun: s.dryRun,
|
|
1377
|
+
env: { ...layerEnv, ...viewerEnv },
|
|
1378
|
+
vars: { ...repo.config.vars, ...req.vars, ...viewerEnv },
|
|
1379
|
+
secretNames: Object.keys(secretValues),
|
|
1380
|
+
platforms: repo.config.platforms,
|
|
1381
|
+
pull: req.pull ?? repo.config.pull,
|
|
1382
|
+
offline: req.offline ?? repo.config.offline,
|
|
1383
|
+
containerArchitecture: repo.config.containerArchitecture,
|
|
1384
|
+
artifactDir,
|
|
1385
|
+
artifactPort,
|
|
1386
|
+
liveView
|
|
1387
|
+
});
|
|
1388
|
+
const context = buildRunContext({
|
|
1389
|
+
request: req,
|
|
1390
|
+
repoEnv: repo.config.env,
|
|
1391
|
+
repoVars: repo.config.vars,
|
|
1392
|
+
repoSecrets: Object.keys(repoSecrets),
|
|
1393
|
+
environment: environment && { env: environment.env, secrets: Object.keys(environment.secrets) },
|
|
1394
|
+
workflowFile: a.isolation ? "job-names" : "original",
|
|
1395
|
+
viewerEnv,
|
|
1396
|
+
viewerVars: viewerEnv,
|
|
1397
|
+
mask: (value) => redact(value, a.redactions)
|
|
1398
|
+
});
|
|
1399
|
+
writeFileSync(path6.join(dir, CONTEXT_FILE), JSON.stringify(context));
|
|
1400
|
+
if (!this.actVersionLoaded) {
|
|
1401
|
+
this.cachedActVersion = await actVersion(cfg);
|
|
1402
|
+
this.actVersionLoaded = true;
|
|
1403
|
+
}
|
|
1404
|
+
s.actVersion = this.cachedActVersion;
|
|
1405
|
+
if (!s.dryRun && needsDocker(a.workflowInfo, s.job, repo.config.platforms)) {
|
|
1406
|
+
const docker = await dockerReachable(cfg);
|
|
1407
|
+
if (!docker.ok) {
|
|
1408
|
+
const msg = "Docker is not running. Start Docker Desktop, then run again (a dry run works without Docker).";
|
|
1409
|
+
this.emit(a, "stderr", [`Error: ${msg}`]);
|
|
1410
|
+
this.finish(a, "error", void 0, msg);
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (a.cancelRequested) {
|
|
1415
|
+
this.finish(a, "cancelled");
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const child = spawn(cfg.actBin, args, {
|
|
1419
|
+
cwd: repo.path,
|
|
1420
|
+
env: { ...process.env, ...secretValues },
|
|
1421
|
+
detached: true,
|
|
1422
|
+
// own process group, so cancel can signal act and everything it started
|
|
1423
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1424
|
+
});
|
|
1425
|
+
a.child = child;
|
|
1426
|
+
if (a.viewablePort !== void 0) this.watchLiveView(a, a.viewablePort);
|
|
1427
|
+
child.on("error", (err) => {
|
|
1428
|
+
const msg = err.code === "ENOENT" ? `Could not find "${cfg.actBin}". Install act (brew install act).` : err.message;
|
|
1429
|
+
this.emit(a, "stderr", [`Error: ${msg}`]);
|
|
1430
|
+
this.finish(a, "error", void 0, msg);
|
|
1431
|
+
});
|
|
1432
|
+
child.stdout.on("data", (chunk) => this.ingest(a, "stdout", chunk));
|
|
1433
|
+
child.stderr.on("data", (chunk) => this.ingest(a, "stderr", chunk));
|
|
1434
|
+
child.on("close", (code) => {
|
|
1435
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
1436
|
+
const tail = a.residual[stream] + a.decoders[stream].end();
|
|
1437
|
+
a.residual[stream] = "";
|
|
1438
|
+
if (tail) this.emit(a, stream, [tail]);
|
|
1439
|
+
}
|
|
1440
|
+
this.finish(a, void 0, code);
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* The live-view URL is published once the player answers, not when the container starts: the container and
|
|
1445
|
+
* the player's own startup take seconds, and Docker accepts connections on a published port before anything
|
|
1446
|
+
* listens behind it, so only an HTTP answer says the player is up. Any status counts (it is up either way).
|
|
1447
|
+
*/
|
|
1448
|
+
watchLiveView(a, port2) {
|
|
1449
|
+
let probing = false;
|
|
1450
|
+
a.viewableTimer = setInterval(() => {
|
|
1451
|
+
if (probing) return;
|
|
1452
|
+
probing = true;
|
|
1453
|
+
fetch(`http://127.0.0.1:${port2}/vnc.html`, { signal: AbortSignal.timeout(1500) }).then((res) => {
|
|
1454
|
+
void res.body?.cancel();
|
|
1455
|
+
if (FINAL_STATUSES.includes(a.summary.status)) return;
|
|
1456
|
+
clearInterval(a.viewableTimer);
|
|
1457
|
+
a.summary.liveViewUrl = `http://localhost:${port2}/vnc.html?autoconnect=1&resize=scale`;
|
|
1458
|
+
this.notifyStatus(a);
|
|
1459
|
+
}).catch(() => {
|
|
1460
|
+
}).finally(() => {
|
|
1461
|
+
probing = false;
|
|
1462
|
+
});
|
|
1463
|
+
}, LIVE_VIEW_PROBE_MS);
|
|
1464
|
+
a.viewableTimer.unref();
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* act's artifact server listens on one fixed port by default, so each run needs its own, and live view needs
|
|
1468
|
+
* one more. The probe only says the port is free right now; `reservedPorts` stops two of our runs probing
|
|
1469
|
+
* the same one. `finish` gives them back.
|
|
1470
|
+
*/
|
|
1471
|
+
async reservePort() {
|
|
1472
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1473
|
+
const port2 = await probeFreePort();
|
|
1474
|
+
if (this.reservedPorts.has(port2)) continue;
|
|
1475
|
+
this.reservedPorts.add(port2);
|
|
1476
|
+
return port2;
|
|
1477
|
+
}
|
|
1478
|
+
throw new Error("could not find a free local port");
|
|
1479
|
+
}
|
|
1480
|
+
// --- log pipeline ------------------------------------------------------------
|
|
1481
|
+
ingest(a, stream, chunk) {
|
|
1482
|
+
const text = a.residual[stream] + a.decoders[stream].write(chunk);
|
|
1483
|
+
const lines = text.split("\n");
|
|
1484
|
+
a.residual[stream] = lines.pop() ?? "";
|
|
1485
|
+
if (lines.length) this.emit(a, stream, lines);
|
|
1486
|
+
}
|
|
1487
|
+
/** Parse lines, persist them, then fan out. Persisting first means replays never miss an event. */
|
|
1488
|
+
emit(a, stream, lines) {
|
|
1489
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1490
|
+
const events = [];
|
|
1491
|
+
const records = [];
|
|
1492
|
+
for (const raw of lines) {
|
|
1493
|
+
let line = redactLine(raw.replace(/\r$/, ""), a.redactions);
|
|
1494
|
+
if (a.isolation) line = line.split(` ${a.isolation.suffix}`).join("");
|
|
1495
|
+
if (!a.sawCloneError && CLONE_ERROR_RE.test(line)) a.sawCloneError = true;
|
|
1496
|
+
const ev = parseActLine(line, stream, a.seq, now);
|
|
1497
|
+
if (!ev) continue;
|
|
1498
|
+
a.seq++;
|
|
1499
|
+
events.push(ev);
|
|
1500
|
+
records.push(JSON.stringify({ s: stream === "stdout" ? "o" : "e", t: now, l: line }));
|
|
1501
|
+
}
|
|
1502
|
+
if (!events.length) return;
|
|
1503
|
+
appendFileSync(a.logFile, records.join("\n") + "\n");
|
|
1504
|
+
if (events.some((e) => e.kind === "job-end")) a.sawJobEnd = true;
|
|
1505
|
+
for (const sub of a.subscribers) sub.onEvents(events);
|
|
1506
|
+
}
|
|
1507
|
+
finish(a, forced, code, note) {
|
|
1508
|
+
const s = a.summary;
|
|
1509
|
+
if (FINAL_STATUSES.includes(s.status)) return;
|
|
1510
|
+
if (a.killTimer) clearTimeout(a.killTimer);
|
|
1511
|
+
clearInterval(a.viewableTimer);
|
|
1512
|
+
for (const port2 of [a.artifactPort, a.viewablePort]) if (port2 !== void 0) this.reservedPorts.delete(port2);
|
|
1513
|
+
a.artifactPort = a.viewablePort = void 0;
|
|
1514
|
+
s.liveViewUrl = void 0;
|
|
1515
|
+
if (s.status === "queued") s.note = void 0;
|
|
1516
|
+
const cloneHint = a.sawCloneError && a.overlapped ? CLONE_HINT : void 0;
|
|
1517
|
+
let status = forced;
|
|
1518
|
+
if (!status) {
|
|
1519
|
+
if (a.cancelRequested) status = "cancelled";
|
|
1520
|
+
else if (code === 0) status = "success";
|
|
1521
|
+
else if (a.sawJobEnd) {
|
|
1522
|
+
status = "failure";
|
|
1523
|
+
note ??= cloneHint;
|
|
1524
|
+
} else {
|
|
1525
|
+
status = "error";
|
|
1526
|
+
note ??= cloneHint ?? `act exited with code ${code ?? "unknown"} before any job finished. See the log for details.`;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
s.status = status;
|
|
1530
|
+
s.exitCode = code ?? void 0;
|
|
1531
|
+
s.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1532
|
+
if (note) s.note = note;
|
|
1533
|
+
this.deps.store.updateRun(s);
|
|
1534
|
+
this.notifyStatus(a);
|
|
1535
|
+
for (const sub of a.subscribers) sub.onEnd();
|
|
1536
|
+
a.subscribers.clear();
|
|
1537
|
+
this.active.delete(s.id);
|
|
1538
|
+
this.queue = this.queue.filter((q) => q !== s.id);
|
|
1539
|
+
this.prune(s.repoId);
|
|
1540
|
+
this.tick();
|
|
1541
|
+
}
|
|
1542
|
+
notifyStatus(a) {
|
|
1543
|
+
const copy = { ...a.summary };
|
|
1544
|
+
for (const sub of a.subscribers) sub.onStatus(copy);
|
|
1545
|
+
}
|
|
1546
|
+
prune(repoId) {
|
|
1547
|
+
for (const id of this.deps.store.runsToPrune(repoId, this.deps.cfg.retentionRuns)) {
|
|
1548
|
+
rmSync2(this.runDir(id), { recursive: true, force: true });
|
|
1549
|
+
this.deps.store.deleteRun(id);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
// --- cancel ------------------------------------------------------------------
|
|
1553
|
+
cancel(id) {
|
|
1554
|
+
const a = this.active.get(id);
|
|
1555
|
+
if (!a) {
|
|
1556
|
+
const run2 = this.deps.store.getRun(id);
|
|
1557
|
+
if (!run2) throw new RepoError("run not found", 404);
|
|
1558
|
+
throw new RepoError("This run has already finished.", 409);
|
|
1559
|
+
}
|
|
1560
|
+
if (a.summary.status === "queued") {
|
|
1561
|
+
a.cancelRequested = true;
|
|
1562
|
+
this.finish(a, "cancelled");
|
|
1563
|
+
return { ...a.summary };
|
|
1564
|
+
}
|
|
1565
|
+
a.cancelRequested = true;
|
|
1566
|
+
this.signalGroup(a, "SIGTERM");
|
|
1567
|
+
a.killTimer ??= setTimeout(() => this.signalGroup(a, "SIGKILL"), KILL_GRACE_MS);
|
|
1568
|
+
return { ...a.summary };
|
|
1569
|
+
}
|
|
1570
|
+
signalGroup(a, signal) {
|
|
1571
|
+
const pid = a.child?.pid;
|
|
1572
|
+
if (!pid) return;
|
|
1573
|
+
try {
|
|
1574
|
+
process.kill(-pid, signal);
|
|
1575
|
+
} catch {
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
async shutdown() {
|
|
1579
|
+
this.shuttingDown = true;
|
|
1580
|
+
for (const a of [...this.active.values()]) {
|
|
1581
|
+
a.cancelRequested = true;
|
|
1582
|
+
if (a.summary.status === "queued") this.finish(a, "cancelled");
|
|
1583
|
+
else this.signalGroup(a, "SIGTERM");
|
|
1584
|
+
}
|
|
1585
|
+
const deadline = Date.now() + 5e3;
|
|
1586
|
+
while (this.active.size > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
|
|
1587
|
+
for (const a of this.active.values()) this.signalGroup(a, "SIGKILL");
|
|
1588
|
+
}
|
|
1589
|
+
// --- reading -----------------------------------------------------------------
|
|
1590
|
+
/** Every event of a run from `from` on, read from the on-disk log. */
|
|
1591
|
+
readEvents(id, from = 0) {
|
|
1592
|
+
const file = path6.join(this.runDir(id), "log.ndjson");
|
|
1593
|
+
if (!existsSync2(file)) return [];
|
|
1594
|
+
const out = [];
|
|
1595
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
1596
|
+
for (let seq = from; seq < lines.length; seq++) {
|
|
1597
|
+
const rec = lines[seq];
|
|
1598
|
+
if (!rec) continue;
|
|
1599
|
+
try {
|
|
1600
|
+
const { s, t, l } = JSON.parse(rec);
|
|
1601
|
+
const ev = parseActLine(l, s === "o" ? "stdout" : "stderr", seq, t);
|
|
1602
|
+
if (ev) out.push(ev);
|
|
1603
|
+
} catch {
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
return out;
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Replay from `from`, then (if the run is live) attach to it. The file read and the attach
|
|
1610
|
+
* happen in one synchronous step, so no event can fall between them.
|
|
1611
|
+
*/
|
|
1612
|
+
subscribe(id, from, sub) {
|
|
1613
|
+
const a = this.active.get(id);
|
|
1614
|
+
const backlog = this.readEvents(id, from);
|
|
1615
|
+
if (a) {
|
|
1616
|
+
a.subscribers.add(sub);
|
|
1617
|
+
return { backlog, run: { ...a.summary }, live: true, unsubscribe: () => void a.subscribers.delete(sub) };
|
|
1618
|
+
}
|
|
1619
|
+
const run2 = this.deps.store.getRun(id);
|
|
1620
|
+
if (!run2) return void 0;
|
|
1621
|
+
return { backlog, run: run2, live: false, unsubscribe: () => {
|
|
1622
|
+
} };
|
|
1623
|
+
}
|
|
1624
|
+
/** What the run was started with. Falls back to the request alone when none was recorded. */
|
|
1625
|
+
readContext(run2) {
|
|
1626
|
+
try {
|
|
1627
|
+
return JSON.parse(readFileSync(path6.join(this.runDir(run2.id), CONTEXT_FILE), "utf8"));
|
|
1628
|
+
} catch {
|
|
1629
|
+
return contextFromRequest(run2.request);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
/** Plain-text rendering for the "download log" button. */
|
|
1633
|
+
logText(id) {
|
|
1634
|
+
return this.readEvents(id).filter((e) => e.kind !== "step-start" && e.kind !== "group").map((e) => e.jobKey ? `[${e.jobKey}] ${e.text}` : e.text).join("\n");
|
|
1635
|
+
}
|
|
1636
|
+
// --- deleting ----------------------------------------------------------------
|
|
1637
|
+
deleteRun(id) {
|
|
1638
|
+
if (this.active.has(id)) throw new RepoError("Cancel the run before deleting it.", 409);
|
|
1639
|
+
if (!this.deps.store.getRun(id)) throw new RepoError("run not found", 404);
|
|
1640
|
+
rmSync2(this.runDir(id), { recursive: true, force: true });
|
|
1641
|
+
this.deps.store.deleteRun(id);
|
|
1642
|
+
}
|
|
1643
|
+
/** Remove all history for a repo. Refuses while one of its runs is active. */
|
|
1644
|
+
deleteRunsForRepo(repoId) {
|
|
1645
|
+
for (const a of this.active.values()) {
|
|
1646
|
+
if (a.summary.repoId === repoId) throw new RepoError("Cancel this repo's running workflows first.", 409);
|
|
1647
|
+
}
|
|
1648
|
+
for (const id of this.deps.store.runIdsForRepo(repoId)) {
|
|
1649
|
+
rmSync2(this.runDir(id), { recursive: true, force: true });
|
|
1650
|
+
this.deps.store.deleteRun(id);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
/** Wipe finished runs (logs, artifacts, build data). Queued and running runs are left alone. */
|
|
1654
|
+
clearFinishedRuns(repoId, workflow) {
|
|
1655
|
+
for (const id of this.deps.store.finishedRunIds(repoId, workflow)) {
|
|
1656
|
+
if (this.active.has(id)) continue;
|
|
1657
|
+
rmSync2(this.runDir(id), { recursive: true, force: true });
|
|
1658
|
+
this.deps.store.deleteRun(id);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
// src/server/app.ts
|
|
1664
|
+
var kv = z.record(z.string(), z.string());
|
|
1665
|
+
var RunRequestSchema = z.object({
|
|
1666
|
+
repoId: z.string().min(1),
|
|
1667
|
+
workflow: z.string().min(1),
|
|
1668
|
+
event: z.string().min(1),
|
|
1669
|
+
job: z.string().min(1).optional(),
|
|
1670
|
+
matrix: kv.optional(),
|
|
1671
|
+
inputs: kv.optional(),
|
|
1672
|
+
eventPayload: z.string().optional(),
|
|
1673
|
+
dryRun: z.boolean().optional(),
|
|
1674
|
+
viewable: z.boolean().optional(),
|
|
1675
|
+
env: kv.optional(),
|
|
1676
|
+
vars: kv.optional(),
|
|
1677
|
+
pull: z.boolean().optional(),
|
|
1678
|
+
offline: z.boolean().optional(),
|
|
1679
|
+
environment: z.string().min(1).optional()
|
|
1680
|
+
}).strict();
|
|
1681
|
+
var ConfigSchema = z.object({
|
|
1682
|
+
platforms: z.array(z.string()).optional(),
|
|
1683
|
+
env: kv.optional(),
|
|
1684
|
+
vars: kv.optional(),
|
|
1685
|
+
pull: z.boolean().optional(),
|
|
1686
|
+
offline: z.boolean().optional(),
|
|
1687
|
+
containerArchitecture: z.string().optional()
|
|
1688
|
+
}).strict();
|
|
1689
|
+
var RepoPatchSchema = z.object({ name: z.string().optional(), config: ConfigSchema.optional() }).strict();
|
|
1690
|
+
var SettingsSchema = z.object({ maxConcurrentRuns: z.number() }).strict();
|
|
1691
|
+
var EnvironmentCreateSchema = z.object({ name: z.string().min(1), env: kv.optional() }).strict();
|
|
1692
|
+
var EnvironmentPatchSchema = z.object({ env: kv }).strict();
|
|
1693
|
+
var SecretValueSchema = z.object({ value: z.string() }).strict();
|
|
1694
|
+
var BadRequest = class extends Error {
|
|
1695
|
+
};
|
|
1696
|
+
function createApp(deps) {
|
|
1697
|
+
const { cfg, security, repos, environments, secrets, runs, store } = deps;
|
|
1698
|
+
const app = new Hono();
|
|
1699
|
+
app.use("*", security.middleware());
|
|
1700
|
+
app.use("/api/*", bodyLimit({ maxSize: 2 * 1024 * 1024, onError: (c) => c.json({ error: "request body too large" }, 413) }));
|
|
1701
|
+
app.onError((err, c) => {
|
|
1702
|
+
if (err instanceof RepoError) return c.json({ error: err.message }, err.status);
|
|
1703
|
+
if (err instanceof ValidationError || err instanceof BadRequest) return c.json({ error: err.message }, 400);
|
|
1704
|
+
if (err instanceof z.ZodError) {
|
|
1705
|
+
const issue = err.issues[0];
|
|
1706
|
+
const where = issue?.path.length ? `${issue.path.join(".")}: ` : "";
|
|
1707
|
+
return c.json({ error: `${where}${issue?.message ?? "invalid request"}` }, 400);
|
|
1708
|
+
}
|
|
1709
|
+
if (err instanceof SyntaxError) return c.json({ error: "request body is not valid JSON" }, 400);
|
|
1710
|
+
console.error(err);
|
|
1711
|
+
return c.json({ error: "internal error" }, 500);
|
|
1712
|
+
});
|
|
1713
|
+
async function json(c, schema) {
|
|
1714
|
+
return schema.parse(await c.req.json());
|
|
1715
|
+
}
|
|
1716
|
+
app.get("/api/session", (c) => c.json({ token: security.token, version: cfg.version }));
|
|
1717
|
+
let doctorCache;
|
|
1718
|
+
app.get("/api/health", async (c) => {
|
|
1719
|
+
if (!doctorCache || c.req.query("refresh") === "1" || Date.now() - doctorCache.at > 15e3) {
|
|
1720
|
+
doctorCache = { at: Date.now(), checks: await runDoctor(cfg) };
|
|
1721
|
+
}
|
|
1722
|
+
return c.json(doctorCache.checks);
|
|
1723
|
+
});
|
|
1724
|
+
app.get("/api/settings", (c) => c.json({ maxConcurrentRuns: runs.maxConcurrentRuns() }));
|
|
1725
|
+
app.put("/api/settings", async (c) => {
|
|
1726
|
+
const body = await json(c, SettingsSchema);
|
|
1727
|
+
return c.json({ maxConcurrentRuns: runs.setMaxConcurrentRuns(body.maxConcurrentRuns) });
|
|
1728
|
+
});
|
|
1729
|
+
app.get("/api/repos", async (c) => c.json(await repos.list()));
|
|
1730
|
+
app.post("/api/repos", async (c) => {
|
|
1731
|
+
const { path: p } = await json(c, z.object({ path: z.string().min(1) }).strict());
|
|
1732
|
+
return c.json(await repos.add(p), 201);
|
|
1733
|
+
});
|
|
1734
|
+
app.patch("/api/repos/:id", async (c) => c.json(await repos.update(c.req.param("id"), await json(c, RepoPatchSchema))));
|
|
1735
|
+
app.delete("/api/repos/:id", async (c) => {
|
|
1736
|
+
const id = c.req.param("id");
|
|
1737
|
+
await repos.get(id);
|
|
1738
|
+
runs.deleteRunsForRepo(id);
|
|
1739
|
+
repos.remove(id);
|
|
1740
|
+
return c.body(null, 204);
|
|
1741
|
+
});
|
|
1742
|
+
app.put("/api/repos/:id/secrets/:name", async (c) => {
|
|
1743
|
+
const repo = await repos.get(c.req.param("id"));
|
|
1744
|
+
const { value } = await json(c, SecretValueSchema);
|
|
1745
|
+
secrets.set(repo.id, c.req.param("name"), value);
|
|
1746
|
+
return c.json(await repos.get(repo.id));
|
|
1747
|
+
});
|
|
1748
|
+
app.delete("/api/repos/:id/secrets/:name", async (c) => {
|
|
1749
|
+
const repo = await repos.get(c.req.param("id"));
|
|
1750
|
+
secrets.delete(repo.id, c.req.param("name"));
|
|
1751
|
+
return c.json(await repos.get(repo.id));
|
|
1752
|
+
});
|
|
1753
|
+
app.get("/api/repos/:id/workflows", async (c) => {
|
|
1754
|
+
const repo = await repos.get(c.req.param("id"));
|
|
1755
|
+
return c.json(await enrichWithStages(cfg, repo.path, await loadWorkflows(repo.path)));
|
|
1756
|
+
});
|
|
1757
|
+
app.get("/api/repos/:id/workflows/:file/source", async (c) => {
|
|
1758
|
+
const repo = await repos.get(c.req.param("id"));
|
|
1759
|
+
return c.json(await readWorkflowSource(repo.path, c.req.param("file")));
|
|
1760
|
+
});
|
|
1761
|
+
app.post("/api/repos/:id/validate", async (c) => {
|
|
1762
|
+
const repo = await repos.get(c.req.param("id"));
|
|
1763
|
+
const { workflow } = await json(c, z.object({ workflow: z.string() }).strict());
|
|
1764
|
+
return c.json(await validateWorkflow(cfg, repo.path, workflowFile(workflow)));
|
|
1765
|
+
});
|
|
1766
|
+
app.get("/api/environments", (c) => c.json(environments.list()));
|
|
1767
|
+
app.post("/api/environments", async (c) => {
|
|
1768
|
+
const { name, env } = await json(c, EnvironmentCreateSchema);
|
|
1769
|
+
return c.json(environments.create(name, env), 201);
|
|
1770
|
+
});
|
|
1771
|
+
app.patch(
|
|
1772
|
+
"/api/environments/:name",
|
|
1773
|
+
async (c) => c.json(environments.update(c.req.param("name"), (await json(c, EnvironmentPatchSchema)).env))
|
|
1774
|
+
);
|
|
1775
|
+
app.delete("/api/environments/:name", (c) => {
|
|
1776
|
+
environments.remove(c.req.param("name"));
|
|
1777
|
+
return c.body(null, 204);
|
|
1778
|
+
});
|
|
1779
|
+
app.put("/api/environments/:name/secrets/:secret", async (c) => {
|
|
1780
|
+
const { value } = await json(c, SecretValueSchema);
|
|
1781
|
+
return c.json(environments.setSecret(c.req.param("name"), c.req.param("secret"), value));
|
|
1782
|
+
});
|
|
1783
|
+
app.delete(
|
|
1784
|
+
"/api/environments/:name/secrets/:secret",
|
|
1785
|
+
(c) => c.json(environments.deleteSecret(c.req.param("name"), c.req.param("secret")))
|
|
1786
|
+
);
|
|
1787
|
+
app.post("/api/runs", async (c) => c.json(await runs.create(await json(c, RunRequestSchema)), 202));
|
|
1788
|
+
app.get("/api/runs", (c) => {
|
|
1789
|
+
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 100) || 100, 1), 500);
|
|
1790
|
+
return c.json(store.listRuns(c.req.query("repoId") || void 0, limit));
|
|
1791
|
+
});
|
|
1792
|
+
app.delete("/api/runs", async (c) => {
|
|
1793
|
+
const repoId = c.req.query("repoId");
|
|
1794
|
+
if (!repoId) throw new BadRequest("repoId is required");
|
|
1795
|
+
await repos.get(repoId);
|
|
1796
|
+
const wf = c.req.query("workflow");
|
|
1797
|
+
runs.clearFinishedRuns(repoId, wf ? workflowFile(wf) : void 0);
|
|
1798
|
+
return c.body(null, 204);
|
|
1799
|
+
});
|
|
1800
|
+
function requireRun(id) {
|
|
1801
|
+
if (!isRunId(id)) throw new RepoError("run not found", 404);
|
|
1802
|
+
const run2 = store.getRun(id);
|
|
1803
|
+
if (!run2) throw new RepoError("run not found", 404);
|
|
1804
|
+
return run2;
|
|
1805
|
+
}
|
|
1806
|
+
app.get("/api/runs/:id", (c) => c.json(requireRun(c.req.param("id"))));
|
|
1807
|
+
app.post("/api/runs/:id/cancel", async (c) => {
|
|
1808
|
+
requireRun(c.req.param("id"));
|
|
1809
|
+
return c.json(runs.cancel(c.req.param("id")));
|
|
1810
|
+
});
|
|
1811
|
+
app.post("/api/runs/:id/rerun", async (c) => {
|
|
1812
|
+
requireRun(c.req.param("id"));
|
|
1813
|
+
return c.json(await runs.rerun(c.req.param("id")), 202);
|
|
1814
|
+
});
|
|
1815
|
+
app.delete("/api/runs/:id", (c) => {
|
|
1816
|
+
requireRun(c.req.param("id"));
|
|
1817
|
+
runs.deleteRun(c.req.param("id"));
|
|
1818
|
+
return c.body(null, 204);
|
|
1819
|
+
});
|
|
1820
|
+
app.get("/api/runs/:id/context", (c) => c.json(runs.readContext(requireRun(c.req.param("id")))));
|
|
1821
|
+
app.get("/api/runs/:id/log", (c) => {
|
|
1822
|
+
const run2 = requireRun(c.req.param("id"));
|
|
1823
|
+
return c.body(runs.logText(run2.id), 200, {
|
|
1824
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1825
|
+
"content-disposition": `attachment; filename="${run2.workflow.replace(/\.ya?ml$/, "")}-${run2.id}.log"`
|
|
1826
|
+
});
|
|
1827
|
+
});
|
|
1828
|
+
app.get("/api/runs/:id/artifacts", (c) => {
|
|
1829
|
+
const run2 = requireRun(c.req.param("id"));
|
|
1830
|
+
return c.json(listArtifacts(runs.runDir(run2.id)));
|
|
1831
|
+
});
|
|
1832
|
+
const sendArtifact = (c, runId, rel, inline) => {
|
|
1833
|
+
const file = resolveArtifactFile(runs.runDir(runId), rel);
|
|
1834
|
+
return c.body(Readable.toWeb(createReadStream(file.abs)), 200, {
|
|
1835
|
+
...fileResponseHeaders(file.downloadName, inline, file.abs),
|
|
1836
|
+
"content-length": String(statSync2(file.abs).size)
|
|
1837
|
+
});
|
|
1838
|
+
};
|
|
1839
|
+
app.get("/api/runs/:id/artifacts/file", (c) => {
|
|
1840
|
+
const run2 = requireRun(c.req.param("id"));
|
|
1841
|
+
const rel = c.req.query("path");
|
|
1842
|
+
if (!rel) throw new BadRequest("missing path");
|
|
1843
|
+
return sendArtifact(c, run2.id, rel, false);
|
|
1844
|
+
});
|
|
1845
|
+
app.get("/api/runs/:id/artifacts/raw/*", (c) => {
|
|
1846
|
+
const run2 = requireRun(c.req.param("id"));
|
|
1847
|
+
const prefix = `/api/runs/${run2.id}/artifacts/raw/`;
|
|
1848
|
+
const pathname = new URL(c.req.url).pathname;
|
|
1849
|
+
if (!pathname.startsWith(prefix)) throw new RepoError("file not found", 404);
|
|
1850
|
+
let rel;
|
|
1851
|
+
try {
|
|
1852
|
+
rel = pathname.slice(prefix.length).split("/").map(decodeURIComponent).join("/");
|
|
1853
|
+
} catch {
|
|
1854
|
+
throw new RepoError("file not found", 404);
|
|
1855
|
+
}
|
|
1856
|
+
return sendArtifact(c, run2.id, rel, true);
|
|
1857
|
+
});
|
|
1858
|
+
app.post("/api/runs/:id/artifacts/extract", async (c) => {
|
|
1859
|
+
const run2 = requireRun(c.req.param("id"));
|
|
1860
|
+
const body = z.object({ path: z.string().min(1) }).strict().parse(await c.req.json());
|
|
1861
|
+
if (!FINAL_STATUSES.includes(run2.status)) {
|
|
1862
|
+
throw new RepoError("this run has not finished yet; extract the zip once it has", 409);
|
|
1863
|
+
}
|
|
1864
|
+
await extractArtifactZip(runs.runDir(run2.id), body.path);
|
|
1865
|
+
return c.json(listArtifacts(runs.runDir(run2.id)));
|
|
1866
|
+
});
|
|
1867
|
+
app.get("/api/runs/:id/events", (c) => {
|
|
1868
|
+
const id = c.req.param("id");
|
|
1869
|
+
requireRun(id);
|
|
1870
|
+
const last = c.req.header("last-event-id");
|
|
1871
|
+
const from = last ? Number(last) + 1 : Number(c.req.query("from") ?? 0);
|
|
1872
|
+
if (!Number.isInteger(from) || from < 0) throw new BadRequest("invalid resume position");
|
|
1873
|
+
return streamSSE(c, async (stream) => {
|
|
1874
|
+
let chain = Promise.resolve();
|
|
1875
|
+
const enqueue = (fn) => {
|
|
1876
|
+
chain = chain.then(fn).catch(() => {
|
|
1877
|
+
});
|
|
1878
|
+
};
|
|
1879
|
+
let finish;
|
|
1880
|
+
const finished = new Promise((resolve) => finish = resolve);
|
|
1881
|
+
const sub = {
|
|
1882
|
+
onEvents: (events) => enqueue(async () => {
|
|
1883
|
+
for (const e of events) await stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
|
|
1884
|
+
}),
|
|
1885
|
+
onStatus: (run2) => enqueue(() => stream.writeSSE({ event: "status", data: JSON.stringify(run2) })),
|
|
1886
|
+
onEnd: () => enqueue(async () => {
|
|
1887
|
+
await stream.writeSSE({ event: "end", data: "" });
|
|
1888
|
+
finish();
|
|
1889
|
+
})
|
|
1890
|
+
};
|
|
1891
|
+
const sc = runs.subscribe(id, from, sub);
|
|
1892
|
+
if (!sc) return;
|
|
1893
|
+
enqueue(async () => {
|
|
1894
|
+
await stream.writeSSE({ event: "status", data: JSON.stringify(sc.run) });
|
|
1895
|
+
for (const e of sc.backlog) await stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
|
|
1896
|
+
if (!sc.live) {
|
|
1897
|
+
await stream.writeSSE({ event: "end", data: "" });
|
|
1898
|
+
finish();
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
const ping = setInterval(() => enqueue(() => stream.writeSSE({ event: "ping", data: "" })), 2e4);
|
|
1902
|
+
stream.onAbort(finish);
|
|
1903
|
+
await finished;
|
|
1904
|
+
clearInterval(ping);
|
|
1905
|
+
sc.unsubscribe();
|
|
1906
|
+
});
|
|
1907
|
+
});
|
|
1908
|
+
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
|
|
1909
|
+
if (deps.staticDir) {
|
|
1910
|
+
const rel = path7.relative(process.cwd(), deps.staticDir) || ".";
|
|
1911
|
+
app.use("/*", serveStatic({ root: rel }));
|
|
1912
|
+
app.get("*", serveStatic({ path: path7.join(rel, "index.html") }));
|
|
1913
|
+
}
|
|
1914
|
+
return app;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// src/server/config.ts
|
|
1918
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1919
|
+
import os2 from "node:os";
|
|
1920
|
+
import path8 from "node:path";
|
|
1921
|
+
function readVersion() {
|
|
1922
|
+
try {
|
|
1923
|
+
const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
|
|
1924
|
+
return pkg.version ?? "0.0.0";
|
|
1925
|
+
} catch {
|
|
1926
|
+
return "0.0.0";
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
function loadConfig(overrides = {}) {
|
|
1930
|
+
return {
|
|
1931
|
+
dataDir: process.env.GHA_VIEW_HOME ?? path8.join(os2.homedir(), ".gha-view"),
|
|
1932
|
+
actBin: process.env.GHA_VIEW_ACT_BIN ?? "act",
|
|
1933
|
+
dockerBin: process.env.GHA_VIEW_DOCKER_BIN ?? "docker",
|
|
1934
|
+
maxConcurrentRuns: DEFAULT_CONCURRENT_RUNS,
|
|
1935
|
+
retentionRuns: 200,
|
|
1936
|
+
version: readVersion(),
|
|
1937
|
+
...overrides
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// src/server/environments.ts
|
|
1942
|
+
var scope = (name) => `env-${name}`;
|
|
1943
|
+
var EnvironmentService = class {
|
|
1944
|
+
constructor(store, secrets) {
|
|
1945
|
+
this.store = store;
|
|
1946
|
+
this.secrets = secrets;
|
|
1947
|
+
}
|
|
1948
|
+
store;
|
|
1949
|
+
secrets;
|
|
1950
|
+
info(row) {
|
|
1951
|
+
return { name: row.name, env: row.env, secretNames: this.secrets.names(scope(row.name)), createdAt: row.createdAt };
|
|
1952
|
+
}
|
|
1953
|
+
row(name) {
|
|
1954
|
+
const row = this.store.getEnvironment(environmentName(name));
|
|
1955
|
+
if (!row) throw new RepoError(`Environment "${name}" not found. Create it on the Environments page, or pick another.`, 404);
|
|
1956
|
+
return row;
|
|
1957
|
+
}
|
|
1958
|
+
list() {
|
|
1959
|
+
return this.store.listEnvironments().map((r) => this.info(r));
|
|
1960
|
+
}
|
|
1961
|
+
get(name) {
|
|
1962
|
+
return this.info(this.row(name));
|
|
1963
|
+
}
|
|
1964
|
+
create(name, env = {}) {
|
|
1965
|
+
environmentName(name);
|
|
1966
|
+
if (this.store.getEnvironment(name)) throw new RepoError(`Environment "${name}" already exists`, 409);
|
|
1967
|
+
const row = { name, env: keyValueMap("env", env), createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1968
|
+
this.store.insertEnvironment(row);
|
|
1969
|
+
return this.info(row);
|
|
1970
|
+
}
|
|
1971
|
+
update(name, env) {
|
|
1972
|
+
this.row(name);
|
|
1973
|
+
this.store.updateEnvironment(name, keyValueMap("env", env));
|
|
1974
|
+
return this.get(name);
|
|
1975
|
+
}
|
|
1976
|
+
remove(name) {
|
|
1977
|
+
this.row(name);
|
|
1978
|
+
this.store.deleteEnvironment(name);
|
|
1979
|
+
this.secrets.deleteAll(scope(name));
|
|
1980
|
+
}
|
|
1981
|
+
setSecret(name, secret, value) {
|
|
1982
|
+
this.row(name);
|
|
1983
|
+
this.secrets.set(scope(name), secret, value);
|
|
1984
|
+
return this.get(name);
|
|
1985
|
+
}
|
|
1986
|
+
deleteSecret(name, secret) {
|
|
1987
|
+
this.row(name);
|
|
1988
|
+
this.secrets.delete(scope(name), secret);
|
|
1989
|
+
return this.get(name);
|
|
1990
|
+
}
|
|
1991
|
+
/** Env vars and secret values for a run. Throws if the environment was deleted after the run was queued. */
|
|
1992
|
+
resolve(name) {
|
|
1993
|
+
return { env: this.row(name).env, secrets: this.secrets.values(scope(name)) };
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
// src/server/secrets.ts
|
|
1998
|
+
import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1999
|
+
import path9 from "node:path";
|
|
2000
|
+
var SecretStore = class {
|
|
2001
|
+
dir;
|
|
2002
|
+
constructor(dataDir) {
|
|
2003
|
+
this.dir = path9.join(dataDir, "secrets");
|
|
2004
|
+
mkdirSync4(this.dir, { recursive: true, mode: 448 });
|
|
2005
|
+
}
|
|
2006
|
+
file(scope2) {
|
|
2007
|
+
return path9.join(this.dir, `${scope2}.json`);
|
|
2008
|
+
}
|
|
2009
|
+
read(scope2) {
|
|
2010
|
+
const f = this.file(scope2);
|
|
2011
|
+
if (!existsSync3(f)) return {};
|
|
2012
|
+
try {
|
|
2013
|
+
return JSON.parse(readFileSync3(f, "utf8"));
|
|
2014
|
+
} catch {
|
|
2015
|
+
return {};
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
write(scope2, data) {
|
|
2019
|
+
const f = this.file(scope2);
|
|
2020
|
+
writeFileSync2(f, JSON.stringify(data), { mode: 384 });
|
|
2021
|
+
chmodSync(f, 384);
|
|
2022
|
+
}
|
|
2023
|
+
names(scope2) {
|
|
2024
|
+
return Object.keys(this.read(scope2)).sort();
|
|
2025
|
+
}
|
|
2026
|
+
/** Values, for building a run's environment. Never expose through the API. */
|
|
2027
|
+
values(scope2) {
|
|
2028
|
+
return this.read(scope2);
|
|
2029
|
+
}
|
|
2030
|
+
set(scope2, name, value) {
|
|
2031
|
+
secretName(name);
|
|
2032
|
+
if (value.includes("\0")) throw new Error("secret value contains a NUL byte");
|
|
2033
|
+
const data = this.read(scope2);
|
|
2034
|
+
data[name] = value;
|
|
2035
|
+
this.write(scope2, data);
|
|
2036
|
+
}
|
|
2037
|
+
delete(scope2, name) {
|
|
2038
|
+
const data = this.read(scope2);
|
|
2039
|
+
delete data[name];
|
|
2040
|
+
this.write(scope2, data);
|
|
2041
|
+
}
|
|
2042
|
+
deleteAll(scope2) {
|
|
2043
|
+
rmSync3(this.file(scope2), { force: true });
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
|
|
2047
|
+
// src/server/security.ts
|
|
2048
|
+
import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
|
|
2049
|
+
var TOKEN_HEADER = "x-gha-view-token";
|
|
2050
|
+
var Security = class {
|
|
2051
|
+
token = randomBytes3(24).toString("hex");
|
|
2052
|
+
hosts = /* @__PURE__ */ new Set();
|
|
2053
|
+
origins = /* @__PURE__ */ new Set();
|
|
2054
|
+
/** Allow the loopback names for a port. */
|
|
2055
|
+
allowPort(port2) {
|
|
2056
|
+
for (const h of ["localhost", "127.0.0.1", "[::1]"]) {
|
|
2057
|
+
this.hosts.add(`${h}:${port2}`);
|
|
2058
|
+
this.origins.add(`http://${h}:${port2}`);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
tokenOk(given) {
|
|
2062
|
+
if (!given) return false;
|
|
2063
|
+
const a = Buffer.from(given);
|
|
2064
|
+
const b = Buffer.from(this.token);
|
|
2065
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
2066
|
+
}
|
|
2067
|
+
middleware() {
|
|
2068
|
+
return async (c, next) => {
|
|
2069
|
+
const host = (c.req.header("host") ?? "").toLowerCase();
|
|
2070
|
+
if (!this.hosts.has(host)) return c.json({ error: "forbidden host" }, 403);
|
|
2071
|
+
const origin = c.req.header("origin");
|
|
2072
|
+
if (origin && !this.origins.has(origin.toLowerCase())) return c.json({ error: "forbidden origin" }, 403);
|
|
2073
|
+
const m = c.req.method;
|
|
2074
|
+
if (m !== "GET" && m !== "HEAD" && m !== "OPTIONS") {
|
|
2075
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) {
|
|
2076
|
+
return c.json({ error: "content-type must be application/json" }, 415);
|
|
2077
|
+
}
|
|
2078
|
+
if (!this.tokenOk(c.req.header(TOKEN_HEADER))) return c.json({ error: "missing or invalid token" }, 403);
|
|
2079
|
+
}
|
|
2080
|
+
await next();
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
|
|
2085
|
+
// src/server/store.ts
|
|
2086
|
+
import { mkdirSync as mkdirSync5 } from "node:fs";
|
|
2087
|
+
import path10 from "node:path";
|
|
2088
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2089
|
+
var SCHEMA = `
|
|
2090
|
+
PRAGMA journal_mode = WAL;
|
|
2091
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
2092
|
+
id TEXT PRIMARY KEY,
|
|
2093
|
+
path TEXT NOT NULL UNIQUE,
|
|
2094
|
+
name TEXT NOT NULL,
|
|
2095
|
+
config_json TEXT NOT NULL,
|
|
2096
|
+
added_at TEXT NOT NULL
|
|
2097
|
+
);
|
|
2098
|
+
CREATE TABLE IF NOT EXISTS environments (
|
|
2099
|
+
name TEXT PRIMARY KEY,
|
|
2100
|
+
env_json TEXT NOT NULL,
|
|
2101
|
+
created_at TEXT NOT NULL
|
|
2102
|
+
);
|
|
2103
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
2104
|
+
key TEXT PRIMARY KEY,
|
|
2105
|
+
value TEXT NOT NULL
|
|
2106
|
+
);
|
|
2107
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
2108
|
+
id TEXT PRIMARY KEY,
|
|
2109
|
+
repo_id TEXT NOT NULL,
|
|
2110
|
+
workflow TEXT NOT NULL,
|
|
2111
|
+
event TEXT NOT NULL,
|
|
2112
|
+
job TEXT,
|
|
2113
|
+
status TEXT NOT NULL,
|
|
2114
|
+
exit_code INTEGER,
|
|
2115
|
+
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
2116
|
+
act_version TEXT,
|
|
2117
|
+
request_json TEXT NOT NULL,
|
|
2118
|
+
queued_at TEXT NOT NULL,
|
|
2119
|
+
started_at TEXT,
|
|
2120
|
+
finished_at TEXT,
|
|
2121
|
+
note TEXT
|
|
2122
|
+
);
|
|
2123
|
+
CREATE INDEX IF NOT EXISTS runs_repo_queued ON runs (repo_id, queued_at DESC);
|
|
2124
|
+
`;
|
|
2125
|
+
function toRun(r) {
|
|
2126
|
+
return {
|
|
2127
|
+
id: r.id,
|
|
2128
|
+
repoId: r.repo_id,
|
|
2129
|
+
workflow: r.workflow,
|
|
2130
|
+
event: r.event,
|
|
2131
|
+
job: r.job ?? void 0,
|
|
2132
|
+
status: r.status,
|
|
2133
|
+
exitCode: r.exit_code ?? void 0,
|
|
2134
|
+
dryRun: r.dry_run === 1,
|
|
2135
|
+
actVersion: r.act_version ?? void 0,
|
|
2136
|
+
queuedAt: r.queued_at,
|
|
2137
|
+
startedAt: r.started_at ?? void 0,
|
|
2138
|
+
finishedAt: r.finished_at ?? void 0,
|
|
2139
|
+
note: r.note ?? void 0,
|
|
2140
|
+
request: JSON.parse(r.request_json)
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
function toRepo(r) {
|
|
2144
|
+
return { id: r.id, path: r.path, name: r.name, config: JSON.parse(r.config_json), addedAt: r.added_at };
|
|
2145
|
+
}
|
|
2146
|
+
function toEnvironment(r) {
|
|
2147
|
+
return { name: r.name, env: JSON.parse(r.env_json), createdAt: r.created_at };
|
|
2148
|
+
}
|
|
2149
|
+
var Store = class {
|
|
2150
|
+
db;
|
|
2151
|
+
constructor(file) {
|
|
2152
|
+
if (file !== ":memory:") mkdirSync5(path10.dirname(file), { recursive: true });
|
|
2153
|
+
this.db = new DatabaseSync(file);
|
|
2154
|
+
this.db.exec(SCHEMA);
|
|
2155
|
+
}
|
|
2156
|
+
close() {
|
|
2157
|
+
this.db.close();
|
|
2158
|
+
}
|
|
2159
|
+
// --- settings ------------------------------------------------------------
|
|
2160
|
+
getSetting(key) {
|
|
2161
|
+
const r = this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
|
2162
|
+
return r?.value;
|
|
2163
|
+
}
|
|
2164
|
+
setSetting(key, value) {
|
|
2165
|
+
this.db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
2166
|
+
}
|
|
2167
|
+
// --- repos ---------------------------------------------------------------
|
|
2168
|
+
listRepos() {
|
|
2169
|
+
return this.db.prepare("SELECT * FROM repos ORDER BY name COLLATE NOCASE").all().map(toRepo);
|
|
2170
|
+
}
|
|
2171
|
+
getRepo(id) {
|
|
2172
|
+
const r = this.db.prepare("SELECT * FROM repos WHERE id = ?").get(id);
|
|
2173
|
+
return r && toRepo(r);
|
|
2174
|
+
}
|
|
2175
|
+
getRepoByPath(p) {
|
|
2176
|
+
const r = this.db.prepare("SELECT * FROM repos WHERE path = ?").get(p);
|
|
2177
|
+
return r && toRepo(r);
|
|
2178
|
+
}
|
|
2179
|
+
insertRepo(repo) {
|
|
2180
|
+
this.db.prepare("INSERT INTO repos (id, path, name, config_json, added_at) VALUES (?, ?, ?, ?, ?)").run(repo.id, repo.path, repo.name, JSON.stringify(repo.config), repo.addedAt);
|
|
2181
|
+
}
|
|
2182
|
+
updateRepo(id, fields) {
|
|
2183
|
+
if (fields.name !== void 0) this.db.prepare("UPDATE repos SET name = ? WHERE id = ?").run(fields.name, id);
|
|
2184
|
+
if (fields.config) this.db.prepare("UPDATE repos SET config_json = ? WHERE id = ?").run(JSON.stringify(fields.config), id);
|
|
2185
|
+
}
|
|
2186
|
+
deleteRepo(id) {
|
|
2187
|
+
this.db.prepare("DELETE FROM repos WHERE id = ?").run(id);
|
|
2188
|
+
}
|
|
2189
|
+
// --- environments --------------------------------------------------------
|
|
2190
|
+
listEnvironments() {
|
|
2191
|
+
return this.db.prepare("SELECT * FROM environments ORDER BY name").all().map(toEnvironment);
|
|
2192
|
+
}
|
|
2193
|
+
getEnvironment(name) {
|
|
2194
|
+
const r = this.db.prepare("SELECT * FROM environments WHERE name = ?").get(name);
|
|
2195
|
+
return r && toEnvironment(r);
|
|
2196
|
+
}
|
|
2197
|
+
insertEnvironment(e) {
|
|
2198
|
+
this.db.prepare("INSERT INTO environments (name, env_json, created_at) VALUES (?, ?, ?)").run(e.name, JSON.stringify(e.env), e.createdAt);
|
|
2199
|
+
}
|
|
2200
|
+
updateEnvironment(name, env) {
|
|
2201
|
+
this.db.prepare("UPDATE environments SET env_json = ? WHERE name = ?").run(JSON.stringify(env), name);
|
|
2202
|
+
}
|
|
2203
|
+
deleteEnvironment(name) {
|
|
2204
|
+
this.db.prepare("DELETE FROM environments WHERE name = ?").run(name);
|
|
2205
|
+
}
|
|
2206
|
+
// --- runs ----------------------------------------------------------------
|
|
2207
|
+
insertRun(r) {
|
|
2208
|
+
this.db.prepare(
|
|
2209
|
+
`INSERT INTO runs (id, repo_id, workflow, event, job, status, exit_code, dry_run, act_version,
|
|
2210
|
+
request_json, queued_at, started_at, finished_at, note)
|
|
2211
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2212
|
+
).run(
|
|
2213
|
+
r.id,
|
|
2214
|
+
r.repoId,
|
|
2215
|
+
r.workflow,
|
|
2216
|
+
r.event,
|
|
2217
|
+
r.job ?? null,
|
|
2218
|
+
r.status,
|
|
2219
|
+
r.exitCode ?? null,
|
|
2220
|
+
r.dryRun ? 1 : 0,
|
|
2221
|
+
r.actVersion ?? null,
|
|
2222
|
+
JSON.stringify(r.request),
|
|
2223
|
+
r.queuedAt,
|
|
2224
|
+
r.startedAt ?? null,
|
|
2225
|
+
r.finishedAt ?? null,
|
|
2226
|
+
r.note ?? null
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
updateRun(r) {
|
|
2230
|
+
this.db.prepare(
|
|
2231
|
+
`UPDATE runs SET status = ?, exit_code = ?, act_version = ?, started_at = ?, finished_at = ?, note = ?
|
|
2232
|
+
WHERE id = ?`
|
|
2233
|
+
).run(r.status, r.exitCode ?? null, r.actVersion ?? null, r.startedAt ?? null, r.finishedAt ?? null, r.note ?? null, r.id);
|
|
2234
|
+
}
|
|
2235
|
+
getRun(id) {
|
|
2236
|
+
const r = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
|
|
2237
|
+
return r && toRun(r);
|
|
2238
|
+
}
|
|
2239
|
+
listRuns(repoId, limit) {
|
|
2240
|
+
const rows = repoId ? this.db.prepare("SELECT * FROM runs WHERE repo_id = ? ORDER BY queued_at DESC LIMIT ?").all(repoId, limit) : this.db.prepare("SELECT * FROM runs ORDER BY queued_at DESC LIMIT ?").all(limit);
|
|
2241
|
+
return rows.map(toRun);
|
|
2242
|
+
}
|
|
2243
|
+
deleteRun(id) {
|
|
2244
|
+
this.db.prepare("DELETE FROM runs WHERE id = ?").run(id);
|
|
2245
|
+
}
|
|
2246
|
+
runIdsForRepo(repoId) {
|
|
2247
|
+
return this.db.prepare("SELECT id FROM runs WHERE repo_id = ?").all(repoId).map((r) => r.id);
|
|
2248
|
+
}
|
|
2249
|
+
/** Finished runs only. Optional `workflow` is a file name (already validated by the caller). */
|
|
2250
|
+
finishedRunIds(repoId, workflow) {
|
|
2251
|
+
const rows = workflow ? this.db.prepare(`SELECT id FROM runs WHERE repo_id = ? AND workflow = ? AND status NOT IN ('queued','running')`).all(repoId, workflow) : this.db.prepare(`SELECT id FROM runs WHERE repo_id = ? AND status NOT IN ('queued','running')`).all(repoId);
|
|
2252
|
+
return rows.map((r) => r.id);
|
|
2253
|
+
}
|
|
2254
|
+
/** Finished runs beyond the newest `keep` for a repo. Returned so callers can delete their logs. */
|
|
2255
|
+
runsToPrune(repoId, keep) {
|
|
2256
|
+
const rows = this.db.prepare(
|
|
2257
|
+
`SELECT id FROM runs WHERE repo_id = ? AND status NOT IN ('queued','running')
|
|
2258
|
+
ORDER BY queued_at DESC LIMIT -1 OFFSET ?`
|
|
2259
|
+
).all(repoId, keep);
|
|
2260
|
+
return rows.map((r) => r.id);
|
|
2261
|
+
}
|
|
2262
|
+
/** After a crash or kill, nothing is really running any more. */
|
|
2263
|
+
failInterruptedRuns(now) {
|
|
2264
|
+
const res = this.db.prepare(
|
|
2265
|
+
`UPDATE runs SET status = 'error', finished_at = ?, note = 'The viewer stopped while this run was in progress.'
|
|
2266
|
+
WHERE status IN ('queued','running')`
|
|
2267
|
+
).run(now);
|
|
2268
|
+
return Number(res.changes);
|
|
2269
|
+
}
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
// src/server/index.ts
|
|
2273
|
+
var DEFAULT_PORT = 4780;
|
|
2274
|
+
var DEV_WEB_PORT = 5173;
|
|
2275
|
+
var here = path11.dirname(fileURLToPath(import.meta.url));
|
|
2276
|
+
var staticDir = path11.resolve(here, "../../dist/web");
|
|
2277
|
+
function listen(app, security, port2, strict) {
|
|
2278
|
+
return new Promise((resolve, reject) => {
|
|
2279
|
+
const attempt = (p, left) => {
|
|
2280
|
+
security.allowPort(p);
|
|
2281
|
+
const server = serve({ fetch: app.fetch, port: p, hostname: "127.0.0.1" });
|
|
2282
|
+
server.once("listening", () => resolve({ server, port: p }));
|
|
2283
|
+
server.once("error", (err) => {
|
|
2284
|
+
if (err.code === "EADDRINUSE" && !strict && left > 0) attempt(p + 1, left - 1);
|
|
2285
|
+
else reject(err);
|
|
2286
|
+
});
|
|
2287
|
+
};
|
|
2288
|
+
attempt(port2, 20);
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
async function main() {
|
|
2292
|
+
const cfg = loadConfig();
|
|
2293
|
+
mkdirSync6(cfg.dataDir, { recursive: true, mode: 448 });
|
|
2294
|
+
const store = new Store(path11.join(cfg.dataDir, "db.sqlite"));
|
|
2295
|
+
const secrets = new SecretStore(cfg.dataDir);
|
|
2296
|
+
const repos = new RepoService(store, secrets);
|
|
2297
|
+
const environments = new EnvironmentService(store, secrets);
|
|
2298
|
+
const runs = new RunManager({ cfg, store, repos, secrets, environments, getWorkflow: loadWorkflow });
|
|
2299
|
+
runs.recover();
|
|
2300
|
+
const security = new Security();
|
|
2301
|
+
const dev = process.env.GHA_VIEW_DEV === "1";
|
|
2302
|
+
if (dev) security.allowPort(DEV_WEB_PORT);
|
|
2303
|
+
const app = createApp({ cfg, security, store, repos, environments, secrets, runs, staticDir: !dev && existsSync4(staticDir) ? staticDir : void 0 });
|
|
2304
|
+
const requested = process.env.GHA_VIEW_PORT ? Number(process.env.GHA_VIEW_PORT) : DEFAULT_PORT;
|
|
2305
|
+
const { server, port: port2 } = await listen(app, security, requested, Boolean(process.env.GHA_VIEW_PORT) || dev);
|
|
2306
|
+
const url = `http://localhost:${port2}`;
|
|
2307
|
+
console.log(`github-action-view ${cfg.version} listening on ${url}`);
|
|
2308
|
+
console.log(`data: ${cfg.dataDir}`);
|
|
2309
|
+
if (!dev && !existsSync4(staticDir)) console.log("UI not built yet: run `npm run build` (or use `npm run dev`).");
|
|
2310
|
+
if (!dev && process.stdout.isTTY && process.platform === "darwin" && existsSync4(staticDir) && !process.env.GHA_VIEW_NO_OPEN) {
|
|
2311
|
+
execFile3("open", [url], () => {
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
let stopping = false;
|
|
2315
|
+
const stop = async () => {
|
|
2316
|
+
if (stopping) return;
|
|
2317
|
+
stopping = true;
|
|
2318
|
+
console.log("\nstopping: cancelling running workflows...");
|
|
2319
|
+
await runs.shutdown();
|
|
2320
|
+
server.close();
|
|
2321
|
+
store.close();
|
|
2322
|
+
process.exit(0);
|
|
2323
|
+
};
|
|
2324
|
+
process.on("SIGINT", stop);
|
|
2325
|
+
process.on("SIGTERM", stop);
|
|
2326
|
+
}
|
|
2327
|
+
main().catch((err) => {
|
|
2328
|
+
console.error(err);
|
|
2329
|
+
process.exit(1);
|
|
2330
|
+
});
|