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.
- package/README.md +238 -32
- package/dist/src/cli/detect.js +60 -16
- package/dist/src/cli/prompt.js +36 -0
- package/dist/src/cli/secret.js +133 -0
- package/dist/src/cli/setup.js +282 -0
- package/dist/src/cli/style.js +31 -0
- package/dist/src/cli/user.js +127 -0
- package/dist/src/config/accounts.js +163 -0
- package/dist/src/config/load.js +61 -1
- package/dist/src/config/schema.js +47 -4
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- package/dist/src/db/cache.js +8 -8
- package/dist/src/db/open.js +15 -0
- package/dist/src/db/runs.js +51 -6
- package/dist/src/db/schema.sql +20 -2
- package/dist/src/db/secrets.js +64 -0
- package/dist/src/fastfile/store.js +53 -0
- package/dist/src/git/workspace.js +95 -5
- package/dist/src/heuristics/blocking-actions.js +65 -0
- package/dist/src/heuristics/platforms.js +77 -0
- package/dist/src/heuristics/readiness.js +336 -0
- package/dist/src/logs/redact.js +86 -0
- package/dist/src/main.js +79 -9
- package/dist/src/runner/orchestrate.js +60 -10
- package/dist/src/runner/pty.js +27 -15
- package/dist/src/runner/queue.js +114 -0
- package/dist/src/secrets/cipher.js +28 -0
- package/dist/src/secrets/key.js +40 -0
- package/dist/src/secrets/vault.js +68 -0
- package/dist/src/server/app.js +135 -13
- package/dist/src/server/auth.js +85 -17
- package/dist/src/server/permissions.js +73 -0
- package/dist/src/server/routes/fastfile.js +131 -0
- package/dist/src/server/routes/projects.js +50 -1
- package/dist/src/server/routes/readiness.js +102 -0
- package/dist/src/server/routes/runs.js +27 -45
- package/dist/src/server/routes/secrets.js +51 -0
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +17 -1
- package/dist/src/sidecar/lanes.js +2 -2
- package/dist/src/sidecar/uses.js +40 -0
- package/dist/web/assets/Editor-DNFBA4gv.js +14 -0
- package/dist/web/assets/index-De6sxx6G.css +1 -0
- package/dist/web/assets/index-TWRQ1cJg.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +13 -5
- package/ruby/introspect.rb +80 -0
- package/dist/tests/cli/add.test.js +0 -64
- package/dist/tests/cli/detect.test.js +0 -63
- package/dist/tests/config/load.test.js +0 -68
- package/dist/tests/config/resolve.test.js +0 -44
- package/dist/tests/config/store.test.js +0 -58
- package/dist/tests/db/runs.test.js +0 -54
- package/dist/tests/e2e/full-thread.test.js +0 -65
- package/dist/tests/fixtures/repos.js +0 -30
- package/dist/tests/git/workspace.test.js +0 -66
- package/dist/tests/heuristics/error-summary.test.js +0 -31
- package/dist/tests/logs/store.test.js +0 -38
- package/dist/tests/main.test.js +0 -27
- package/dist/tests/ruby/introspect.test.js +0 -59
- package/dist/tests/runner/artifacts.test.js +0 -49
- package/dist/tests/runner/live-steps.test.js +0 -35
- package/dist/tests/runner/orchestrate.test.js +0 -124
- package/dist/tests/runner/pty.test.js +0 -53
- package/dist/tests/runner/report.test.js +0 -91
- package/dist/tests/server/api.test.js +0 -132
- package/dist/tests/server/auth.test.js +0 -53
- package/dist/tests/server/ws.test.js +0 -43
- package/dist/tests/sidecar/lanes.test.js +0 -52
- package/dist/tests/sidecar/ruby-env.test.js +0 -25
- package/dist/tests/smoke.test.js +0 -7
- package/dist/web/assets/index-583x0xuo.css +0 -1
- 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
|
-

|
|
12
|
+

|
|
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 |
|
|
36
|
+
| build queue across a team | yes, serial | yes | yes |
|
|
37
37
|
| runs on pull requests | planned | yes | yes |
|
|
38
38
|
|
|
39
|
-
|
|
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
|
|
61
|
+
laneyard setup # answer a few questions about this project
|
|
61
62
|
laneyard # start the server
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
`laneyard
|
|
65
|
-
`Gemfile`, an Xcode project or a Gradle build — writes the matching block into
|
|
66
|
-
`~/.laneyard/config.yml
|
|
67
|
-
|
|
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
|
-

|
|
86
|
+

|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- **
|
|
137
|
-
in memory and do not survive a
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
-
|
|
156
|
-
-
|
|
157
|
-
-
|
|
158
|
-
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
package/dist/src/cli/detect.js
CHANGED
|
@@ -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
|
-
*
|
|
37
|
-
*
|
|
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
|
-
//
|
|
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
|
-
?
|
|
79
|
+
? toRepoPath(root, await realpath(join(fastfile, "..")).catch(() => join(fastfile, "..")))
|
|
49
80
|
: null;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
const isAndroid = (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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(
|
|
90
|
+
artifactGlobs.push(`${prefix}**/*.ipa`, `${prefix}**/*.app.dSYM.zip`);
|
|
59
91
|
if (isAndroid)
|
|
60
|
-
artifactGlobs.push(
|
|
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
|
|
63
|
-
gitUrl
|
|
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
|
-
|
|
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
|
+
}
|