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
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
@@ -57,14 +58,15 @@ Then, from a project you already build with fastlane:
57
58
 
58
59
  ```bash
59
60
  cd ~/code/your-app
60
- laneyard add # adopt this project
61
+ laneyard setup # answer a few questions about this project
61
62
  laneyard # start the server
62
63
  ```
63
64
 
64
- `laneyard add` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
65
- `Gemfile`, an Xcode project or a Gradle build — writes the matching block into
66
- `~/.laneyard/config.yml`, and prints a generated server password once. Write it down; it is not
67
- shown again.
65
+ `laneyard setup` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
66
+ `Gemfile`, an Xcode project or a Gradle build — and writes the matching block into
67
+ `~/.laneyard/config.yml`. On a machine that has no account yet, it also creates the first admin:
68
+ it asks what to call it and prints its generated password once. Write it down; it is not shown
69
+ again, and nothing stores it.
68
70
 
69
71
  <details>
70
72
  <summary>Running from source instead</summary>
@@ -81,7 +83,7 @@ npm link # puts `laneyard` on your PATH
81
83
  Open it from any machine on your network, sign in, and your lanes are listed — because Laneyard
82
84
  asked your project's own fastlane for them.
83
85
 
84
- ![The project screen: lanes read from the Fastfile, with their descriptions, and the run history](docs/screenshots/project.png)
86
+ ![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
87
 
86
88
  ## Configuration
87
89
 
@@ -94,8 +96,10 @@ Laneyard means copying one file, and restoring it means copying it back.
94
96
  server:
95
97
  port: 7890
96
98
  bind: 0.0.0.0
97
- password_hash: "scrypt$…" # written by `laneyard add`
98
- max_concurrent_runs: 1
99
+ users: # written by `laneyard setup`, see Accounts
100
+ - { name: martin, role: admin, password_hash: "scrypt$…" }
101
+ - { name: lea, role: builder, password_hash: "scrypt$…" }
102
+ max_concurrent_runs: 1 # only 1 is accepted, see below
99
103
  retention: { runs: 50, artifact_days: 30 }
100
104
 
101
105
  projects:
@@ -106,9 +110,61 @@ projects:
106
110
  git_auth: { kind: ssh_key, ref: ~/.ssh/id_ed25519 }
107
111
  ```
108
112
 
109
- ### `laneyard.yml` optional, committed in your repository
113
+ `max_concurrent_runs` accepts `1` and nothing else. Runs are drained from a single queue, one at
114
+ a time across every project — parallel runs would need a working directory per run, which does
115
+ not exist yet. A larger number is refused when the file loads rather than silently ignored, so a
116
+ server is never configured for builds that never happen.
110
117
 
111
- Build behaviour belongs next to the code, so it can be versioned with it:
118
+ ### Accounts
119
+
120
+ Everyone who signs in has a name, a password and one of two roles. Two, because a third role is
121
+ easy to add and impossible to remove.
122
+
123
+ | | **admin** | **builder** |
124
+ |---|---|---|
125
+ | start a build, watch it, cancel it | ✓ | ✓ |
126
+ | download artifacts, read logs and the Fastfile | ✓ | ✓ |
127
+ | see the readiness checklist | ✓ | ✓ |
128
+ | read and write secrets | ✓ | |
129
+ | save, commit and push the Fastfile | ✓ | |
130
+ | remove a project | ✓ | |
131
+ | manage accounts | ✓ | |
132
+
133
+ A builder is what you give someone who ships without being trusted with the signing chain: they
134
+ can press the button and watch what happens, and they never see a credential.
135
+
136
+ The interface shows a builder only what a builder can use — the secrets, fastfile and settings
137
+ tabs are not drawn, and neither is the accounts screen. That is courtesy, not security: the
138
+ server refuses those routes on its own, whatever the browser was shown, and the test suite
139
+ proves it for every verb and every spelling of the address.
140
+
141
+ Add and remove accounts from the accounts screen, or from the command line:
142
+
143
+ ```bash
144
+ echo "$PASSWORD" | laneyard user add lea --role builder
145
+ ```
146
+
147
+ The password is read from standard input, never taken as an argument — an argument lands in your
148
+ shell history. Without `--role`, the account is a builder.
149
+
150
+ Two things are refused, in the API and on the command line alike: removing the last admin, and
151
+ demoting the last admin. A server nobody can administer cannot be repaired from the interface.
152
+
153
+ Removing an account ends its sessions immediately — "remove the account" and "revoke access" are
154
+ the same act. So does editing `config.yml` by hand: every request looks the account up again, so
155
+ a demotion takes effect at once rather than at the next restart.
156
+
157
+ **Upgrading from 0.2.** An existing `server.password_hash` keeps working, unedited. It is read as
158
+ a single admin account called `admin` — sign in with that name and the password you already have.
159
+ The first time you add someone, the file is rewritten into the `users` form above, comments and
160
+ all. Do not write both forms: a file holding a `password_hash` *and* a `users` list is refused at
161
+ load, because there is no obvious winner.
162
+
163
+ ### `laneyard.yml` — in your repository, and committed
164
+
165
+ Build behaviour belongs next to the code, so it can be versioned with it — `laneyard setup`
166
+ writes this file for you, and you should commit it. A colleague who clones the repository then
167
+ builds it the same way, without configuring anything.
112
168
 
113
169
  ```yaml
114
170
  fastlane_dir: fastlane
@@ -117,8 +173,13 @@ timeout_minutes: 60
117
173
  artifact_globs:
118
174
  - "build/**/*.ipa"
119
175
  - "build/**/*.app.dSYM.zip"
176
+ platforms: [ios] # or `[android]`, or both
120
177
  ```
121
178
 
179
+ `platforms` decides which half of the readiness checklist applies: an Android project is never
180
+ asked for an App Store Connect key. Left out, Laneyard looks at the repository — an Xcode project
181
+ means iOS, a Gradle build means Android — and reports what it found rather than assuming.
182
+
122
183
  Field by field, the repository file wins over the server block, which wins over the defaults. Any
123
184
  field of `laneyard.yml` may also be written in the server block, so a repository you would rather
124
185
  not touch can be configured entirely from `config.yml`.
@@ -126,24 +187,160 @@ not touch can be configured entirely from `config.yml`.
126
187
  Both files are watched: edit them by hand and Laneyard picks the change up. An invalid file is
127
188
  reported and the last valid configuration stays live — a typo never takes the server down.
128
189
 
190
+ ### Secrets
191
+
192
+ Credentials do not live in a file. They go into an encrypted vault, from the Secrets tab of a
193
+ project or from the command line:
194
+
195
+ ```bash
196
+ laneyard secret set MATCH_PASSWORD --project cartes-ios # reads the value from standard input
197
+ echo "$GITHUB_TOKEN" | laneyard secret set GITHUB_TOKEN # global, and out of your shell history
198
+ ```
199
+
200
+ The value is never an argument: a command line ends up in `~/.zsh_history` and in the output of
201
+ `ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
202
+ `Ctrl-D`.
203
+
204
+ **Two of them are files.** An App Store Connect key arrives as a `.p8` and a Play Store service
205
+ account as a JSON file, and pasting either into a text field is the moment you are most likely to
206
+ paste it somewhere else by accident. The Secrets tab takes the file directly — *app store connect
207
+ key* and *play store service account*, beside the value field. Your browser reads it and sends its
208
+ text to the same route a typed value goes through, under the name fastlane and the readiness
209
+ checklist both look for: `APP_STORE_CONNECT_API_KEY_P8` and `SUPPLY_JSON_KEY_DATA`. Nothing is
210
+ uploaded, nothing is written to disk on the way, and the page only ever shows the file's name.
211
+
212
+ A secret becomes an environment variable for every run of the project it belongs to. Without
213
+ `--project` it applies to every project; a project secret of the same name wins over a global
214
+ one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
215
+ least four characters long — see below.
216
+
217
+ ### Readiness
218
+
219
+ Every project has a Readiness tab: what stands between it and a build that runs while nobody
220
+ watches. Only the checks that apply to the project are shown — an Android project is never asked
221
+ for an App Store Connect key, because one irrelevant warning teaches you to ignore the screen.
222
+
223
+ Always:
224
+
225
+ - **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
226
+ password prompt does not fail, it waits;
227
+ - **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
228
+ would otherwise find on the PATH;
229
+ - **no lane calls an action known to stop and ask** — `prompt`, `sigh`, `cert`, a writable
230
+ `match`, an upload waiting for its summary to be confirmed.
231
+
232
+ On iOS:
233
+
234
+ - **App Store Connect** has an API key in the vault rather than a `FASTLANE_SESSION`, which
235
+ expires and takes the next night's build with it;
236
+ - **match** has its `MATCH_PASSWORD` stored and is called `readonly`, so it fetches certificates
237
+ instead of trying to create them.
238
+
239
+ On Android:
240
+
241
+ - **the keystore** is reachable without a prompt: a lane handing `gradle` a `storeFile` needs a
242
+ passphrase, and one that is neither in the call nor in the vault makes gradle stop and ask;
243
+ - **the Play Store service account** is in the vault when a lane calls `upload_to_play_store`.
244
+
245
+ Like the iOS ones, the Android checks read **literal arguments only**. `gradle(storePassword:
246
+ ENV["PW"])` is reported as undetermined, never guessed at: a checklist that guesses gets believed.
247
+
248
+ They run when you open the tab or press refresh, never on their own: they shell out to git and to
249
+ bundler. The time of the last run is on screen, because a stale green tick is worse than a red
250
+ cross.
251
+
252
+ Nothing here blocks anything. A red check is never the reason a run cannot be started, and
253
+ Laneyard never edits a Fastfile to make its own checklist go green — each line explains, you
254
+ decide. Where the fix genuinely is one action, the line links to the Secrets tab instead of
255
+ growing a second copy of its form.
256
+
257
+ **What it cannot see.** The checklist reads *literal* arguments only. `match(readonly: true)` is
258
+ green and `match(readonly: false)` is a warning, but `match(readonly: ENV["RO"])` has no value
259
+ until the lane runs, so it is reported as undetermined — `○`, with the reason — rather than
260
+ guessed either way. The same applies to anything a lane computes: a checklist that guesses gets
261
+ believed, and then it is worse than no checklist. Android signing is not covered at all yet.
262
+
263
+ ### The Fastfile
264
+
265
+ Every project has a Fastfile tab. **It is a text editor** — your file, in a box, with Ruby
266
+ syntax highlighting and nothing between you and it. The structured view described in the design
267
+ document, where lanes and actions are things you arrange rather than type, is still to come. This
268
+ is the honest first half of it, and it is useful on its own: fixing a lane at 2am should not
269
+ require an SSH session.
270
+
271
+ **Every write is verified.** Saving sends the file to the server, which writes it byte-for-byte —
272
+ no reformatting, no trailing-newline fixing, no reordering — then asks fastlane to parse it and
273
+ list its lanes. If that fails, the previous content is put back on disk before the request
274
+ answers, and the reason fastlane gave appears above the editor with your work still in the box.
275
+ A broken Fastfile never reaches a workspace a run might build from.
276
+
277
+ Saving is explicit, never automatic: verification is a Ruby subprocess, not a regular expression,
278
+ and an editor that ran it on every keystroke would be both slow and dangerous. `⌘S` is another
279
+ way to ask, not an autosave. Laneyard also refuses to write at all while a run of that project is
280
+ in flight — that run is reading the very file the write would replace.
281
+
282
+ Below the editor is what git makes of the workspace: the diff, a message field, `commit` and
283
+ `push`. A commit stages exactly the files that changed and never `git add -A` — a build leaves
284
+ artifacts and reports scattered around, and none of them belong in your history.
285
+
286
+ ### Removing a project
287
+
288
+ Every project has a Settings tab, and the one thing on it is removal. It takes the project's block
289
+ out of `config.yml` — through the YAML document, so your comments and your key order survive —
290
+ and stops showing it. It is confirmed by typing the project's name: it is the one destructive
291
+ action in Laneyard, and a dialogue you can click through is not a confirmation.
292
+
293
+ What it does *not* do is most of the point, because "delete" elsewhere usually means the opposite:
294
+
295
+ - **the runs stay.** Every build that project ever ran keeps its page, its log and its artifacts,
296
+ each still at its own address. Removing a project means stop showing it, not destroy its past;
297
+ - **the clone and the artifacts stay on disk.** Their paths are printed when it is done, so you
298
+ can remove them yourself. Nothing is deleted from a web page on one click;
299
+ - **the secrets stay in the vault**, encrypted and unreachable, and come back if you add the
300
+ project again under the same name;
301
+ - **the repository is untouched.** Its `laneyard.yml`, its Fastfile and its history are the
302
+ repository's, not Laneyard's.
303
+
304
+ Removal is refused while a run of that project is in flight — that run is using the workspace. A
305
+ run still waiting in the queue will not start: it ends as failed, saying its project is gone.
306
+
129
307
  ## Security
130
308
 
131
309
  Read this before putting Laneyard on a network.
132
310
 
133
311
  - **It is built for a local network, not the internet.** It listens on `0.0.0.0` so you can reach
134
- it from your laptop, behind one password. Do not expose it publicly. If you need remote access,
312
+ it from your laptop, behind a password. Do not expose it publicly. If you need remote access,
135
313
  put it behind a VPN or an SSH tunnel.
136
- - **The password** is stored as an scrypt hash and repeated failures are throttled. Sessions live
137
- 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.
314
+ - **Passwords** are stored as scrypt hashes and repeated failures are throttled, per account, so
315
+ hammering one name cannot lock out the others. Sessions live in memory and do not survive a
316
+ restart.
317
+ - **A role is enforced by the server, not by the interface.** One table names the routes that
318
+ require an admin, and one hook is the only thing that reads it there is no permission check
319
+ hidden inside a handler. What a builder is not shown is also what a builder is refused.
320
+ - **Secrets are encrypted at rest.** Values are stored with AES-256-GCM under a key kept in
321
+ `~/.laneyard/key` outside the database, mode `600`, and Laneyard refuses to start if anyone
322
+ else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
323
+ process holds plaintext: the store, the API and the interface deal in names only, and no route
324
+ ever sends a value back — which is why the Secrets tab has no reveal button.
325
+ - **Masked values are removed from output before it is written, not when it is displayed.** The
326
+ substitution happens once, at the point where a run's output fans out, so the log file on disk,
327
+ the live stream to your browser and the stored error summary all contain `••••••` and never the
328
+ value. It survives being split across two chunks of terminal output.
329
+ - **Do not put secrets in `config.yml`.** It is a plain file with ordinary permissions. Use
330
+ `laneyard secret set` or the Secrets tab.
331
+
332
+ What this does *not* cover, stated plainly:
333
+
334
+ - **Git credentials are not in the vault.** `git_auth` points at an SSH key on disk by path;
335
+ token authentication is refused at load time rather than silently ignored, so a project cannot
336
+ be configured for something that never happens. Laneyard removes the configured repository URL
337
+ from its own git error messages — so a token embedded in an HTTPS URL does not leak that way —
338
+ but that is one string, not a vault.
339
+ - **A value shorter than four characters is refused, not protected.** Removing a two-character
340
+ string from a log would shred the output while hiding nothing, so Laneyard says no rather than
341
+ pretending. Store it unmasked if it genuinely does not matter.
342
+ - **Anything fastlane prints that is not a stored secret is stored in the clear**, under
343
+ `~/.laneyard/logs/`.
147
344
 
148
345
  ## Status
149
346
 
@@ -152,15 +349,24 @@ this blunt.
152
349
  - `✓` declare a project, clone it, list its lanes, run one, watch it live, download the artifact
153
350
  - `✓` configuration entirely in files you can version and back up
154
351
  - `✓` 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
352
+ - `✓` encrypted secret vault and log redaction
353
+ - `✓` build queue, cancellation, timeouts surfaced in the UI
354
+ - `✓` a checklist that gets a project running unattended
355
+ - `✓` edit the Fastfile in the browser, verified on every save
356
+ - `✓` store a signing credential straight from its `.p8` or JSON file
357
+ - `✓` remove a project from the interface, without touching its history
358
+ - `✓` named accounts, with a builder role that never sees a credential
159
359
  - `○` git-triggered and scheduled builds
160
360
 
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.
361
+ Two things worth knowing today: listing lanes does not fetch the repository, so a lane you have
362
+ just pushed appears after the next run; and runs execute one at a time across all projects, so a
363
+ build triggered while another is going waits its turn rather than starting alongside it. A queued
364
+ run survives a restart of the server — it is still queued when it comes back up, and starts on
365
+ its own.
366
+
367
+ ## Changelog
368
+
369
+ See [CHANGELOG.md](CHANGELOG.md).
164
370
 
165
371
  ## Contributing
166
372
 
@@ -1,8 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
- import { access } from "node:fs/promises";
2
+ import { access, realpath } from "node:fs/promises";
3
3
  import { basename, join, relative, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { glob } from "tinyglobby";
6
+ import { detectPlatforms } from "../heuristics/platforms.js";
6
7
  const exec = promisify(execFile);
7
8
  const exists = async (p) => {
8
9
  try {
@@ -30,14 +31,44 @@ function slugify(name) {
30
31
  .replace(/^-+|-+$/g, "");
31
32
  return s === "" ? "project" : s;
32
33
  }
34
+ /** `git@host:owner/name.git` or `https://host/owner/name.git` → `name`. */
35
+ function repositoryName(url) {
36
+ if (!url)
37
+ return null;
38
+ const last = url.replace(/\/+$/, "").split(/[/:]/).pop();
39
+ if (!last)
40
+ return null;
41
+ const name = last.replace(/\.git$/, "");
42
+ return name === "" ? null : name;
43
+ }
44
+ /** The globbing `heuristics/platforms.ts` asks for, bound to one directory. */
45
+ const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
46
+ /** Turns an absolute path into a repository-relative one, with forward slashes. */
47
+ const toRepoPath = (root, absolute) => relative(root, absolute).split(sep).join("/");
33
48
  /**
34
49
  * Inspects an existing project and proposes a configuration.
35
50
  *
36
- * Decides nothing irreversible: everything it returns is a proposal the
37
- * user sees and can correct before it's written.
51
+ * **Every path it reports is relative to the repository root, not to the
52
+ * directory the command was run in.** That distinction is the whole difficulty:
53
+ * Laneyard clones the repository, so a Fastfile at `app/fastlane` is at
54
+ * `app/fastlane` in the workspace no matter which folder someone happened to
55
+ * be standing in when they ran `laneyard setup`. Measuring from the current
56
+ * directory produced a configuration that looked right and pointed nowhere.
57
+ *
58
+ * Decides nothing irreversible: everything it returns is a proposal the user
59
+ * sees and can correct before it's written.
38
60
  */
39
61
  export async function detectProject(dir) {
40
- // The Fastfile can be at the root or under a subfolder, for monorepos.
62
+ // Without a repository there is nothing to clone; `runAddCommand` refuses
63
+ // shortly after, so falling back to `dir` here only keeps this function total.
64
+ // Both sides are resolved before being compared: on macOS the temporary
65
+ // directory is a symlink, and git always answers with the real path — so a
66
+ // raw comparison yields a nonsense `../../private/...` relative path.
67
+ const here = await realpath(dir).catch(() => dir);
68
+ const root = await realpath((await gitOr(["rev-parse", "--show-toplevel"], dir, null)) ?? dir).catch(() => dir);
69
+ const subPath = toRepoPath(root, here);
70
+ // Look from where the user is standing — that is what they meant by "this
71
+ // project" — but report what is found relative to the repository root.
41
72
  const fastfiles = await glob(["fastlane/Fastfile", "*/fastlane/Fastfile", "*/*/fastlane/Fastfile"], {
42
73
  cwd: dir,
43
74
  absolute: true,
@@ -45,26 +76,39 @@ export async function detectProject(dir) {
45
76
  });
46
77
  const fastfile = fastfiles.sort((a, b) => a.length - b.length)[0] ?? null;
47
78
  const fastlaneDir = fastfile
48
- ? relative(dir, join(fastfile, "..")).split(sep).join("/")
79
+ ? toRepoPath(root, await realpath(join(fastfile, "..")).catch(() => join(fastfile, "..")))
49
80
  : null;
50
- const isIos = (await glob(["*.xcodeproj", "*.xcworkspace", "*/*.xcodeproj"], { cwd: dir, onlyDirectories: true }))
51
- .length > 0;
52
- const isAndroid = (await glob(["build.gradle", "build.gradle.kts", "*/build.gradle", "*/build.gradle.kts"], {
53
- cwd: dir,
54
- onlyFiles: true,
55
- })).length > 0;
81
+ const platforms = await detectPlatforms(findIn(dir));
82
+ const isIos = platforms.includes("ios");
83
+ const isAndroid = platforms.includes("android");
84
+ // Artifact patterns are anchored to the sub-project too. In a monorepo an
85
+ // unanchored `**/*.ipa` would collect a sibling app's build as if it were
86
+ // this one's — and nothing downstream would notice.
87
+ const prefix = subPath === "" ? "" : `${subPath}/`;
56
88
  const artifactGlobs = [];
57
89
  if (isIos)
58
- artifactGlobs.push("**/*.ipa", "**/*.app.dSYM.zip");
90
+ artifactGlobs.push(`${prefix}**/*.ipa`, `${prefix}**/*.app.dSYM.zip`);
59
91
  if (isAndroid)
60
- artifactGlobs.push("**/*.apk", "**/*.aab");
92
+ artifactGlobs.push(`${prefix}**/*.apk`, `${prefix}**/*.aab`);
93
+ const gitUrl = await gitOr(["remote", "get-url", "origin"], dir, null);
94
+ // The slug names the repository, and the sub-project when there is one: two
95
+ // apps in the same monorepo must not both want to be called `app`.
96
+ //
97
+ // The name comes from the remote rather than the local folder, because the
98
+ // folder is an accident of where someone cloned. `…/popotheque.git` checked
99
+ // out into `~/work/current` should still be called `popotheque`.
100
+ const repoName = repositoryName(gitUrl) ?? basename(root);
101
+ const slug = slugify(subPath === "" ? repoName : `${repoName}-${subPath}`);
61
102
  return {
62
- slug: slugify(basename(dir)),
63
- gitUrl: await gitOr(["remote", "get-url", "origin"], dir, null),
103
+ slug,
104
+ gitUrl,
64
105
  defaultBranch: (await gitOr(["rev-parse", "--abbrev-ref", "HEAD"], dir, "main")) ?? "main",
65
106
  fastlaneDir,
107
+ // A Gemfile beside the Fastfile is the one fastlane will use, not one at the
108
+ // repository root — `bundle exec` runs from the sub-project.
66
109
  runtime: (await exists(join(dir, "Gemfile"))) ? "bundle" : "system",
67
110
  artifactGlobs,
68
- platform: isIos ? "ios" : isAndroid ? "android" : "unknown",
111
+ platforms,
112
+ subPath,
69
113
  };
70
114
  }
@@ -0,0 +1,36 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ /** Reads from the real terminal. */
3
+ export function terminalAsker() {
4
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5
+ return {
6
+ async ask(label, proposed, hint) {
7
+ if (hint)
8
+ process.stdout.write(`\n ${hint}\n`);
9
+ // The proposal is shown in the prompt rather than typed for the user:
10
+ // pressing Return accepts it, which is what someone does nine times out
11
+ // of ten, and correcting it costs one line.
12
+ const answer = (await rl.question(` ${label} [${proposed}]: `)).trim();
13
+ return answer === "" ? proposed : answer;
14
+ },
15
+ async confirm(question, defaultYes) {
16
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
17
+ const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
18
+ if (answer === "")
19
+ return defaultYes;
20
+ return answer.startsWith("y");
21
+ },
22
+ close() {
23
+ rl.close();
24
+ },
25
+ };
26
+ }
27
+ /** Accepts every proposal without asking. Used by `--yes` and by the tests. */
28
+ export const acceptingAsker = {
29
+ async ask(_label, proposed) {
30
+ return proposed;
31
+ },
32
+ async confirm(_question, defaultYes) {
33
+ return defaultYes;
34
+ },
35
+ close() { },
36
+ };
@@ -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
+ }