laneyard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +120 -21
  2. package/dist/src/cli/secret.js +133 -0
  3. package/dist/src/config/schema.js +21 -3
  4. package/dist/src/db/cache.js +8 -8
  5. package/dist/src/db/open.js +15 -0
  6. package/dist/src/db/runs.js +51 -6
  7. package/dist/src/db/schema.sql +20 -2
  8. package/dist/src/db/secrets.js +64 -0
  9. package/dist/src/fastfile/store.js +53 -0
  10. package/dist/src/git/workspace.js +95 -5
  11. package/dist/src/heuristics/blocking-actions.js +45 -0
  12. package/dist/src/heuristics/readiness.js +193 -0
  13. package/dist/src/logs/redact.js +86 -0
  14. package/dist/src/main.js +32 -3
  15. package/dist/src/runner/orchestrate.js +60 -10
  16. package/dist/src/runner/pty.js +27 -15
  17. package/dist/src/runner/queue.js +114 -0
  18. package/dist/src/secrets/cipher.js +28 -0
  19. package/dist/src/secrets/key.js +40 -0
  20. package/dist/src/secrets/vault.js +68 -0
  21. package/dist/src/server/app.js +70 -0
  22. package/dist/src/server/routes/fastfile.js +131 -0
  23. package/dist/src/server/routes/projects.js +8 -1
  24. package/dist/src/server/routes/readiness.js +89 -0
  25. package/dist/src/server/routes/runs.js +27 -45
  26. package/dist/src/server/routes/secrets.js +51 -0
  27. package/dist/src/sidecar/lanes.js +2 -2
  28. package/dist/src/sidecar/uses.js +40 -0
  29. package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
  30. package/dist/web/assets/index-D9_EL8LA.js +62 -0
  31. package/dist/web/assets/index-ZUqTX6d1.css +1 -0
  32. package/dist/web/index.html +2 -2
  33. package/package.json +13 -5
  34. package/ruby/introspect.rb +80 -0
  35. package/dist/tests/cli/add.test.js +0 -64
  36. package/dist/tests/cli/detect.test.js +0 -63
  37. package/dist/tests/config/load.test.js +0 -68
  38. package/dist/tests/config/resolve.test.js +0 -44
  39. package/dist/tests/config/store.test.js +0 -58
  40. package/dist/tests/db/runs.test.js +0 -54
  41. package/dist/tests/e2e/full-thread.test.js +0 -65
  42. package/dist/tests/fixtures/repos.js +0 -30
  43. package/dist/tests/git/workspace.test.js +0 -66
  44. package/dist/tests/heuristics/error-summary.test.js +0 -31
  45. package/dist/tests/logs/store.test.js +0 -38
  46. package/dist/tests/main.test.js +0 -27
  47. package/dist/tests/ruby/introspect.test.js +0 -59
  48. package/dist/tests/runner/artifacts.test.js +0 -49
  49. package/dist/tests/runner/live-steps.test.js +0 -35
  50. package/dist/tests/runner/orchestrate.test.js +0 -124
  51. package/dist/tests/runner/pty.test.js +0 -53
  52. package/dist/tests/runner/report.test.js +0 -91
  53. package/dist/tests/server/api.test.js +0 -132
  54. package/dist/tests/server/auth.test.js +0 -53
  55. package/dist/tests/server/ws.test.js +0 -43
  56. package/dist/tests/sidecar/lanes.test.js +0 -52
  57. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  58. package/dist/tests/smoke.test.js +0 -7
  59. package/dist/web/assets/index-583x0xuo.css +0 -1
  60. package/dist/web/assets/index-BEpABKPS.js +0 -61
package/README.md CHANGED
@@ -9,7 +9,7 @@ your signing keys, and nobody meters your minutes.
9
9
  It replaces neither fastlane nor your Fastfile — it drives them. Your lanes stay exactly where
10
10
  they are.
11
11
 
12
- ![A build running in Laneyard: step timeline on the left, live output on the right, artifacts below](docs/screenshots/run.png)
12
+ ![A build running in Laneyard: step timeline on the left, live output on the right, artifacts below](https://raw.githubusercontent.com/martinfrouin/laneyard/main/docs/screenshots/run.png)
13
13
 
14
14
  ## Why
15
15
 
@@ -33,10 +33,11 @@ change here.
33
33
  | cost per build | electricity | per minute | electricity |
34
34
  | setup | one command | a signup form| a weekend |
35
35
  | works offline | yes | no | yes |
36
- | build queue across a team | planned | yes | yes |
36
+ | build queue across a team | yes, serial | yes | yes |
37
37
  | runs on pull requests | planned | yes | yes |
38
38
 
39
- If your team needs parallel builds across a fleet today, use something else and come back later.
39
+ Laneyard's queue is serial: runs are drained one at a time across every project, in the order
40
+ they were asked for. If your team needs parallel builds across a fleet today, use something else.
40
41
  Laneyard is for one machine you control.
41
42
 
42
43
  ## Requirements
@@ -81,7 +82,7 @@ npm link # puts `laneyard` on your PATH
81
82
  Open it from any machine on your network, sign in, and your lanes are listed — because Laneyard
82
83
  asked your project's own fastlane for them.
83
84
 
84
- ![The project screen: lanes read from the Fastfile, with their descriptions, and the run history](docs/screenshots/project.png)
85
+ ![The project screen: lanes read from the Fastfile, with their descriptions, and the run history](https://raw.githubusercontent.com/martinfrouin/laneyard/main/docs/screenshots/project.png)
85
86
 
86
87
  ## Configuration
87
88
 
@@ -95,7 +96,7 @@ server:
95
96
  port: 7890
96
97
  bind: 0.0.0.0
97
98
  password_hash: "scrypt$…" # written by `laneyard add`
98
- max_concurrent_runs: 1
99
+ max_concurrent_runs: 1 # only 1 is accepted, see below
99
100
  retention: { runs: 50, artifact_days: 30 }
100
101
 
101
102
  projects:
@@ -106,6 +107,11 @@ projects:
106
107
  git_auth: { kind: ssh_key, ref: ~/.ssh/id_ed25519 }
107
108
  ```
108
109
 
110
+ `max_concurrent_runs` accepts `1` and nothing else. Runs are drained from a single queue, one at
111
+ a time across every project — parallel runs would need a working directory per run, which does
112
+ not exist yet. A larger number is refused when the file loads rather than silently ignored, so a
113
+ server is never configured for builds that never happen.
114
+
109
115
  ### `laneyard.yml` — optional, committed in your repository
110
116
 
111
117
  Build behaviour belongs next to the code, so it can be versioned with it:
@@ -126,6 +132,78 @@ not touch can be configured entirely from `config.yml`.
126
132
  Both files are watched: edit them by hand and Laneyard picks the change up. An invalid file is
127
133
  reported and the last valid configuration stays live — a typo never takes the server down.
128
134
 
135
+ ### Secrets
136
+
137
+ Credentials do not live in a file. They go into an encrypted vault, from the Secrets tab of a
138
+ project or from the command line:
139
+
140
+ ```bash
141
+ laneyard secret set MATCH_PASSWORD --project cartes-ios # reads the value from standard input
142
+ echo "$GITHUB_TOKEN" | laneyard secret set GITHUB_TOKEN # global, and out of your shell history
143
+ ```
144
+
145
+ The value is never an argument: a command line ends up in `~/.zsh_history` and in the output of
146
+ `ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
147
+ `Ctrl-D`.
148
+
149
+ A secret becomes an environment variable for every run of the project it belongs to. Without
150
+ `--project` it applies to every project; a project secret of the same name wins over a global
151
+ one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
152
+ least four characters long — see below.
153
+
154
+ ### Readiness
155
+
156
+ Every project has a Readiness tab: five checks on what stands between it and a build that runs
157
+ while nobody watches.
158
+
159
+ - **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
160
+ password prompt does not fail, it waits;
161
+ - **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
162
+ would otherwise find on the PATH;
163
+ - **App Store Connect** has an API key in the vault rather than a `FASTLANE_SESSION`, which
164
+ expires and takes the next night's build with it;
165
+ - **match** has its `MATCH_PASSWORD` stored and is called `readonly`, so it fetches certificates
166
+ instead of trying to create them;
167
+ - **no lane calls an action known to stop and ask** — `prompt`, `sigh`, a writable `match`.
168
+
169
+ They run when you open the tab or press refresh, never on their own: they shell out to git and to
170
+ bundler. The time of the last run is on screen, because a stale green tick is worse than a red
171
+ cross.
172
+
173
+ Nothing here blocks anything. A red check is never the reason a run cannot be started, and
174
+ Laneyard never edits a Fastfile to make its own checklist go green — each line explains, you
175
+ decide. Where the fix genuinely is one action, the line links to the Secrets tab instead of
176
+ growing a second copy of its form.
177
+
178
+ **What it cannot see.** The checklist reads *literal* arguments only. `match(readonly: true)` is
179
+ green and `match(readonly: false)` is a warning, but `match(readonly: ENV["RO"])` has no value
180
+ until the lane runs, so it is reported as undetermined — `○`, with the reason — rather than
181
+ guessed either way. The same applies to anything a lane computes: a checklist that guesses gets
182
+ believed, and then it is worse than no checklist. Android signing is not covered at all yet.
183
+
184
+ ### The Fastfile
185
+
186
+ Every project has a Fastfile tab. **It is a text editor** — your file, in a box, with Ruby
187
+ syntax highlighting and nothing between you and it. The structured view described in the design
188
+ document, where lanes and actions are things you arrange rather than type, is still to come. This
189
+ is the honest first half of it, and it is useful on its own: fixing a lane at 2am should not
190
+ require an SSH session.
191
+
192
+ **Every write is verified.** Saving sends the file to the server, which writes it byte-for-byte —
193
+ no reformatting, no trailing-newline fixing, no reordering — then asks fastlane to parse it and
194
+ list its lanes. If that fails, the previous content is put back on disk before the request
195
+ answers, and the reason fastlane gave appears above the editor with your work still in the box.
196
+ A broken Fastfile never reaches a workspace a run might build from.
197
+
198
+ Saving is explicit, never automatic: verification is a Ruby subprocess, not a regular expression,
199
+ and an editor that ran it on every keystroke would be both slow and dangerous. `⌘S` is another
200
+ way to ask, not an autosave. Laneyard also refuses to write at all while a run of that project is
201
+ in flight — that run is reading the very file the write would replace.
202
+
203
+ Below the editor is what git makes of the workspace: the diff, a message field, `commit` and
204
+ `push`. A commit stages exactly the files that changed and never `git add -A` — a build leaves
205
+ artifacts and reports scattered around, and none of them belong in your history.
206
+
129
207
  ## Security
130
208
 
131
209
  Read this before putting Laneyard on a network.
@@ -135,15 +213,30 @@ Read this before putting Laneyard on a network.
135
213
  put it behind a VPN or an SSH tunnel.
136
214
  - **The password** is stored as an scrypt hash and repeated failures are throttled. Sessions live
137
215
  in memory and do not survive a restart.
138
- - **There is no secret vault yet.** Today, fastlane sees the environment the Laneyard process was
139
- started with, so credentials are managed the way you already manage them an `.env` read by
140
- your Fastfile, the system keychain, or exported variables. Do not put secrets in `config.yml`.
141
- - **Logs are not redacted yet.** A run's full output is written to disk under `~/.laneyard/logs/`.
142
- Assume anything fastlane prints is stored in the clear. Laneyard does remove its own git remote
143
- URL from error messages, so a token embedded in a repository URL does not leak that way.
144
-
145
- The encrypted vault and log redaction are the next milestone, and the reason this section is
146
- this blunt.
216
+ - **Secrets are encrypted at rest.** Values are stored with AES-256-GCM under a key kept in
217
+ `~/.laneyard/key` outside the database, mode `600`, and Laneyard refuses to start if anyone
218
+ else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
219
+ process holds plaintext: the store, the API and the interface deal in names only, and no route
220
+ ever sends a value back which is why the Secrets tab has no reveal button.
221
+ - **Masked values are removed from output before it is written, not when it is displayed.** The
222
+ substitution happens once, at the point where a run's output fans out, so the log file on disk,
223
+ the live stream to your browser and the stored error summary all contain `••••••` and never the
224
+ value. It survives being split across two chunks of terminal output.
225
+ - **Do not put secrets in `config.yml`.** It is a plain file with ordinary permissions. Use
226
+ `laneyard secret set` or the Secrets tab.
227
+
228
+ What this does *not* cover, stated plainly:
229
+
230
+ - **Git credentials are not in the vault.** `git_auth` points at an SSH key on disk by path;
231
+ token authentication is refused at load time rather than silently ignored, so a project cannot
232
+ be configured for something that never happens. Laneyard removes the configured repository URL
233
+ from its own git error messages — so a token embedded in an HTTPS URL does not leak that way —
234
+ but that is one string, not a vault.
235
+ - **A value shorter than four characters is refused, not protected.** Removing a two-character
236
+ string from a log would shred the output while hiding nothing, so Laneyard says no rather than
237
+ pretending. Store it unmasked if it genuinely does not matter.
238
+ - **Anything fastlane prints that is not a stored secret is stored in the clear**, under
239
+ `~/.laneyard/logs/`.
147
240
 
148
241
  ## Status
149
242
 
@@ -152,15 +245,21 @@ this blunt.
152
245
  - `✓` declare a project, clone it, list its lanes, run one, watch it live, download the artifact
153
246
  - `✓` configuration entirely in files you can version and back up
154
247
  - `✓` adopt an existing fastlane project with one command
155
- - `▸` encrypted secret vault and log redaction
156
- - `▸` build queue, cancellation, timeouts surfaced in the UI
157
- - `○` a checklist that gets a project running unattended
158
- - `○` Fastfile editor in the browser
248
+ - `✓` encrypted secret vault and log redaction
249
+ - `✓` build queue, cancellation, timeouts surfaced in the UI
250
+ - `✓` a checklist that gets a project running unattended
251
+ - `✓` edit the Fastfile in the browser, verified on every save
159
252
  - `○` git-triggered and scheduled builds
160
253
 
161
- Two limitations worth knowing today: listing lanes does not fetch the repository, so a lane you
162
- have just pushed appears after the next run; and a second run on a project is refused while the
163
- first is still going, since they would share one git workspace.
254
+ Two things worth knowing today: listing lanes does not fetch the repository, so a lane you have
255
+ just pushed appears after the next run; and runs execute one at a time across all projects, so a
256
+ build triggered while another is going waits its turn rather than starting alongside it. A queued
257
+ run survives a restart of the server — it is still queued when it comes back up, and starts on
258
+ its own.
259
+
260
+ ## Changelog
261
+
262
+ See [CHANGELOG.md](CHANGELOG.md).
164
263
 
165
264
  ## Contributing
166
265
 
@@ -0,0 +1,133 @@
1
+ import { join } from "node:path";
2
+ import { ConfigStore } from "../config/store.js";
3
+ import { openDatabase } from "../db/open.js";
4
+ import { SecretStore } from "../db/secrets.js";
5
+ import { MIN_LENGTH as MIN_REDACTABLE } from "../logs/redact.js";
6
+ import { Vault } from "../secrets/vault.js";
7
+ /** Same rule as the API: what cannot become an environment variable is refused here too. */
8
+ const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ export const SECRET_USAGE = `laneyard secret set <NAME> [--project <slug>] [--no-mask]
10
+
11
+ The value is read from standard input, never from an argument:
12
+
13
+ laneyard secret set MATCH_PASSWORD --project app
14
+ echo "$TOKEN" | laneyard secret set GITHUB_TOKEN
15
+ `;
16
+ /**
17
+ * Reads standard input whole.
18
+ *
19
+ * A single trailing newline is dropped: `echo "$TOKEN" |` adds one, and a secret
20
+ * that silently gained a `\n` would fail authentication somewhere far away from
21
+ * here, with nothing on screen to explain why.
22
+ */
23
+ async function readValue(stdin) {
24
+ const chunks = [];
25
+ for await (const chunk of stdin)
26
+ chunks.push(Buffer.from(chunk));
27
+ const text = Buffer.concat(chunks).toString("utf8");
28
+ return text.replace(/\r?\n$/, "");
29
+ }
30
+ function parse(args) {
31
+ let key = null;
32
+ let project = null;
33
+ let masked = true;
34
+ for (let i = 0; i < args.length; i += 1) {
35
+ const arg = args[i];
36
+ if (arg === "--project" || arg === "-p") {
37
+ const next = args[i + 1];
38
+ if (next === undefined || next.startsWith("-"))
39
+ return "--project needs a project slug.";
40
+ project = next;
41
+ i += 1;
42
+ }
43
+ else if (arg === "--no-mask") {
44
+ masked = false;
45
+ }
46
+ else if (arg.startsWith("-")) {
47
+ return `Unknown option: ${arg}`;
48
+ }
49
+ else if (key === null) {
50
+ key = arg;
51
+ }
52
+ else {
53
+ // A second bare argument is almost certainly the value typed on the command
54
+ // line — exactly what this command exists to prevent. Saying so is kinder
55
+ // than storing the name and ignoring the rest.
56
+ return "The value is read from standard input, not from the command line.";
57
+ }
58
+ }
59
+ if (key === null)
60
+ return "Which secret? Give it a name.";
61
+ return { key, project, masked };
62
+ }
63
+ /**
64
+ * Entry point for `laneyard secret set`.
65
+ *
66
+ * Nothing it prints ever contains the value — not on success, not in an error.
67
+ * A terminal keeps scrollback and a shell keeps history; the point of reading
68
+ * from stdin would be lost if the value came straight back out.
69
+ */
70
+ export async function runSecretCommand(home, args, io) {
71
+ const [subcommand, ...rest] = args;
72
+ if (subcommand !== "set") {
73
+ io.err(subcommand === undefined ? `${SECRET_USAGE}` : `Unknown subcommand: ${subcommand}\n\n${SECRET_USAGE}`);
74
+ return 1;
75
+ }
76
+ const parsed = parse(rest);
77
+ if (typeof parsed === "string") {
78
+ io.err(`${parsed}\n\n${SECRET_USAGE}`);
79
+ return 1;
80
+ }
81
+ if (!VALID_KEY.test(parsed.key)) {
82
+ io.err(`"${parsed.key}" is not a valid environment variable name: letters, digits and underscore, ` +
83
+ "not starting with a digit.\n");
84
+ return 1;
85
+ }
86
+ // A slug typo would store a secret that no project ever sees, and the run
87
+ // would fail much later with fastlane complaining about a missing variable.
88
+ if (parsed.project !== null) {
89
+ const config = new ConfigStore(join(home, "config.yml"));
90
+ const loaded = await config.load();
91
+ if (!loaded.ok) {
92
+ io.err(`Unreadable configuration in ${join(home, "config.yml")}: ${loaded.error}\n`);
93
+ return 1;
94
+ }
95
+ if (!config.project(parsed.project)) {
96
+ const known = config.projects().map((p) => p.slug);
97
+ io.err(`Unknown project: "${parsed.project}".` +
98
+ (known.length > 0 ? ` Known projects: ${known.join(", ")}.` : " No project is declared yet.") +
99
+ "\n");
100
+ return 1;
101
+ }
102
+ }
103
+ if (io.interactive) {
104
+ io.err("The value is read from standard input, and standard input is a terminal.\n" +
105
+ "Pipe it in instead, so it stays out of your shell history:\n\n" +
106
+ ` echo "$TOKEN" | laneyard secret set ${parsed.key}\n`);
107
+ return 1;
108
+ }
109
+ const value = await readValue(io.stdin);
110
+ if (value === "") {
111
+ io.err("Nothing came in on standard input, so there is no value to store.\n");
112
+ return 1;
113
+ }
114
+ // Same refusal as the API: accepting the request and quietly not redacting
115
+ // would leave someone believing they are protected.
116
+ if (parsed.masked && value.length < MIN_REDACTABLE) {
117
+ io.err(`A value kept out of the logs must be at least ${MIN_REDACTABLE} characters. ` +
118
+ "Shorter than that, removing it would shred the log without hiding anything. " +
119
+ "Pass --no-mask if you accept it appearing in the output.\n");
120
+ return 1;
121
+ }
122
+ const db = openDatabase(join(home, "laneyard.db"));
123
+ try {
124
+ const vault = await Vault.open(home, new SecretStore(db));
125
+ await vault.set(parsed.project, parsed.key, value, parsed.masked);
126
+ }
127
+ finally {
128
+ db.close();
129
+ }
130
+ io.out(`✓ ${parsed.key} ${parsed.project === null ? "global" : `project ${parsed.project}`}` +
131
+ ` ${parsed.masked ? "kept out of the logs" : "shown in the logs"}\n`);
132
+ return 0;
133
+ }
@@ -27,8 +27,11 @@ export const projectEntrySchema = projectSettingsInputSchema.extend({
27
27
  default_branch: z.string().default("main"),
28
28
  git_auth: z
29
29
  .object({
30
- kind: z.enum(["none", "ssh_key", "token"]),
31
- /** File path if kind is ssh_key, secret name if kind is token. */
30
+ // `token` is not accepted yet. The workspace only knows how to use an SSH
31
+ // key, so accepting it would leave a project configured for something that
32
+ // silently never happens — worse than a clear refusal at load time.
33
+ kind: z.enum(["none", "ssh_key"]),
34
+ /** Path to the SSH key file. */
32
35
  ref: z.string().optional(),
33
36
  })
34
37
  .default({ kind: "none" }),
@@ -41,7 +44,22 @@ export const serverConfigSchema = z.object({
41
44
  port: z.number().int().positive().default(7890),
42
45
  bind: z.string().default("0.0.0.0"),
43
46
  password_hash: z.string().min(1),
44
- max_concurrent_runs: z.number().int().positive().default(1),
47
+ // Only 1 is accepted. Runs share one working directory per project, so a
48
+ // higher number would promise parallel builds that never happen — the
49
+ // queue drains one run at a time, whatever this says. Refusing at load
50
+ // time is the honest answer, as with `git_auth: token`.
51
+ max_concurrent_runs: z
52
+ .number()
53
+ .int()
54
+ .positive()
55
+ // Refined on the field itself, so zod reports `server.max_concurrent_runs`
56
+ // on its own: `load.ts` prefixes the message with that path, and an error
57
+ // saying `(root)` would leave the reader hunting for the field.
58
+ .refine((n) => n === 1, {
59
+ message: "only 1 is supported: runs are executed one at a time, and accepting more would " +
60
+ "promise parallel builds that never happen",
61
+ })
62
+ .default(1),
45
63
  retention: z
46
64
  .object({
47
65
  runs: z.number().int().positive().default(50),
@@ -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
+ }