laneyard 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 +175 -0
- package/dist/src/cli/add.js +81 -0
- package/dist/src/cli/detect.js +70 -0
- package/dist/src/config/load.js +46 -0
- package/dist/src/config/resolve.js +29 -0
- package/dist/src/config/schema.js +55 -0
- package/dist/src/config/store.js +71 -0
- package/dist/src/db/cache.js +22 -0
- package/dist/src/db/open.js +12 -0
- package/dist/src/db/runs.js +97 -0
- package/dist/src/db/schema.sql +45 -0
- package/dist/src/git/workspace.js +106 -0
- package/dist/src/heuristics/error-summary.js +43 -0
- package/dist/src/logs/store.js +66 -0
- package/dist/src/main.js +124 -0
- package/dist/src/runner/artifacts.js +59 -0
- package/dist/src/runner/live-steps.js +38 -0
- package/dist/src/runner/orchestrate.js +140 -0
- package/dist/src/runner/pty.js +83 -0
- package/dist/src/runner/report.js +86 -0
- package/dist/src/server/app.js +84 -0
- package/dist/src/server/auth.js +81 -0
- package/dist/src/server/routes/projects.js +35 -0
- package/dist/src/server/routes/runs.js +100 -0
- package/dist/src/server/ws.js +66 -0
- package/dist/src/sidecar/bridge.js +44 -0
- package/dist/src/sidecar/lanes.js +40 -0
- package/dist/src/sidecar/ruby-env.js +63 -0
- package/dist/tests/cli/add.test.js +64 -0
- package/dist/tests/cli/detect.test.js +63 -0
- package/dist/tests/config/load.test.js +68 -0
- package/dist/tests/config/resolve.test.js +44 -0
- package/dist/tests/config/store.test.js +58 -0
- package/dist/tests/db/runs.test.js +54 -0
- package/dist/tests/e2e/full-thread.test.js +65 -0
- package/dist/tests/fixtures/repos.js +30 -0
- package/dist/tests/git/workspace.test.js +66 -0
- package/dist/tests/heuristics/error-summary.test.js +31 -0
- package/dist/tests/logs/store.test.js +38 -0
- package/dist/tests/main.test.js +27 -0
- package/dist/tests/ruby/introspect.test.js +59 -0
- package/dist/tests/runner/artifacts.test.js +49 -0
- package/dist/tests/runner/live-steps.test.js +35 -0
- package/dist/tests/runner/orchestrate.test.js +124 -0
- package/dist/tests/runner/pty.test.js +53 -0
- package/dist/tests/runner/report.test.js +91 -0
- package/dist/tests/server/api.test.js +132 -0
- package/dist/tests/server/auth.test.js +53 -0
- package/dist/tests/server/ws.test.js +43 -0
- package/dist/tests/sidecar/lanes.test.js +52 -0
- package/dist/tests/sidecar/ruby-env.test.js +25 -0
- package/dist/tests/smoke.test.js +7 -0
- package/dist/web/assets/index-583x0xuo.css +1 -0
- package/dist/web/assets/index-BEpABKPS.js +61 -0
- package/dist/web/index.html +13 -0
- package/package.json +70 -0
- package/ruby/introspect.rb +87 -0
- package/scripts/fix-node-pty-permissions.mjs +26 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** A run is active until it reaches a terminal state. */
|
|
2
|
+
const ACTIVE = ["queued", "preparing", "running"];
|
|
3
|
+
const toRun = (r) => ({
|
|
4
|
+
id: r.id,
|
|
5
|
+
projectSlug: r.project_slug,
|
|
6
|
+
lane: r.lane,
|
|
7
|
+
platform: r.platform,
|
|
8
|
+
params: JSON.parse(r.params),
|
|
9
|
+
status: r.status,
|
|
10
|
+
branch: r.branch,
|
|
11
|
+
commitSha: r.commit_sha,
|
|
12
|
+
interactive: r.interactive === 1,
|
|
13
|
+
queuedAt: r.queued_at,
|
|
14
|
+
startedAt: r.started_at,
|
|
15
|
+
finishedAt: r.finished_at,
|
|
16
|
+
exitCode: r.exit_code,
|
|
17
|
+
errorSummary: r.error_summary,
|
|
18
|
+
});
|
|
19
|
+
const now = () => new Date().toISOString();
|
|
20
|
+
export class RunStore {
|
|
21
|
+
db;
|
|
22
|
+
constructor(db) {
|
|
23
|
+
this.db = db;
|
|
24
|
+
}
|
|
25
|
+
create(input) {
|
|
26
|
+
const res = this.db
|
|
27
|
+
.prepare(`INSERT INTO run (project_slug, lane, platform, params, status, interactive, queued_at)
|
|
28
|
+
VALUES (?, ?, ?, ?, 'queued', ?, ?)`)
|
|
29
|
+
.run(input.projectSlug, input.lane, input.platform, JSON.stringify(input.params), input.interactive ? 1 : 0, now());
|
|
30
|
+
return Number(res.lastInsertRowid);
|
|
31
|
+
}
|
|
32
|
+
get(id) {
|
|
33
|
+
const row = this.db.prepare("SELECT * FROM run WHERE id = ?").get(id);
|
|
34
|
+
return row ? toRun(row) : null;
|
|
35
|
+
}
|
|
36
|
+
listByProject(slug, limit = 50) {
|
|
37
|
+
const rows = this.db
|
|
38
|
+
.prepare("SELECT * FROM run WHERE project_slug = ? ORDER BY id DESC LIMIT ?")
|
|
39
|
+
.all(slug, limit);
|
|
40
|
+
return rows.map(toRun);
|
|
41
|
+
}
|
|
42
|
+
setStatus(id, status) {
|
|
43
|
+
this.db.prepare("UPDATE run SET status = ? WHERE id = ?").run(status, id);
|
|
44
|
+
}
|
|
45
|
+
markRunning(id, git) {
|
|
46
|
+
this.db
|
|
47
|
+
.prepare("UPDATE run SET status = 'running', started_at = ?, branch = ?, commit_sha = ? WHERE id = ?")
|
|
48
|
+
.run(now(), git.branch, git.commitSha, id);
|
|
49
|
+
}
|
|
50
|
+
finish(id, r) {
|
|
51
|
+
this.db
|
|
52
|
+
.prepare("UPDATE run SET status = ?, finished_at = ?, exit_code = ?, error_summary = ? WHERE id = ?")
|
|
53
|
+
.run(r.status, now(), r.exitCode, r.errorSummary, id);
|
|
54
|
+
}
|
|
55
|
+
/** At startup: no run can still be in progress, the process that carried it is dead. */
|
|
56
|
+
interruptActive() {
|
|
57
|
+
const placeholders = ACTIVE.map(() => "?").join(", ");
|
|
58
|
+
const res = this.db
|
|
59
|
+
.prepare(`UPDATE run SET status = 'interrupted', finished_at = ? WHERE status IN (${placeholders})`)
|
|
60
|
+
.run(now(), ...ACTIVE);
|
|
61
|
+
return res.changes;
|
|
62
|
+
}
|
|
63
|
+
replaceSteps(runId, steps) {
|
|
64
|
+
const del = this.db.prepare("DELETE FROM run_step WHERE run_id = ?");
|
|
65
|
+
const ins = this.db.prepare(`INSERT INTO run_step (run_id, idx, name, duration_ms, status, log_offset, source)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
67
|
+
this.db.transaction(() => {
|
|
68
|
+
del.run(runId);
|
|
69
|
+
for (const s of steps) {
|
|
70
|
+
ins.run(runId, s.idx, s.name, s.durationMs, s.status, s.logOffset, s.source);
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
}
|
|
74
|
+
steps(runId) {
|
|
75
|
+
const rows = this.db
|
|
76
|
+
.prepare("SELECT * FROM run_step WHERE run_id = ? ORDER BY idx")
|
|
77
|
+
.all(runId);
|
|
78
|
+
return rows.map((r) => ({
|
|
79
|
+
idx: r.idx,
|
|
80
|
+
name: r.name,
|
|
81
|
+
durationMs: r.duration_ms,
|
|
82
|
+
status: r.status,
|
|
83
|
+
logOffset: r.log_offset,
|
|
84
|
+
source: r.source,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
addArtifact(runId, a) {
|
|
88
|
+
this.db
|
|
89
|
+
.prepare("INSERT INTO artifact (run_id, filename, path, size, kind) VALUES (?, ?, ?, ?, ?)")
|
|
90
|
+
.run(runId, a.filename, a.path, a.size, a.kind);
|
|
91
|
+
}
|
|
92
|
+
artifacts(runId) {
|
|
93
|
+
return this.db
|
|
94
|
+
.prepare("SELECT id, filename, path, size, kind FROM artifact WHERE run_id = ? ORDER BY filename")
|
|
95
|
+
.all(runId);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS run (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
project_slug TEXT NOT NULL,
|
|
4
|
+
lane TEXT NOT NULL,
|
|
5
|
+
platform TEXT,
|
|
6
|
+
params TEXT NOT NULL DEFAULT '{}',
|
|
7
|
+
status TEXT NOT NULL,
|
|
8
|
+
branch TEXT,
|
|
9
|
+
commit_sha TEXT,
|
|
10
|
+
trigger TEXT NOT NULL DEFAULT 'manual',
|
|
11
|
+
interactive INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
queued_at TEXT NOT NULL,
|
|
13
|
+
started_at TEXT,
|
|
14
|
+
finished_at TEXT,
|
|
15
|
+
exit_code INTEGER,
|
|
16
|
+
error_summary TEXT
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS run_by_project ON run (project_slug, id DESC);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS run_step (
|
|
21
|
+
run_id INTEGER NOT NULL REFERENCES run (id) ON DELETE CASCADE,
|
|
22
|
+
idx INTEGER NOT NULL,
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
duration_ms INTEGER,
|
|
25
|
+
status TEXT NOT NULL,
|
|
26
|
+
log_offset INTEGER,
|
|
27
|
+
source TEXT NOT NULL,
|
|
28
|
+
PRIMARY KEY (run_id, idx)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS artifact (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
run_id INTEGER NOT NULL REFERENCES run (id) ON DELETE CASCADE,
|
|
34
|
+
filename TEXT NOT NULL,
|
|
35
|
+
path TEXT NOT NULL,
|
|
36
|
+
size INTEGER NOT NULL,
|
|
37
|
+
kind TEXT NOT NULL
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS introspection_cache (
|
|
41
|
+
project_slug TEXT PRIMARY KEY,
|
|
42
|
+
config_hash TEXT NOT NULL,
|
|
43
|
+
payload TEXT NOT NULL,
|
|
44
|
+
fetched_at TEXT NOT NULL
|
|
45
|
+
);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
/**
|
|
7
|
+
* A clone managed by Laneyard, kept between runs.
|
|
8
|
+
* All git commands go through here to share the authentication environment.
|
|
9
|
+
*/
|
|
10
|
+
export class Workspace {
|
|
11
|
+
path;
|
|
12
|
+
gitUrl;
|
|
13
|
+
auth;
|
|
14
|
+
constructor(path, gitUrl, auth = { kind: "none" }) {
|
|
15
|
+
this.path = path;
|
|
16
|
+
this.gitUrl = gitUrl;
|
|
17
|
+
this.auth = auth;
|
|
18
|
+
}
|
|
19
|
+
env() {
|
|
20
|
+
// Without this, git can block on a credentials prompt and freeze the run.
|
|
21
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
22
|
+
if (this.auth.kind === "ssh_key" && this.auth.ref) {
|
|
23
|
+
env["GIT_SSH_COMMAND"] = `ssh -i ${this.auth.ref} -o IdentitiesOnly=yes -o BatchMode=yes`;
|
|
24
|
+
}
|
|
25
|
+
return env;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Replaces the repository URL with a neutral token in a piece of text.
|
|
29
|
+
*
|
|
30
|
+
* An HTTPS URL can carry a password — `https://user:token@github.com/…`
|
|
31
|
+
* is perfectly legal in `config.yml`. But git errors end up in the run's
|
|
32
|
+
* log file. General secret redaction comes at the next milestone; this
|
|
33
|
+
* particular leak comes from our own formatting, and is fixed here.
|
|
34
|
+
*/
|
|
35
|
+
redact(text) {
|
|
36
|
+
return text.split(this.gitUrl).join("<repository>");
|
|
37
|
+
}
|
|
38
|
+
async git(args, cwd = this.path) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await exec("git", args, { cwd, env: this.env(), maxBuffer: 32 * 1024 * 1024 });
|
|
41
|
+
return stdout.trim();
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
const err = cause;
|
|
45
|
+
const detail = (err.stderr || err.message).trim();
|
|
46
|
+
throw new Error(`git ${this.redact(args.join(" "))} failed: ${this.redact(detail)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async exists() {
|
|
50
|
+
try {
|
|
51
|
+
await access(join(this.path, ".git"));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* True if there are uncommitted changes to *tracked* files.
|
|
60
|
+
*
|
|
61
|
+
* Untracked files are deliberately ignored: a build scatters them around
|
|
62
|
+
* (fastlane rewrites `fastlane/README.md` on every run, artifacts land in
|
|
63
|
+
* `build/`), and above all `git checkout` doesn't destroy them. Counting
|
|
64
|
+
* them would make every second run impossible without protecting anything.
|
|
65
|
+
*/
|
|
66
|
+
async isDirty() {
|
|
67
|
+
if (!(await this.exists()))
|
|
68
|
+
return false;
|
|
69
|
+
return (await this.git(["status", "--porcelain", "--untracked-files=no"])) !== "";
|
|
70
|
+
}
|
|
71
|
+
async headSha() {
|
|
72
|
+
return this.git(["rev-parse", "HEAD"]);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Guarantees the clone is present, without touching the current branch.
|
|
76
|
+
*
|
|
77
|
+
* Needed before any read of the repository outside a run — listing lanes,
|
|
78
|
+
* reading laneyard.yml — since that information lives in the project's files.
|
|
79
|
+
*/
|
|
80
|
+
async ensureCloned(onProgress) {
|
|
81
|
+
if (await this.exists())
|
|
82
|
+
return;
|
|
83
|
+
onProgress?.(`Cloning ${this.redact(this.gitUrl)}…`);
|
|
84
|
+
await this.git(["clone", this.gitUrl, this.path], process.cwd());
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Brings the workspace to the requested branch, up to date.
|
|
88
|
+
* Clones on the first call, just fetches afterwards.
|
|
89
|
+
*/
|
|
90
|
+
async prepare(branch, onProgress) {
|
|
91
|
+
if (!(await this.exists())) {
|
|
92
|
+
await this.ensureCloned(onProgress);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
if (await this.isDirty()) {
|
|
96
|
+
throw new Error("The workspace has uncommitted changes. " +
|
|
97
|
+
"Commit them or clean the workspace before starting a run.");
|
|
98
|
+
}
|
|
99
|
+
onProgress?.("Fetching updates…");
|
|
100
|
+
await this.git(["fetch", "--prune", "origin"]);
|
|
101
|
+
}
|
|
102
|
+
onProgress?.(`Switching to ${branch}…`);
|
|
103
|
+
await this.git(["checkout", "-q", "-B", branch, `origin/${branch}`]);
|
|
104
|
+
return this.headSha();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extraction of a readable failure cause from a run's output.
|
|
3
|
+
*
|
|
4
|
+
* This is a heuristic: it knows fastlane by its display habits, not by a
|
|
5
|
+
* contract. It therefore lives in this isolated module, and follows the
|
|
6
|
+
* rule that applies to it — it blocks nothing, changes nothing, and only
|
|
7
|
+
* produces supplementary information. The full log remains the reference.
|
|
8
|
+
*/
|
|
9
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
10
|
+
/** Timestamp prefix that fastlane puts at the head of every line. */
|
|
11
|
+
const TIMESTAMP = /^\[\d{2}:\d{2}:\d{2}\]:\s*/;
|
|
12
|
+
/** Marker that fastlane reserves for a failure's final cause. */
|
|
13
|
+
const FASTLANE_ERROR = /^\[!\]\s*(.+)$/;
|
|
14
|
+
const NOISE = /^(fastlane finished with errors|fastlane\.tools finished)/i;
|
|
15
|
+
const LOOKS_LIKE_FAILURE = /error|failed|failure|échou/i;
|
|
16
|
+
/**
|
|
17
|
+
* Renders a displayable sentence next to a failed run, or a fallback sentence.
|
|
18
|
+
*
|
|
19
|
+
* The order of preference follows decreasing reliability: fastlane's explicit
|
|
20
|
+
* marker first, a line mentioning an error next, the exit code as a last
|
|
21
|
+
* resort. The generic "fastlane finished with errors" is discarded: it is
|
|
22
|
+
* always present and teaches nothing.
|
|
23
|
+
*/
|
|
24
|
+
export function summarizeFailure(log, exitCode) {
|
|
25
|
+
const lines = log
|
|
26
|
+
.replace(ANSI, "")
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((line) => line.replace(TIMESTAMP, "").trim())
|
|
29
|
+
.filter((line) => line.length > 0);
|
|
30
|
+
for (const line of [...lines].reverse()) {
|
|
31
|
+
const marked = FASTLANE_ERROR.exec(line);
|
|
32
|
+
if (marked?.[1])
|
|
33
|
+
return marked[1].slice(0, 500);
|
|
34
|
+
}
|
|
35
|
+
const mentioning = [...lines]
|
|
36
|
+
.reverse()
|
|
37
|
+
.find((line) => LOOKS_LIKE_FAILURE.test(line) && !NOISE.test(line));
|
|
38
|
+
if (mentioning)
|
|
39
|
+
return mentioning.slice(0, 500);
|
|
40
|
+
return exitCode === null
|
|
41
|
+
? "The run failed with no usable message"
|
|
42
|
+
: `fastlane stopped with exit code ${exitCode}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Append-only writer for a run.
|
|
6
|
+
* `offset` counts bytes, never characters: that's what the browser-side
|
|
7
|
+
* read resumption works with, and a multi-byte character can span several bytes.
|
|
8
|
+
*/
|
|
9
|
+
export class LogWriter {
|
|
10
|
+
handle;
|
|
11
|
+
_offset = 0;
|
|
12
|
+
queue = Promise.resolve();
|
|
13
|
+
constructor(handle) {
|
|
14
|
+
this.handle = handle;
|
|
15
|
+
}
|
|
16
|
+
get offset() {
|
|
17
|
+
return this._offset;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reserves the offset immediately then serializes the writes.
|
|
21
|
+
*
|
|
22
|
+
* Fragments arrive from a PTY, without waiting: if the offset were computed
|
|
23
|
+
* after the write, two concurrent fragments could claim the same position
|
|
24
|
+
* and the browser-side catch-up would duplicate or lose text.
|
|
25
|
+
*/
|
|
26
|
+
async append(chunk) {
|
|
27
|
+
const buf = Buffer.from(chunk, "utf8");
|
|
28
|
+
const start = this._offset;
|
|
29
|
+
this._offset += buf.byteLength;
|
|
30
|
+
this.queue = this.queue.then(() => this.handle.write(buf)).catch(() => {
|
|
31
|
+
// The file may have been closed while the process was still finishing up.
|
|
32
|
+
});
|
|
33
|
+
await this.queue;
|
|
34
|
+
return start;
|
|
35
|
+
}
|
|
36
|
+
async close() {
|
|
37
|
+
await this.queue;
|
|
38
|
+
await this.handle.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class LogStore {
|
|
42
|
+
dir;
|
|
43
|
+
constructor(dir) {
|
|
44
|
+
this.dir = dir;
|
|
45
|
+
}
|
|
46
|
+
pathFor(runId) {
|
|
47
|
+
return join(this.dir, `${runId}.log`);
|
|
48
|
+
}
|
|
49
|
+
async open(runId) {
|
|
50
|
+
await mkdir(this.dir, { recursive: true });
|
|
51
|
+
return new LogWriter(await open(this.pathFor(runId), "w"));
|
|
52
|
+
}
|
|
53
|
+
async read(runId, fromOffset = 0) {
|
|
54
|
+
try {
|
|
55
|
+
const buf = await readFile(this.pathFor(runId));
|
|
56
|
+
return buf.subarray(fromOffset).toString("utf8");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** For serving a large log without loading it entirely into memory. */
|
|
63
|
+
stream(runId, fromOffset = 0) {
|
|
64
|
+
return createReadStream(this.pathFor(runId), { start: fromOffset });
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/src/main.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { runAddCommand } from "./cli/add.js";
|
|
8
|
+
import { ConfigStore } from "./config/store.js";
|
|
9
|
+
import { CacheStore } from "./db/cache.js";
|
|
10
|
+
import { openDatabase } from "./db/open.js";
|
|
11
|
+
import { RunStore } from "./db/runs.js";
|
|
12
|
+
import { buildApp } from "./server/app.js";
|
|
13
|
+
import { makeInvoke } from "./sidecar/bridge.js";
|
|
14
|
+
import { LaneReader } from "./sidecar/lanes.js";
|
|
15
|
+
export const version = "0.1.0";
|
|
16
|
+
/** Assembles the server from a data folder. */
|
|
17
|
+
export async function createServerFromConfig(root) {
|
|
18
|
+
const config = new ConfigStore(join(root, "config.yml"));
|
|
19
|
+
const loaded = await config.load();
|
|
20
|
+
if (!loaded.ok)
|
|
21
|
+
throw new Error(`Unreadable configuration: ${loaded.error}`);
|
|
22
|
+
const db = openDatabase(join(root, "laneyard.db"));
|
|
23
|
+
// No run can survive the shutdown of the process that carried it.
|
|
24
|
+
new RunStore(db).interruptActive();
|
|
25
|
+
const cache = new CacheStore(db);
|
|
26
|
+
const app = await buildApp({
|
|
27
|
+
config,
|
|
28
|
+
db,
|
|
29
|
+
root,
|
|
30
|
+
lanes: async (slug, workspacePath, fastlaneDir) => {
|
|
31
|
+
const resolved = await config.resolve(slug, workspacePath);
|
|
32
|
+
const reader = new LaneReader(cache, makeInvoke(resolved?.settings.runtime ?? "bundle"));
|
|
33
|
+
return reader.read(slug, workspacePath, fastlaneDir);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return { app, db, config };
|
|
37
|
+
}
|
|
38
|
+
/** Real startup, outside tests. */
|
|
39
|
+
async function main() {
|
|
40
|
+
const root = process.env["LANEYARD_HOME"] ?? join(homedir(), ".laneyard");
|
|
41
|
+
const { app, config } = await createServerFromConfig(root);
|
|
42
|
+
config.watch((ok) => {
|
|
43
|
+
if (!ok)
|
|
44
|
+
console.error(`Invalid configuration, the previous one stays active: ${config.lastError()}`);
|
|
45
|
+
});
|
|
46
|
+
const server = config.server();
|
|
47
|
+
await app.listen({ port: server.port, host: server.bind });
|
|
48
|
+
console.log(`Laneyard is listening on http://localhost:${server.port}`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when this file is what the user launched, rather than something that
|
|
52
|
+
* imported it.
|
|
53
|
+
*
|
|
54
|
+
* Comparing real paths and not file names: installed globally, `laneyard` is a
|
|
55
|
+
* symlink whose name has nothing to do with this file, and a check on "main.js"
|
|
56
|
+
* would leave the command silently doing nothing.
|
|
57
|
+
*/
|
|
58
|
+
function invokedDirectly() {
|
|
59
|
+
const entry = process.argv[1];
|
|
60
|
+
if (!entry)
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const USAGE = `laneyard — a self-hosted web UI for fastlane
|
|
70
|
+
|
|
71
|
+
laneyard add adopt the project in the current directory
|
|
72
|
+
laneyard start the server
|
|
73
|
+
laneyard --version print the version
|
|
74
|
+
|
|
75
|
+
Configuration lives in ~/.laneyard/config.yml, or in $LANEYARD_HOME.
|
|
76
|
+
`;
|
|
77
|
+
function homeDir() {
|
|
78
|
+
return process.env["LANEYARD_HOME"] ?? join(homedir(), ".laneyard");
|
|
79
|
+
}
|
|
80
|
+
/** Turns a startup failure into something a newcomer can act on. */
|
|
81
|
+
function explainStartupFailure(cause) {
|
|
82
|
+
const message = cause.message;
|
|
83
|
+
if (message.includes("ENOENT") && message.includes("config.yml")) {
|
|
84
|
+
return ("No configuration yet.\n\n" +
|
|
85
|
+
" cd into a project that already uses fastlane, then run:\n" +
|
|
86
|
+
" laneyard add\n\n" +
|
|
87
|
+
"That writes " + join(homeDir(), "config.yml") + " for you.");
|
|
88
|
+
}
|
|
89
|
+
return message;
|
|
90
|
+
}
|
|
91
|
+
if (invokedDirectly()) {
|
|
92
|
+
const [, , command, ...rest] = process.argv;
|
|
93
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
94
|
+
process.stdout.write(USAGE);
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
if (command === "--version" || command === "-v") {
|
|
98
|
+
process.stdout.write(`${version}\n`);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
if (command === "add") {
|
|
102
|
+
const home = homeDir();
|
|
103
|
+
await mkdir(home, { recursive: true });
|
|
104
|
+
const slugIndex = rest.indexOf("--slug");
|
|
105
|
+
const slug = slugIndex === -1 ? undefined : rest[slugIndex + 1];
|
|
106
|
+
try {
|
|
107
|
+
process.exit(await runAddCommand(process.cwd(), join(home, "config.yml"), slug));
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
// A taken slug or an unreadable file are ordinary situations. A stack
|
|
111
|
+
// trace is not an error message; it just suggests the tool is broken.
|
|
112
|
+
process.stderr.write(`${cause.message}\n`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (command !== undefined) {
|
|
117
|
+
process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
main().catch((cause) => {
|
|
121
|
+
process.stderr.write(`${explainStartupFailure(cause)}\n`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdir, rename, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { glob } from "tinyglobby";
|
|
4
|
+
export function guessKind(filename) {
|
|
5
|
+
const name = filename.toLowerCase();
|
|
6
|
+
if (name.endsWith(".dsym.zip") || name.includes(".dsym"))
|
|
7
|
+
return "dsym";
|
|
8
|
+
switch (extname(name)) {
|
|
9
|
+
case ".ipa":
|
|
10
|
+
return "ipa";
|
|
11
|
+
case ".apk":
|
|
12
|
+
return "apk";
|
|
13
|
+
case ".aab":
|
|
14
|
+
return "aab";
|
|
15
|
+
default:
|
|
16
|
+
return "other";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Moves out of the workspace any file matching the configured patterns.
|
|
21
|
+
*
|
|
22
|
+
* The patterns are the only contract: Laneyard doesn't parse the run's
|
|
23
|
+
* output to guess paths. Moving — rather than copying — avoids doubling
|
|
24
|
+
* disk usage and guarantees the next build won't accidentally reuse a
|
|
25
|
+
* stale artifact.
|
|
26
|
+
*/
|
|
27
|
+
export async function collectArtifacts(workspacePath, patterns, destDir) {
|
|
28
|
+
if (patterns.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
const matches = await glob(patterns, {
|
|
31
|
+
cwd: workspacePath,
|
|
32
|
+
absolute: true,
|
|
33
|
+
onlyFiles: true,
|
|
34
|
+
dot: false,
|
|
35
|
+
});
|
|
36
|
+
if (matches.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
await mkdir(destDir, { recursive: true });
|
|
39
|
+
const used = new Set();
|
|
40
|
+
const collected = [];
|
|
41
|
+
for (const source of matches.sort()) {
|
|
42
|
+
let filename = basename(source);
|
|
43
|
+
if (used.has(filename)) {
|
|
44
|
+
// Two paths can produce the same name; we prefix rather than overwrite.
|
|
45
|
+
const ext = extname(filename);
|
|
46
|
+
const stem = filename.slice(0, filename.length - ext.length);
|
|
47
|
+
let n = 2;
|
|
48
|
+
while (used.has(`${stem}-${n}${ext}`))
|
|
49
|
+
n += 1;
|
|
50
|
+
filename = `${stem}-${n}${ext}`;
|
|
51
|
+
}
|
|
52
|
+
used.add(filename);
|
|
53
|
+
const dest = join(destDir, filename);
|
|
54
|
+
await rename(source, dest);
|
|
55
|
+
const info = await stat(dest);
|
|
56
|
+
collected.push({ filename, path: dest, size: info.size, kind: guessKind(filename) });
|
|
57
|
+
}
|
|
58
|
+
return collected;
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spotting of step separators in fastlane's output, during the run.
|
|
3
|
+
*
|
|
4
|
+
* Fragile by nature: this is text meant for humans. We therefore keep only
|
|
5
|
+
* one thing from it, the byte offset where each step starts — the only
|
|
6
|
+
* piece of information report.xml doesn't contain. The names and durations
|
|
7
|
+
* that count come from the report at the end of the run.
|
|
8
|
+
*/
|
|
9
|
+
// Real form observed, ANSI sequences included:
|
|
10
|
+
// [13:14:00]: \x1b[32m--- Step: mkdir -p ../build && echo x > y.ipa ---\x1b[0m
|
|
11
|
+
// The name isn't an identifier: for a `sh` action, it's the entire command,
|
|
12
|
+
// spaces included. The capture is therefore lazy up to the closing dashes,
|
|
13
|
+
// and definitely not `\S+`.
|
|
14
|
+
const SEPARATOR = /-{2,}\s+Step:\s*(.+?)\s+-{2,}/;
|
|
15
|
+
export class LiveStepTracker {
|
|
16
|
+
pending = "";
|
|
17
|
+
pendingOffset = 0;
|
|
18
|
+
found = [];
|
|
19
|
+
/** `offset` is the fragment's position in the log file. */
|
|
20
|
+
consume(chunk, offset) {
|
|
21
|
+
if (this.pending === "")
|
|
22
|
+
this.pendingOffset = offset;
|
|
23
|
+
this.pending += chunk;
|
|
24
|
+
let nl;
|
|
25
|
+
while ((nl = this.pending.indexOf("\n")) !== -1) {
|
|
26
|
+
const line = this.pending.slice(0, nl);
|
|
27
|
+
const lineOffset = this.pendingOffset;
|
|
28
|
+
this.pendingOffset += Buffer.byteLength(this.pending.slice(0, nl + 1), "utf8");
|
|
29
|
+
this.pending = this.pending.slice(nl + 1);
|
|
30
|
+
const m = SEPARATOR.exec(line);
|
|
31
|
+
if (m?.[1])
|
|
32
|
+
this.found.push({ name: m[1], logOffset: lineOffset });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
steps() {
|
|
36
|
+
return this.found;
|
|
37
|
+
}
|
|
38
|
+
}
|