laneyard 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +238 -32
  2. package/dist/src/cli/detect.js +60 -16
  3. package/dist/src/cli/prompt.js +36 -0
  4. package/dist/src/cli/secret.js +133 -0
  5. package/dist/src/cli/setup.js +282 -0
  6. package/dist/src/cli/style.js +31 -0
  7. package/dist/src/cli/user.js +127 -0
  8. package/dist/src/config/accounts.js +163 -0
  9. package/dist/src/config/load.js +61 -1
  10. package/dist/src/config/schema.js +47 -4
  11. package/dist/src/config/store.js +10 -0
  12. package/dist/src/config/yaml.js +14 -0
  13. package/dist/src/db/cache.js +8 -8
  14. package/dist/src/db/open.js +15 -0
  15. package/dist/src/db/runs.js +51 -6
  16. package/dist/src/db/schema.sql +20 -2
  17. package/dist/src/db/secrets.js +64 -0
  18. package/dist/src/fastfile/store.js +53 -0
  19. package/dist/src/git/workspace.js +95 -5
  20. package/dist/src/heuristics/blocking-actions.js +65 -0
  21. package/dist/src/heuristics/platforms.js +77 -0
  22. package/dist/src/heuristics/readiness.js +336 -0
  23. package/dist/src/logs/redact.js +86 -0
  24. package/dist/src/main.js +79 -9
  25. package/dist/src/runner/orchestrate.js +60 -10
  26. package/dist/src/runner/pty.js +27 -15
  27. package/dist/src/runner/queue.js +114 -0
  28. package/dist/src/secrets/cipher.js +28 -0
  29. package/dist/src/secrets/key.js +40 -0
  30. package/dist/src/secrets/vault.js +68 -0
  31. package/dist/src/server/app.js +135 -13
  32. package/dist/src/server/auth.js +85 -17
  33. package/dist/src/server/permissions.js +73 -0
  34. package/dist/src/server/routes/fastfile.js +131 -0
  35. package/dist/src/server/routes/projects.js +50 -1
  36. package/dist/src/server/routes/readiness.js +102 -0
  37. package/dist/src/server/routes/runs.js +27 -45
  38. package/dist/src/server/routes/secrets.js +51 -0
  39. package/dist/src/server/routes/users.js +64 -0
  40. package/dist/src/sidecar/bridge.js +17 -1
  41. package/dist/src/sidecar/lanes.js +2 -2
  42. package/dist/src/sidecar/uses.js +40 -0
  43. package/dist/web/assets/Editor-DNFBA4gv.js +14 -0
  44. package/dist/web/assets/index-De6sxx6G.css +1 -0
  45. package/dist/web/assets/index-TWRQ1cJg.js +62 -0
  46. package/dist/web/index.html +2 -2
  47. package/package.json +13 -5
  48. package/ruby/introspect.rb +80 -0
  49. package/dist/tests/cli/add.test.js +0 -64
  50. package/dist/tests/cli/detect.test.js +0 -63
  51. package/dist/tests/config/load.test.js +0 -68
  52. package/dist/tests/config/resolve.test.js +0 -44
  53. package/dist/tests/config/store.test.js +0 -58
  54. package/dist/tests/db/runs.test.js +0 -54
  55. package/dist/tests/e2e/full-thread.test.js +0 -65
  56. package/dist/tests/fixtures/repos.js +0 -30
  57. package/dist/tests/git/workspace.test.js +0 -66
  58. package/dist/tests/heuristics/error-summary.test.js +0 -31
  59. package/dist/tests/logs/store.test.js +0 -38
  60. package/dist/tests/main.test.js +0 -27
  61. package/dist/tests/ruby/introspect.test.js +0 -59
  62. package/dist/tests/runner/artifacts.test.js +0 -49
  63. package/dist/tests/runner/live-steps.test.js +0 -35
  64. package/dist/tests/runner/orchestrate.test.js +0 -124
  65. package/dist/tests/runner/pty.test.js +0 -53
  66. package/dist/tests/runner/report.test.js +0 -91
  67. package/dist/tests/server/api.test.js +0 -132
  68. package/dist/tests/server/auth.test.js +0 -53
  69. package/dist/tests/server/ws.test.js +0 -43
  70. package/dist/tests/sidecar/lanes.test.js +0 -52
  71. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  72. package/dist/tests/smoke.test.js +0 -7
  73. package/dist/web/assets/index-583x0xuo.css +0 -1
  74. package/dist/web/assets/index-BEpABKPS.js +0 -61
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Which platforms a project builds for.
3
+ *
4
+ * Named knowledge of mobile toolchains — that `.xcodeproj` means Xcode, that
5
+ * `build.gradle` means Gradle — hence its place in this module, and the
6
+ * boundary that comes with it: nothing here refuses anything. It answers a
7
+ * question; `setup` writes the answer down and the checklist reads it.
8
+ *
9
+ * It lives here rather than in `cli/detect.ts` because two callers need the
10
+ * same answer. Setup proposes a configuration from it, and the checklist
11
+ * decides which sections apply from it. Two copies of this reasoning would be
12
+ * free to disagree about the same repository, and the checklist would be the
13
+ * one that looked wrong.
14
+ */
15
+ /**
16
+ * What a repository looks like from the outside, as a table.
17
+ *
18
+ * The depth is deliberate: an app in a monorepo sits one level down
19
+ * (`android/build.gradle`, `ios/App.xcodeproj`), which is where Flutter and
20
+ * React Native put them.
21
+ */
22
+ export const PLATFORM_MARKERS = [
23
+ {
24
+ platform: "ios",
25
+ globs: ["*.xcodeproj", "*.xcworkspace", "*/*.xcodeproj", "*/*.xcworkspace"],
26
+ onlyDirectories: true,
27
+ },
28
+ {
29
+ platform: "android",
30
+ globs: ["build.gradle", "build.gradle.kts", "*/build.gradle", "*/build.gradle.kts"],
31
+ onlyDirectories: false,
32
+ },
33
+ ];
34
+ /**
35
+ * What the repository contains.
36
+ *
37
+ * Never throws. An absent clone is a reason to answer "nothing found", not to
38
+ * fail the checklist that was about to explain why the clone is absent.
39
+ */
40
+ export async function detectPlatforms(find) {
41
+ const found = [];
42
+ // Sequential, and in the table's order, so the answer is stable: this list
43
+ // decides the order of the sections someone reads.
44
+ for (const marker of PLATFORM_MARKERS) {
45
+ try {
46
+ const paths = await find(marker.globs, { onlyDirectories: marker.onlyDirectories });
47
+ if (paths.length > 0)
48
+ found.push(marker.platform);
49
+ }
50
+ catch {
51
+ // A marker that cannot be looked for is simply not found.
52
+ }
53
+ }
54
+ return found;
55
+ }
56
+ /**
57
+ * The configuration first, then the repository.
58
+ *
59
+ * An empty configured list counts as saying nothing: `platforms: []` is what a
60
+ * commented-out line leaves behind, and reading it as "this project builds for
61
+ * nothing" would silently empty the checklist.
62
+ */
63
+ export function platformsOf(configured, detected) {
64
+ const chosen = configured && configured.length > 0 ? configured : detected;
65
+ // Normalised through the table: a hand-written `[android, ios]` must not
66
+ // reorder the screen, and a platform named twice must not appear twice.
67
+ return PLATFORM_MARKERS.map((m) => m.platform).filter((p) => chosen.includes(p));
68
+ }
69
+ /** The two steps in one call, for callers that have a directory to look at. */
70
+ export async function resolvePlatforms(configured, find) {
71
+ // The repository is only listed when the configuration did not answer:
72
+ // globbing a workspace costs a directory walk, and a project that says what
73
+ // it builds for should not pay for one.
74
+ if (configured && configured.length > 0)
75
+ return platformsOf(configured, []);
76
+ return platformsOf(undefined, await detectPlatforms(find));
77
+ }
@@ -0,0 +1,336 @@
1
+ import { findBlockingActions } from "./blocking-actions.js";
2
+ const ok = (detail) => ({ state: "ok", detail });
3
+ const warn = (detail, fix, fixIn) => ({
4
+ state: "warn",
5
+ detail,
6
+ ...(fix === undefined ? {} : { fix }),
7
+ ...(fixIn === undefined ? {} : { fixIn }),
8
+ });
9
+ const undetermined = (detail, fix) => ({
10
+ state: "unknown",
11
+ detail,
12
+ ...(fix === undefined ? {} : { fix }),
13
+ });
14
+ /** A rejection is not always an `Error`; a checklist saying "undefined" is worse than useless. */
15
+ const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
16
+ /** Identity and wording live here so a check and its fallback cannot drift apart. */
17
+ const META = {
18
+ repository: { id: "repository", title: "repository reachable without a password" },
19
+ dependencies: { id: "dependencies", title: "dependencies installable" },
20
+ appStoreConnect: { id: "app-store-connect", title: "App Store Connect authentication" },
21
+ match: { id: "match", title: "match usable without intervention" },
22
+ blockingActions: { id: "blocking-actions", title: "no action known to stop and ask" },
23
+ androidKeystore: { id: "android-keystore", title: "keystore reachable without a prompt" },
24
+ playStore: { id: "play-store", title: "Play Store service account" },
25
+ platforms: { id: "platforms", title: "what this project builds for" },
26
+ };
27
+ /**
28
+ * Does the remote answer without asking anyone for credentials?
29
+ *
30
+ * The probe is injected — `Workspace` already knows how to run git with
31
+ * `GIT_TERMINAL_PROMPT=0` and how to keep the repository URL, which may carry a
32
+ * token, out of its own error messages. Redacting is the probe's job, not this
33
+ * check's: whatever it rejects with is repeated verbatim.
34
+ */
35
+ export async function checkRepository(probe) {
36
+ try {
37
+ await probe();
38
+ return { ...META.repository, ...ok("the remote answers without asking for credentials.") };
39
+ }
40
+ catch (cause) {
41
+ return {
42
+ ...META.repository,
43
+ ...warn(reasonOf(cause), "Give the project a key it can use without a passphrase — " +
44
+ "`git_auth: {kind: ssh_key, ref: /path/to/key}` in config.yml — or a URL that needs no password. " +
45
+ "A run that meets a credentials prompt does not fail: it waits."),
46
+ };
47
+ }
48
+ }
49
+ export async function checkDependencies(input) {
50
+ const { workspace } = input;
51
+ if (!workspace.ok) {
52
+ return { ...META.dependencies, ...undetermined(`could not read the workspace: ${workspace.reason}`) };
53
+ }
54
+ if (workspace.value.hasGemfile) {
55
+ try {
56
+ await input.bundleCheck();
57
+ return { ...META.dependencies, ...ok("the Gemfile's bundle is installed.") };
58
+ }
59
+ catch (cause) {
60
+ return {
61
+ ...META.dependencies,
62
+ ...warn(reasonOf(cause), "Run `bundle install` in the project's workspace."),
63
+ };
64
+ }
65
+ }
66
+ let fastlane;
67
+ try {
68
+ fastlane = await input.findFastlane();
69
+ }
70
+ catch (cause) {
71
+ return { ...META.dependencies, ...undetermined(`could not look for fastlane: ${reasonOf(cause)}`) };
72
+ }
73
+ if (fastlane !== null) {
74
+ // Not a failure, but a fact worth stating: a system fastlane can be
75
+ // upgraded underneath a project that was working yesterday.
76
+ return {
77
+ ...META.dependencies,
78
+ ...ok(`no Gemfile — runs use ${fastlane}, whose version nothing pins.`),
79
+ };
80
+ }
81
+ return {
82
+ ...META.dependencies,
83
+ ...warn("no Gemfile, and no fastlane on the PATH: a run has nothing to execute.", 'Add a Gemfile with `gem "fastlane"` and run `bundle install`, or install fastlane system-wide.'),
84
+ };
85
+ }
86
+ /** Any suffix: the key is split across several variables, and their names vary by lane. */
87
+ const API_KEY = /^APP_STORE_CONNECT_API_KEY/;
88
+ const STORE_API_KEY = "Store an App Store Connect API key — `APP_STORE_CONNECT_API_KEY_ID`, " +
89
+ "`APP_STORE_CONNECT_API_KEY_ISSUER_ID` and the `.p8` contents — from the secrets tab. " +
90
+ "An API key does not expire on its own.";
91
+ export function checkAppStoreConnect(secretKeys) {
92
+ if (secretKeys.some((key) => API_KEY.test(key))) {
93
+ return { ...META.appStoreConnect, ...ok("an App Store Connect API key is in the vault.") };
94
+ }
95
+ if (secretKeys.includes("FASTLANE_SESSION")) {
96
+ return {
97
+ ...META.appStoreConnect,
98
+ ...warn("only a FASTLANE_SESSION: Apple sessions expire, and the run that finds it expired " +
99
+ "stops and asks for a verification code.", STORE_API_KEY, "secrets"),
100
+ };
101
+ }
102
+ return {
103
+ ...META.appStoreConnect,
104
+ ...warn("no App Store Connect credential in the vault: a lane that uploads will ask for an Apple ID.", STORE_API_KEY, "secrets"),
105
+ };
106
+ }
107
+ /** The two names for the same action. `match` is the older one, still the common one. */
108
+ const MATCH_ACTIONS = new Set(["match", "sync_code_signing"]);
109
+ /** "beta and release" reads better than a list of one, and than a trailing comma. */
110
+ function nameList(names) {
111
+ const unique = [...new Set(names)];
112
+ if (unique.length <= 1)
113
+ return unique[0] ?? "";
114
+ return `${unique.slice(0, -1).join(", ")} and ${unique[unique.length - 1]}`;
115
+ }
116
+ export function checkMatch(uses, secretKeys) {
117
+ if (!uses.ok) {
118
+ return { ...META.match, ...undetermined(`could not read the lanes: ${uses.reason}`) };
119
+ }
120
+ const calls = uses.value.flatMap((lane) => lane.actions.filter((a) => MATCH_ACTIONS.has(a.name)).map((action) => ({ lane: lane.lane, action })));
121
+ if (calls.length === 0) {
122
+ return { ...META.match, ...ok("no lane uses match.") };
123
+ }
124
+ if (!secretKeys.includes("MATCH_PASSWORD")) {
125
+ return {
126
+ ...META.match,
127
+ ...warn(`${nameList(calls.map((c) => c.lane))} calls match, but MATCH_PASSWORD is not in the vault: ` +
128
+ "match asks for the passphrase and waits for it.", "Store `MATCH_PASSWORD` from the secrets tab.", "secrets"),
129
+ };
130
+ }
131
+ const writable = calls.filter((c) => c.action.args["readonly"] === false);
132
+ if (writable.length > 0) {
133
+ return {
134
+ ...META.match,
135
+ ...warn(`${nameList(writable.map((c) => c.lane))} calls match with \`readonly: false\`: it may try to ` +
136
+ "create certificates, which needs an Apple account interactively.", "Pass `readonly: true` so it only fetches what already exists."),
137
+ };
138
+ }
139
+ // `match(readonly: ENV["RO"])` arrives with no `readonly` at all: the sidecar
140
+ // reports literal arguments and refuses to guess at the rest. Calling that
141
+ // green would be the checklist claiming to know something it does not.
142
+ const undecided = calls.filter((c) => !("readonly" in c.action.args));
143
+ if (undecided.length > 0) {
144
+ return {
145
+ ...META.match,
146
+ ...undetermined(`MATCH_PASSWORD is stored, but \`readonly\` is not a literal in ` +
147
+ `${nameList(undecided.map((c) => c.lane))}: Laneyard reads literal arguments only, and will not guess.`, "Pass `readonly: true` in the call itself if that is what you mean."),
148
+ };
149
+ }
150
+ return { ...META.match, ...ok("MATCH_PASSWORD is stored, and every match call is readonly.") };
151
+ }
152
+ export function checkBlockingActions(uses) {
153
+ if (!uses.ok) {
154
+ return { ...META.blockingActions, ...undetermined(`could not read the lanes: ${uses.reason}`) };
155
+ }
156
+ const findings = uses.value.flatMap((lane) => findBlockingActions(lane.actions).map((finding) => ({ lane: lane.lane, finding })));
157
+ if (findings.length === 0) {
158
+ return { ...META.blockingActions, ...ok("no lane calls an action known to stop and ask.") };
159
+ }
160
+ return {
161
+ ...META.blockingActions,
162
+ ...warn(findings.map((f) => `${f.lane} calls ${f.finding.action}: it ${f.finding.because}`).join("; ") + ".",
163
+ // The same fix stated twice reads as two different fixes.
164
+ [...new Set(findings.map((f) => f.finding.fix))].join(" ")),
165
+ };
166
+ }
167
+ /** `build_android_app` is the newer name for the same action. */
168
+ const GRADLE_ACTIONS = new Set(["gradle", "build_android_app"]);
169
+ /**
170
+ * The arguments that say a lane hands gradle a keystore of its own.
171
+ *
172
+ * Read literally, like everything else here. `gradle(storePassword: ENV["PW"])`
173
+ * arrives with no `storePassword` at all — the sidecar drops what it cannot
174
+ * read — and this check says so rather than inventing a verdict.
175
+ */
176
+ const KEYSTORE_ARGS = ["storeFile", "storePassword"];
177
+ /** `ANDROID_KEYSTORE_PASSWORD`, `KEYSTORE_PASSWORD`, `STORE_PASSWORD`. */
178
+ const KEYSTORE_PASSWORD = /(^|_)(KEYSTORE|STORE)_PASSWORD$/;
179
+ const STORE_KEYSTORE_PASSWORD = "Store the keystore passphrase as `ANDROID_KEYSTORE_PASSWORD` from the secrets tab, " +
180
+ "and read it in the lane with `storePassword: ENV[\"ANDROID_KEYSTORE_PASSWORD\"]`.";
181
+ export function checkAndroidKeystore(uses, secretKeys) {
182
+ if (!uses.ok) {
183
+ return { ...META.androidKeystore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
184
+ }
185
+ const calls = uses.value.flatMap((lane) => lane.actions.filter((a) => GRADLE_ACTIONS.has(a.name)).map((action) => ({ lane: lane.lane, action })));
186
+ if (calls.length === 0) {
187
+ return { ...META.androidKeystore, ...ok("no lane builds with gradle.") };
188
+ }
189
+ const signing = calls.filter((c) => KEYSTORE_ARGS.some((arg) => arg in c.action.args));
190
+ if (signing.length === 0) {
191
+ // Deliberately a statement about what was read, not about the build: a
192
+ // keystore configured in `build.gradle` or through the environment is
193
+ // invisible from here, and claiming it is absent would be a guess.
194
+ return {
195
+ ...META.androidKeystore,
196
+ ...ok("no lane passes `storeFile` or `storePassword` to gradle: nothing here needs unlocking."),
197
+ };
198
+ }
199
+ const withoutPassphrase = signing.filter((c) => !("storePassword" in c.action.args));
200
+ if (withoutPassphrase.length === 0) {
201
+ return {
202
+ ...META.androidKeystore,
203
+ ...ok(`${nameList(signing.map((c) => c.lane))} passes \`storePassword\` in the call itself: ` +
204
+ "gradle has what it needs."),
205
+ };
206
+ }
207
+ if (secretKeys.some((key) => KEYSTORE_PASSWORD.test(key))) {
208
+ return { ...META.androidKeystore, ...ok("a keystore passphrase is in the vault.") };
209
+ }
210
+ return {
211
+ ...META.androidKeystore,
212
+ ...warn(`${nameList(withoutPassphrase.map((c) => c.lane))} hands gradle a keystore, but no keystore ` +
213
+ "passphrase is in the vault: gradle asks for it and waits.", STORE_KEYSTORE_PASSWORD, "secrets"),
214
+ };
215
+ }
216
+ /** The two names for the same action. `supply` is the older one. */
217
+ const PLAY_UPLOAD_ACTIONS = new Set(["upload_to_play_store", "supply"]);
218
+ /** `SUPPLY_JSON_KEY` and `SUPPLY_JSON_KEY_DATA`, the names fastlane itself reads. */
219
+ const PLAY_JSON_KEY = /^SUPPLY_JSON_KEY/;
220
+ /** The same credential, named in the call instead of in the vault. */
221
+ const PLAY_KEY_ARGS = ["json_key", "json_key_data"];
222
+ const STORE_PLAY_KEY = "Store the service account JSON as `SUPPLY_JSON_KEY_DATA` from the secrets tab. " +
223
+ "A service account does not expire on its own.";
224
+ export function checkPlayStore(uses, secretKeys) {
225
+ if (!uses.ok) {
226
+ return { ...META.playStore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
227
+ }
228
+ const calls = uses.value.flatMap((lane) => lane.actions
229
+ .filter((a) => PLAY_UPLOAD_ACTIONS.has(a.name))
230
+ .map((action) => ({ lane: lane.lane, action })));
231
+ if (calls.length === 0) {
232
+ return { ...META.playStore, ...ok("no lane uploads to the Play Store.") };
233
+ }
234
+ if (secretKeys.some((key) => PLAY_JSON_KEY.test(key))) {
235
+ return { ...META.playStore, ...ok("a Play Store service account is in the vault.") };
236
+ }
237
+ const named = calls.filter((c) => PLAY_KEY_ARGS.some((arg) => arg in c.action.args));
238
+ if (named.length > 0) {
239
+ // A path in a Fastfile says nothing about whether that file exists on this
240
+ // machine. Neither a tick nor a warning would be honest.
241
+ return {
242
+ ...META.playStore,
243
+ ...undetermined(`${nameList(named.map((c) => c.lane))} passes \`json_key\` in the call: whether that file is ` +
244
+ "on this machine is not something a Fastfile says.", STORE_PLAY_KEY),
245
+ };
246
+ }
247
+ return {
248
+ ...META.playStore,
249
+ ...warn(`${nameList(calls.map((c) => c.lane))} uploads to the Play Store, but no service account is in ` +
250
+ "the vault: supply stops and asks which credentials to use.", STORE_PLAY_KEY, "secrets"),
251
+ };
252
+ }
253
+ /**
254
+ * The line a project with no platform gets instead of half a checklist.
255
+ *
256
+ * It is not a check — nothing was examined — and it is not a warning either.
257
+ * It says what is missing and where to write it down, and then the shared
258
+ * checks above it still stand on their own.
259
+ */
260
+ function platformNote(platforms) {
261
+ return {
262
+ ...META.platforms,
263
+ ...undetermined(platforms.ok
264
+ ? "no platform detected: no Xcode project and no Gradle build in the repository, " +
265
+ "and laneyard.yml names none. Only the checks above apply."
266
+ : `could not tell: ${platforms.reason}. Only the checks above apply.`, "Add `platforms: [ios]`, `[android]` or both to laneyard.yml in the repository, " +
267
+ "and the checks for that platform appear here."),
268
+ };
269
+ }
270
+ /**
271
+ * The checklist itself: a table, so that adding a check is adding a row.
272
+ *
273
+ * Exported because the order it declares is the order the interface shows, and
274
+ * a test that checks that should read the same list.
275
+ */
276
+ export const SECTIONS = [
277
+ {
278
+ platform: "all",
279
+ checks: [
280
+ { ...META.repository, run: (i) => checkRepository(i.probeRepository) },
281
+ { ...META.dependencies, run: (i) => checkDependencies(i.dependencies) },
282
+ { ...META.blockingActions, run: (i) => checkBlockingActions(i.uses) },
283
+ ],
284
+ },
285
+ {
286
+ platform: "ios",
287
+ checks: [
288
+ { ...META.appStoreConnect, run: (i) => checkAppStoreConnect(i.secretKeys) },
289
+ { ...META.match, run: (i) => checkMatch(i.uses, i.secretKeys) },
290
+ ],
291
+ },
292
+ {
293
+ platform: "android",
294
+ checks: [
295
+ { ...META.androidKeystore, run: (i) => checkAndroidKeystore(i.uses, i.secretKeys) },
296
+ { ...META.playStore, run: (i) => checkPlayStore(i.uses, i.secretKeys) },
297
+ ],
298
+ },
299
+ ];
300
+ /**
301
+ * Runs the sections that apply, and guarantees the list comes back whole.
302
+ *
303
+ * Each check already commits to not throwing. This wrapper exists because that
304
+ * promise is worth exactly as much as the next person to edit a check: one
305
+ * probe misbehaving must cost one line of the checklist, not the checklist.
306
+ */
307
+ export async function runChecklist(input) {
308
+ // Read once and defensively: this is the one value used outside a check body,
309
+ // and a malformed one must not be the thing that empties the whole screen.
310
+ const platforms = input.platforms && input.platforms.ok && Array.isArray(input.platforms.value)
311
+ ? input.platforms.value
312
+ : [];
313
+ const sections = await Promise.all(SECTIONS.filter((s) => s.platform === "all" || platforms.includes(s.platform)).map(async (section) => ({
314
+ platform: section.platform,
315
+ checks: await Promise.all(section.checks.map(async ({ id, title, run }) => {
316
+ try {
317
+ return await run(input);
318
+ }
319
+ catch (cause) {
320
+ return {
321
+ id,
322
+ title,
323
+ state: "unknown",
324
+ detail: `the check itself failed: ${reasonOf(cause)}`,
325
+ };
326
+ }
327
+ })),
328
+ })));
329
+ // The shared section is always first and always present, so the note about a
330
+ // missing platform sits under the checks that still applied.
331
+ const shared = sections[0];
332
+ if (platforms.length === 0 && shared) {
333
+ shared.checks.push(platformNote(input.platforms ?? { ok: true, value: [] }));
334
+ }
335
+ return sections;
336
+ }
@@ -0,0 +1,86 @@
1
+ /** What a redacted value is replaced with. Fixed width, so it leaks no length. */
2
+ const MARKER = "••••••";
3
+ /**
4
+ * Values shorter than this are left alone.
5
+ *
6
+ * A two-character secret would match constantly and turn the log into confetti
7
+ * while hiding nothing an attacker could not guess. Refusing is more honest than
8
+ * pretending to protect it.
9
+ */
10
+ export const MIN_LENGTH = 4;
11
+ /**
12
+ * Removes secret values from a stream of text.
13
+ *
14
+ * The difficulty is not the replacement, it is the boundaries: a pseudo-terminal
15
+ * cuts its output wherever it likes, so a secret can arrive as `hun` then `ter2`.
16
+ * Replacing chunk by chunk would let it through in two pieces — and the file on
17
+ * disk would contain it in full.
18
+ *
19
+ * So the redactor holds back the last few characters, exactly as many as could
20
+ * still turn out to be the beginning of a secret, and releases them only once
21
+ * they cannot. `flush()` empties that buffer at the end of the run.
22
+ */
23
+ /**
24
+ * One-shot removal, for text that is not part of the live stream.
25
+ *
26
+ * A run's error summary is stored in the database and rendered in the interface
27
+ * without ever passing through the stream, so it needs its own pass. Using the
28
+ * live `Redactor` instance here would corrupt its buffer mid-run.
29
+ */
30
+ export function scrub(text, values) {
31
+ let out = text;
32
+ for (const value of [...new Set(values.filter((v) => v.length >= MIN_LENGTH))].sort((a, b) => b.length - a.length)) {
33
+ out = out.split(value).join(MARKER);
34
+ }
35
+ return out;
36
+ }
37
+ export class Redactor {
38
+ values;
39
+ longest;
40
+ held = "";
41
+ constructor(values) {
42
+ // Longest first: replacing "token" before "token-suffix" would leave the
43
+ // suffix behind in the log.
44
+ this.values = [...new Set(values.filter((v) => v.length >= MIN_LENGTH))].sort((a, b) => b.length - a.length);
45
+ this.longest = this.values.reduce((max, v) => Math.max(max, v.length), 0);
46
+ }
47
+ replaceAll(text) {
48
+ let out = text;
49
+ for (const value of this.values)
50
+ out = out.split(value).join(MARKER);
51
+ return out;
52
+ }
53
+ /**
54
+ * How many trailing characters could still turn into a secret.
55
+ *
56
+ * Holding a fixed window of `longest - 1` would be correct but wasteful: with
57
+ * a 200-character API key in the vault, every chunk would stall its last 199
58
+ * characters until the next one arrived, and the live terminal would lag
59
+ * visibly. So we keep only what is genuinely ambiguous — the longest suffix
60
+ * that is a proper prefix of some secret — which is usually nothing at all.
61
+ */
62
+ ambiguousTail(text) {
63
+ const max = Math.min(this.longest - 1, text.length);
64
+ for (let length = max; length > 0; length -= 1) {
65
+ const suffix = text.slice(text.length - length);
66
+ if (this.values.some((value) => value.startsWith(suffix)))
67
+ return length;
68
+ }
69
+ return 0;
70
+ }
71
+ /** Takes a chunk, returns the part that is safe to write out. */
72
+ push(chunk) {
73
+ if (this.values.length === 0)
74
+ return chunk;
75
+ const combined = this.replaceAll(this.held + chunk);
76
+ const keep = this.ambiguousTail(combined);
77
+ this.held = combined.slice(combined.length - keep);
78
+ return keep === 0 ? combined : combined.slice(0, combined.length - keep);
79
+ }
80
+ /** Releases the tail. Call once, when the stream is over. */
81
+ flush() {
82
+ const rest = this.replaceAll(this.held);
83
+ this.held = "";
84
+ return rest;
85
+ }
86
+ }
package/dist/src/main.js CHANGED
@@ -3,16 +3,22 @@ import { realpathSync } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { bold, dim, field, heading } from "./cli/style.js";
6
7
  import { fileURLToPath } from "node:url";
7
- import { runAddCommand } from "./cli/add.js";
8
+ import { runSetupCommand } from "./cli/setup.js";
9
+ import { runSecretCommand } from "./cli/secret.js";
10
+ import { runUserCommand } from "./cli/user.js";
8
11
  import { ConfigStore } from "./config/store.js";
9
12
  import { CacheStore } from "./db/cache.js";
10
13
  import { openDatabase } from "./db/open.js";
11
14
  import { RunStore } from "./db/runs.js";
15
+ import { SecretStore } from "./db/secrets.js";
12
16
  import { buildApp } from "./server/app.js";
17
+ import { Vault } from "./secrets/vault.js";
13
18
  import { makeInvoke } from "./sidecar/bridge.js";
14
19
  import { LaneReader } from "./sidecar/lanes.js";
15
- export const version = "0.1.0";
20
+ import { UsesReader } from "./sidecar/uses.js";
21
+ export const version = "0.3.0";
16
22
  /** Assembles the server from a data folder. */
17
23
  export async function createServerFromConfig(root) {
18
24
  const config = new ConfigStore(join(root, "config.yml"));
@@ -20,19 +26,31 @@ export async function createServerFromConfig(root) {
20
26
  if (!loaded.ok)
21
27
  throw new Error(`Unreadable configuration: ${loaded.error}`);
22
28
  const db = openDatabase(join(root, "laneyard.db"));
23
- // No run can survive the shutdown of the process that carried it.
24
- new RunStore(db).interruptActive();
29
+ // No run that had begun can survive the shutdown of the process that carried
30
+ // it. Queued runs never began, so they stay queued for the next start.
31
+ new RunStore(db).interruptInFlight();
25
32
  const cache = new CacheStore(db);
33
+ const vault = await Vault.open(root, new SecretStore(db));
26
34
  const app = await buildApp({
27
35
  config,
28
36
  db,
29
37
  root,
38
+ vault,
30
39
  lanes: async (slug, workspacePath, fastlaneDir) => {
31
40
  const resolved = await config.resolve(slug, workspacePath);
32
41
  const reader = new LaneReader(cache, makeInvoke(resolved?.settings.runtime ?? "bundle"));
33
42
  return reader.read(slug, workspacePath, fastlaneDir);
34
43
  },
44
+ uses: async (slug, workspacePath, fastlaneDir) => {
45
+ const resolved = await config.resolve(slug, workspacePath);
46
+ const reader = new UsesReader(cache, makeInvoke(resolved?.settings.runtime ?? "bundle"));
47
+ return reader.read(slug, workspacePath, fastlaneDir);
48
+ },
35
49
  });
50
+ // Anything left queued from the previous life starts moving again now. Without
51
+ // this, `wake()` would only ever be called from the trigger route, and three
52
+ // runs queued before a restart would wait for someone to trigger a fourth.
53
+ app.queue.wake();
36
54
  return { app, db, config };
37
55
  }
38
56
  /** Real startup, outside tests. */
@@ -45,7 +63,24 @@ async function main() {
45
63
  });
46
64
  const server = config.server();
47
65
  await app.listen({ port: server.port, host: server.bind });
48
- console.log(`Laneyard is listening on http://localhost:${server.port}`);
66
+ const projects = config.projects();
67
+ process.stdout.write(heading(`laneyard ${version}`) +
68
+ field("listening", `http://localhost:${server.port}`) +
69
+ "\n" +
70
+ field("config", join(root, "config.yml")) +
71
+ "\n" +
72
+ field("projects", projects.length === 0
73
+ ? dim("none yet")
74
+ : projects.map((p) => p.slug).join(", ")) +
75
+ "\n" +
76
+ (projects.length === 0
77
+ ? // A server with nothing to build should say what to do next rather
78
+ // than sit there looking successful.
79
+ "\n" +
80
+ dim(" No project yet. From a folder that already uses fastlane:\n") +
81
+ ` ${bold("laneyard setup")}\n`
82
+ : "") +
83
+ "\n");
49
84
  }
50
85
  /**
51
86
  * True when this file is what the user launched, rather than something that
@@ -68,7 +103,12 @@ function invokedDirectly() {
68
103
  }
69
104
  const USAGE = `laneyard — a self-hosted web UI for fastlane
70
105
 
71
- laneyard add adopt the project in the current directory
106
+ laneyard setup set up the project in the current directory
107
+ --yes accepts every detected value without asking
108
+ laneyard secret set NAME [--project <slug>]
109
+ store a secret, its value read from standard input
110
+ laneyard user add NAME [--role admin|builder]
111
+ create an account, its password read from standard input
72
112
  laneyard start the server
73
113
  laneyard --version print the version
74
114
 
@@ -83,7 +123,7 @@ function explainStartupFailure(cause) {
83
123
  if (message.includes("ENOENT") && message.includes("config.yml")) {
84
124
  return ("No configuration yet.\n\n" +
85
125
  " cd into a project that already uses fastlane, then run:\n" +
86
- " laneyard add\n\n" +
126
+ " laneyard setup\n\n" +
87
127
  "That writes " + join(homeDir(), "config.yml") + " for you.");
88
128
  }
89
129
  return message;
@@ -98,13 +138,16 @@ if (invokedDirectly()) {
98
138
  process.stdout.write(`${version}\n`);
99
139
  process.exit(0);
100
140
  }
101
- if (command === "add") {
141
+ if (command === "setup") {
102
142
  const home = homeDir();
103
143
  await mkdir(home, { recursive: true });
104
144
  const slugIndex = rest.indexOf("--slug");
105
145
  const slug = slugIndex === -1 ? undefined : rest[slugIndex + 1];
146
+ // `--yes` accepts every proposal, for scripts and for anyone who has done
147
+ // this before. Interactive is the default because the values are guesses.
148
+ const yes = rest.includes("--yes") || rest.includes("-y");
106
149
  try {
107
- process.exit(await runAddCommand(process.cwd(), join(home, "config.yml"), slug));
150
+ process.exit(await runSetupCommand(process.cwd(), join(home, "config.yml"), { slug, yes }));
108
151
  }
109
152
  catch (cause) {
110
153
  // A taken slug or an unreadable file are ordinary situations. A stack
@@ -113,6 +156,33 @@ if (invokedDirectly()) {
113
156
  process.exit(1);
114
157
  }
115
158
  }
159
+ // Above the catch-all below, which would otherwise reject it as unknown.
160
+ if (command === "secret") {
161
+ const home = homeDir();
162
+ await mkdir(home, { recursive: true });
163
+ process.exit(await runSecretCommand(home, rest, {
164
+ stdin: process.stdin,
165
+ interactive: process.stdin.isTTY === true,
166
+ out: (text) => process.stdout.write(text),
167
+ err: (text) => process.stderr.write(text),
168
+ }));
169
+ }
170
+ if (command === "user") {
171
+ const home = homeDir();
172
+ await mkdir(home, { recursive: true });
173
+ process.exit(await runUserCommand(home, rest, {
174
+ stdin: process.stdin,
175
+ interactive: process.stdin.isTTY === true,
176
+ out: (text) => process.stdout.write(text),
177
+ err: (text) => process.stderr.write(text),
178
+ }));
179
+ }
180
+ // 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
181
+ // sentence rather than "Unknown command".
182
+ if (command === "add") {
183
+ process.stderr.write("`laneyard add` is now `laneyard setup`.\n");
184
+ process.exit(1);
185
+ }
116
186
  if (command !== undefined) {
117
187
  process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
118
188
  process.exit(1);