laneyard 0.1.0 → 0.2.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 (60) hide show
  1. package/README.md +120 -21
  2. package/dist/src/cli/secret.js +133 -0
  3. package/dist/src/config/schema.js +21 -3
  4. package/dist/src/db/cache.js +8 -8
  5. package/dist/src/db/open.js +15 -0
  6. package/dist/src/db/runs.js +51 -6
  7. package/dist/src/db/schema.sql +20 -2
  8. package/dist/src/db/secrets.js +64 -0
  9. package/dist/src/fastfile/store.js +53 -0
  10. package/dist/src/git/workspace.js +95 -5
  11. package/dist/src/heuristics/blocking-actions.js +45 -0
  12. package/dist/src/heuristics/readiness.js +193 -0
  13. package/dist/src/logs/redact.js +86 -0
  14. package/dist/src/main.js +32 -3
  15. package/dist/src/runner/orchestrate.js +60 -10
  16. package/dist/src/runner/pty.js +27 -15
  17. package/dist/src/runner/queue.js +114 -0
  18. package/dist/src/secrets/cipher.js +28 -0
  19. package/dist/src/secrets/key.js +40 -0
  20. package/dist/src/secrets/vault.js +68 -0
  21. package/dist/src/server/app.js +70 -0
  22. package/dist/src/server/routes/fastfile.js +131 -0
  23. package/dist/src/server/routes/projects.js +8 -1
  24. package/dist/src/server/routes/readiness.js +89 -0
  25. package/dist/src/server/routes/runs.js +27 -45
  26. package/dist/src/server/routes/secrets.js +51 -0
  27. package/dist/src/sidecar/lanes.js +2 -2
  28. package/dist/src/sidecar/uses.js +40 -0
  29. package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
  30. package/dist/web/assets/index-D9_EL8LA.js +62 -0
  31. package/dist/web/assets/index-ZUqTX6d1.css +1 -0
  32. package/dist/web/index.html +2 -2
  33. package/package.json +13 -5
  34. package/ruby/introspect.rb +80 -0
  35. package/dist/tests/cli/add.test.js +0 -64
  36. package/dist/tests/cli/detect.test.js +0 -63
  37. package/dist/tests/config/load.test.js +0 -68
  38. package/dist/tests/config/resolve.test.js +0 -44
  39. package/dist/tests/config/store.test.js +0 -58
  40. package/dist/tests/db/runs.test.js +0 -54
  41. package/dist/tests/e2e/full-thread.test.js +0 -65
  42. package/dist/tests/fixtures/repos.js +0 -30
  43. package/dist/tests/git/workspace.test.js +0 -66
  44. package/dist/tests/heuristics/error-summary.test.js +0 -31
  45. package/dist/tests/logs/store.test.js +0 -38
  46. package/dist/tests/main.test.js +0 -27
  47. package/dist/tests/ruby/introspect.test.js +0 -59
  48. package/dist/tests/runner/artifacts.test.js +0 -49
  49. package/dist/tests/runner/live-steps.test.js +0 -35
  50. package/dist/tests/runner/orchestrate.test.js +0 -124
  51. package/dist/tests/runner/pty.test.js +0 -53
  52. package/dist/tests/runner/report.test.js +0 -91
  53. package/dist/tests/server/api.test.js +0 -132
  54. package/dist/tests/server/auth.test.js +0 -53
  55. package/dist/tests/server/ws.test.js +0 -43
  56. package/dist/tests/sidecar/lanes.test.js +0 -52
  57. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  58. package/dist/tests/smoke.test.js +0 -7
  59. package/dist/web/assets/index-583x0xuo.css +0 -1
  60. package/dist/web/assets/index-BEpABKPS.js +0 -61
@@ -28,30 +28,42 @@ export function startPty(opts) {
28
28
  let timedOut = false;
29
29
  let timer;
30
30
  proc.onData(opts.onData);
31
+ /**
32
+ * SIGINT first so fastlane can clean up after itself; SIGKILL five seconds
33
+ * later if it will not. One path, so cancelling and timing out cannot drift
34
+ * apart — and so neither can leave the single worker blocked.
35
+ */
36
+ const stop = () => {
37
+ try {
38
+ proc.kill("SIGINT");
39
+ }
40
+ catch {
41
+ /* already gone */
42
+ }
43
+ setTimeout(() => {
44
+ try {
45
+ proc.kill("SIGKILL");
46
+ }
47
+ catch {
48
+ /* already gone */
49
+ }
50
+ }, 5000);
51
+ };
52
+ opts.signal?.addEventListener("abort", stop, { once: true });
31
53
  const done = new Promise((resolve) => {
32
54
  if (opts.timeoutMs !== undefined) {
33
55
  timer = setTimeout(() => {
34
56
  timedOut = true;
35
- // SIGINT first: fastlane cleans up after itself. SIGKILL if it keeps stalling.
36
- try {
37
- proc.kill("SIGINT");
38
- }
39
- catch {
40
- /* the process may have died in the meantime */
41
- }
42
- setTimeout(() => {
43
- try {
44
- proc.kill("SIGKILL");
45
- }
46
- catch {
47
- /* same */
48
- }
49
- }, 5000);
57
+ stop();
50
58
  }, opts.timeoutMs);
51
59
  }
52
60
  proc.onExit(({ exitCode, signal }) => {
53
61
  if (timer)
54
62
  clearTimeout(timer);
63
+ // The listener would otherwise accumulate on every run sharing a
64
+ // long-lived signal (the queue's per-run AbortController is not one,
65
+ // but nothing here should depend on that).
66
+ opts.signal?.removeEventListener("abort", stop);
55
67
  // `waitpid` only reports an exit code for a normal end: a process
56
68
  // killed by a signal leaves 0, which would pass a cancellation off
57
69
  // as a success. We apply the shell convention, 128 + signal, so an
@@ -0,0 +1,114 @@
1
+ /** Statuses from which a run never moves again. */
2
+ const TERMINAL = ["success", "failed", "cancelled", "interrupted"];
3
+ /**
4
+ * Drains queued runs, one at a time.
5
+ *
6
+ * The queue is not held in memory: a run's `queued` status in the database *is*
7
+ * its place in line. That is what lets the server restart mid-queue and carry
8
+ * on, and what makes the position shown in the interface the truth rather than
9
+ * a second copy of it.
10
+ *
11
+ * One at a time, globally. A mobile build saturates the machine it runs on, and
12
+ * two at once are frequently slower than two in sequence.
13
+ */
14
+ export class RunQueue {
15
+ runs;
16
+ job;
17
+ draining = false;
18
+ pending = false;
19
+ closed = false;
20
+ idlePromise = Promise.resolve();
21
+ running = new Map();
22
+ constructor(runs, job) {
23
+ this.runs = runs;
24
+ this.job = job;
25
+ }
26
+ /** Tells the queue there may be work. Safe to call at any time, from anywhere. */
27
+ wake() {
28
+ if (this.closed)
29
+ return;
30
+ if (this.draining) {
31
+ // A call arriving mid-drain is remembered rather than dropped: the drain
32
+ // may already have read an empty queue and be on its way out.
33
+ this.pending = true;
34
+ return;
35
+ }
36
+ this.draining = true;
37
+ this.idlePromise = this.drainUntilQuiet().finally(() => {
38
+ this.draining = false;
39
+ });
40
+ }
41
+ async drainUntilQuiet() {
42
+ do {
43
+ this.pending = false;
44
+ await this.drain();
45
+ } while (this.pending && !this.closed);
46
+ }
47
+ /** Resolves when the queue has nothing left to do. For tests and shutdown. */
48
+ async idle() {
49
+ await this.idlePromise;
50
+ }
51
+ async drain() {
52
+ for (;;) {
53
+ if (this.closed)
54
+ return;
55
+ // One at a time: if something is already in flight — including a run this
56
+ // process did not start — the queue waits rather than doubling up.
57
+ if (this.runs.activeCount() > 0)
58
+ return;
59
+ const next = this.runs.queued()[0];
60
+ if (!next)
61
+ return;
62
+ const controller = new AbortController();
63
+ this.running.set(next.id, controller);
64
+ try {
65
+ await this.job(next.id, controller.signal);
66
+ }
67
+ catch {
68
+ // A job that throws must not stall every run behind it. `executeRun`
69
+ // promises not to, but the queue cannot afford to depend on that.
70
+ }
71
+ finally {
72
+ this.running.delete(next.id);
73
+ // Whatever happened, the run must not still be waiting or in flight.
74
+ //
75
+ // Two failures hide here, and both are worse than a wrong status. If the
76
+ // job returns without moving the run out of `queued` — a project deleted
77
+ // from config.yml while its run waited, say — the next iteration reads
78
+ // the same row and calls the job again, forever. And a job that throws
79
+ // after `executeRun` reached `preparing` leaves a run with no end, which
80
+ // the interface polls until someone gives up.
81
+ const after = this.runs.get(next.id);
82
+ if (after && !TERMINAL.includes(after.status)) {
83
+ this.runs.finish(next.id, {
84
+ status: "failed",
85
+ exitCode: null,
86
+ errorSummary: "The run ended unexpectedly",
87
+ });
88
+ }
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * Cancels a run whether it has started or not.
94
+ *
95
+ * A run still waiting is finished on the spot: there is nothing to signal, and
96
+ * making someone wait for a build they have cancelled would be absurd.
97
+ */
98
+ cancel(runId) {
99
+ const active = this.running.get(runId);
100
+ if (active) {
101
+ active.abort();
102
+ return true;
103
+ }
104
+ const run = this.runs.get(runId);
105
+ if (run?.status !== "queued")
106
+ return false;
107
+ this.runs.finish(runId, { status: "cancelled", exitCode: null, errorSummary: "Cancelled before it started" });
108
+ return true;
109
+ }
110
+ /** Stops taking new work; the run in flight is left to finish. */
111
+ close() {
112
+ this.closed = true;
113
+ }
114
+ }
@@ -0,0 +1,28 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
+ /**
3
+ * AES-256-GCM, from Node's standard library — no dependency to audit.
4
+ *
5
+ * GCM is authenticated: a modified ciphertext fails to decrypt rather than
6
+ * producing plausible rubbish, which is what you want for a value that will be
7
+ * handed to a build as a password.
8
+ *
9
+ * Payload format: `v1.<iv>.<tag>.<ciphertext>`, each part base64. The version
10
+ * prefix exists so the format can be changed without guessing what old rows are.
11
+ */
12
+ const VERSION = "v1";
13
+ const IV_BYTES = 12; // 96 bits, the size GCM is specified for.
14
+ export function encrypt(plaintext, key) {
15
+ const iv = randomBytes(IV_BYTES);
16
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
17
+ const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
18
+ return [VERSION, iv.toString("base64"), cipher.getAuthTag().toString("base64"), body.toString("base64")].join(".");
19
+ }
20
+ export function decrypt(payload, key) {
21
+ const [version, ivB64, tagB64, bodyB64] = payload.split(".");
22
+ if (version !== VERSION || !ivB64 || !tagB64 || bodyB64 === undefined) {
23
+ throw new Error("Unrecognised secret format");
24
+ }
25
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
26
+ decipher.setAuthTag(Buffer.from(tagB64, "base64"));
27
+ return Buffer.concat([decipher.update(Buffer.from(bodyB64, "base64")), decipher.final()]).toString("utf8");
28
+ }
@@ -0,0 +1,40 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ const KEY_BYTES = 32;
5
+ /**
6
+ * Reads the vault key, creating it on first use.
7
+ *
8
+ * The key lives beside the database but never inside it: someone who walks off
9
+ * with `laneyard.db` gets ciphertext and nothing else.
10
+ *
11
+ * A key another user can read is treated as an error rather than a warning —
12
+ * the same stance `ssh` takes on private keys, and for the same reason: silently
13
+ * carrying on would make the encryption decorative.
14
+ */
15
+ export async function loadOrCreateKey(home) {
16
+ const path = join(home, "key");
17
+ try {
18
+ const info = await stat(path);
19
+ if ((info.mode & 0o077) !== 0) {
20
+ throw new Error(`Vault key ${path} is readable by other users. Run \`chmod 600 ${path}\` and start again.`);
21
+ }
22
+ const key = await readFile(path);
23
+ if (key.byteLength !== KEY_BYTES) {
24
+ throw new Error(`Vault key ${path} is ${key.byteLength} bytes, expected ${KEY_BYTES}. ` +
25
+ "Refusing to guess: move it aside and Laneyard will create a new one, " +
26
+ "but every stored secret will have to be entered again.");
27
+ }
28
+ return key;
29
+ }
30
+ catch (cause) {
31
+ if (cause.code !== "ENOENT")
32
+ throw cause;
33
+ }
34
+ await mkdir(home, { recursive: true });
35
+ const key = randomBytes(KEY_BYTES);
36
+ // The mode is set at creation, not after: a `chmod` afterwards leaves a window
37
+ // during which the key exists and is world-readable.
38
+ await writeFile(path, key, { mode: 0o600 });
39
+ return key;
40
+ }
@@ -0,0 +1,68 @@
1
+ import { scrub } from "../logs/redact.js";
2
+ import { decrypt, encrypt } from "./cipher.js";
3
+ import { loadOrCreateKey } from "./key.js";
4
+ /**
5
+ * The only component that ever holds a decrypted secret.
6
+ *
7
+ * Everything else — the store, the API, the interface — deals in names and
8
+ * ciphertext. Keeping plaintext to one small file is what makes "a secret never
9
+ * reaches a log" a claim you can check by reading, rather than a hope.
10
+ */
11
+ export class Vault {
12
+ key;
13
+ store;
14
+ constructor(key, store) {
15
+ this.key = key;
16
+ this.store = store;
17
+ }
18
+ static async open(home, store) {
19
+ return new Vault(await loadOrCreateKey(home), store);
20
+ }
21
+ async set(projectSlug, key, value, masked) {
22
+ this.store.set(projectSlug, key, encrypt(value, this.key), masked);
23
+ }
24
+ remove(projectSlug, key) {
25
+ return this.store.remove(projectSlug, key);
26
+ }
27
+ list(projectSlug) {
28
+ return this.store.list(projectSlug);
29
+ }
30
+ listGlobal() {
31
+ return this.store.listGlobal();
32
+ }
33
+ /**
34
+ * Removes this project's secret values from a piece of text, in one shot.
35
+ *
36
+ * Separate from `Redactor`, which is stateful and belongs to a live stream:
37
+ * reusing that instance here would corrupt its buffer mid-run.
38
+ */
39
+ scrub(projectSlug, text) {
40
+ return scrub(text, this.maskedValues(projectSlug));
41
+ }
42
+ /**
43
+ * Every secret that applies to a project, ready to become environment variables.
44
+ *
45
+ * A row that will not decrypt is skipped rather than thrown: a key that was
46
+ * rotated or a corrupted row should cost one variable, not the whole build.
47
+ * The run then fails on its own terms, with fastlane saying what was missing.
48
+ */
49
+ resolve(projectSlug) {
50
+ const out = {};
51
+ for (const [key, payload] of Object.entries(this.store.encrypted(projectSlug))) {
52
+ try {
53
+ out[key] = decrypt(payload, this.key);
54
+ }
55
+ catch {
56
+ // Deliberately silent here; the interface reports unreadable secrets.
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /** The values a run's output must not contain. */
62
+ maskedValues(projectSlug) {
63
+ const masked = this.store.maskedKeys(projectSlug);
64
+ return Object.entries(this.resolve(projectSlug))
65
+ .filter(([key]) => masked.has(key))
66
+ .map(([, value]) => value);
67
+ }
68
+ }
@@ -7,9 +7,14 @@ import { fileURLToPath } from "node:url";
7
7
  import { RunStore } from "../db/runs.js";
8
8
  import { Workspace } from "../git/workspace.js";
9
9
  import { LogStore } from "../logs/store.js";
10
+ import { executeRun } from "../runner/orchestrate.js";
11
+ import { RunQueue } from "../runner/queue.js";
10
12
  import { LoginThrottle, SESSION_COOKIE, SessionStore, verifyPassword } from "./auth.js";
13
+ import { registerFastfileRoutes } from "./routes/fastfile.js";
11
14
  import { registerProjectRoutes } from "./routes/projects.js";
15
+ import { registerReadinessRoutes } from "./routes/readiness.js";
12
16
  import { registerRunRoutes } from "./routes/runs.js";
17
+ import { registerSecretRoutes } from "./routes/secrets.js";
13
18
  import { registerWebSocket } from "./ws.js";
14
19
  export async function buildApp(deps) {
15
20
  const app = Fastify({ logger: false });
@@ -29,6 +34,68 @@ export async function buildApp(deps) {
29
34
  await new Workspace(workspacePath(slug), entry.git_url, entry.git_auth).ensureCloned();
30
35
  },
31
36
  };
37
+ // The queue is assembled here rather than in `createServerFromConfig`: its job
38
+ // needs `runs`, `logs`, the workspace and artifact paths and the sockets, none
39
+ // of which exist before `ctx` does. It is assigned after the context literal
40
+ // because the job it drives closes over that very context — hence the field
41
+ // being optional in the type rather than a cast pretending it is already set.
42
+ ctx.queue = new RunQueue(ctx.runs, async (runId, signal) => {
43
+ const run = ctx.runs.get(runId);
44
+ if (!run)
45
+ return;
46
+ const slug = run.projectSlug;
47
+ const entry = deps.config.project(slug);
48
+ if (!entry) {
49
+ // The project was removed from config.yml while this run waited. Ending it
50
+ // here is what keeps the queue from re-reading the same row for ever.
51
+ ctx.runs.finish(runId, {
52
+ status: "failed",
53
+ exitCode: null,
54
+ errorSummary: `Project "${slug}" is no longer in the configuration`,
55
+ });
56
+ return;
57
+ }
58
+ await executeRun({
59
+ runId,
60
+ runs: ctx.runs,
61
+ logs: ctx.logs,
62
+ workspacePath: ctx.workspacePath(slug),
63
+ artifactsDir: ctx.artifactsDir(runId),
64
+ gitUrl: entry.git_url,
65
+ gitAuth: entry.git_auth,
66
+ branch: entry.default_branch,
67
+ // Resolved after the clone, once the repository's laneyard.yml is finally readable.
68
+ resolveSettings: async () => {
69
+ const r = await ctx.config.resolve(slug, ctx.workspacePath(slug));
70
+ return r.settings;
71
+ },
72
+ env: process.env,
73
+ secrets: ctx.vault.resolve(slug),
74
+ maskedValues: ctx.vault.maskedValues(slug),
75
+ signal,
76
+ onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
77
+ })
78
+ .then((r) => ctx.sockets?.finish(runId, r.status))
79
+ .catch((cause) => {
80
+ // Last safety net. `executeRun` commits to never throwing, but the queue
81
+ // cannot afford to depend on that: a run left neither finished nor
82
+ // failed is a row the interface polls until someone gives up.
83
+ ctx.runs.finish(runId, {
84
+ status: "failed",
85
+ exitCode: null,
86
+ errorSummary: ctx.vault.scrub(slug, `Unexpected failure: ${cause.message}`),
87
+ });
88
+ ctx.sockets?.finish(runId, "failed");
89
+ });
90
+ });
91
+ // `ctx.queue` is assigned immediately above; naming it here rather than
92
+ // reaching through the optional field is what lets every route treat the
93
+ // queue as simply present.
94
+ const queue = ctx.queue;
95
+ app.decorate("queue", queue);
96
+ // A closed server must stop taking new work: the run in flight is left to
97
+ // finish, but nothing behind it starts on a server nobody is listening to.
98
+ app.addHook("onClose", async () => queue.close());
32
99
  const throttle = new LoginThrottle();
33
100
  app.post("/api/login", async (req, reply) => {
34
101
  const { password } = req.body;
@@ -61,6 +128,9 @@ export async function buildApp(deps) {
61
128
  ctx.sockets = await registerWebSocket(app, ctx);
62
129
  await registerProjectRoutes(app, ctx);
63
130
  await registerRunRoutes(app, ctx);
131
+ await registerSecretRoutes(app, ctx);
132
+ await registerReadinessRoutes(app, ctx);
133
+ await registerFastfileRoutes(app, ctx);
64
134
  // Resolved from the module's location, not from the data folder:
65
135
  // `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
66
136
  // relative positions, depending on whether we're running on the sources
@@ -0,0 +1,131 @@
1
+ import { join } from "node:path";
2
+ import { FastfileStore } from "../../fastfile/store.js";
3
+ import { Workspace } from "../../git/workspace.js";
4
+ export async function registerFastfileRoutes(app, ctx) {
5
+ // Stateless: the previous content it keeps in memory during a write lives
6
+ // on the call stack, not on the instance, so sharing one across requests
7
+ // costs nothing and mirrors how the other routes reuse a single `Workspace`.
8
+ const store = new FastfileStore();
9
+ /**
10
+ * Common preamble: the project must exist and its clone must be present —
11
+ * the Fastfile, like the lane list, lives in the repository. Sends the
12
+ * response itself and returns null on failure so callers can bail out with
13
+ * a single early return.
14
+ */
15
+ const ready = async (slug, reply) => {
16
+ const entry = ctx.config.project(slug);
17
+ if (!entry) {
18
+ reply.code(404).send({ error: "Unknown project" });
19
+ return null;
20
+ }
21
+ try {
22
+ await ctx.ensureWorkspace(slug);
23
+ }
24
+ catch (cause) {
25
+ reply.code(503).send({ error: cause.message });
26
+ return null;
27
+ }
28
+ const workspacePath = ctx.workspacePath(slug);
29
+ const resolved = await ctx.config.resolve(slug, workspacePath);
30
+ return {
31
+ workspacePath,
32
+ fastlaneDir: resolved.settings.fastlane_dir,
33
+ workspace: new Workspace(workspacePath, entry.git_url, entry.git_auth),
34
+ defaultBranch: entry.default_branch,
35
+ };
36
+ };
37
+ app.get("/api/projects/:slug/fastfile", async (req, reply) => {
38
+ const { slug } = req.params;
39
+ const r = await ready(slug, reply);
40
+ if (!r)
41
+ return;
42
+ try {
43
+ const [content, dirty, diff] = await Promise.all([
44
+ store.read(r.workspacePath, r.fastlaneDir),
45
+ r.workspace.isDirty(),
46
+ r.workspace.diff(join(r.fastlaneDir, "Fastfile")),
47
+ ]);
48
+ return { content, dirty, diff };
49
+ }
50
+ catch (cause) {
51
+ // Unreadable Fastfile — deleted by hand, say — is the same kind of
52
+ // "could not tell" as an unreadable lane list elsewhere in the API.
53
+ return reply.code(503).send({ error: cause.message });
54
+ }
55
+ });
56
+ app.put("/api/projects/:slug/fastfile", async (req, reply) => {
57
+ const { slug } = req.params;
58
+ const { content } = (req.body ?? {});
59
+ if (typeof content !== "string") {
60
+ return reply.code(400).send({ error: "content is required" });
61
+ }
62
+ // The one refusal in this file, and not a heuristic: a run in flight is
63
+ // reading the very file this write would replace, the same reason
64
+ // `Workspace.prepare` refuses to touch a dirty workspace.
65
+ if (ctx.runs.hasActiveRun(slug)) {
66
+ return reply.code(409).send({
67
+ error: "A run is in progress for this project. Wait for it to finish before editing the Fastfile.",
68
+ });
69
+ }
70
+ const r = await ready(slug, reply);
71
+ if (!r)
72
+ return;
73
+ // Asks the sidecar for the lanes: that parses the file and lists what it
74
+ // found, which is exactly the two things that matter here — it still
75
+ // parses, and the lanes are still there. The introspection cache is keyed
76
+ // on a hash of the whole fastlane folder, so the changed content here is
77
+ // what makes the next read of the lane list fresh — no separate
78
+ // invalidation step needed.
79
+ const verify = async () => {
80
+ try {
81
+ await ctx.lanes(slug, r.workspacePath, r.fastlaneDir);
82
+ return { ok: true };
83
+ }
84
+ catch (cause) {
85
+ return { ok: false, reason: cause.message };
86
+ }
87
+ };
88
+ const result = await store.write(r.workspacePath, content, verify, r.fastlaneDir);
89
+ if (!result.ok)
90
+ return reply.code(400).send({ error: result.reason });
91
+ return reply.code(204).send();
92
+ });
93
+ app.get("/api/projects/:slug/changes", async (req, reply) => {
94
+ const { slug } = req.params;
95
+ const r = await ready(slug, reply);
96
+ if (!r)
97
+ return;
98
+ const [files, diff] = await Promise.all([r.workspace.status(), r.workspace.diff()]);
99
+ return { files, diff };
100
+ });
101
+ app.post("/api/projects/:slug/commit", async (req, reply) => {
102
+ const { slug } = req.params;
103
+ const { message } = (req.body ?? {});
104
+ if (!message)
105
+ return reply.code(400).send({ error: "A commit message is required" });
106
+ const r = await ready(slug, reply);
107
+ if (!r)
108
+ return;
109
+ // Exactly what changed, never `git add -A`: a build leaves files
110
+ // scattered in the workspace, and this is the one place that must not
111
+ // scoop them up because they happened to be there.
112
+ const files = await r.workspace.status();
113
+ if (files.length === 0)
114
+ return reply.code(400).send({ error: "Nothing to commit" });
115
+ await r.workspace.commit(message, files);
116
+ return reply.code(204).send();
117
+ });
118
+ app.post("/api/projects/:slug/push", async (req, reply) => {
119
+ const { slug } = req.params;
120
+ const r = await ready(slug, reply);
121
+ if (!r)
122
+ return;
123
+ try {
124
+ await r.workspace.push(r.defaultBranch);
125
+ return reply.code(204).send();
126
+ }
127
+ catch (cause) {
128
+ return reply.code(400).send({ error: cause.message });
129
+ }
130
+ });
131
+ }
@@ -30,6 +30,13 @@ export async function registerProjectRoutes(app, ctx) {
30
30
  const { slug } = req.params;
31
31
  if (!ctx.config.project(slug))
32
32
  return reply.code(404).send({ error: "Unknown project" });
33
- return ctx.runs.listByProject(slug);
33
+ // The whole line is read once and the positions are looked up in it, rather
34
+ // than asking the database where each of fifty runs stands. The position is
35
+ // the global one: the queue is shared by every project.
36
+ const line = ctx.runs.queued().map((r) => r.id);
37
+ return ctx.runs.listByProject(slug).map((run) => {
38
+ const at = line.indexOf(run.id);
39
+ return { ...run, queuePosition: at === -1 ? null : at + 1 };
40
+ });
34
41
  });
35
42
  }
@@ -0,0 +1,89 @@
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
+ import { Workspace } from "../../git/workspace.js";
6
+ import { runChecks } from "../../heuristics/readiness.js";
7
+ const exec = promisify(execFile);
8
+ /**
9
+ * `bundle check` in the workspace, rejecting with what bundler said.
10
+ *
11
+ * Installs nothing: the checklist reports, it does not act. The timeout is
12
+ * generous because bundler resolves the whole Gemfile.lock, and short enough
13
+ * that a wedged bundler does not hold the request open.
14
+ */
15
+ async function bundleCheck(cwd) {
16
+ try {
17
+ const { stdout } = await exec("bundle", ["check"], { cwd, timeout: 60_000 });
18
+ return stdout.trim();
19
+ }
20
+ catch (cause) {
21
+ const err = cause;
22
+ throw new Error((err.stdout || err.stderr || err.message).trim());
23
+ }
24
+ }
25
+ /** The path of a `fastlane` a run would find, or null. */
26
+ async function findFastlane() {
27
+ try {
28
+ const { stdout } = await exec("which", ["fastlane"], { timeout: 5_000 });
29
+ return stdout.trim() || null;
30
+ }
31
+ catch {
32
+ // A non-zero exit from `which` is the answer "no", not a failure.
33
+ return null;
34
+ }
35
+ }
36
+ const exists = async (path) => access(path).then(() => true, () => false);
37
+ export async function registerReadinessRoutes(app, ctx) {
38
+ /**
39
+ * Computed only when asked for.
40
+ *
41
+ * These checks shell out to git and to bundler, so nothing else in the
42
+ * interface may trigger them: the tab asks when it is opened, and when the
43
+ * user presses refresh. Nothing here is cached either — a stale green tick is
44
+ * worse than a red cross, which is why the answer carries the time it was
45
+ * produced.
46
+ */
47
+ app.get("/api/projects/:slug/readiness", async (req, reply) => {
48
+ const { slug } = req.params;
49
+ const entry = ctx.config.project(slug);
50
+ if (!entry)
51
+ return reply.code(404).send({ error: "Unknown project" });
52
+ const workspacePath = ctx.workspacePath(slug);
53
+ const workspace = new Workspace(workspacePath, entry.git_url, entry.git_auth);
54
+ // What each lane calls lives in the repository, so the clone has to exist.
55
+ // A clone that fails is not an error page: it is the reason two of the five
56
+ // checks cannot answer, and the other three still can.
57
+ let unreachable = null;
58
+ try {
59
+ await ctx.ensureWorkspace(slug);
60
+ }
61
+ catch (cause) {
62
+ unreachable = cause.message;
63
+ }
64
+ const resolved = await ctx.config.resolve(slug, workspacePath);
65
+ const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
66
+ const uses = unreachable !== null
67
+ ? { ok: false, reason: unreachable }
68
+ : await ctx
69
+ .uses(slug, workspacePath, fastlaneDir)
70
+ .then((lanes) => ({ ok: true, value: lanes }))
71
+ // Broken Fastfile, no Ruby, no fastlane: all of them are "could not
72
+ // tell", none of them is a 500.
73
+ .catch((cause) => ({ ok: false, reason: cause.message }));
74
+ const checks = await runChecks({
75
+ probeRepository: () => workspace.probeRemote(),
76
+ dependencies: {
77
+ workspace: unreachable !== null
78
+ ? { ok: false, reason: unreachable }
79
+ : { ok: true, value: { hasGemfile: await exists(join(workspacePath, "Gemfile")) } },
80
+ bundleCheck: () => bundleCheck(workspacePath),
81
+ findFastlane,
82
+ },
83
+ // Names only: the vault never hands a value to anything but a run.
84
+ secretKeys: ctx.vault.list(slug).map((s) => s.key),
85
+ uses,
86
+ });
87
+ return { checkedAt: new Date().toISOString(), checks };
88
+ });
89
+ }