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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/dist/src/cli/add.js +81 -0
  4. package/dist/src/cli/detect.js +70 -0
  5. package/dist/src/config/load.js +46 -0
  6. package/dist/src/config/resolve.js +29 -0
  7. package/dist/src/config/schema.js +55 -0
  8. package/dist/src/config/store.js +71 -0
  9. package/dist/src/db/cache.js +22 -0
  10. package/dist/src/db/open.js +12 -0
  11. package/dist/src/db/runs.js +97 -0
  12. package/dist/src/db/schema.sql +45 -0
  13. package/dist/src/git/workspace.js +106 -0
  14. package/dist/src/heuristics/error-summary.js +43 -0
  15. package/dist/src/logs/store.js +66 -0
  16. package/dist/src/main.js +124 -0
  17. package/dist/src/runner/artifacts.js +59 -0
  18. package/dist/src/runner/live-steps.js +38 -0
  19. package/dist/src/runner/orchestrate.js +140 -0
  20. package/dist/src/runner/pty.js +83 -0
  21. package/dist/src/runner/report.js +86 -0
  22. package/dist/src/server/app.js +84 -0
  23. package/dist/src/server/auth.js +81 -0
  24. package/dist/src/server/routes/projects.js +35 -0
  25. package/dist/src/server/routes/runs.js +100 -0
  26. package/dist/src/server/ws.js +66 -0
  27. package/dist/src/sidecar/bridge.js +44 -0
  28. package/dist/src/sidecar/lanes.js +40 -0
  29. package/dist/src/sidecar/ruby-env.js +63 -0
  30. package/dist/tests/cli/add.test.js +64 -0
  31. package/dist/tests/cli/detect.test.js +63 -0
  32. package/dist/tests/config/load.test.js +68 -0
  33. package/dist/tests/config/resolve.test.js +44 -0
  34. package/dist/tests/config/store.test.js +58 -0
  35. package/dist/tests/db/runs.test.js +54 -0
  36. package/dist/tests/e2e/full-thread.test.js +65 -0
  37. package/dist/tests/fixtures/repos.js +30 -0
  38. package/dist/tests/git/workspace.test.js +66 -0
  39. package/dist/tests/heuristics/error-summary.test.js +31 -0
  40. package/dist/tests/logs/store.test.js +38 -0
  41. package/dist/tests/main.test.js +27 -0
  42. package/dist/tests/ruby/introspect.test.js +59 -0
  43. package/dist/tests/runner/artifacts.test.js +49 -0
  44. package/dist/tests/runner/live-steps.test.js +35 -0
  45. package/dist/tests/runner/orchestrate.test.js +124 -0
  46. package/dist/tests/runner/pty.test.js +53 -0
  47. package/dist/tests/runner/report.test.js +91 -0
  48. package/dist/tests/server/api.test.js +132 -0
  49. package/dist/tests/server/auth.test.js +53 -0
  50. package/dist/tests/server/ws.test.js +43 -0
  51. package/dist/tests/sidecar/lanes.test.js +52 -0
  52. package/dist/tests/sidecar/ruby-env.test.js +25 -0
  53. package/dist/tests/smoke.test.js +7 -0
  54. package/dist/web/assets/index-583x0xuo.css +1 -0
  55. package/dist/web/assets/index-BEpABKPS.js +61 -0
  56. package/dist/web/index.html +13 -0
  57. package/package.json +70 -0
  58. package/ruby/introspect.rb +87 -0
  59. 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
+ }
@@ -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
+ }