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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
|
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);
|