laneyard 0.1.0 → 0.3.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 (74) hide show
  1. package/README.md +238 -32
  2. package/dist/src/cli/detect.js +60 -16
  3. package/dist/src/cli/prompt.js +36 -0
  4. package/dist/src/cli/secret.js +133 -0
  5. package/dist/src/cli/setup.js +282 -0
  6. package/dist/src/cli/style.js +31 -0
  7. package/dist/src/cli/user.js +127 -0
  8. package/dist/src/config/accounts.js +163 -0
  9. package/dist/src/config/load.js +61 -1
  10. package/dist/src/config/schema.js +47 -4
  11. package/dist/src/config/store.js +10 -0
  12. package/dist/src/config/yaml.js +14 -0
  13. package/dist/src/db/cache.js +8 -8
  14. package/dist/src/db/open.js +15 -0
  15. package/dist/src/db/runs.js +51 -6
  16. package/dist/src/db/schema.sql +20 -2
  17. package/dist/src/db/secrets.js +64 -0
  18. package/dist/src/fastfile/store.js +53 -0
  19. package/dist/src/git/workspace.js +95 -5
  20. package/dist/src/heuristics/blocking-actions.js +65 -0
  21. package/dist/src/heuristics/platforms.js +77 -0
  22. package/dist/src/heuristics/readiness.js +336 -0
  23. package/dist/src/logs/redact.js +86 -0
  24. package/dist/src/main.js +79 -9
  25. package/dist/src/runner/orchestrate.js +60 -10
  26. package/dist/src/runner/pty.js +27 -15
  27. package/dist/src/runner/queue.js +114 -0
  28. package/dist/src/secrets/cipher.js +28 -0
  29. package/dist/src/secrets/key.js +40 -0
  30. package/dist/src/secrets/vault.js +68 -0
  31. package/dist/src/server/app.js +135 -13
  32. package/dist/src/server/auth.js +85 -17
  33. package/dist/src/server/permissions.js +73 -0
  34. package/dist/src/server/routes/fastfile.js +131 -0
  35. package/dist/src/server/routes/projects.js +50 -1
  36. package/dist/src/server/routes/readiness.js +102 -0
  37. package/dist/src/server/routes/runs.js +27 -45
  38. package/dist/src/server/routes/secrets.js +51 -0
  39. package/dist/src/server/routes/users.js +64 -0
  40. package/dist/src/sidecar/bridge.js +17 -1
  41. package/dist/src/sidecar/lanes.js +2 -2
  42. package/dist/src/sidecar/uses.js +40 -0
  43. package/dist/web/assets/Editor-DNFBA4gv.js +14 -0
  44. package/dist/web/assets/index-De6sxx6G.css +1 -0
  45. package/dist/web/assets/index-TWRQ1cJg.js +62 -0
  46. package/dist/web/index.html +2 -2
  47. package/package.json +13 -5
  48. package/ruby/introspect.rb +80 -0
  49. package/dist/tests/cli/add.test.js +0 -64
  50. package/dist/tests/cli/detect.test.js +0 -63
  51. package/dist/tests/config/load.test.js +0 -68
  52. package/dist/tests/config/resolve.test.js +0 -44
  53. package/dist/tests/config/store.test.js +0 -58
  54. package/dist/tests/db/runs.test.js +0 -54
  55. package/dist/tests/e2e/full-thread.test.js +0 -65
  56. package/dist/tests/fixtures/repos.js +0 -30
  57. package/dist/tests/git/workspace.test.js +0 -66
  58. package/dist/tests/heuristics/error-summary.test.js +0 -31
  59. package/dist/tests/logs/store.test.js +0 -38
  60. package/dist/tests/main.test.js +0 -27
  61. package/dist/tests/ruby/introspect.test.js +0 -59
  62. package/dist/tests/runner/artifacts.test.js +0 -49
  63. package/dist/tests/runner/live-steps.test.js +0 -35
  64. package/dist/tests/runner/orchestrate.test.js +0 -124
  65. package/dist/tests/runner/pty.test.js +0 -53
  66. package/dist/tests/runner/report.test.js +0 -91
  67. package/dist/tests/server/api.test.js +0 -132
  68. package/dist/tests/server/auth.test.js +0 -53
  69. package/dist/tests/server/ws.test.js +0 -43
  70. package/dist/tests/sidecar/lanes.test.js +0 -52
  71. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  72. package/dist/tests/smoke.test.js +0 -7
  73. package/dist/web/assets/index-583x0xuo.css +0 -1
  74. package/dist/web/assets/index-BEpABKPS.js +0 -61
@@ -37,45 +37,113 @@ export async function verifyPassword(password, stored) {
37
37
  return false;
38
38
  }
39
39
  }
40
+ /**
41
+ * A hash no password matches, used to spend the same work on an unknown name.
42
+ *
43
+ * Built from random bytes rather than by hashing something: `hashPassword` is
44
+ * synchronous and would freeze the server for its duration. The shape is what
45
+ * matters — `verifyPassword` derives a 32-byte key with the same parameters and
46
+ * then fails the comparison, which is exactly what a wrong password costs.
47
+ */
48
+ const DECOY_HASH = `scrypt$${randomBytes(16).toString("hex")}$${randomBytes(32).toString("hex")}`;
49
+ /**
50
+ * Turns a name and a password into who that is, or into nothing.
51
+ *
52
+ * An unknown name is verified against a decoy hash instead of returning early.
53
+ * Without that, a refusal for a name that does not exist comes back in
54
+ * microseconds while a wrong password takes the thirty milliseconds scrypt
55
+ * costs — and the login form becomes a way to enumerate accounts before trying
56
+ * a single password against them.
57
+ */
58
+ export async function authenticate(users, name, password) {
59
+ const user = users.find((u) => u.name === name);
60
+ if (!user) {
61
+ await verifyPassword(password, DECOY_HASH);
62
+ return null;
63
+ }
64
+ if (!(await verifyPassword(password, user.password_hash)))
65
+ return null;
66
+ return { name: user.name, role: user.role };
67
+ }
40
68
  /** In-memory sessions: they don't survive a restart, and that's just fine. */
41
69
  export class SessionStore {
42
- tokens = new Set();
43
- issue() {
70
+ tokens = new Map();
71
+ issue(identity) {
44
72
  const token = randomBytes(32).toString("hex");
45
- this.tokens.add(token);
73
+ this.tokens.set(token, identity);
46
74
  return token;
47
75
  }
76
+ /** Who the token belongs to, or undefined if it belongs to nobody. */
77
+ get(token) {
78
+ return token === undefined ? undefined : this.tokens.get(token);
79
+ }
48
80
  valid(token) {
49
- return token !== undefined && this.tokens.has(token);
81
+ return this.get(token) !== undefined;
50
82
  }
51
83
  revoke(token) {
52
84
  this.tokens.delete(token);
53
85
  }
86
+ /**
87
+ * Drops every session belonging to a name.
88
+ *
89
+ * The identity is snapshotted when the session is issued, which is what makes
90
+ * a request cheap — but it also means removing an account or changing its role
91
+ * leaves the old answer live in whatever browsers already had it. Without this,
92
+ * "remove the account" and "revoke access" are two different things, while
93
+ * every interface that offers the first implies the second.
94
+ */
95
+ revokeAllFor(name) {
96
+ for (const [token, identity] of this.tokens) {
97
+ if (identity.name === name)
98
+ this.tokens.delete(token);
99
+ }
100
+ }
54
101
  }
55
102
  export const SESSION_COOKIE = "laneyard_session";
103
+ /** Past this many tracked names, the entries that no longer delay anyone go. */
104
+ const THROTTLE_PRUNE_ABOVE = 1_000;
56
105
  /**
57
- * Slows down repeated login attempts.
106
+ * Slows down repeated login attempts, one account at a time.
58
107
  *
59
108
  * Without this, a network neighbour could try passwords as fast as the
60
109
  * server responds. The delay grows with failures and resets to zero on a
61
110
  * success: the legitimate user who mistypes once doesn't feel it.
111
+ *
112
+ * Counted per name rather than globally: a single counter means anyone able to
113
+ * reach the login form can lock out every account on the server by hammering
114
+ * one name — a denial of service disguised as a security measure.
62
115
  */
63
116
  export class LoginThrottle {
64
- failures = 0;
65
- until = 0;
66
- /** Milliseconds left to wait, 0 if the way is clear. */
67
- retryAfterMs(now = Date.now()) {
68
- return Math.max(0, this.until - now);
117
+ perName = new Map();
118
+ /** Milliseconds left to wait for that name, 0 if the way is clear. */
119
+ retryAfterMs(name, now = Date.now()) {
120
+ return Math.max(0, (this.perName.get(name)?.until ?? 0) - now);
69
121
  }
70
- recordFailure(now = Date.now()) {
71
- this.failures += 1;
122
+ recordFailure(name, now = Date.now()) {
123
+ const state = this.perName.get(name) ?? { failures: 0, until: 0 };
124
+ state.failures += 1;
72
125
  // 0, 0, 0, then 1s, 2s, 4s… capped at one minute.
73
- if (this.failures > 3) {
74
- this.until = now + Math.min(60_000, 2 ** (this.failures - 4) * 1000);
126
+ if (state.failures > 3) {
127
+ state.until = now + Math.min(60_000, 2 ** (state.failures - 4) * 1000);
75
128
  }
129
+ this.perName.set(name, state);
130
+ // The key is whatever the caller sent, so an attacker chooses how many
131
+ // entries exist. Dropping those whose delay has run out bounds the map by
132
+ // how fast someone can be delayed, not by how many names they can invent.
133
+ if (this.perName.size > THROTTLE_PRUNE_ABOVE)
134
+ this.prune(now);
76
135
  }
77
- recordSuccess() {
78
- this.failures = 0;
79
- this.until = 0;
136
+ recordSuccess(name) {
137
+ this.perName.delete(name);
138
+ }
139
+ /** Number of names currently tracked. Exposed so the pruning can be tested. */
140
+ size() {
141
+ return this.perName.size;
142
+ }
143
+ prune(now) {
144
+ for (const [name, state] of this.perName) {
145
+ if (state.until <= now)
146
+ this.perName.delete(name);
147
+ }
80
148
  }
81
149
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Who can do what — the whole answer, in one file.
3
+ *
4
+ * A permission expressed as an `if` inside a handler is a permission nobody
5
+ * finds when it matters: during an audit, or after an incident. So there are no
6
+ * such `if`s. Every route that needs more than a session is named below, and the
7
+ * single hook in `app.ts` is the only thing that reads this.
8
+ *
9
+ * Two roles, and only two. `admin` may do everything; `builder` may start a
10
+ * build, watch it, cancel it and download what it produced — nothing that
11
+ * changes what a build does, and nothing that reveals a credential.
12
+ */
13
+ /**
14
+ * What each route needs. Everything absent from this list needs only a session.
15
+ *
16
+ * Reading the Fastfile is deliberately not here: a builder who can start a lane
17
+ * benefits from seeing what it does, and it contains no credential — anything
18
+ * that does is in the vault, which is.
19
+ */
20
+ export const REQUIRES_ADMIN = [
21
+ { method: "*", path: "/api/secrets" },
22
+ { method: "*", path: "/api/projects/:slug/secrets" },
23
+ { method: "PUT", path: "/api/projects/:slug/fastfile" },
24
+ { method: "POST", path: "/api/projects/:slug/commit" },
25
+ { method: "POST", path: "/api/projects/:slug/push" },
26
+ { method: "DELETE", path: "/api/projects/:slug" },
27
+ { method: "*", path: "/api/users" },
28
+ ];
29
+ /**
30
+ * `/api/secrets/APP_KEY?x=1` → `["api", "secrets", "APP_KEY"]`.
31
+ *
32
+ * Each segment is percent-decoded, because the router decodes before it matches
33
+ * and this must see the same path the router did. Comparing the raw text sent
34
+ * `GET /api/%73ecrets` straight past the admin list and into the vault: Fastify
35
+ * routed it to `/api/secrets`, and this function had been looking at `%73ecrets`.
36
+ */
37
+ function segments(url) {
38
+ return (url.split("?")[0] ?? "")
39
+ .split("/")
40
+ .filter((s) => s !== "")
41
+ .map((s) => {
42
+ try {
43
+ return decodeURIComponent(s);
44
+ }
45
+ catch {
46
+ // Malformed escapes reach no route either; left as-is rather than
47
+ // swallowed, so a segment is never silently emptied into a match.
48
+ return s;
49
+ }
50
+ });
51
+ }
52
+ /**
53
+ * Does this request need an admin?
54
+ *
55
+ * A pattern matches a request whose path *starts with* it, segment by segment,
56
+ * with `:name` standing for any one segment. The prefix is deliberate:
57
+ * `/api/secrets` means the vault, not one URL, and a table naming every key's
58
+ * route separately would be a table someone forgets to extend the next time a
59
+ * route is added under it. It only ever errs towards refusing, since every
60
+ * entry here is a restriction.
61
+ */
62
+ export function requiresAdmin(method, url) {
63
+ const parts = segments(url);
64
+ const verb = method.toUpperCase();
65
+ return REQUIRES_ADMIN.some((pattern) => {
66
+ if (pattern.method !== "*" && pattern.method.toUpperCase() !== verb)
67
+ return false;
68
+ const expected = segments(pattern.path);
69
+ if (parts.length < expected.length)
70
+ return false;
71
+ return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
72
+ });
73
+ }
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ import { removeProjectFromConfig } from "../../cli/setup.js";
1
3
  export async function registerProjectRoutes(app, ctx) {
2
4
  app.get("/api/projects", async () => ctx.config.projects().map((p) => {
3
5
  const last = ctx.runs.listByProject(p.slug, 1)[0] ?? null;
@@ -8,6 +10,46 @@ export async function registerProjectRoutes(app, ctx) {
8
10
  lastRun: last && { id: last.id, status: last.status, lane: last.lane, finishedAt: last.finishedAt },
9
11
  };
10
12
  }));
13
+ /**
14
+ * Stops showing a project. It is the one destructive route in the product,
15
+ * and what it does not destroy is most of the point:
16
+ *
17
+ * - the project's block leaves config.yml, through the YAML document so the
18
+ * rest of a hand-written file is untouched;
19
+ * - its runs stay in the database, still reachable at their own URL — the
20
+ * history of what this machine built is not the project's to take away;
21
+ * - the clone and the artifacts stay on disk, named in the answer so they
22
+ * can be removed by hand. Deleting files someone may still want, from a
23
+ * web page, on one click, is not a thing to do.
24
+ */
25
+ app.delete("/api/projects/:slug", async (req, reply) => {
26
+ const { slug } = req.params;
27
+ const entry = ctx.config.project(slug);
28
+ if (!entry)
29
+ return reply.code(404).send({ error: "Unknown project" });
30
+ // A run that has begun is reading the workspace this project points at.
31
+ // Queued runs are not: the queue already fails a run whose project went
32
+ // away, so waiting on them would only mean refusing for longer.
33
+ if (ctx.runs.hasActiveRun(slug)) {
34
+ return reply.code(409).send({
35
+ error: `"${slug}" has a run in flight. Wait for it to finish, or cancel it, then remove the project.`,
36
+ });
37
+ }
38
+ const removed = await removeProjectFromConfig(ctx.config.configPath(), slug);
39
+ if (!removed)
40
+ return reply.code(404).send({ error: "Unknown project" });
41
+ // The file is watched, but on a debounce: reloading here is what makes the
42
+ // very next request — the listing this page is about to ask for — truthful.
43
+ await ctx.config.load();
44
+ // -1 is SQLite's "no limit": the answer names every artifact folder left
45
+ // behind, and a project with sixty runs must not be told about fifty of them.
46
+ const runs = ctx.runs.listByProject(slug, -1);
47
+ const leftOnDisk = [
48
+ ctx.workspacePath(slug),
49
+ ...runs.map((run) => ctx.artifactsDir(run.id)),
50
+ ].filter((path) => existsSync(path));
51
+ return reply.send({ slug, name: entry.name, runsKept: runs.length, leftOnDisk });
52
+ });
11
53
  app.get("/api/projects/:slug/lanes", async (req, reply) => {
12
54
  const { slug } = req.params;
13
55
  const entry = ctx.config.project(slug);
@@ -30,6 +72,13 @@ export async function registerProjectRoutes(app, ctx) {
30
72
  const { slug } = req.params;
31
73
  if (!ctx.config.project(slug))
32
74
  return reply.code(404).send({ error: "Unknown project" });
33
- return ctx.runs.listByProject(slug);
75
+ // The whole line is read once and the positions are looked up in it, rather
76
+ // than asking the database where each of fifty runs stands. The position is
77
+ // the global one: the queue is shared by every project.
78
+ const line = ctx.runs.queued().map((r) => r.id);
79
+ return ctx.runs.listByProject(slug).map((run) => {
80
+ const at = line.indexOf(run.id);
81
+ return { ...run, queuePosition: at === -1 ? null : at + 1 };
82
+ });
34
83
  });
35
84
  }
@@ -0,0 +1,102 @@
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 { glob } from "tinyglobby";
6
+ import { Workspace } from "../../git/workspace.js";
7
+ import { resolvePlatforms } from "../../heuristics/platforms.js";
8
+ import { runChecklist } from "../../heuristics/readiness.js";
9
+ const exec = promisify(execFile);
10
+ /**
11
+ * `bundle check` in the workspace, rejecting with what bundler said.
12
+ *
13
+ * Installs nothing: the checklist reports, it does not act. The timeout is
14
+ * generous because bundler resolves the whole Gemfile.lock, and short enough
15
+ * that a wedged bundler does not hold the request open.
16
+ */
17
+ async function bundleCheck(cwd) {
18
+ try {
19
+ const { stdout } = await exec("bundle", ["check"], { cwd, timeout: 60_000 });
20
+ return stdout.trim();
21
+ }
22
+ catch (cause) {
23
+ const err = cause;
24
+ throw new Error((err.stdout || err.stderr || err.message).trim());
25
+ }
26
+ }
27
+ /** The path of a `fastlane` a run would find, or null. */
28
+ async function findFastlane() {
29
+ try {
30
+ const { stdout } = await exec("which", ["fastlane"], { timeout: 5_000 });
31
+ return stdout.trim() || null;
32
+ }
33
+ catch {
34
+ // A non-zero exit from `which` is the answer "no", not a failure.
35
+ return null;
36
+ }
37
+ }
38
+ /** The globbing `heuristics/platforms.ts` asks for, bound to the workspace. */
39
+ const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
40
+ const exists = async (path) => access(path).then(() => true, () => false);
41
+ export async function registerReadinessRoutes(app, ctx) {
42
+ /**
43
+ * Computed only when asked for.
44
+ *
45
+ * These checks shell out to git and to bundler, so nothing else in the
46
+ * interface may trigger them: the tab asks when it is opened, and when the
47
+ * user presses refresh. Nothing here is cached either — a stale green tick is
48
+ * worse than a red cross, which is why the answer carries the time it was
49
+ * produced.
50
+ */
51
+ app.get("/api/projects/:slug/readiness", async (req, reply) => {
52
+ const { slug } = req.params;
53
+ const entry = ctx.config.project(slug);
54
+ if (!entry)
55
+ return reply.code(404).send({ error: "Unknown project" });
56
+ const workspacePath = ctx.workspacePath(slug);
57
+ const workspace = new Workspace(workspacePath, entry.git_url, entry.git_auth);
58
+ // What each lane calls lives in the repository, so the clone has to exist.
59
+ // A clone that fails is not an error page: it is the reason two of the five
60
+ // checks cannot answer, and the other three still can.
61
+ let unreachable = null;
62
+ try {
63
+ await ctx.ensureWorkspace(slug);
64
+ }
65
+ catch (cause) {
66
+ unreachable = cause.message;
67
+ }
68
+ const resolved = await ctx.config.resolve(slug, workspacePath);
69
+ const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
70
+ const uses = unreachable !== null
71
+ ? { ok: false, reason: unreachable }
72
+ : await ctx
73
+ .uses(slug, workspacePath, fastlaneDir)
74
+ .then((lanes) => ({ ok: true, value: lanes }))
75
+ // Broken Fastfile, no Ruby, no fastlane: all of them are "could not
76
+ // tell", none of them is a 500.
77
+ .catch((cause) => ({ ok: false, reason: cause.message }));
78
+ // What the project builds for decides which half of the checklist applies.
79
+ // `laneyard.yml` answers on its own; without it the workspace is looked at,
80
+ // and an unreachable workspace is a "could not tell" rather than a claim
81
+ // that the repository holds neither an Xcode project nor a Gradle build.
82
+ const configured = resolved?.settings.platforms;
83
+ const platforms = unreachable !== null && (configured === undefined || configured.length === 0)
84
+ ? { ok: false, reason: unreachable }
85
+ : { ok: true, value: await resolvePlatforms(configured, findIn(workspacePath)) };
86
+ const sections = await runChecklist({
87
+ probeRepository: () => workspace.probeRemote(),
88
+ dependencies: {
89
+ workspace: unreachable !== null
90
+ ? { ok: false, reason: unreachable }
91
+ : { ok: true, value: { hasGemfile: await exists(join(workspacePath, "Gemfile")) } },
92
+ bundleCheck: () => bundleCheck(workspacePath),
93
+ findFastlane,
94
+ },
95
+ // Names only: the vault never hands a value to anything but a run.
96
+ secretKeys: ctx.vault.list(slug).map((s) => s.key),
97
+ uses,
98
+ platforms,
99
+ });
100
+ return { checkedAt: new Date().toISOString(), sections };
101
+ });
102
+ }
@@ -1,5 +1,6 @@
1
1
  import { createReadStream } from "node:fs";
2
- import { executeRun } from "../../runner/orchestrate.js";
2
+ /** Statuses a run can still be cancelled from. */
3
+ const CANCELLABLE = ["queued", "preparing", "running"];
3
4
  export async function registerRunRoutes(app, ctx) {
4
5
  app.post("/api/projects/:slug/runs", async (req, reply) => {
5
6
  const { slug } = req.params;
@@ -9,17 +10,6 @@ export async function registerRunRoutes(app, ctx) {
9
10
  return reply.code(404).send({ error: "Unknown project" });
10
11
  if (!body.lane)
11
12
  return reply.code(400).send({ error: "Missing lane" });
12
- // Only one run at a time per project: they share the same git workspace.
13
- // Two concurrent runs would silently trip over each other — one would
14
- // change the commit out from under the other, carry off its artifacts
15
- // and delete its report. The real queue comes at the next milestone;
16
- // this refusal, for its part, already prevents false results.
17
- const last = ctx.runs.listByProject(slug, 1)[0];
18
- if (last && ["queued", "preparing", "running"].includes(last.status)) {
19
- return reply.code(409).send({
20
- error: `Run #${last.id} is still in progress on this project. Wait for it to finish.`,
21
- });
22
- }
23
13
  // We check that the lane genuinely exists before creating a run doomed to fail.
24
14
  try {
25
15
  await ctx.ensureWorkspace(slug);
@@ -38,45 +28,37 @@ export async function registerRunRoutes(app, ctx) {
38
28
  platform: body.platform ?? null,
39
29
  params: body.params ?? {},
40
30
  });
41
- // Launched without waiting: the HTTP response mustn't take as long as a build.
42
- void executeRun({
43
- runId: id,
44
- runs: ctx.runs,
45
- logs: ctx.logs,
46
- workspacePath: ctx.workspacePath(slug),
47
- artifactsDir: ctx.artifactsDir(id),
48
- gitUrl: entry.git_url,
49
- gitAuth: entry.git_auth,
50
- branch: entry.default_branch,
51
- // Resolved after the clone, once the repository's laneyard.yml is finally readable.
52
- resolveSettings: async () => {
53
- const r = await ctx.config.resolve(slug, ctx.workspacePath(slug));
54
- return r.settings;
55
- },
56
- env: process.env,
57
- onChunk: (chunk, offset) => app.broadcastRunChunk?.(id, chunk, offset),
58
- })
59
- .then((r) => ctx.sockets?.finish(id, r.status))
60
- .catch((cause) => {
61
- // Last safety net. `executeRun` commits to never throwing, but a
62
- // rejected promise with no handler brings down the whole Node
63
- // process — and with it, the other runs in progress. The cost of
64
- // forgetting this would be disproportionate.
65
- ctx.runs.finish(id, {
66
- status: "failed",
67
- exitCode: null,
68
- errorSummary: `Unexpected failure: ${cause.message}`,
69
- });
70
- ctx.sockets?.finish(id, "failed");
71
- });
72
- return reply.code(201).send({ id });
31
+ // Read before the queue is woken, so the answer describes the line the
32
+ // caller just joined rather than one the worker has already moved on from.
33
+ const queuePosition = ctx.runs.queuePosition(id);
34
+ // The route creates a row and rings the bell; the worker does the rest.
35
+ app.queue.wake();
36
+ return reply.code(201).send({ id, queuePosition });
73
37
  });
74
38
  app.get("/api/runs/:id", async (req, reply) => {
75
39
  const id = Number(req.params.id);
76
40
  const run = ctx.runs.get(id);
77
41
  if (!run)
78
42
  return reply.code(404).send({ error: "Unknown run" });
79
- return { ...run, steps: ctx.runs.steps(id), artifacts: ctx.runs.artifacts(id) };
43
+ return {
44
+ ...run,
45
+ queuePosition: ctx.runs.queuePosition(id),
46
+ steps: ctx.runs.steps(id),
47
+ artifacts: ctx.runs.artifacts(id),
48
+ };
49
+ });
50
+ app.post("/api/runs/:id/cancel", async (req, reply) => {
51
+ const id = Number(req.params.id);
52
+ const run = ctx.runs.get(id);
53
+ if (!run)
54
+ return reply.code(404).send({ error: "Unknown run" });
55
+ if (!CANCELLABLE.includes(run.status)) {
56
+ return reply.code(409).send({ error: `Run #${id} has already finished` });
57
+ }
58
+ // A queued run is finished on the spot; a running one is signalled and ends
59
+ // a few moments later. Either way the caller has nothing left to wait for.
60
+ app.queue.cancel(id);
61
+ return reply.code(204).send();
80
62
  });
81
63
  app.get("/api/runs/:id/log", async (req, reply) => {
82
64
  const id = Number(req.params.id);
@@ -0,0 +1,51 @@
1
+ import { MIN_LENGTH as MIN_REDACTABLE } from "../../logs/redact.js";
2
+ /** POSIX environment variable names. Anything else would never reach fastlane. */
3
+ const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ export async function registerSecretRoutes(app, ctx) {
5
+ const listRoute = (slug) => slug === null ? ctx.vault.listGlobal() : ctx.vault.list(slug);
6
+ app.get("/api/secrets", async () => listRoute(null));
7
+ app.get("/api/projects/:slug/secrets", async (req, reply) => {
8
+ const { slug } = req.params;
9
+ if (!ctx.config.project(slug))
10
+ return reply.code(404).send({ error: "Unknown project" });
11
+ return listRoute(slug);
12
+ });
13
+ const put = async (slug, key, body, reply) => {
14
+ const { value, masked } = (body ?? {});
15
+ if (!VALID_KEY.test(key)) {
16
+ return reply.code(400).send({
17
+ error: `"${key}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
18
+ });
19
+ }
20
+ if (typeof value !== "string" || value === "") {
21
+ return reply.code(400).send({ error: "A value is required" });
22
+ }
23
+ // Accepting the tick box and quietly not honouring it would leave someone
24
+ // believing they are protected. Refusing is the honest answer.
25
+ if (masked !== false && value.length < MIN_REDACTABLE) {
26
+ return reply.code(400).send({
27
+ error: `A value kept out of the logs must be at least ${MIN_REDACTABLE} characters. ` +
28
+ "Shorter than that, removing it would shred the log without hiding anything. " +
29
+ "Store it unmasked if you accept it appearing in the output.",
30
+ });
31
+ }
32
+ await ctx.vault.set(slug, key, value, masked !== false);
33
+ return reply.code(204).send();
34
+ };
35
+ app.put("/api/secrets/:key", async (req, reply) => put(null, req.params.key, req.body, reply));
36
+ app.put("/api/projects/:slug/secrets/:key", async (req, reply) => {
37
+ const { slug, key } = req.params;
38
+ if (!ctx.config.project(slug))
39
+ return reply.code(404).send({ error: "Unknown project" });
40
+ return put(slug, key, req.body, reply);
41
+ });
42
+ app.delete("/api/secrets/:key", async (req, reply) => {
43
+ const removed = ctx.vault.remove(null, req.params.key);
44
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
45
+ });
46
+ app.delete("/api/projects/:slug/secrets/:key", async (req, reply) => {
47
+ const { slug, key } = req.params;
48
+ const removed = ctx.vault.remove(slug, key);
49
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
50
+ });
51
+ }