laneyard 0.3.0 → 0.4.1

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 (46) hide show
  1. package/README.md +227 -20
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/cli/uninstall.js +390 -0
  8. package/dist/src/credentials/kinds.js +120 -0
  9. package/dist/src/db/credentials.js +100 -0
  10. package/dist/src/db/schema.sql +39 -0
  11. package/dist/src/db/secrets.js +53 -0
  12. package/dist/src/db/sessions.js +61 -0
  13. package/dist/src/git/workspace.js +32 -6
  14. package/dist/src/heuristics/android-root.js +49 -0
  15. package/dist/src/heuristics/android-signing.js +98 -0
  16. package/dist/src/heuristics/appfile.js +60 -0
  17. package/dist/src/heuristics/blocking-actions.js +2 -0
  18. package/dist/src/heuristics/env-example.js +36 -0
  19. package/dist/src/heuristics/platforms.js +69 -10
  20. package/dist/src/heuristics/readiness.js +554 -25
  21. package/dist/src/main.js +23 -2
  22. package/dist/src/runner/gradle-properties.js +187 -0
  23. package/dist/src/runner/materialise.js +92 -0
  24. package/dist/src/runner/orchestrate.js +91 -2
  25. package/dist/src/secrets/vault.js +137 -5
  26. package/dist/src/server/app.js +30 -3
  27. package/dist/src/server/auth.js +50 -10
  28. package/dist/src/server/permissions.js +2 -0
  29. package/dist/src/server/required-secrets.js +30 -0
  30. package/dist/src/server/routes/account.js +57 -0
  31. package/dist/src/server/routes/credentials.js +108 -0
  32. package/dist/src/server/routes/projects.js +60 -2
  33. package/dist/src/server/routes/readiness.js +162 -6
  34. package/dist/src/server/routes/secrets.js +101 -0
  35. package/dist/src/sidecar/bridge.js +21 -1
  36. package/dist/src/sidecar/fastlane-dir.js +23 -0
  37. package/dist/src/sidecar/lanes.js +6 -0
  38. package/dist/src/sidecar/uses.js +21 -5
  39. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-9nLYwtg7.js} +1 -1
  40. package/dist/web/assets/index-DsjxZtsO.css +1 -0
  41. package/dist/web/assets/index-pzPa9EZr.js +62 -0
  42. package/dist/web/index.html +2 -2
  43. package/package.json +1 -1
  44. package/ruby/introspect.rb +122 -9
  45. package/dist/web/assets/index-De6sxx6G.css +0 -1
  46. package/dist/web/assets/index-TWRQ1cJg.js +0 -62
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 |
@@ -150,6 +155,13 @@ shell history. Without `--role`, the account is a builder.
150
155
  Two things are refused, in the API and on the command line alike: removing the last admin, and
151
156
  demoting the last admin. A server nobody can administer cannot be repaired from the interface.
152
157
 
158
+ Anyone changes their own password from **your account**, which is said in those words in the header
159
+ and again on your own row of the accounts screen — a builder included, since that page is about one
160
+ person rather than about the server's 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
+
153
165
  Removing an account ends its sessions immediately — "remove the account" and "revoke access" are
154
166
  the same act. So does editing `config.yml` by hand: every request looks the account up again, so
155
167
  a demotion takes effect at once rather than at the next restart.
@@ -180,6 +192,13 @@ platforms: [ios] # or `[android]`, or both
180
192
  asked for an App Store Connect key. Left out, Laneyard looks at the repository — an Xcode project
181
193
  means iOS, a Gradle build means Android — and reports what it found rather than assuming.
182
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
+
183
202
  Field by field, the repository file wins over the server block, which wins over the defaults. Any
184
203
  field of `laneyard.yml` may also be written in the server block, so a repository you would rather
185
204
  not touch can be configured entirely from `config.yml`.
@@ -189,8 +208,9 @@ reported and the last valid configuration stays live — a typo never takes the
189
208
 
190
209
  ### Secrets
191
210
 
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:
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:
194
214
 
195
215
  ```bash
196
216
  laneyard secret set MATCH_PASSWORD --project cartes-ios # reads the value from standard input
@@ -201,25 +221,117 @@ The value is never an argument: a command line ends up in `~/.zsh_history` and i
201
221
  `ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
202
222
  `Ctrl-D`.
203
223
 
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.
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.
211
252
 
212
253
  A secret becomes an environment variable for every run of the project it belongs to. Without
213
254
  `--project` it applies to every project; a project secret of the same name wins over a global
214
255
  one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
215
256
  least four characters long — see below.
216
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
+
217
321
  ### Readiness
218
322
 
219
323
  Every project has a Readiness tab: what stands between it and a build that runs while nobody
220
324
  watches. Only the checks that apply to the project are shown — an Android project is never asked
221
325
  for an App Store Connect key, because one irrelevant warning teaches you to ignore the screen.
222
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
+
223
335
  Always:
224
336
 
225
337
  - **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
@@ -227,20 +339,61 @@ Always:
227
339
  - **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
228
340
  would otherwise find on the PATH;
229
341
  - **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.
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.
231
359
 
232
360
  On iOS:
233
361
 
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;
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;
236
373
  - **match** has its `MATCH_PASSWORD` stored and is called `readonly`, so it fetches certificates
237
374
  instead of trying to create them.
238
375
 
239
376
  On Android:
240
377
 
241
378
  - **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`.
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.
244
397
 
245
398
  Like the iOS ones, the Android checks read **literal arguments only**. `gradle(storePassword:
246
399
  ENV["PW"])` is reported as undetermined, never guessed at: a checklist that guesses gets believed.
@@ -258,7 +411,9 @@ growing a second copy of its form.
258
411
  green and `match(readonly: false)` is a warning, but `match(readonly: ENV["RO"])` has no value
259
412
  until the lane runs, so it is reported as undetermined — `○`, with the reason — rather than
260
413
  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.
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.
262
417
 
263
418
  ### The Fastfile
264
419
 
@@ -296,14 +451,52 @@ What it does *not* do is most of the point, because "delete" elsewhere usually m
296
451
  each still at its own address. Removing a project means stop showing it, not destroy its past;
297
452
  - **the clone and the artifacts stay on disk.** Their paths are printed when it is done, so you
298
453
  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;
454
+ - **the secrets and the signing blocks stay in the vault**, encrypted. The screen counts them
455
+ before you confirm and again once it is done, because they are the one thing on this list you
456
+ cannot go and look at — no route ever sends a credential back. Their scope is the slug, so a
457
+ project set up later under the same name finds them and would sign with a keystore nobody
458
+ uploaded;
301
459
  - **the repository is untouched.** Its `laneyard.yml`, its Fastfile and its history are the
302
460
  repository's, not Laneyard's.
303
461
 
304
462
  Removal is refused while a run of that project is in flight — that run is using the workspace. A
305
463
  run still waiting in the queue will not start: it ends as failed, saying its project is gone.
306
464
 
465
+ Once it is done, the result screen offers the one thing removal deliberately did not do: removing
466
+ what the vault still holds under that name. It is a second action, confirmed by typing the name
467
+ again, and it is never part of removing the project — a `.p8` or a keystore cannot be read back
468
+ out of Laneyard, so the copy it holds is the only one it ever had. Global secrets and global
469
+ signing blocks are shared by every project and are left alone.
470
+
471
+ ### Uninstalling
472
+
473
+ ```bash
474
+ laneyard uninstall --dry-run # list what is there, and stop
475
+ laneyard uninstall # remove it, after a typed confirmation
476
+ npm uninstall -g laneyard # remove the package itself
477
+ ```
478
+
479
+ `laneyard uninstall` removes the data folder: `config.yml`, the vault key, the database, the
480
+ workspaces, the artifacts and the logs. It reads the whole inventory from disk first — the
481
+ projects, the number of secrets and signing blocks, the real sizes and paths — and prints it
482
+ before asking anything.
483
+
484
+ The vault key is the one loss that cannot be undone. Every secret and every signing block is
485
+ encrypted under `~/.laneyard/key`; once it is gone the database is ciphertext nobody can read, and
486
+ restoring a backup of `laneyard.db` alone brings nothing back. The originals are yours and are
487
+ untouched — the `.p8` in your downloads, the keystore in your safe — so what you are agreeing to
488
+ is uploading them again. Global secrets and global signing blocks are shared by every project and
489
+ go too; the inventory says so on its own line.
490
+
491
+ It is confirmed by typing the folder's path, not `y`: this is the one command in Laneyard that
492
+ destroys credentials, and `$LANEYARD_HOME` is exactly the case where a reflex is wrong. Anything
493
+ in the folder that Laneyard did not put there is named, left alone, and the folder is kept for it.
494
+
495
+ It does not remove the npm package — a command cannot sensibly delete the binary it is running
496
+ from — and it prints the command that does. There is no npm lifecycle hook doing any of this on
497
+ `npm uninstall`, on purpose: a package manager must not delete someone's signing keys on its own,
498
+ and a lifecycle script cannot ask.
499
+
307
500
  ## Security
308
501
 
309
502
  Read this before putting Laneyard on a network.
@@ -312,8 +505,9 @@ Read this before putting Laneyard on a network.
312
505
  it from your laptop, behind a password. Do not expose it publicly. If you need remote access,
313
506
  put it behind a VPN or an SSH tunnel.
314
507
  - **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.
508
+ hammering one name cannot lock out the others. Sessions survive a restart, and what is stored is
509
+ a SHA-256 of the token rather than the token: a stolen `laneyard.db` is a list of digests, not a
510
+ ring of working keys.
317
511
  - **A role is enforced by the server, not by the interface.** One table names the routes that
318
512
  require an admin, and one hook is the only thing that reads it — there is no permission check
319
513
  hidden inside a handler. What a builder is not shown is also what a builder is refused.
@@ -322,6 +516,11 @@ Read this before putting Laneyard on a network.
322
516
  else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
323
517
  process holds plaintext: the store, the API and the interface deal in names only, and no route
324
518
  ever sends a value back — which is why the Secrets tab has no reveal button.
519
+ - **A signing block is on disk only while a run needs it.** A keystore has no string form, so the
520
+ file is written into `~/.laneyard/runs/<run id>/secrets/`, mode `600` inside a `700` directory,
521
+ and that directory goes when the run ends. The block's secret fields — the two keystore
522
+ passphrases — are removed from a run's output the same way a masked secret is, because gradle is
523
+ perfectly willing to echo one back on failure.
325
524
  - **Masked values are removed from output before it is written, not when it is displayed.** The
326
525
  substitution happens once, at the point where a run's output fans out, so the log file on disk,
327
526
  the live stream to your browser and the stored error summary all contain `••••••` and never the
@@ -341,6 +540,12 @@ What this does *not* cover, stated plainly:
341
540
  pretending. Store it unmasked if it genuinely does not matter.
342
541
  - **Anything fastlane prints that is not a stored secret is stored in the clear**, under
343
542
  `~/.laneyard/logs/`.
543
+ - **`key.properties` is written into the workspace, and it holds passwords.** It is the one
544
+ credential Laneyard puts in the clone rather than in the run's own directory, because Gradle
545
+ resolves that path relative to the build. It is mode `600`, carries a marker line as its first
546
+ line, is removed when the run ends, and is swept for again at the start of the next run in case a
547
+ server was killed mid-build. A file of yours without that marker is never written over and never
548
+ removed.
344
549
 
345
550
  ## Status
346
551
 
@@ -353,8 +558,10 @@ What this does *not* cover, stated plainly:
353
558
  - `✓` build queue, cancellation, timeouts surfaced in the UI
354
559
  - `✓` a checklist that gets a project running unattended
355
560
  - `✓` edit the Fastfile in the browser, verified on every save
356
- - `✓` store a signing credential straight from its `.p8` or JSON file
561
+ - `✓` signing credentials stored whole the file and the fields beside it — written to disk for
562
+ the length of a run and exported under the names your project already reads
357
563
  - `✓` remove a project from the interface, without touching its history
564
+ - `✓` `laneyard uninstall`: the whole inventory first, then a typed confirmation, then the folder
358
565
  - `✓` named accounts, with a builder role that never sees a credential
359
566
  - `○` git-triggered and scheduled builds
360
567
 
@@ -3,7 +3,7 @@ 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
+ import { detectPlatforms, searchDir } from "../heuristics/platforms.js";
7
7
  const exec = promisify(execFile);
8
8
  const exists = async (p) => {
9
9
  try {
@@ -59,7 +59,7 @@ const toRepoPath = (root, absolute) => relative(root, absolute).split(sep).join(
59
59
  * sees and can correct before it's written.
60
60
  */
61
61
  export async function detectProject(dir) {
62
- // Without a repository there is nothing to clone; `runAddCommand` refuses
62
+ // Without a repository there is nothing to clone; `runSetupCommand` refuses
63
63
  // shortly after, so falling back to `dir` here only keeps this function total.
64
64
  // Both sides are resolved before being compared: on macOS the temporary
65
65
  // directory is a symlink, and git always answers with the real path — so a
@@ -78,7 +78,16 @@ export async function detectProject(dir) {
78
78
  const fastlaneDir = fastfile
79
79
  ? toRepoPath(root, await realpath(join(fastfile, "..")).catch(() => join(fastfile, "..")))
80
80
  : null;
81
- const platforms = await detectPlatforms(findIn(dir));
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)));
82
91
  const isIos = platforms.includes("ios");
83
92
  const isAndroid = platforms.includes("android");
84
93
  // Artifact patterns are anchored to the sub-project too. In a monorepo an
@@ -1,7 +1,32 @@
1
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
+ }
2
12
  /** Reads from the real terminal. */
3
13
  export function terminalAsker() {
4
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
+ }
5
30
  return {
6
31
  async ask(label, proposed, hint) {
7
32
  if (hint)
@@ -9,12 +34,12 @@ export function terminalAsker() {
9
34
  // The proposal is shown in the prompt rather than typed for the user:
10
35
  // pressing Return accepts it, which is what someone does nine times out
11
36
  // of ten, and correcting it costs one line.
12
- const answer = (await rl.question(` ${label} [${proposed}]: `)).trim();
37
+ const answer = (await question(` ${label} [${proposed}]: `)).trim();
13
38
  return answer === "" ? proposed : answer;
14
39
  },
15
- async confirm(question, defaultYes) {
40
+ async confirm(question_, defaultYes) {
16
41
  const suffix = defaultYes ? "[Y/n]" : "[y/N]";
17
- const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
42
+ const answer = (await question(`${question_} ${suffix} `)).trim().toLowerCase();
18
43
  if (answer === "")
19
44
  return defaultYes;
20
45
  return answer.startsWith("y");
@@ -0,0 +1,162 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { parseEnvExample } from "../heuristics/env-example.js";
4
+ /**
5
+ * Bringing an existing `fastlane/.env` into the vault.
6
+ *
7
+ * Every project that already builds has its variables somewhere, and that
8
+ * somewhere is almost always `fastlane/.env` — gitignored, on one laptop, and
9
+ * therefore absent from the clone a build server works from. Typing eight
10
+ * values into a web form to reproduce a file that is already on disk is a poor
11
+ * way to start, and the sort of chore where one typo costs an evening.
12
+ *
13
+ * This runs from the CLI and not from the server on purpose: the `.env` exists
14
+ * where the working copy is, and the server only ever sees a clone.
15
+ *
16
+ * Values are never printed, never logged, and never passed as arguments. What
17
+ * the command shows is names.
18
+ */
19
+ /**
20
+ * The variables whose value is a *path*, and the name their *contents* belong
21
+ * under.
22
+ *
23
+ * This is the translation that makes an import worth doing. `SUPPLY_JSON_KEY`
24
+ * points at a service account JSON on this laptop; copied verbatim into the
25
+ * vault it would point at nothing on the build machine, and the run would fail
26
+ * exactly as it does today. What has to travel is the file, so the file is
27
+ * read and stored under `SUPPLY_JSON_KEY_DATA` — a name supply reads on its
28
+ * own, verified against fastlane 2.237.
29
+ *
30
+ * There is no such entry for a `.p8`. `APP_STORE_CONNECT_API_KEY_P8` was one —
31
+ * an earlier version of this interface invented it, no action in fastlane has
32
+ * ever declared it, and the interface has since dropped it. See `P8_PATH_NAMES`
33
+ * for what a `.p8` path becomes instead.
34
+ */
35
+ export const PATH_TO_CONTENTS = {
36
+ SUPPLY_JSON_KEY: "SUPPLY_JSON_KEY_DATA",
37
+ GOOGLE_APPLICATION_CREDENTIALS: "SUPPLY_JSON_KEY_DATA",
38
+ };
39
+ /**
40
+ * Variables that name a `.p8` the way projects and this interface's own
41
+ * earlier version did.
42
+ *
43
+ * None of them is a name fastlane reads: the App Store Connect action takes
44
+ * its key from `key_id`, `issuer_id` and a file, not from an environment
45
+ * variable an import could invent on its own. What this command can do
46
+ * honestly is find the file and say where it belongs — an App Store Connect
47
+ * key block, uploaded from the secrets tab with the two fields the file alone
48
+ * cannot supply.
49
+ */
50
+ export const P8_PATH_NAMES = new Set([
51
+ "ASC_KEY_FILEPATH",
52
+ "APP_STORE_CONNECT_API_KEY_PATH",
53
+ "APP_STORE_CONNECT_API_KEY_FILEPATH",
54
+ ]);
55
+ /**
56
+ * Reads a `.env` into a name → value map.
57
+ *
58
+ * Shares `parseEnvExample`'s idea of what a line is, and adds the values, which
59
+ * that one deliberately drops. Quotes are stripped because a `.env` written by
60
+ * hand often has them and fastlane's own dotenv strips them too — a value that
61
+ * silently kept its quotes is a credential that never works, with nothing on
62
+ * screen to say why.
63
+ */
64
+ export function parseEnvFile(content) {
65
+ const out = new Map();
66
+ for (const name of parseEnvExample(content)) {
67
+ // Re-scan for this name's value; `parseEnvExample` already vouched for the
68
+ // name, so this only has to find the last assignment, the way dotenv does.
69
+ for (const raw of content.split("\n")) {
70
+ const line = raw.trim().replace(/^export\s+/, "");
71
+ const eq = line.indexOf("=");
72
+ if (eq <= 0 || line.slice(0, eq).trim() !== name)
73
+ continue;
74
+ let value = line.slice(eq + 1).trim();
75
+ const quoted = /^(["'])(.*)\1$/.exec(value);
76
+ if (quoted)
77
+ value = quoted[2];
78
+ out.set(name, value);
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * Decides what would be stored, without storing anything.
85
+ *
86
+ * Separate from the writing so the command can show its work first. An import
87
+ * that reads eight files and writes eight secrets before saying a word is one
88
+ * nobody can check.
89
+ */
90
+ export async function planImport(env, cwd, existingKeys) {
91
+ const planned = [];
92
+ const already = new Set(existingKeys);
93
+ for (const [name, value] of env) {
94
+ if (value === "")
95
+ continue;
96
+ if (P8_PATH_NAMES.has(name)) {
97
+ const path = isAbsolute(value) ? value : resolve(cwd, value);
98
+ const found = await access(path)
99
+ .then(() => true)
100
+ .catch(() => false);
101
+ if (!found) {
102
+ // Same handling as any other missing file: reported, not skipped in
103
+ // silence, because it is the credential the project most needs.
104
+ planned.push({ key: name, kind: "unresolved-path", path, value });
105
+ continue;
106
+ }
107
+ // The file is real, but nothing is stored under `name` — there is
108
+ // nowhere fastlane would read it from. Left to the caller to say what
109
+ // to do instead, in the same voice as the rest of the plan.
110
+ planned.push({ key: name, kind: "suggest-block", path, value });
111
+ continue;
112
+ }
113
+ const contentsKey = PATH_TO_CONTENTS[name];
114
+ if (contentsKey) {
115
+ const path = isAbsolute(value) ? value : resolve(cwd, value);
116
+ const contents = await readFile(path, "utf8").catch(() => null);
117
+ if (contents === null) {
118
+ // Named a file that is not there. Reported rather than skipped: it is
119
+ // the credential the project most needs, and silence would read as
120
+ // success.
121
+ planned.push({ key: name, kind: "unresolved-path", path, value });
122
+ continue;
123
+ }
124
+ planned.push({
125
+ key: contentsKey,
126
+ ...(contentsKey === name ? {} : { from: name }),
127
+ kind: "file-contents",
128
+ path,
129
+ value: contents,
130
+ });
131
+ continue;
132
+ }
133
+ planned.push({ key: name, kind: "value", value });
134
+ }
135
+ return {
136
+ planned,
137
+ replacing: planned
138
+ .filter((p) => p.kind !== "unresolved-path" && p.kind !== "suggest-block" && already.has(p.key))
139
+ .map((p) => p.key),
140
+ };
141
+ }
142
+ /**
143
+ * Writes the plan into the vault.
144
+ *
145
+ * Everything is masked. A value that came out of a `.env` is a credential by
146
+ * assumption — that is what the file is for — and the one that turns out not to
147
+ * be secret costs a redacted line in a log, while the reverse costs a leak.
148
+ */
149
+ export async function applyImport(vault, slug, plan) {
150
+ let stored = 0;
151
+ for (const item of plan.planned) {
152
+ if (item.kind === "unresolved-path" || item.kind === "suggest-block")
153
+ continue;
154
+ await vault.set(slug, item.key, item.value, true);
155
+ stored += 1;
156
+ }
157
+ return stored;
158
+ }
159
+ /** Where a project's `.env` files live, in the order dotenv itself reads them. */
160
+ export function envFilesIn(fastlaneDir) {
161
+ return [join(fastlaneDir, ".env.default"), join(fastlaneDir, ".env")];
162
+ }