laneyard 0.3.0 → 0.4.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 (44) hide show
  1. package/README.md +186 -18
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/credentials/kinds.js +120 -0
  8. package/dist/src/db/credentials.js +75 -0
  9. package/dist/src/db/schema.sql +39 -0
  10. package/dist/src/db/secrets.js +28 -0
  11. package/dist/src/db/sessions.js +61 -0
  12. package/dist/src/git/workspace.js +32 -6
  13. package/dist/src/heuristics/android-root.js +49 -0
  14. package/dist/src/heuristics/android-signing.js +98 -0
  15. package/dist/src/heuristics/appfile.js +60 -0
  16. package/dist/src/heuristics/blocking-actions.js +2 -0
  17. package/dist/src/heuristics/env-example.js +36 -0
  18. package/dist/src/heuristics/platforms.js +69 -10
  19. package/dist/src/heuristics/readiness.js +554 -25
  20. package/dist/src/main.js +10 -1
  21. package/dist/src/runner/gradle-properties.js +187 -0
  22. package/dist/src/runner/materialise.js +92 -0
  23. package/dist/src/runner/orchestrate.js +91 -2
  24. package/dist/src/secrets/vault.js +106 -5
  25. package/dist/src/server/app.js +30 -3
  26. package/dist/src/server/auth.js +50 -10
  27. package/dist/src/server/permissions.js +2 -0
  28. package/dist/src/server/required-secrets.js +30 -0
  29. package/dist/src/server/routes/account.js +57 -0
  30. package/dist/src/server/routes/credentials.js +108 -0
  31. package/dist/src/server/routes/readiness.js +162 -6
  32. package/dist/src/server/routes/secrets.js +101 -0
  33. package/dist/src/sidecar/bridge.js +21 -1
  34. package/dist/src/sidecar/fastlane-dir.js +23 -0
  35. package/dist/src/sidecar/lanes.js +6 -0
  36. package/dist/src/sidecar/uses.js +21 -5
  37. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
  38. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  39. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  40. package/dist/web/index.html +2 -2
  41. package/package.json +1 -1
  42. package/ruby/introspect.rb +122 -9
  43. package/dist/web/assets/index-De6sxx6G.css +0 -1
  44. package/dist/web/assets/index-TWRQ1cJg.js +0 -62
@@ -6,14 +6,18 @@ import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { LEGACY_ADMIN_NAME } from "../config/load.js";
8
8
  import { RunStore } from "../db/runs.js";
9
+ import { SessionRecords } from "../db/sessions.js";
9
10
  import { Workspace } from "../git/workspace.js";
10
11
  import { LogStore } from "../logs/store.js";
12
+ import { materialiseCredentials } from "../runner/materialise.js";
11
13
  import { executeRun } from "../runner/orchestrate.js";
12
14
  import { RunQueue } from "../runner/queue.js";
13
- import { authenticate, LoginThrottle, SESSION_COOKIE, SessionStore } from "./auth.js";
15
+ import { authenticate, LoginThrottle, COOKIE_OPTIONS, SESSION_COOKIE, SessionStore } from "./auth.js";
14
16
  import { requiresAdmin } from "./permissions.js";
17
+ import { registerCredentialRoutes } from "./routes/credentials.js";
15
18
  import { registerFastfileRoutes } from "./routes/fastfile.js";
16
19
  import { registerProjectRoutes } from "./routes/projects.js";
20
+ import { registerAccountRoutes } from "./routes/account.js";
17
21
  import { registerReadinessRoutes } from "./routes/readiness.js";
18
22
  import { registerRunRoutes } from "./routes/runs.js";
19
23
  import { registerSecretRoutes } from "./routes/secrets.js";
@@ -30,9 +34,10 @@ export async function buildApp(deps) {
30
34
  ...deps,
31
35
  runs: new RunStore(deps.db),
32
36
  logs: new LogStore(join(deps.root, "logs")),
33
- sessions: new SessionStore(),
37
+ sessions: new SessionStore(new SessionRecords(deps.db)),
34
38
  workspacePath,
35
39
  artifactsDir: (runId) => join(deps.root, "artifacts", String(runId)),
40
+ runSecretsDir: (runId) => join(deps.root, "runs", String(runId), "secrets"),
36
41
  ensureWorkspace: async (slug) => {
37
42
  const entry = deps.config.project(slug);
38
43
  if (!entry)
@@ -61,6 +66,23 @@ export async function buildApp(deps) {
61
66
  });
62
67
  return;
63
68
  }
69
+ // Before the run, and outside it: `executeRun` is handed plaintext and
70
+ // never the vault, a boundary worth more than the convenience of moving
71
+ // this one call inside. A block that will not decrypt stops the run here,
72
+ // with the reason, rather than producing an artifact signed by nothing.
73
+ let credentials;
74
+ try {
75
+ credentials = await materialiseCredentials(ctx.vault, slug, ctx.runSecretsDir(runId));
76
+ }
77
+ catch (cause) {
78
+ ctx.runs.finish(runId, {
79
+ status: "failed",
80
+ exitCode: null,
81
+ errorSummary: ctx.vault.scrub(slug, cause.message),
82
+ });
83
+ ctx.sockets?.finish(runId, "failed");
84
+ return;
85
+ }
64
86
  await executeRun({
65
87
  runId,
66
88
  runs: ctx.runs,
@@ -77,6 +99,9 @@ export async function buildApp(deps) {
77
99
  },
78
100
  env: process.env,
79
101
  secrets: ctx.vault.resolve(slug),
102
+ credentialEnv: credentials.env,
103
+ androidKeystore: credentials.keystore,
104
+ cleanup: credentials.cleanup,
80
105
  maskedValues: ctx.vault.maskedValues(slug),
81
106
  signal,
82
107
  onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
@@ -130,7 +155,7 @@ export async function buildApp(deps) {
130
155
  throttle.recordSuccess(account);
131
156
  const token = ctx.sessions.issue(identity);
132
157
  return reply
133
- .setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
158
+ .setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
134
159
  .send({ ok: true, name: identity.name, role: identity.role });
135
160
  });
136
161
  // Every /api route except /api/login requires a session, and the routes on
@@ -180,9 +205,11 @@ export async function buildApp(deps) {
180
205
  await registerProjectRoutes(app, ctx);
181
206
  await registerRunRoutes(app, ctx);
182
207
  await registerSecretRoutes(app, ctx);
208
+ await registerCredentialRoutes(app, ctx);
183
209
  await registerReadinessRoutes(app, ctx);
184
210
  await registerFastfileRoutes(app, ctx);
185
211
  await registerUserRoutes(app, ctx);
212
+ await registerAccountRoutes(app, ctx);
186
213
  // Resolved from the module's location, not from the data folder:
187
214
  // `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
188
215
  // relative positions, depending on whether we're running on the sources
@@ -65,23 +65,47 @@ export async function authenticate(users, name, password) {
65
65
  return null;
66
66
  return { name: user.name, role: user.role };
67
67
  }
68
- /** In-memory sessions: they don't survive a restart, and that's just fine. */
68
+ /**
69
+ * How long a session lasts, and how long the cookie is kept for.
70
+ *
71
+ * Thirty days, not forever: a browser left on a desk is the reason the password
72
+ * change asks for the current password, and a session that never ended would
73
+ * make that precaution pointless. The cookie carries the same figure, so the
74
+ * browser stops presenting a token the server would refuse anyway.
75
+ */
76
+ export const SESSION_TTL_DAYS = 30;
77
+ export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
78
+ /**
79
+ * Sessions, kept in the database so they outlive a restart.
80
+ *
81
+ * They used to be a Map, with a comment saying they did not survive a restart
82
+ * "and that's just fine". It was not: restarting the server to pick up an edit
83
+ * to config.yml signed everybody out, which on a machine still being set up is
84
+ * several times an hour. The API is unchanged — every caller still issues, gets
85
+ * and revokes — only the shelf underneath is different.
86
+ */
69
87
  export class SessionStore {
70
- tokens = new Map();
71
- issue(identity) {
88
+ records;
89
+ constructor(records) {
90
+ this.records = records;
91
+ }
92
+ issue(identity, now = new Date()) {
72
93
  const token = randomBytes(32).toString("hex");
73
- this.tokens.set(token, identity);
94
+ this.records.insert(token, identity, new Date(now.getTime() + SESSION_TTL_MS));
74
95
  return token;
75
96
  }
76
97
  /** Who the token belongs to, or undefined if it belongs to nobody. */
77
98
  get(token) {
78
- return token === undefined ? undefined : this.tokens.get(token);
99
+ if (token === undefined)
100
+ return undefined;
101
+ const owner = this.records.find(token);
102
+ return owner ? { name: owner.name, role: owner.role } : undefined;
79
103
  }
80
104
  valid(token) {
81
105
  return this.get(token) !== undefined;
82
106
  }
83
107
  revoke(token) {
84
- this.tokens.delete(token);
108
+ this.records.remove(token);
85
109
  }
86
110
  /**
87
111
  * Drops every session belonging to a name.
@@ -93,13 +117,29 @@ export class SessionStore {
93
117
  * every interface that offers the first implies the second.
94
118
  */
95
119
  revokeAllFor(name) {
96
- for (const [token, identity] of this.tokens) {
97
- if (identity.name === name)
98
- this.tokens.delete(token);
99
- }
120
+ this.records.removeAllFor(name);
100
121
  }
101
122
  }
102
123
  export const SESSION_COOKIE = "laneyard_session";
124
+ /**
125
+ * How the session cookie is written, in one place so the two routes that issue
126
+ * one cannot disagree.
127
+ *
128
+ * `maxAge` is the half that was missing: without it the browser treats this as
129
+ * a session cookie and forgets it the moment the window closes, so signing in
130
+ * again was the price of quitting Chrome. It matches the server's own TTL, so
131
+ * the browser stops presenting a token the server would refuse anyway.
132
+ *
133
+ * Not `secure`: Laneyard is commonly reached over plain HTTP on a local
134
+ * network, and a cookie the browser refuses to send is a login page that never
135
+ * goes away.
136
+ */
137
+ export const COOKIE_OPTIONS = {
138
+ path: "/",
139
+ httpOnly: true,
140
+ sameSite: "lax",
141
+ maxAge: SESSION_TTL_MS / 1000,
142
+ };
103
143
  /** Past this many tracked names, the entries that no longer delay anyone go. */
104
144
  const THROTTLE_PRUNE_ABOVE = 1_000;
105
145
  /**
@@ -20,6 +20,8 @@
20
20
  export const REQUIRES_ADMIN = [
21
21
  { method: "*", path: "/api/secrets" },
22
22
  { method: "*", path: "/api/projects/:slug/secrets" },
23
+ { method: "*", path: "/api/credentials" },
24
+ { method: "*", path: "/api/projects/:slug/credentials" },
23
25
  { method: "PUT", path: "/api/projects/:slug/fastfile" },
24
26
  { method: "POST", path: "/api/projects/:slug/commit" },
25
27
  { method: "POST", path: "/api/projects/:slug/push" },
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parseEnvExample } from "../heuristics/env-example.js";
4
+ export async function requiredSecrets(input) {
5
+ const fromExample = await envExampleNames(input.workspacePath, input.fastlaneDir);
6
+ const required = [
7
+ ...new Set([...input.lanes.flatMap((l) => l.env ?? []), ...input.declared, ...fromExample]),
8
+ ].sort();
9
+ const supplied = new Set([...input.vaultKeys, ...input.blockNames, ...input.serverEnv]);
10
+ return {
11
+ required,
12
+ missing: required.filter((name) => !supplied.has(name)),
13
+ };
14
+ }
15
+ /**
16
+ * The names a committed `.env.example` advertises, if there is one.
17
+ *
18
+ * Two conventional filenames and no more: a convenience for the common case,
19
+ * not a search. Never throws — an absent file is the ordinary situation and
20
+ * means "nothing declared", not a failure.
21
+ */
22
+ export async function envExampleNames(workspacePath, fastlaneDir) {
23
+ const names = [];
24
+ for (const file of [".env.example", ".env.sample"]) {
25
+ const text = await readFile(join(workspacePath, fastlaneDir, file), "utf8").catch(() => null);
26
+ if (text !== null)
27
+ names.push(...parseEnvExample(text));
28
+ }
29
+ return names;
30
+ }
@@ -0,0 +1,57 @@
1
+ import { MIN_PASSWORD_LENGTH, upsertUserInConfig } from "../../config/accounts.js";
2
+ import { COOKIE_OPTIONS, SESSION_COOKIE, authenticate } from "../auth.js";
3
+ /**
4
+ * What someone may do to their own account, whatever their role.
5
+ *
6
+ * Deliberately not under `/api/users`: that prefix is on the admin list in
7
+ * `permissions.ts`, and it means "the accounts on this machine". This is the
8
+ * other thing entirely — one person, their own password — and a builder must be
9
+ * able to reach it. Changing the password of somebody *else* stays admin-only,
10
+ * and stays over there.
11
+ *
12
+ * The current password is required even though the session already proves who
13
+ * this is. A session is a cookie in a browser that may have been left open on a
14
+ * desk; a password is the one thing that says the person at the keyboard is
15
+ * still the person who signed in.
16
+ */
17
+ export async function registerAccountRoutes(app, ctx) {
18
+ app.post("/api/account/password", async (req, reply) => {
19
+ // Non-null: the hook in `app.ts` rejected every request without a session
20
+ // before this handler could run.
21
+ const { name, role } = req.identity;
22
+ const { current, next } = (req.body ?? {});
23
+ if (typeof next !== "string" || next.length < MIN_PASSWORD_LENGTH) {
24
+ return reply
25
+ .code(400)
26
+ .send({ error: `A password is at least ${MIN_PASSWORD_LENGTH} characters.` });
27
+ }
28
+ const users = ctx.config.server()?.users ?? [];
29
+ if (typeof current !== "string" || !(await authenticate(users, name, current))) {
30
+ // No throttle here, unlike `/api/login`: reaching this route already costs
31
+ // a valid session, so there is nobody to slow down who is not already in.
32
+ return reply.code(401).send({ error: "That is not your current password." });
33
+ }
34
+ if (next === current) {
35
+ return reply.code(400).send({ error: "That is the password you already have." });
36
+ }
37
+ // The role is carried over rather than read from the body: this route
38
+ // changes a password and nothing else, and an account that could hand
39
+ // itself `admin` in a field named `role` would make the whole permission
40
+ // table decorative.
41
+ await upsertUserInConfig(ctx.config.configPath(), { name, role, password: next });
42
+ // Every session this account has, gone — including this one. That is the
43
+ // point: changing a password is what someone does when they think another
44
+ // browser somewhere should stop being signed in. A fresh session is issued
45
+ // immediately afterwards, so the person who just did it is not signed out
46
+ // of the page they did it on.
47
+ ctx.sessions.revokeAllFor(name);
48
+ const token = ctx.sessions.issue({ name, role });
49
+ // The file is watched on a debounce; loading here is what makes the very
50
+ // next request see the new hash instead of the old one.
51
+ await ctx.config.load();
52
+ return reply
53
+ .setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
54
+ .code(204)
55
+ .send();
56
+ });
57
+ }
@@ -0,0 +1,108 @@
1
+ import { CREDENTIAL_KINDS, defaultVarNames, fieldsOf } from "../../credentials/kinds.js";
2
+ /**
3
+ * Signing blocks over HTTP: the file and the fields that make it usable.
4
+ *
5
+ * The file arrives base64 inside a JSON body rather than as a multipart upload.
6
+ * No multipart plugin is registered — see `app.ts` — and a `.p8` is two
7
+ * kilobytes, so adding a dependency to carry it would be paying in supply chain
8
+ * for a convenience the browser can provide in one `FileReader` call.
9
+ *
10
+ * A block is taken or refused whole. That is what makes it a block rather than
11
+ * three loose rows: a keystore stored without its alias is not a partial
12
+ * success, it is a build that fails in a month with an unusable artifact.
13
+ */
14
+ /** POSIX environment variable names. Anything else would never reach fastlane. */
15
+ const VALID_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+ export async function registerCredentialRoutes(app, ctx) {
17
+ app.get("/api/credentials", async () => ctx.vault.listGlobalCredentials());
18
+ app.get("/api/projects/:slug/credentials", async (req, reply) => {
19
+ const { slug } = req.params;
20
+ if (!ctx.config.project(slug))
21
+ return reply.code(404).send({ error: "Unknown project" });
22
+ return ctx.vault.listCredentials(slug);
23
+ });
24
+ const put = async (slug, kind, body, reply) => {
25
+ const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
26
+ if (!spec) {
27
+ return reply.code(400).send({
28
+ error: `"${kind}" is not a kind of credential Laneyard knows: ${CREDENTIAL_KINDS.map((k) => k.kind).join(", ")}.`,
29
+ });
30
+ }
31
+ const { fileName, fileBase64, fields, varNames } = (body ?? {});
32
+ if (typeof fileName !== "string" || fileName === "") {
33
+ return reply.code(400).send({ error: "A file name is required" });
34
+ }
35
+ if (typeof fileBase64 !== "string" || fileBase64 === "") {
36
+ return reply.code(400).send({ error: `The ${spec.what} file is required, base64-encoded.` });
37
+ }
38
+ const given = (fields ?? {});
39
+ if (typeof given !== "object" || Array.isArray(given)) {
40
+ return reply.code(400).send({ error: "`fields` is an object of name to value." });
41
+ }
42
+ // Optional fields are the settings a block may leave unanswered — where a
43
+ // gradle properties file goes, what it is read under. Laneyard is allowed to
44
+ // ask; refusing the whole block over an unanswered one would be requiring.
45
+ const missing = fieldsOf(spec.kind)
46
+ .filter((f) => !f.optional && (typeof given[f.name] !== "string" || given[f.name] === ""))
47
+ .map((f) => f.name);
48
+ if (missing.length > 0) {
49
+ return reply.code(400).send({
50
+ error: `A ${spec.what} needs ${missing.join(", ")}. Without it the file alone signs nothing.`,
51
+ });
52
+ }
53
+ // Only the fields the kind declares are kept. An extra one would be stored,
54
+ // never read, and quietly disagree with what the block claims to be.
55
+ const kept = {};
56
+ for (const field of fieldsOf(spec.kind)) {
57
+ const value = given[field.name];
58
+ // An unanswered optional field is absent from the block rather than
59
+ // stored empty: the reader then falls back to what it would have used
60
+ // anyway, instead of taking "" for an answer someone gave.
61
+ if (typeof value === "string" && value !== "")
62
+ kept[field.name] = value;
63
+ }
64
+ const names = { ...defaultVarNames(spec.kind), ...(varNames ?? {}) };
65
+ for (const [slot, name] of Object.entries(names)) {
66
+ if (!(slot in defaultVarNames(spec.kind))) {
67
+ return reply.code(400).send({ error: `A ${spec.what} exports nothing called "${slot}".` });
68
+ }
69
+ if (typeof name !== "string" || !VALID_VAR.test(name)) {
70
+ return reply.code(400).send({
71
+ error: `"${name}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
72
+ });
73
+ }
74
+ }
75
+ // Node's base64 decoder ignores what it cannot read rather than refusing, so
76
+ // a truncated or mistyped upload would be stored as a shorter file and only
77
+ // surface as an unreadable key at signing time.
78
+ const fileBytes = Buffer.from(fileBase64, "base64");
79
+ if (fileBytes.length === 0 || fileBytes.toString("base64").replace(/=+$/, "") !== fileBase64.replace(/=+$/, "")) {
80
+ return reply.code(400).send({ error: "The file is not valid base64." });
81
+ }
82
+ await ctx.vault.setCredential(slug, spec.kind, { fileName, fileBytes, fields: kept, varNames: names });
83
+ return reply.code(204).send();
84
+ };
85
+ app.put("/api/credentials/:kind", async (req, reply) => put(null, req.params.kind, req.body, reply));
86
+ app.put("/api/projects/:slug/credentials/:kind", async (req, reply) => {
87
+ const { slug, kind } = req.params;
88
+ if (!ctx.config.project(slug))
89
+ return reply.code(404).send({ error: "Unknown project" });
90
+ return put(slug, kind, req.body, reply);
91
+ });
92
+ app.delete("/api/credentials/:kind", async (req, reply) => {
93
+ const { kind } = req.params;
94
+ const removed = ctx.vault.removeCredential(null, kind);
95
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
96
+ });
97
+ /**
98
+ * Removes this project's own block, and only that one. A global block that
99
+ * was shadowed comes back into view, which is the deletion someone asked for:
100
+ * they are undoing an override, not deleting everyone's key from inside one
101
+ * project.
102
+ */
103
+ app.delete("/api/projects/:slug/credentials/:kind", async (req, reply) => {
104
+ const { slug, kind } = req.params;
105
+ const removed = ctx.vault.removeCredential(slug, kind);
106
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
107
+ });
108
+ }
@@ -1,11 +1,16 @@
1
1
  import { execFile } from "node:child_process";
2
- import { access } from "node:fs/promises";
2
+ import { access, readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { glob } from "tinyglobby";
6
6
  import { Workspace } from "../../git/workspace.js";
7
- import { resolvePlatforms } from "../../heuristics/platforms.js";
7
+ import { NO_APPFILE, parseAppfile } from "../../heuristics/appfile.js";
8
+ import { findAndroidBuild } from "../../heuristics/android-root.js";
9
+ import { exportedVarNames } from "../../credentials/kinds.js";
10
+ import { envExampleNames } from "../required-secrets.js";
11
+ import { appRootOf, resolvePlatforms, searchDir } from "../../heuristics/platforms.js";
8
12
  import { runChecklist } from "../../heuristics/readiness.js";
13
+ import { LANEYARD_MARKER, propertyNames } from "../../runner/gradle-properties.js";
9
14
  const exec = promisify(execFile);
10
15
  /**
11
16
  * `bundle check` in the workspace, rejecting with what bundler said.
@@ -35,9 +40,100 @@ async function findFastlane() {
35
40
  return null;
36
41
  }
37
42
  }
38
- /** The globbing `heuristics/platforms.ts` asks for, bound to the workspace. */
43
+ /** The globbing `heuristics/platforms.ts` asks for, bound to a directory. */
39
44
  const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
40
45
  const exists = async (path) => access(path).then(() => true, () => false);
46
+ /**
47
+ * Is this the user's own properties file — as opposed to one Laneyard wrote
48
+ * and failed to clean up?
49
+ *
50
+ * `gradle-properties.ts` marks every file it writes with `LANEYARD_MARKER` as
51
+ * its first line, precisely so this check can tell the two apart. A run killed
52
+ * between writing the file and reaching its `finally` leaves a marked one
53
+ * behind in the persistent clone; counting it as the user's own signing
54
+ * configuration would flip the checklist from "the release build will use the
55
+ * debug key" to "the release key is used" — a green verdict Laneyard
56
+ * manufactured for itself out of a cleanup it failed to run.
57
+ *
58
+ * Reading fails the same way `exists` does: a missing file, a permission
59
+ * error, or one this process cannot open are all "not the user's file",
60
+ * because none of them is evidence of a real signing configuration.
61
+ */
62
+ async function isUsersOwn(path) {
63
+ const text = await readFile(path, "utf8").catch(() => null);
64
+ if (text === null)
65
+ return false;
66
+ return (text.split("\n")[0] ?? "").trimEnd() !== LANEYARD_MARKER;
67
+ }
68
+ /**
69
+ * Is the properties file where the build script would look for it — and is it
70
+ * the user's, not Laneyard's own leftover?
71
+ *
72
+ * The module directory is the one holding the build script, and the Gradle root
73
+ * is its parent — `android/` for an `android/app/build.gradle`. Which of the two
74
+ * the name is relative to is the script's decision, and the parser reports which
75
+ * one it made. When it could not tell, both are looked in: answering "not in the
76
+ * clone" because the wrong directory was searched would have the checklist
77
+ * inventing the very failure it exists to catch. Both places apply the same
78
+ * marker rule — a leftover in either one is still Laneyard's, not the user's.
79
+ */
80
+ async function isPresent(build, file) {
81
+ const { moduleDir, gradleRoot } = build;
82
+ const places = file.scope === "root"
83
+ ? [gradleRoot]
84
+ : file.scope === "module"
85
+ ? [moduleDir]
86
+ : [gradleRoot, moduleDir];
87
+ const found = await Promise.all(places.map((dir) => isUsersOwn(join(dir, file.name))));
88
+ return found.some(Boolean);
89
+ }
90
+ /**
91
+ * What the android build script says about release signing, and whether the
92
+ * file it depends on is in the clone.
93
+ *
94
+ * The listing is the caller's half of the answer, as everywhere else here: the
95
+ * check reads text and reaches for nothing. Which script speaks for the android
96
+ * side is `heuristics/android-root.ts`'s decision rather than this file's,
97
+ * because the runner writes the properties file against that same decision — see
98
+ * that module for why the two must not be able to disagree.
99
+ */
100
+ async function androidSigning(workspacePath, appRoot, unreachable) {
101
+ if (unreachable !== null) {
102
+ return { androidSigning: { ok: false, reason: unreachable }, signingFilePresent: false };
103
+ }
104
+ const build = await findAndroidBuild(join(workspacePath, appRoot));
105
+ if (build === null) {
106
+ return {
107
+ androidSigning: { ok: false, reason: "no android build.gradle found in the clone" },
108
+ signingFilePresent: false,
109
+ };
110
+ }
111
+ const present = build.facts.conditionalOn === null ? false : await isPresent(build, build.facts.conditionalOn);
112
+ return { androidSigning: { ok: true, value: build.facts }, signingFilePresent: present };
113
+ }
114
+ /**
115
+ * What the keystore block says about the properties file, and nothing else.
116
+ *
117
+ * The block has to be decrypted to be asked — `property_names` and
118
+ * `properties_path` are stored with the passphrases — so the narrowing happens
119
+ * here, at the last point that touches plaintext. What crosses into the
120
+ * checklist is two settings a browser is already shown on the block's own form.
121
+ *
122
+ * A block that will not decrypt is not an error page: it is a keystore the
123
+ * checklist cannot speak for, and `credentials` already reports that separately.
124
+ */
125
+ function keystoreSetting(vault, slug) {
126
+ try {
127
+ const block = vault.resolveCredential(slug, "android_keystore");
128
+ if (!block)
129
+ return null;
130
+ const path = (block.fields["properties_path"] ?? "").trim();
131
+ return { propertyNames: propertyNames(block.fields), propertiesPath: path === "" ? null : path };
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
41
137
  export async function registerReadinessRoutes(app, ctx) {
42
138
  /**
43
139
  * Computed only when asked for.
@@ -67,14 +163,30 @@ export async function registerReadinessRoutes(app, ctx) {
67
163
  }
68
164
  const resolved = await ctx.config.resolve(slug, workspacePath);
69
165
  const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
70
- const uses = unreachable !== null
166
+ const read = unreachable !== null
71
167
  ? { ok: false, reason: unreachable }
72
168
  : await ctx
73
169
  .uses(slug, workspacePath, fastlaneDir)
74
- .then((lanes) => ({ ok: true, value: lanes }))
170
+ .then((value) => ({ ok: true, value }))
75
171
  // Broken Fastfile, no Ruby, no fastlane: all of them are "could not
76
172
  // tell", none of them is a 500.
77
173
  .catch((cause) => ({ ok: false, reason: cause.message }));
174
+ const uses = read.ok
175
+ ? { ok: true, value: read.value.lanes }
176
+ : { ok: false, reason: read.reason };
177
+ // What reading the Fastfile could not account for. `fastlane/actions` is the
178
+ // caller's half of the answer — a directory listing, not something the Ruby
179
+ // parser could know — and the sidecar supplies the other, `import`. A check
180
+ // that would conclude something from finding nothing consults this first.
181
+ const unread = read.ok
182
+ ? {
183
+ ok: true,
184
+ value: {
185
+ imports: read.value.imports,
186
+ customActions: await exists(join(workspacePath, fastlaneDir, "actions")),
187
+ },
188
+ }
189
+ : { ok: false, reason: read.reason };
78
190
  // What the project builds for decides which half of the checklist applies.
79
191
  // `laneyard.yml` answers on its own; without it the workspace is looked at,
80
192
  // and an unreachable workspace is a "could not tell" rather than a claim
@@ -82,7 +194,29 @@ export async function registerReadinessRoutes(app, ctx) {
82
194
  const configured = resolved?.settings.platforms;
83
195
  const platforms = unreachable !== null && (configured === undefined || configured.length === 0)
84
196
  ? { ok: false, reason: unreachable }
85
- : { ok: true, value: await resolvePlatforms(configured, findIn(workspacePath)) };
197
+ : {
198
+ ok: true,
199
+ // Beside the Fastfile rather than at the repository root: in a
200
+ // monorepo the app is one directory down, and so are its platform
201
+ // folders.
202
+ value: await resolvePlatforms(configured, findIn(searchDir(workspacePath, appRootOf(fastlaneDir)))),
203
+ };
204
+ // The Appfile is fastlane's own file, beside the Fastfile, and it is where a
205
+ // project configured long before it met Laneyard keeps its Play Store
206
+ // service account. An absent one is a fact — `NO_APPFILE` — not a failure;
207
+ // an unreachable workspace is the only reason this cannot be answered.
208
+ const appfile = unreachable !== null
209
+ ? { ok: false, reason: unreachable }
210
+ : await readFile(join(workspacePath, fastlaneDir, "Appfile"), "utf8").then((text) => ({ ok: true, value: parseAppfile(text) }),
211
+ // Missing, unreadable, a directory: all of them mean the same to a
212
+ // check, which is that the Appfile says nothing.
213
+ () => ({ ok: true, value: NO_APPFILE }));
214
+ // Listed the same way platforms are, from the clone rather than from any
215
+ // path a Fastfile mentions: what is asked is "does the repository carry a
216
+ // key", which is a question about the repository.
217
+ const keyFilesInRepo = unreachable !== null
218
+ ? { ok: false, reason: unreachable }
219
+ : await glob(["**/*.p8"], { cwd: workspacePath, onlyFiles: true, dot: true }).then((found) => ({ ok: true, value: found.sort() }), (cause) => ({ ok: false, reason: cause.message }));
86
220
  const sections = await runChecklist({
87
221
  probeRepository: () => workspace.probeRemote(),
88
222
  dependencies: {
@@ -94,8 +228,30 @@ export async function registerReadinessRoutes(app, ctx) {
94
228
  },
95
229
  // Names only: the vault never hands a value to anything but a run.
96
230
  secretKeys: ctx.vault.list(slug).map((s) => s.key),
231
+ // Which blocks apply, resolved the way a run resolves them — a project's
232
+ // own shadowing a global one — so the checklist and the run cannot
233
+ // disagree about whether a credential exists.
234
+ blocks: ctx.vault.listCredentials(slug).map((c) => c.kind),
235
+ // And the names those blocks will export, which the environment check
236
+ // counts as supplied — Laneyard writes the file and sets the variable
237
+ // itself, so a lane reading it is not a lane short of anything. The same
238
+ // list the secrets screen is given, from the same call.
239
+ blockNames: exportedVarNames(ctx.vault.listCredentials(slug)),
240
+ keystore: keystoreSetting(ctx.vault, slug),
97
241
  uses,
98
242
  platforms,
243
+ appfile,
244
+ keyFilesInRepo,
245
+ unread,
246
+ // A run inherits the server's environment, so a variable exported where
247
+ // Laneyard was started really is available to a lane. Names only: a
248
+ // checklist has no business reading a value, here least of all.
249
+ serverEnv: Object.keys(process.env),
250
+ ...(await androidSigning(workspacePath, appRootOf(fastlaneDir), unreachable)),
251
+ declaredSecrets: [
252
+ ...(resolved?.settings.required_secrets ?? []),
253
+ ...(await envExampleNames(workspacePath, fastlaneDir)),
254
+ ],
99
255
  });
100
256
  return { checkedAt: new Date().toISOString(), sections };
101
257
  });