laneyard 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +295 -20
  2. package/dist/src/cli/detect.js +69 -16
  3. package/dist/src/cli/prompt.js +61 -0
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +319 -0
  7. package/dist/src/cli/style.js +31 -0
  8. package/dist/src/cli/user.js +127 -0
  9. package/dist/src/config/accounts.js +163 -0
  10. package/dist/src/config/load.js +61 -1
  11. package/dist/src/config/schema.js +26 -1
  12. package/dist/src/config/store.js +10 -0
  13. package/dist/src/config/yaml.js +14 -0
  14. package/dist/src/credentials/kinds.js +120 -0
  15. package/dist/src/db/credentials.js +75 -0
  16. package/dist/src/db/schema.sql +39 -0
  17. package/dist/src/db/secrets.js +28 -0
  18. package/dist/src/db/sessions.js +61 -0
  19. package/dist/src/git/workspace.js +32 -6
  20. package/dist/src/heuristics/android-root.js +49 -0
  21. package/dist/src/heuristics/android-signing.js +98 -0
  22. package/dist/src/heuristics/appfile.js +60 -0
  23. package/dist/src/heuristics/blocking-actions.js +22 -0
  24. package/dist/src/heuristics/env-example.js +36 -0
  25. package/dist/src/heuristics/platforms.js +136 -0
  26. package/dist/src/heuristics/readiness.js +697 -25
  27. package/dist/src/main.js +58 -8
  28. package/dist/src/runner/gradle-properties.js +187 -0
  29. package/dist/src/runner/materialise.js +92 -0
  30. package/dist/src/runner/orchestrate.js +91 -2
  31. package/dist/src/secrets/vault.js +106 -5
  32. package/dist/src/server/app.js +94 -15
  33. package/dist/src/server/auth.js +127 -19
  34. package/dist/src/server/permissions.js +75 -0
  35. package/dist/src/server/required-secrets.js +30 -0
  36. package/dist/src/server/routes/account.js +57 -0
  37. package/dist/src/server/routes/credentials.js +108 -0
  38. package/dist/src/server/routes/projects.js +42 -0
  39. package/dist/src/server/routes/readiness.js +175 -6
  40. package/dist/src/server/routes/secrets.js +101 -0
  41. package/dist/src/server/routes/users.js +64 -0
  42. package/dist/src/sidecar/bridge.js +37 -1
  43. package/dist/src/sidecar/fastlane-dir.js +23 -0
  44. package/dist/src/sidecar/lanes.js +6 -0
  45. package/dist/src/sidecar/uses.js +21 -5
  46. package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DynuBC2l.js} +1 -1
  47. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  48. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  49. package/dist/web/index.html +2 -2
  50. package/package.json +1 -1
  51. package/ruby/introspect.rb +122 -9
  52. package/dist/web/assets/index-D9_EL8LA.js +0 -62
  53. package/dist/web/assets/index-ZUqTX6d1.css +0 -1
package/README.md CHANGED
@@ -25,6 +25,11 @@ the Fastfile you already have and asks *your* fastlane what it can do — plugin
25
25
  hard-codes no knowledge of fastlane at all, so upgrading fastlane or adding a plugin needs no
26
26
  change here.
27
27
 
28
+ The adaptation goes one way. A repository that builds today keeps building unedited: signing
29
+ credentials reach your lanes under the variable names your Fastfile already reads, and where
30
+ Laneyard needs to know something no file can tell it, it asks on a form rather than asking you to
31
+ change the file.
32
+
28
33
  ## Where it fits
29
34
 
30
35
  | | Laneyard | Hosted CI | Self-hosted runner |
@@ -58,14 +63,15 @@ Then, from a project you already build with fastlane:
58
63
 
59
64
  ```bash
60
65
  cd ~/code/your-app
61
- laneyard add # adopt this project
66
+ laneyard setup # answer a few questions about this project
62
67
  laneyard # start the server
63
68
  ```
64
69
 
65
- `laneyard add` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
66
- `Gemfile`, an Xcode project or a Gradle build — writes the matching block into
67
- `~/.laneyard/config.yml`, and prints a generated server password once. Write it down; it is not
68
- shown again.
70
+ `laneyard setup` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
71
+ `Gemfile`, an Xcode project or a Gradle build — and writes the matching block into
72
+ `~/.laneyard/config.yml`. On a machine that has no account yet, it also creates the first admin:
73
+ it asks what to call it and prints its generated password once. Write it down; it is not shown
74
+ again, and nothing stores it.
69
75
 
70
76
  <details>
71
77
  <summary>Running from source instead</summary>
@@ -95,7 +101,9 @@ Laneyard means copying one file, and restoring it means copying it back.
95
101
  server:
96
102
  port: 7890
97
103
  bind: 0.0.0.0
98
- password_hash: "scrypt$…" # written by `laneyard add`
104
+ users: # written by `laneyard setup`, see Accounts
105
+ - { name: martin, role: admin, password_hash: "scrypt$…" }
106
+ - { name: lea, role: builder, password_hash: "scrypt$…" }
99
107
  max_concurrent_runs: 1 # only 1 is accepted, see below
100
108
  retention: { runs: 50, artifact_days: 30 }
101
109
 
@@ -112,9 +120,63 @@ a time across every project — parallel runs would need a working directory per
112
120
  not exist yet. A larger number is refused when the file loads rather than silently ignored, so a
113
121
  server is never configured for builds that never happen.
114
122
 
115
- ### `laneyard.yml` — optional, committed in your repository
123
+ ### Accounts
124
+
125
+ Everyone who signs in has a name, a password and one of two roles. Two, because a third role is
126
+ easy to add and impossible to remove.
127
+
128
+ | | **admin** | **builder** |
129
+ |---|---|---|
130
+ | start a build, watch it, cancel it | ✓ | ✓ |
131
+ | download artifacts, read logs and the Fastfile | ✓ | ✓ |
132
+ | see the readiness checklist | ✓ | ✓ |
133
+ | read and write secrets | ✓ | |
134
+ | save, commit and push the Fastfile | ✓ | |
135
+ | remove a project | ✓ | |
136
+ | manage accounts | ✓ | |
137
+
138
+ A builder is what you give someone who ships without being trusted with the signing chain: they
139
+ can press the button and watch what happens, and they never see a credential.
140
+
141
+ The interface shows a builder only what a builder can use — the secrets, fastfile and settings
142
+ tabs are not drawn, and neither is the accounts screen. That is courtesy, not security: the
143
+ server refuses those routes on its own, whatever the browser was shown, and the test suite
144
+ proves it for every verb and every spelling of the address.
145
+
146
+ Add and remove accounts from the accounts screen, or from the command line:
147
+
148
+ ```bash
149
+ echo "$PASSWORD" | laneyard user add lea --role builder
150
+ ```
116
151
 
117
- Build behaviour belongs next to the code, so it can be versioned with it:
152
+ The password is read from standard input, never taken as an argument an argument lands in your
153
+ shell history. Without `--role`, the account is a builder.
154
+
155
+ Two things are refused, in the API and on the command line alike: removing the last admin, and
156
+ demoting the last admin. A server nobody can administer cannot be repaired from the interface.
157
+
158
+ Anyone changes their own password from **your account**, reached by clicking your name in the
159
+ header — a builder included, since that page is about one person rather than about the server's
160
+ list of people. It asks for the current password even though you are already signed in: a session
161
+ is a cookie in a browser that may have been left open on a desk. Doing it ends every other session
162
+ that account has, and leaves the page you did it on signed in. That is how the random password
163
+ `laneyard setup` printed once stops being a string on a sticky note.
164
+
165
+ Removing an account ends its sessions immediately — "remove the account" and "revoke access" are
166
+ the same act. So does editing `config.yml` by hand: every request looks the account up again, so
167
+ a demotion takes effect at once rather than at the next restart.
168
+
169
+ **Upgrading from 0.2.** An existing `server.password_hash` keeps working, unedited. It is read as
170
+ a single admin account called `admin` — sign in with that name and the password you already have.
171
+ The first time you add someone, the file is rewritten into the `users` form above, comments and
172
+ all. Do not write both forms: a file holding a `password_hash` *and* a `users` list is refused at
173
+ load, because there is no obvious winner.
174
+
175
+ ### `laneyard.yml` — in your repository, and committed
176
+
177
+ Build behaviour belongs next to the code, so it can be versioned with it — `laneyard setup`
178
+ writes this file for you, and you should commit it. A colleague who clones the repository then
179
+ builds it the same way, without configuring anything.
118
180
 
119
181
  ```yaml
120
182
  fastlane_dir: fastlane
@@ -123,8 +185,20 @@ timeout_minutes: 60
123
185
  artifact_globs:
124
186
  - "build/**/*.ipa"
125
187
  - "build/**/*.app.dSYM.zip"
188
+ platforms: [ios] # or `[android]`, or both
126
189
  ```
127
190
 
191
+ `platforms` decides which half of the readiness checklist applies: an Android project is never
192
+ asked for an App Store Connect key. Left out, Laneyard looks at the repository — an Xcode project
193
+ means iOS, a Gradle build means Android — and reports what it found rather than assuming.
194
+
195
+ It looks **beside the Fastfile**, not at the repository root, because that is where an app keeps
196
+ its platform folders: `ios/` and `fastlane/` are siblings, and both move together when the app is
197
+ one directory of a monorepo. So `app/fastlane/Fastfile` alongside `app/ios/Runner.xcodeproj` is
198
+ found, and a project configured with `ios/fastlane` reports iOS alone rather than being shown the
199
+ Android section on the strength of a sibling folder its lanes never touch. When that guess is
200
+ wrong, `platforms` is read first and settles it.
201
+
128
202
  Field by field, the repository file wins over the server block, which wins over the defaults. Any
129
203
  field of `laneyard.yml` may also be written in the server block, so a repository you would rather
130
204
  not touch can be configured entirely from `config.yml`.
@@ -134,8 +208,9 @@ reported and the last valid configuration stays live — a typo never takes the
134
208
 
135
209
  ### Secrets
136
210
 
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:
211
+ The variables your lanes read do not live in a file. They go into an encrypted vault, from the
212
+ Secrets tab of a project or from the command line — the files a project signs with go in the same
213
+ vault, as blocks, and have a section of their own below:
139
214
 
140
215
  ```bash
141
216
  laneyard secret set MATCH_PASSWORD --project cartes-ios # reads the value from standard input
@@ -146,25 +221,182 @@ The value is never an argument: a command line ends up in `~/.zsh_history` and i
146
221
  `ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
147
222
  `Ctrl-D`.
148
223
 
224
+ **Reading one back.** The vault is write-only for anything you called a secret: the server never
225
+ sends a masked value back, so the interface has nothing to uncover and no browser ever holds one.
226
+
227
+ Not everything stored here is a secret, though — `APP_VERSION`, `SENTRY_ORG`, an issuer id are
228
+ identifiers, and being unable to check what an import stored makes the import something you take on
229
+ faith. So the line is the one you drew yourself: a value kept out of the logs is never returned; a
230
+ value you stored without that is shown on request, one named key at a time. `mask` and `unmask`
231
+ change which it is without touching the value — otherwise revealing something would mean first
232
+ retyping the value you were trying to read.
233
+
234
+ **Bring the ones you already have.** A project that builds today has its variables in
235
+ `fastlane/.env` — gitignored, on one laptop, and therefore absent from the clone a build runs
236
+ from. From that working copy:
237
+
238
+ ```bash
239
+ laneyard secret import --project cartes-ios # shows what it would store
240
+ laneyard secret import --project cartes-ios --yes # stores it
241
+ ```
242
+
243
+ It runs from the CLI because that is where the `.env` is; the server only ever sees a clone. A
244
+ variable naming a service account JSON has the **file's contents** stored, under `SUPPLY_JSON_KEY_DATA`
245
+ — a name supply reads on its own — because a path does not travel to another machine. A variable
246
+ naming a `.p8` is reported and left alone: no action in fastlane reads a `.p8` out of an environment
247
+ variable, and that credential belongs in a signing block, described below. Everything stored is
248
+ masked, and nothing is printed but names.
249
+
250
+ Nothing in your lanes has to change afterwards. `key_filepath:` and `json_key:` keep working as
251
+ written — a signing block puts a real file back on disk for the length of a run.
252
+
149
253
  A secret becomes an environment variable for every run of the project it belongs to. Without
150
254
  `--project` it applies to every project; a project secret of the same name wins over a global
151
255
  one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
152
256
  least four characters long — see below.
153
257
 
258
+ ### Signing credentials
259
+
260
+ A signing credential is not a string. An Android keystore is bytes that Gradle reads through a path,
261
+ and a `.p8` is useless without the key id and the issuer id that go with it — so these are stored as
262
+ blocks: one file, plus the handful of fields that make it usable, taken whole or refused. A keystore
263
+ stored without its alias is not a partial success; it is a build that fails in a month.
264
+
265
+ | block | the file | the fields beside it |
266
+ | ------------------------------ | ---------------------- | ------------------------------------------- |
267
+ | *app store connect key* | `.p8` | key id, issuer id |
268
+ | *android upload keystore* | `.jks` or `.keystore` | key alias, store password, key password |
269
+ | *play store service account* | JSON | — |
270
+
271
+ They live in the **signing** part of a project's Secrets tab, beside the variables and the secrets.
272
+ The file is encrypted at rest like everything else in the vault and never comes back out to a
273
+ browser: a stored block shows its file name and nothing more, so replacing one means giving it again
274
+ in full.
275
+
276
+ A variable a block has made redundant is said to be so, beside the row and in one sentence:
277
+ `SUPPLY_JSON_KEY_DATA` once a Play block applies — it still works, but the same credential is then
278
+ stored twice — and `APP_STORE_CONNECT_API_KEY_P8`, which no action in fastlane has ever read.
279
+ Neither is removed for you: the row is yours, and the button to drop it is on the same line.
280
+
281
+ **A block becomes real files, for the length of a run.** Gradle's `storeFile` is a path, and
282
+ `app_store_connect_api_key` wants a path, so a credential that exists only as ciphertext in a
283
+ database cannot be used by anything. Each block that applies is written into
284
+ `~/.laneyard/runs/<run id>/secrets/`, mode `600` in a `700` directory, and that directory is removed
285
+ when the run ends — whether it passed, failed, was cancelled or timed out. Every applicable block is
286
+ written, whether or not the lane looks like it needs one: a Fastfile can reach anything through `sh`
287
+ or a plugin, and a guess of "not needed" that is wrong is a debug-signed artifact rather than a
288
+ missing variable.
289
+
290
+ **It reaches your lanes under the names your project already reads.** Each block's form arrives
291
+ pre-filled with the names fastlane itself declares — `APP_STORE_CONNECT_API_KEY_KEY_FILEPATH`,
292
+ `APP_STORE_CONNECT_API_KEY_KEY_ID`, `APP_STORE_CONNECT_API_KEY_ISSUER_ID` for the key, and
293
+ `SUPPLY_JSON_KEY` for the service account — and every one of them is editable. A Fastfile written
294
+ around `ENV.fetch("ASC_KEY_FILEPATH")` is not a Fastfile doing it wrong: you say so on that form,
295
+ rather than being asked to rename anything in the repository. The name stored with the block is the
296
+ only name exported, and no default is emitted alongside it as a courtesy — that courtesy is what
297
+ would make a typo in the configured name look like it had worked.
298
+
299
+ Nothing in fastlane reads a keystore by convention, so the keystore's names are Laneyard's own —
300
+ `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` —
301
+ and the same rule applies to them.
302
+
303
+ **The keystore block can also supply `key.properties`.** The Flutter documentation's own build
304
+ script signs with the release config when that file exists and with the debug config when it does
305
+ not, and gitignores it — so on a build server it is always absent. Rather than telling you to
306
+ rewrite your build script, Laneyard writes the file the script is already looking for, for the
307
+ length of the run, out of the keystore block. Two things about that file cannot be read out of a
308
+ build script and are asked for on the block instead: where it goes, when the script names it in a
309
+ way that leaves the directory unresolved, and what the keys inside it are called, which start from
310
+ the Flutter documentation's four. Asking at configuration time is allowed; requiring a change to
311
+ your repository is not.
312
+
313
+ **Only the projects that sign need any of this.** fastlane is not only for shipping to stores —
314
+ lanes take screenshots, run tests, sync certificates — so the three blocks are an offer rather than
315
+ a gate. A project that wants an artifact out of a Gradle build needs the keystore and nothing else,
316
+ and three untouched circles are not three things it is failing.
317
+
318
+ A block stored on a project belongs to that project. One stored globally applies to every project,
319
+ and a project's own block wins over it. Both are admin-only, like the rest of the vault.
320
+
154
321
  ### Readiness
155
322
 
156
- Every project has a Readiness tab: five checks on what stands between it and a build that runs
157
- while nobody watches.
323
+ Every project has a Readiness tab: what stands between it and a build that runs while nobody
324
+ watches. Only the checks that apply to the project are shown — an Android project is never asked
325
+ for an App Store Connect key, because one irrelevant warning teaches you to ignore the screen.
326
+
327
+ **What a tick means.** The checks read your Fastfile, following a lane into the methods that
328
+ Fastfile defines — factoring your lanes into `def deploy_ios` is good practice, not something that
329
+ should make Laneyard blind. Two things stay out of reach and always will: `import`/`import_from_git`
330
+ brings in lanes written elsewhere, and `fastlane/actions/` holds actions whose names mean nothing to
331
+ a reader that has only seen the Fastfile. Where either applies, a check that found nothing says
332
+ *could not tell* rather than ticking. A green tick here means "looked, and it is fine" — never
333
+ "looked, and saw nothing".
334
+
335
+ Always:
158
336
 
159
337
  - **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
160
338
  password prompt does not fail, it waits;
161
339
  - **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
162
340
  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;
341
+ - **no lane calls an action known to stop and ask** `prompt`, `sigh`, `cert`, a writable
342
+ `match`, an upload waiting for its summary to be confirmed;
343
+ - **the variables the lanes read** are in the vault. Every `ENV.fetch("…")` a lane reaches is
344
+ collected and looked up. This is the check for the commonest way a project that works on your
345
+ laptop fails on a build server: the variables live in `fastlane/.env`, that file is gitignored,
346
+ and it never reaches the clone a build runs from — so the run stops at the first one with
347
+ nothing on screen to say why.
348
+
349
+ Two things a Fastfile cannot tell you, and two places to say them. A variable read by a tool the
350
+ lane shells out to — `sentry-cli` and its `SENTRY_AUTH_TOKEN` — is named nowhere in the lanes. A
351
+ committed `fastlane/.env.example` is read for exactly this, since that is what the file is for,
352
+ and `required_secrets` in `laneyard.yml` covers whatever it does not. A variable found only in
353
+ the server's own environment is reported rather than ticked over: it works, but it works because
354
+ of how this server was started.
355
+
356
+ A name a signing block exports counts as being in the vault, since that is where the block is:
357
+ a lane reading `SUPPLY_JSON_KEY` with a Play block stored is not asked for it again, on this
358
+ checklist or on the Secrets tab. The two read the same answer.
359
+
360
+ On iOS:
361
+
362
+ - **App Store Connect** has an API key. The vault is checked first and is the only thing that
363
+ earns a tick — a signing block, or the variables a project stored before blocks existed, since
364
+ fastlane reads those exactly as it did. A project that configured fastlane long before it met
365
+ Laneyard keeps its key elsewhere, so the lanes are read for `app_store_connect_api_key` and for a `key_filepath` or
366
+ `api_key_path` argument, and the repository for a `.p8`. Any of those is reported as *could not
367
+ tell*, not as a warning: a path in a Fastfile says a key was arranged, not that the file is on
368
+ this machine. An Appfile holding only an `apple_id` is a warning — that is the account
369
+ two-factor authentication will stop the run to ask about. One name is called out rather than
370
+ accepted: a value stored under `APP_STORE_CONNECT_API_KEY_P8` used to earn a tick here, and no
371
+ action in fastlane has ever read a variable of that name — the check now says so, because being
372
+ told to redo the work beats a screen that has quietly gone silent about it;
165
373
  - **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`.
374
+ instead of trying to create them.
375
+
376
+ On Android:
377
+
378
+ - **the keystore** is reachable without a prompt: a lane handing `gradle` a `storeFile` needs a
379
+ passphrase, and one that is neither in the call nor in the vault makes gradle stop and ask. A
380
+ keystore block settles this before the lanes are read at all — the file and both passphrases
381
+ reach the run together, so nothing can stop and ask, whichever lane runs;
382
+ - **the release is signed with the release key.** The one check here whose failure is silent:
383
+ the Flutter documentation's own snippet signs with the release config when `key.properties`
384
+ exists and with the *debug* config when it does not — and gitignores `key.properties`, so it is
385
+ absent from every clone. The build then succeeds, produces an artifact signed with the debug key,
386
+ and the rejection arrives from the store minutes later saying nothing about signing. This reads
387
+ the Gradle file as text and says so before the build, not after — and then, for a project whose
388
+ keystore is stored here, writes the `key.properties` that build is already asking for, for the
389
+ length of the run. Your build script is not asked to change: the file arrives where it looks for
390
+ it, marked `# written by laneyard, do not commit`, and is removed when fastlane stops. A file of
391
+ your own without that marker is never written over and never deleted;
392
+ - **the Play Store service account** is there when a lane calls `upload_to_play_store`. The vault
393
+ first — a block, or a `SUPPLY_JSON_KEY` variable stored before blocks existed — then the
394
+ `json_key` argument in the call, then the **Appfile**: `json_key_file` and `json_key_data`, which
395
+ is where a long-standing project almost always keeps it. Only the vault is a tick; the other two
396
+ are *could not tell*, for the same reason as above.
397
+
398
+ Like the iOS ones, the Android checks read **literal arguments only**. `gradle(storePassword:
399
+ ENV["PW"])` is reported as undetermined, never guessed at: a checklist that guesses gets believed.
168
400
 
169
401
  They run when you open the tab or press refresh, never on their own: they shell out to git and to
170
402
  bundler. The time of the last run is on screen, because a stale green tick is worse than a red
@@ -179,7 +411,9 @@ growing a second copy of its form.
179
411
  green and `match(readonly: false)` is a warning, but `match(readonly: ENV["RO"])` has no value
180
412
  until the lane runs, so it is reported as undetermined — `○`, with the reason — rather than
181
413
  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.
414
+ believed, and then it is worse than no checklist. Android signing is read out of the Gradle build
415
+ script rather than the Fastfile, since that is where it lives — and read as text, because running
416
+ someone's build script to ask it a question is not something a checklist may do.
183
417
 
184
418
  ### The Fastfile
185
419
 
@@ -204,20 +438,51 @@ Below the editor is what git makes of the workspace: the diff, a message field,
204
438
  `push`. A commit stages exactly the files that changed and never `git add -A` — a build leaves
205
439
  artifacts and reports scattered around, and none of them belong in your history.
206
440
 
441
+ ### Removing a project
442
+
443
+ Every project has a Settings tab, and the one thing on it is removal. It takes the project's block
444
+ out of `config.yml` — through the YAML document, so your comments and your key order survive —
445
+ and stops showing it. It is confirmed by typing the project's name: it is the one destructive
446
+ action in Laneyard, and a dialogue you can click through is not a confirmation.
447
+
448
+ What it does *not* do is most of the point, because "delete" elsewhere usually means the opposite:
449
+
450
+ - **the runs stay.** Every build that project ever ran keeps its page, its log and its artifacts,
451
+ each still at its own address. Removing a project means stop showing it, not destroy its past;
452
+ - **the clone and the artifacts stay on disk.** Their paths are printed when it is done, so you
453
+ can remove them yourself. Nothing is deleted from a web page on one click;
454
+ - **the secrets stay in the vault**, encrypted and unreachable, and come back if you add the
455
+ project again under the same name;
456
+ - **the repository is untouched.** Its `laneyard.yml`, its Fastfile and its history are the
457
+ repository's, not Laneyard's.
458
+
459
+ Removal is refused while a run of that project is in flight — that run is using the workspace. A
460
+ run still waiting in the queue will not start: it ends as failed, saying its project is gone.
461
+
207
462
  ## Security
208
463
 
209
464
  Read this before putting Laneyard on a network.
210
465
 
211
466
  - **It is built for a local network, not the internet.** It listens on `0.0.0.0` so you can reach
212
- it from your laptop, behind one password. Do not expose it publicly. If you need remote access,
467
+ it from your laptop, behind a password. Do not expose it publicly. If you need remote access,
213
468
  put it behind a VPN or an SSH tunnel.
214
- - **The password** is stored as an scrypt hash and repeated failures are throttled. Sessions live
215
- in memory and do not survive a restart.
469
+ - **Passwords** are stored as scrypt hashes and repeated failures are throttled, per account, so
470
+ hammering one name cannot lock out the others. Sessions survive a restart, and what is stored is
471
+ a SHA-256 of the token rather than the token: a stolen `laneyard.db` is a list of digests, not a
472
+ ring of working keys.
473
+ - **A role is enforced by the server, not by the interface.** One table names the routes that
474
+ require an admin, and one hook is the only thing that reads it — there is no permission check
475
+ hidden inside a handler. What a builder is not shown is also what a builder is refused.
216
476
  - **Secrets are encrypted at rest.** Values are stored with AES-256-GCM under a key kept in
217
477
  `~/.laneyard/key` — outside the database, mode `600`, and Laneyard refuses to start if anyone
218
478
  else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
219
479
  process holds plaintext: the store, the API and the interface deal in names only, and no route
220
480
  ever sends a value back — which is why the Secrets tab has no reveal button.
481
+ - **A signing block is on disk only while a run needs it.** A keystore has no string form, so the
482
+ file is written into `~/.laneyard/runs/<run id>/secrets/`, mode `600` inside a `700` directory,
483
+ and that directory goes when the run ends. The block's secret fields — the two keystore
484
+ passphrases — are removed from a run's output the same way a masked secret is, because gradle is
485
+ perfectly willing to echo one back on failure.
221
486
  - **Masked values are removed from output before it is written, not when it is displayed.** The
222
487
  substitution happens once, at the point where a run's output fans out, so the log file on disk,
223
488
  the live stream to your browser and the stored error summary all contain `••••••` and never the
@@ -237,6 +502,12 @@ What this does *not* cover, stated plainly:
237
502
  pretending. Store it unmasked if it genuinely does not matter.
238
503
  - **Anything fastlane prints that is not a stored secret is stored in the clear**, under
239
504
  `~/.laneyard/logs/`.
505
+ - **`key.properties` is written into the workspace, and it holds passwords.** It is the one
506
+ credential Laneyard puts in the clone rather than in the run's own directory, because Gradle
507
+ resolves that path relative to the build. It is mode `600`, carries a marker line as its first
508
+ line, is removed when the run ends, and is swept for again at the start of the next run in case a
509
+ server was killed mid-build. A file of yours without that marker is never written over and never
510
+ removed.
240
511
 
241
512
  ## Status
242
513
 
@@ -249,6 +520,10 @@ What this does *not* cover, stated plainly:
249
520
  - `✓` build queue, cancellation, timeouts surfaced in the UI
250
521
  - `✓` a checklist that gets a project running unattended
251
522
  - `✓` edit the Fastfile in the browser, verified on every save
523
+ - `✓` signing credentials stored whole — the file and the fields beside it — written to disk for
524
+ the length of a run and exported under the names your project already reads
525
+ - `✓` remove a project from the interface, without touching its history
526
+ - `✓` named accounts, with a builder role that never sees a credential
252
527
  - `○` git-triggered and scheduled builds
253
528
 
254
529
  Two things worth knowing today: listing lanes does not fetch the repository, so a lane you have
@@ -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, searchDir } 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; `runSetupCommand` 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,48 @@ 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
+ // From beside the Fastfile rather than from where the user is standing: the
82
+ // markers are the app's siblings, and `*/*/fastlane/Fastfile` above already
83
+ // allows the app to be two directories down, out of reach of the two-level
84
+ // marker globs from here.
85
+ // `fastlaneDir` is relative to the repository root, while the listing happens
86
+ // where the user is standing — the same directory only when they are at the
87
+ // root. The absolute Fastfile path needs no such reconciling, so the app root
88
+ // is taken from it: the Fastfile's grandparent is the app.
89
+ const appRoot = fastfile ? toRepoPath(dir, join(fastfile, "..", "..")) : null;
90
+ const platforms = await detectPlatforms(findIn(searchDir(dir, appRoot)));
91
+ const isIos = platforms.includes("ios");
92
+ const isAndroid = platforms.includes("android");
93
+ // Artifact patterns are anchored to the sub-project too. In a monorepo an
94
+ // unanchored `**/*.ipa` would collect a sibling app's build as if it were
95
+ // this one's — and nothing downstream would notice.
96
+ const prefix = subPath === "" ? "" : `${subPath}/`;
56
97
  const artifactGlobs = [];
57
98
  if (isIos)
58
- artifactGlobs.push("**/*.ipa", "**/*.app.dSYM.zip");
99
+ artifactGlobs.push(`${prefix}**/*.ipa`, `${prefix}**/*.app.dSYM.zip`);
59
100
  if (isAndroid)
60
- artifactGlobs.push("**/*.apk", "**/*.aab");
101
+ artifactGlobs.push(`${prefix}**/*.apk`, `${prefix}**/*.aab`);
102
+ const gitUrl = await gitOr(["remote", "get-url", "origin"], dir, null);
103
+ // The slug names the repository, and the sub-project when there is one: two
104
+ // apps in the same monorepo must not both want to be called `app`.
105
+ //
106
+ // The name comes from the remote rather than the local folder, because the
107
+ // folder is an accident of where someone cloned. `…/popotheque.git` checked
108
+ // out into `~/work/current` should still be called `popotheque`.
109
+ const repoName = repositoryName(gitUrl) ?? basename(root);
110
+ const slug = slugify(subPath === "" ? repoName : `${repoName}-${subPath}`);
61
111
  return {
62
- slug: slugify(basename(dir)),
63
- gitUrl: await gitOr(["remote", "get-url", "origin"], dir, null),
112
+ slug,
113
+ gitUrl,
64
114
  defaultBranch: (await gitOr(["rev-parse", "--abbrev-ref", "HEAD"], dir, "main")) ?? "main",
65
115
  fastlaneDir,
116
+ // A Gemfile beside the Fastfile is the one fastlane will use, not one at the
117
+ // repository root — `bundle exec` runs from the sub-project.
66
118
  runtime: (await exists(join(dir, "Gemfile"))) ? "bundle" : "system",
67
119
  artifactGlobs,
68
- platform: isIos ? "ios" : isAndroid ? "android" : "unknown",
120
+ platforms,
121
+ subPath,
69
122
  };
70
123
  }
@@ -0,0 +1,61 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ /**
3
+ * Thrown when the user walks away from a question — Ctrl-C or Ctrl-D. Not a
4
+ * failure: the caller turns it into one sentence and a 130 exit code.
5
+ */
6
+ export class PromptAborted extends Error {
7
+ constructor() {
8
+ super("interrupted");
9
+ this.name = "PromptAborted";
10
+ }
11
+ }
12
+ /** Reads from the real terminal. */
13
+ export function terminalAsker() {
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ // Left alone, Ctrl-C closes the interface while `rl.question` stays pending
16
+ // forever, and node reports that as "Detected unsettled top-level await" —
17
+ // an internal complaint about our code, printed at someone who just pressed
18
+ // Ctrl-C. Aborting the question makes it a rejection we can answer for.
19
+ const interrupted = new AbortController();
20
+ rl.on("SIGINT", () => rl.close());
21
+ rl.on("close", () => interrupted.abort());
22
+ async function question(prompt) {
23
+ try {
24
+ return await rl.question(prompt, { signal: interrupted.signal });
25
+ }
26
+ catch {
27
+ throw new PromptAborted();
28
+ }
29
+ }
30
+ return {
31
+ async ask(label, proposed, hint) {
32
+ if (hint)
33
+ process.stdout.write(`\n ${hint}\n`);
34
+ // The proposal is shown in the prompt rather than typed for the user:
35
+ // pressing Return accepts it, which is what someone does nine times out
36
+ // of ten, and correcting it costs one line.
37
+ const answer = (await question(` ${label} [${proposed}]: `)).trim();
38
+ return answer === "" ? proposed : answer;
39
+ },
40
+ async confirm(question_, defaultYes) {
41
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
42
+ const answer = (await question(`${question_} ${suffix} `)).trim().toLowerCase();
43
+ if (answer === "")
44
+ return defaultYes;
45
+ return answer.startsWith("y");
46
+ },
47
+ close() {
48
+ rl.close();
49
+ },
50
+ };
51
+ }
52
+ /** Accepts every proposal without asking. Used by `--yes` and by the tests. */
53
+ export const acceptingAsker = {
54
+ async ask(_label, proposed) {
55
+ return proposed;
56
+ },
57
+ async confirm(_question, defaultYes) {
58
+ return defaultYes;
59
+ },
60
+ close() { },
61
+ };