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
@@ -1,6 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { parse as parseYaml } from "yaml";
3
3
  import { repoConfigSchema, serverConfigSchema } from "./schema.js";
4
+ /**
5
+ * The name a lone `password_hash` is read under.
6
+ *
7
+ * Also the name the legacy `{ password }` login form authenticates as, which is
8
+ * what lets a 0.2 installation upgrade without anyone editing a file.
9
+ */
10
+ export const LEGACY_ADMIN_NAME = "admin";
4
11
  /** Reads and validates a YAML file. Never fails by throwing: the caller decides. */
5
12
  async function loadYamlFile(path, schema) {
6
13
  let raw;
@@ -26,10 +33,59 @@ async function loadYamlFile(path, schema) {
26
33
  }
27
34
  return { ok: true, config: parsed.data };
28
35
  }
36
+ /**
37
+ * Folds both ways of declaring accounts into the one the rest of the code sees.
38
+ *
39
+ * Every refusal here is a locked room avoided: a server with no account, or
40
+ * with none that can administer it, is one nobody can fix from the interface.
41
+ */
42
+ function normaliseUsers(server) {
43
+ const { password_hash, users } = server;
44
+ if (password_hash !== undefined && users !== undefined) {
45
+ return {
46
+ ok: false,
47
+ error: "server.password_hash and server.users both set — they are two ways to say the same " +
48
+ "thing and there is no obvious winner. Keep server.users and drop server.password_hash.",
49
+ };
50
+ }
51
+ if (password_hash !== undefined) {
52
+ return {
53
+ ok: true,
54
+ users: [{ name: LEGACY_ADMIN_NAME, role: "admin", password_hash }],
55
+ };
56
+ }
57
+ if (users === undefined) {
58
+ return {
59
+ ok: false,
60
+ error: "server.users is missing — without an account nobody can log in.",
61
+ };
62
+ }
63
+ if (users.length === 0) {
64
+ return { ok: false, error: "server.users is empty — declare at least one admin account." };
65
+ }
66
+ const seen = new Set();
67
+ for (const u of users) {
68
+ if (seen.has(u.name)) {
69
+ return { ok: false, error: `duplicate user: ${u.name}` };
70
+ }
71
+ seen.add(u.name);
72
+ }
73
+ if (!users.some((u) => u.role === "admin")) {
74
+ return {
75
+ ok: false,
76
+ error: "server.users has no admin — a server nobody can administer is a locked room.",
77
+ };
78
+ }
79
+ return { ok: true, users };
80
+ }
29
81
  export async function loadServerConfig(path) {
30
82
  const res = await loadYamlFile(path, serverConfigSchema);
31
83
  if (!res.ok)
32
84
  return res;
85
+ const accounts = normaliseUsers(res.config.server);
86
+ if (!accounts.ok) {
87
+ return { ok: false, error: `Invalid configuration in ${path} — ${accounts.error}` };
88
+ }
33
89
  const seen = new Set();
34
90
  for (const p of res.config.projects) {
35
91
  if (seen.has(p.slug)) {
@@ -39,7 +95,11 @@ export async function loadServerConfig(path) {
39
95
  }
40
96
  // The display name falls back to the slug rather than being optional everywhere downstream.
41
97
  const projects = res.config.projects.map((p) => ({ ...p, name: p.name ?? p.slug }));
42
- return { ok: true, config: { ...res.config, projects } };
98
+ // `password_hash` is dropped rather than carried along: it has been folded
99
+ // into `users`, and leaving it in place would give downstream code a second
100
+ // source of truth to disagree with.
101
+ const { password_hash: _dropped, ...server } = res.config.server;
102
+ return { ok: true, config: { server: { ...server, users: accounts.users }, projects } };
43
103
  }
44
104
  export async function loadRepoConfig(path) {
45
105
  return loadYamlFile(path, repoConfigSchema);
@@ -7,6 +7,11 @@ export const projectSettingsSchema = z.object({
7
7
  interactive_default: z.boolean().default(false),
8
8
  artifact_globs: z.array(z.string()).default([]),
9
9
  required_secrets: z.array(z.string()).default([]),
10
+ // What this project builds for. No default: absent means "nobody said", and
11
+ // the readiness checklist falls back to looking at the repository. Setting it
12
+ // is how a repository that happens to carry an Xcode project it never builds
13
+ // stops being asked for App Store Connect credentials.
14
+ platforms: z.array(z.enum(["ios", "android"])).optional(),
10
15
  retention: z
11
16
  .object({
12
17
  runs: z.number().int().positive(),
@@ -27,8 +32,11 @@ export const projectEntrySchema = projectSettingsInputSchema.extend({
27
32
  default_branch: z.string().default("main"),
28
33
  git_auth: z
29
34
  .object({
30
- kind: z.enum(["none", "ssh_key", "token"]),
31
- /** File path if kind is ssh_key, secret name if kind is token. */
35
+ // `token` is not accepted yet. The workspace only knows how to use an SSH
36
+ // key, so accepting it would leave a project configured for something that
37
+ // silently never happens — worse than a clear refusal at load time.
38
+ kind: z.enum(["none", "ssh_key"]),
39
+ /** Path to the SSH key file. */
32
40
  ref: z.string().optional(),
33
41
  })
34
42
  .default({ kind: "none" }),
@@ -36,12 +44,47 @@ export const projectEntrySchema = projectSettingsInputSchema.extend({
36
44
  notify_browser: z.boolean().default(true),
37
45
  webhook_url: z.string().optional(),
38
46
  });
47
+ /**
48
+ * Two roles, and only two.
49
+ *
50
+ * A third is easy to add and impossible to remove. These two cover the case
51
+ * that prompted them — someone who ships without being trusted with the
52
+ * signing chain — and two can be held in a reader's head.
53
+ */
54
+ export const userRoleSchema = z.enum(["admin", "builder"]);
55
+ export const userEntrySchema = z.object({
56
+ // The name is typed into a login form and printed in a status bar; keeping it
57
+ // to a plain identifier avoids a name that reads differently than it is stored.
58
+ name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "name: letters, digits, dot, dash, underscore"),
59
+ role: userRoleSchema,
60
+ password_hash: z.string().min(1),
61
+ });
39
62
  export const serverConfigSchema = z.object({
40
63
  server: z.object({
41
64
  port: z.number().int().positive().default(7890),
42
65
  bind: z.string().default("0.0.0.0"),
43
- password_hash: z.string().min(1),
44
- max_concurrent_runs: z.number().int().positive().default(1),
66
+ // Both optional in the file, exactly one required by the loader. The old
67
+ // single-password form is what a 0.2 installation has on disk, and it must
68
+ // keep working unedited; `load.ts` normalises it into `users` so that
69
+ // nothing downstream ever learns which of the two forms was written.
70
+ password_hash: z.string().min(1).optional(),
71
+ users: z.array(userEntrySchema).optional(),
72
+ // Only 1 is accepted. Runs share one working directory per project, so a
73
+ // higher number would promise parallel builds that never happen — the
74
+ // queue drains one run at a time, whatever this says. Refusing at load
75
+ // time is the honest answer, as with `git_auth: token`.
76
+ max_concurrent_runs: z
77
+ .number()
78
+ .int()
79
+ .positive()
80
+ // Refined on the field itself, so zod reports `server.max_concurrent_runs`
81
+ // on its own: `load.ts` prefixes the message with that path, and an error
82
+ // saying `(root)` would leave the reader hunting for the field.
83
+ .refine((n) => n === 1, {
84
+ message: "only 1 is supported: runs are executed one at a time, and accepting more would " +
85
+ "promise parallel builds that never happen",
86
+ })
87
+ .default(1),
45
88
  retention: z
46
89
  .object({
47
90
  runs: z.number().int().positive().default(50),
@@ -42,6 +42,16 @@ export class ConfigStore {
42
42
  watcher.close();
43
43
  };
44
44
  }
45
+ /**
46
+ * The file this store reads.
47
+ *
48
+ * Exposed because removing a project edits that very file: the route would
49
+ * otherwise have to rebuild the path from the data root and hope the two
50
+ * agree, which is exactly the kind of duplicated assumption that drifts.
51
+ */
52
+ configPath() {
53
+ return this.path;
54
+ }
45
55
  server() {
46
56
  return this.config?.server ?? null;
47
57
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A YAML document back as text, with the line width left alone.
3
+ *
4
+ * The default folds anything past eighty columns, which means writing one
5
+ * project rewraps the password hash someone else's line already held — a
6
+ * hand-written file coming back out changed where nobody touched it. A git url
7
+ * or a scrypt hash is a single token; breaking it makes the file harder to read
8
+ * and harder to grep, and gains nothing.
9
+ *
10
+ * Shared, because config.yml is now edited from three places — setting up a
11
+ * project, adding an account, removing one — and a file that comes back
12
+ * differently depending on which of them touched it is a file nobody trusts.
13
+ */
14
+ export const serializeYaml = (doc) => doc.toString({ lineWidth: 0 });
@@ -3,20 +3,20 @@ export class CacheStore {
3
3
  constructor(db) {
4
4
  this.db = db;
5
5
  }
6
- get(slug, hash) {
6
+ get(slug, kind, hash) {
7
7
  const row = this.db
8
- .prepare("SELECT payload FROM introspection_cache WHERE project_slug = ? AND config_hash = ?")
9
- .get(slug, hash);
8
+ .prepare("SELECT payload FROM introspection_cache WHERE project_slug = ? AND kind = ? AND config_hash = ?")
9
+ .get(slug, kind, hash);
10
10
  return row ? JSON.parse(row.payload) : null;
11
11
  }
12
- put(slug, hash, payload) {
12
+ put(slug, kind, hash, payload) {
13
13
  this.db
14
- .prepare(`INSERT INTO introspection_cache (project_slug, config_hash, payload, fetched_at)
15
- VALUES (?, ?, ?, ?)
16
- ON CONFLICT (project_slug) DO UPDATE
14
+ .prepare(`INSERT INTO introspection_cache (project_slug, kind, config_hash, payload, fetched_at)
15
+ VALUES (?, ?, ?, ?, ?)
16
+ ON CONFLICT (project_slug, kind) DO UPDATE
17
17
  SET config_hash = excluded.config_hash,
18
18
  payload = excluded.payload,
19
19
  fetched_at = excluded.fetched_at`)
20
- .run(slug, hash, JSON.stringify(payload), new Date().toISOString());
20
+ .run(slug, kind, hash, JSON.stringify(payload), new Date().toISOString());
21
21
  }
22
22
  }
@@ -6,7 +6,22 @@ export function openDatabase(path) {
6
6
  const db = new Database(path);
7
7
  db.pragma("journal_mode = WAL");
8
8
  db.pragma("foreign_keys = ON");
9
+ migrateIntrospectionCache(db);
9
10
  const here = dirname(fileURLToPath(import.meta.url));
10
11
  db.exec(readFileSync(join(here, "schema.sql"), "utf8"));
11
12
  return db;
12
13
  }
14
+ /**
15
+ * Rebuilds the introspection cache when it predates the `kind` column.
16
+ *
17
+ * `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a database
18
+ * written by 0.1.0 would keep the old single-key shape and collide. Dropping is
19
+ * the right answer here and only here: this table is a cache, and losing it
20
+ * costs one slow read.
21
+ */
22
+ function migrateIntrospectionCache(db) {
23
+ const columns = db.prepare("PRAGMA table_info(introspection_cache)").all();
24
+ if (columns.length > 0 && !columns.some((c) => c.name === "kind")) {
25
+ db.exec("DROP TABLE introspection_cache");
26
+ }
27
+ }
@@ -1,5 +1,5 @@
1
- /** A run is active until it reaches a terminal state. */
2
- const ACTIVE = ["queued", "preparing", "running"];
1
+ /** Statuses that mean a run has begun and cannot survive the process that started it. */
2
+ const IN_FLIGHT = ["preparing", "running"];
3
3
  const toRun = (r) => ({
4
4
  id: r.id,
5
5
  projectSlug: r.project_slug,
@@ -52,12 +52,57 @@ export class RunStore {
52
52
  .prepare("UPDATE run SET status = ?, finished_at = ?, exit_code = ?, error_summary = ? WHERE id = ?")
53
53
  .run(r.status, now(), r.exitCode, r.errorSummary, id);
54
54
  }
55
- /** At startup: no run can still be in progress, the process that carried it is dead. */
56
- interruptActive() {
57
- const placeholders = ACTIVE.map(() => "?").join(", ");
55
+ /** Runs waiting to start, oldest first. Insertion order is the queue. */
56
+ queued() {
57
+ const rows = this.db
58
+ .prepare("SELECT * FROM run WHERE status = 'queued' ORDER BY id")
59
+ .all();
60
+ return rows.map(toRun);
61
+ }
62
+ /** 1 for the next to start, null if the run is not waiting. */
63
+ queuePosition(id) {
64
+ const row = this.db
65
+ .prepare(`SELECT COUNT(*) AS ahead FROM run
66
+ WHERE status = 'queued' AND id <= ?
67
+ AND EXISTS (SELECT 1 FROM run r2 WHERE r2.id = ? AND r2.status = 'queued')`)
68
+ .get(id, id);
69
+ return row.ahead === 0 ? null : row.ahead;
70
+ }
71
+ /** How many runs have begun. The worker consults it before taking the next. */
72
+ activeCount() {
73
+ const placeholders = IN_FLIGHT.map(() => "?").join(", ");
74
+ const row = this.db
75
+ .prepare(`SELECT COUNT(*) AS n FROM run WHERE status IN (${placeholders})`)
76
+ .get(...IN_FLIGHT);
77
+ return row.n;
78
+ }
79
+ /**
80
+ * True if this project has a run that has begun and not yet finished.
81
+ *
82
+ * Used to refuse a Fastfile write while it's true: that run is reading the
83
+ * very file the write would replace, the same reason `prepare` refuses to
84
+ * touch a dirty workspace.
85
+ */
86
+ hasActiveRun(slug) {
87
+ const placeholders = IN_FLIGHT.map(() => "?").join(", ");
88
+ const row = this.db
89
+ .prepare(`SELECT COUNT(*) AS n FROM run WHERE project_slug = ? AND status IN (${placeholders})`)
90
+ .get(slug, ...IN_FLIGHT);
91
+ return row.n > 0;
92
+ }
93
+ /**
94
+ * Marks as interrupted every run that had begun, leaving queued ones alone.
95
+ *
96
+ * A run that started cannot survive the process that spawned it — its
97
+ * pseudo-terminal died with it. A queued run never began: it still means
98
+ * exactly what it meant, and dropping it would be a silent surprise for
99
+ * someone who queued three builds and restarted the server.
100
+ */
101
+ interruptInFlight() {
102
+ const placeholders = IN_FLIGHT.map(() => "?").join(", ");
58
103
  const res = this.db
59
104
  .prepare(`UPDATE run SET status = 'interrupted', finished_at = ? WHERE status IN (${placeholders})`)
60
- .run(now(), ...ACTIVE);
105
+ .run(now(), ...IN_FLIGHT);
61
106
  return res.changes;
62
107
  }
63
108
  replaceSteps(runId, steps) {
@@ -37,9 +37,27 @@ CREATE TABLE IF NOT EXISTS artifact (
37
37
  kind TEXT NOT NULL
38
38
  );
39
39
 
40
+ -- `kind` is part of the key because more than one reader caches per project —
41
+ -- lanes and the actions each lane calls. Without it the second write overwrites
42
+ -- the first, and the next read returns a payload of the wrong shape rather than
43
+ -- missing the cache.
40
44
  CREATE TABLE IF NOT EXISTS introspection_cache (
41
- project_slug TEXT PRIMARY KEY,
45
+ project_slug TEXT NOT NULL,
46
+ kind TEXT NOT NULL,
42
47
  config_hash TEXT NOT NULL,
43
48
  payload TEXT NOT NULL,
44
- fetched_at TEXT NOT NULL
49
+ fetched_at TEXT NOT NULL,
50
+ PRIMARY KEY (project_slug, kind)
51
+ );
52
+
53
+ -- A global secret is stored with an empty project_slug rather than NULL:
54
+ -- SQLite considers two NULLs distinct in a UNIQUE index, so NULL would let the
55
+ -- same global name be inserted twice.
56
+ CREATE TABLE IF NOT EXISTS secret (
57
+ project_slug TEXT NOT NULL DEFAULT '',
58
+ key TEXT NOT NULL,
59
+ value_enc TEXT NOT NULL,
60
+ masked INTEGER NOT NULL DEFAULT 1,
61
+ updated_at TEXT NOT NULL,
62
+ PRIMARY KEY (project_slug, key)
45
63
  );
@@ -0,0 +1,64 @@
1
+ const GLOBAL = "";
2
+ /**
3
+ * Stores encrypted secrets. Knows nothing about encryption itself: it takes and
4
+ * returns ciphertext, so a bug here cannot leak a plaintext value.
5
+ *
6
+ * A project secret shadows a global one of the same name — the same precedence
7
+ * as the configuration, and the least surprising rule.
8
+ */
9
+ export class SecretStore {
10
+ db;
11
+ constructor(db) {
12
+ this.db = db;
13
+ }
14
+ set(projectSlug, key, valueEnc, masked) {
15
+ this.db
16
+ .prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, updated_at)
17
+ VALUES (?, ?, ?, ?, ?)
18
+ ON CONFLICT (project_slug, key) DO UPDATE
19
+ SET value_enc = excluded.value_enc,
20
+ masked = excluded.masked,
21
+ updated_at = excluded.updated_at`)
22
+ .run(projectSlug ?? GLOBAL, key, valueEnc, masked ? 1 : 0, new Date().toISOString());
23
+ }
24
+ /** Rows that apply to a project, project scope winning over global. */
25
+ applicable(projectSlug) {
26
+ const rows = this.db
27
+ .prepare("SELECT * FROM secret WHERE project_slug IN (?, ?) ORDER BY key")
28
+ .all(projectSlug, GLOBAL);
29
+ const byKey = new Map();
30
+ for (const row of rows) {
31
+ const existing = byKey.get(row.key);
32
+ if (!existing || row.project_slug !== GLOBAL)
33
+ byKey.set(row.key, row);
34
+ }
35
+ return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
36
+ }
37
+ list(projectSlug) {
38
+ return this.applicable(projectSlug).map((row) => ({
39
+ key: row.key,
40
+ masked: row.masked === 1,
41
+ scope: row.project_slug === GLOBAL ? "global" : "project",
42
+ }));
43
+ }
44
+ listGlobal() {
45
+ const rows = this.db
46
+ .prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
47
+ .all(GLOBAL);
48
+ return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "global" }));
49
+ }
50
+ /** Ciphertext by name, for the vault to decrypt. */
51
+ encrypted(projectSlug) {
52
+ return Object.fromEntries(this.applicable(projectSlug).map((row) => [row.key, row.value_enc]));
53
+ }
54
+ /** Which of the applicable secrets should be kept out of the logs. */
55
+ maskedKeys(projectSlug) {
56
+ return new Set(this.applicable(projectSlug).filter((r) => r.masked === 1).map((r) => r.key));
57
+ }
58
+ remove(projectSlug, key) {
59
+ const res = this.db
60
+ .prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?")
61
+ .run(projectSlug ?? GLOBAL, key);
62
+ return res.changes > 0;
63
+ }
64
+ }
@@ -0,0 +1,53 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ const DEFAULT_FASTLANE_DIR = "fastlane";
4
+ /**
5
+ * Reads and writes the Fastfile byte-for-byte.
6
+ *
7
+ * A file written by hand must never come back mangled: no reformatting, no
8
+ * trailing-newline fixing, no reordering. Someone may have spent a long time
9
+ * on that file, so `write` keeps the previous content only in memory — never
10
+ * in a sibling file, which would show up as an untracked file in the git
11
+ * workspace and eventually get committed by someone in a hurry — and puts it
12
+ * back the moment the injected verifier says the new content doesn't hold up.
13
+ */
14
+ export class FastfileStore {
15
+ async read(workspacePath, fastlaneDir = DEFAULT_FASTLANE_DIR) {
16
+ return readFile(this.resolvePath(workspacePath, fastlaneDir), "utf8");
17
+ }
18
+ /**
19
+ * Writes `content` verbatim, then verifies it. On failure, the previous
20
+ * content is written back before returning, so the workspace never lands
21
+ * between the old file and the new one.
22
+ */
23
+ async write(workspacePath, content, verify, fastlaneDir = DEFAULT_FASTLANE_DIR) {
24
+ const path = this.resolvePath(workspacePath, fastlaneDir);
25
+ const previous = await readFile(path, "utf8");
26
+ await writeFile(path, content, "utf8");
27
+ const result = await verify();
28
+ if (!result.ok) {
29
+ // Whatever broke, the file on disk goes back to exactly what it was
30
+ // before this call — a stray backup file would be a second thing to
31
+ // clean up and forget about.
32
+ await writeFile(path, previous, "utf8");
33
+ }
34
+ return result;
35
+ }
36
+ /**
37
+ * Resolves the Fastfile's path, refusing to leave the workspace.
38
+ *
39
+ * `fastlaneDir` comes from configuration — the project's own
40
+ * `laneyard.yml`, or the server's `config.yml` — and a value like
41
+ * `../../etc` must not turn an editor into a way to write anywhere on the
42
+ * machine.
43
+ */
44
+ resolvePath(workspacePath, fastlaneDir) {
45
+ const root = resolve(workspacePath);
46
+ const full = resolve(root, fastlaneDir, "Fastfile");
47
+ const rel = relative(root, full);
48
+ if (rel.startsWith("..") || isAbsolute(rel)) {
49
+ throw new Error(`fastlane_dir resolves outside the workspace: ${fastlaneDir}`);
50
+ }
51
+ return full;
52
+ }
53
+ }
@@ -29,16 +29,33 @@ export class Workspace {
29
29
  *
30
30
  * An HTTPS URL can carry a password — `https://user:token@github.com/…`
31
31
  * is perfectly legal in `config.yml`. But git errors end up in the run's
32
- * log file. General secret redaction comes at the next milestone; this
33
- * particular leak comes from our own formatting, and is fixed here.
32
+ * log file. The vault's redaction does not help here: the repository URL is
33
+ * configuration, not a stored secret, so this leak — which comes from our own
34
+ * formatting — has to be closed on the spot.
34
35
  */
35
36
  redact(text) {
36
37
  return text.split(this.gitUrl).join("<repository>");
37
38
  }
38
- async git(args, cwd = this.path) {
39
+ async git(args, cwd = this.path, timeout) {
40
+ return (await this.gitRaw(args, cwd, timeout)).trim();
41
+ }
42
+ /**
43
+ * Same as `git`, but without trimming the output.
44
+ *
45
+ * `status --porcelain` output is column-sensitive: its first two characters
46
+ * are a status code that can themselves be a literal space. Trimming the
47
+ * whole blob — fine for a commit hash or a single config value — would eat
48
+ * that leading space and misalign every line parsed after it.
49
+ */
50
+ async gitRaw(args, cwd = this.path, timeout) {
39
51
  try {
40
- const { stdout } = await exec("git", args, { cwd, env: this.env(), maxBuffer: 32 * 1024 * 1024 });
41
- return stdout.trim();
52
+ const { stdout } = await exec("git", args, {
53
+ cwd,
54
+ env: this.env(),
55
+ maxBuffer: 32 * 1024 * 1024,
56
+ ...(timeout === undefined ? {} : { timeout }),
57
+ });
58
+ return stdout;
42
59
  }
43
60
  catch (cause) {
44
61
  const err = cause;
@@ -46,6 +63,18 @@ export class Workspace {
46
63
  throw new Error(`git ${this.redact(args.join(" "))} failed: ${this.redact(detail)}`);
47
64
  }
48
65
  }
66
+ /**
67
+ * Asks the remote for its branches, and nothing else.
68
+ *
69
+ * Reads no working copy and writes nothing, so it answers the one question
70
+ * the readiness checklist asks: would a run reach this repository on its own?
71
+ * The timeout matters as much as the exit code — with `GIT_TERMINAL_PROMPT=0`
72
+ * git gives up on a credentials prompt, but a host that never answers would
73
+ * otherwise hang the checklist exactly the way it hangs a run at 2am.
74
+ */
75
+ async probeRemote(timeoutMs = 10_000) {
76
+ await this.git(["ls-remote", "--heads", this.gitUrl], process.cwd(), timeoutMs);
77
+ }
49
78
  async exists() {
50
79
  try {
51
80
  await access(join(this.path, ".git"));
@@ -71,6 +100,67 @@ export class Workspace {
71
100
  async headSha() {
72
101
  return this.git(["rev-parse", "HEAD"]);
73
102
  }
103
+ /**
104
+ * Paths with uncommitted changes, tracked only — the same rule as
105
+ * `isDirty`, for the same reason: a build scatters untracked files around,
106
+ * and listing them here would bury the change someone actually made.
107
+ */
108
+ async status() {
109
+ const raw = await this.gitRaw(["status", "--porcelain", "--untracked-files=no"]);
110
+ // Porcelain v1: two status letters, a space, then the path. The trailing
111
+ // split produces one empty element for the final newline; drop it rather
112
+ // than turn it into a bogus empty path.
113
+ return raw
114
+ .split("\n")
115
+ .filter((line) => line.length > 0)
116
+ .map((line) => line.slice(3));
117
+ }
118
+ /** The unified diff of uncommitted changes, as text. Every path if none is given. */
119
+ async diff(path) {
120
+ return this.git(path === undefined ? ["diff"] : ["diff", "--", path]);
121
+ }
122
+ /**
123
+ * Stages exactly the given paths and commits them — never `git add -A`. A
124
+ * build leaves files scattered in the workspace, and committing them
125
+ * because they happened to be there is how something ends up in someone's
126
+ * release.
127
+ *
128
+ * Commits under the repository's own git identity if it has one; otherwise
129
+ * as `Laneyard <laneyard@localhost>`, because a commit from a name nobody
130
+ * recognises is worse than one that admits what made it. The identity
131
+ * actually used is returned so the interface can say so.
132
+ */
133
+ async commit(message, paths) {
134
+ if (paths.length === 0)
135
+ throw new Error("commit: no paths given");
136
+ await this.git(["add", "--", ...paths]);
137
+ const identity = await this.gitIdentity();
138
+ const author = identity ?? "Laneyard <laneyard@localhost>";
139
+ const asLaneyard = identity
140
+ ? []
141
+ : ["-c", "user.name=Laneyard", "-c", "user.email=laneyard@localhost"];
142
+ await this.git([...asLaneyard, "commit", "-m", message]);
143
+ return { author };
144
+ }
145
+ /**
146
+ * `name <email>` from the repository's own git configuration — local or
147
+ * global, however git itself resolves it for this workspace — or null if
148
+ * none is set at all.
149
+ */
150
+ async gitIdentity() {
151
+ try {
152
+ const name = await this.git(["config", "user.name"]);
153
+ const email = await this.git(["config", "user.email"]);
154
+ return name && email ? `${name} <${email}>` : null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ /** Pushes the branch, surfacing git's own message on failure rather than a generic one. */
161
+ async push(branch) {
162
+ await this.git(["push", "origin", branch]);
163
+ }
74
164
  /**
75
165
  * Guarantees the clone is present, without touching the current branch.
76
166
  *
@@ -0,0 +1,65 @@
1
+ export const BLOCKING_RULES = [
2
+ {
3
+ action: "prompt",
4
+ because: "asks a question and waits for an answer",
5
+ fix: "Remove it from the lane, or give it a default that applies when `CI` is set.",
6
+ },
7
+ {
8
+ action: "match",
9
+ when: { arg: "readonly", equals: false },
10
+ because: "may create certificates, which needs an Apple account interactively",
11
+ fix: "Pass `readonly: true` so it only fetches what already exists.",
12
+ },
13
+ {
14
+ action: "sync_code_signing",
15
+ when: { arg: "readonly", equals: false },
16
+ because: "may create certificates, which needs an Apple account interactively",
17
+ fix: "Pass `readonly: true` so it only fetches what already exists.",
18
+ },
19
+ {
20
+ action: "sigh",
21
+ because: "renews provisioning profiles, which needs an Apple account interactively",
22
+ fix: "Use `match` in readonly mode instead, with profiles stored in a repository.",
23
+ },
24
+ {
25
+ action: "cert",
26
+ because: "creates or downloads a signing certificate, which needs an Apple account interactively",
27
+ fix: "Use `match` in readonly mode instead, with certificates stored in a repository.",
28
+ },
29
+ // `deliver` renders an HTML summary and waits for a yes before uploading.
30
+ // Only reported when the lane says `force: false` outright: the default is
31
+ // not something the sidecar reports, and inventing one would be a guess.
32
+ {
33
+ action: "deliver",
34
+ when: { arg: "force", equals: false },
35
+ because: "shows a summary and waits for it to be confirmed before uploading",
36
+ fix: "Pass `force: true` so it uploads without asking.",
37
+ },
38
+ {
39
+ action: "upload_to_app_store",
40
+ when: { arg: "force", equals: false },
41
+ because: "shows a summary and waits for it to be confirmed before uploading",
42
+ fix: "Pass `force: true` so it uploads without asking.",
43
+ },
44
+ ];
45
+ /**
46
+ * Applies the table to the actions a lane calls.
47
+ *
48
+ * A rule whose `when` names an argument the lane didn't pass literally is not
49
+ * reported: the sidecar already dropped that argument rather than guess at
50
+ * it (see `ruby/introspect.rb`), so `arg in action.args` is false and the
51
+ * rule stays silent instead of pretending to know the answer.
52
+ */
53
+ export function findBlockingActions(actions) {
54
+ const findings = [];
55
+ for (const action of actions) {
56
+ for (const rule of BLOCKING_RULES) {
57
+ if (rule.action !== action.name)
58
+ continue;
59
+ if (rule.when && action.args[rule.when.arg] !== rule.when.equals)
60
+ continue;
61
+ findings.push({ action: rule.action, because: rule.because, fix: rule.fix });
62
+ }
63
+ }
64
+ return findings;
65
+ }