laneyard 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -18
- package/dist/src/cli/detect.js +12 -3
- package/dist/src/cli/prompt.js +28 -3
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +44 -7
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +75 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +28 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +2 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +69 -10
- package/dist/src/heuristics/readiness.js +554 -25
- package/dist/src/main.js +10 -1
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +106 -5
- package/dist/src/server/app.js +30 -3
- package/dist/src/server/auth.js +50 -10
- package/dist/src/server/permissions.js +2 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/readiness.js +162 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/sidecar/bridge.js +21 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +1 -0
- package/dist/web/assets/index-lyZs-Y7J.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-De6sxx6G.css +0 -1
- package/dist/web/assets/index-TWRQ1cJg.js +0 -62
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { parseAndroidSigning } from "./android-signing.js";
|
|
4
|
+
/**
|
|
5
|
+
* Which build script speaks for the android side of a project — asked once, in
|
|
6
|
+
* one place.
|
|
7
|
+
*
|
|
8
|
+
* Two callers need the answer and they must never disagree. The checklist reads
|
|
9
|
+
* the script to decide whether a release build could go out signed with the
|
|
10
|
+
* debug key, and the runner writes the properties file that build asks for. If
|
|
11
|
+
* one of them stopped at `app/build.gradle` while the other read
|
|
12
|
+
* `android/app/build.gradle.kts`, the file would land where nothing reads it and
|
|
13
|
+
* the checklist would report green over an artifact signed by the debug key —
|
|
14
|
+
* the exact failure both of them exist to prevent. A shared list is what makes
|
|
15
|
+
* that disagreement impossible rather than unlikely.
|
|
16
|
+
*
|
|
17
|
+
* The order is a preference, not a ranking of correctness: `android/app` is
|
|
18
|
+
* where Flutter and React Native put theirs, and `app` is a repository that is
|
|
19
|
+
* an Android project outright. A repository with both is a monorepo whose
|
|
20
|
+
* fastlane directory should have pointed one level down, and the first match is
|
|
21
|
+
* the honest guess to make until it does.
|
|
22
|
+
*/
|
|
23
|
+
export const ANDROID_BUILD_SCRIPTS = [
|
|
24
|
+
"android/app/build.gradle.kts",
|
|
25
|
+
"android/app/build.gradle",
|
|
26
|
+
"app/build.gradle.kts",
|
|
27
|
+
"app/build.gradle",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Reads the first android build script under `root`, or null when there is none.
|
|
31
|
+
*
|
|
32
|
+
* `root` is the app root inside the clone, not the repository root: in a
|
|
33
|
+
* monorepo the app is one directory down and so are its platform folders.
|
|
34
|
+
*
|
|
35
|
+
* Never throws. An unreadable script is the same as an absent one to both
|
|
36
|
+
* callers — "could not tell" — and a checklist that raised here would turn a
|
|
37
|
+
* question it failed to answer into an error page.
|
|
38
|
+
*/
|
|
39
|
+
export async function findAndroidBuild(root) {
|
|
40
|
+
for (const candidate of ANDROID_BUILD_SCRIPTS) {
|
|
41
|
+
const scriptPath = join(root, candidate);
|
|
42
|
+
const text = await readFile(scriptPath, "utf8").catch(() => null);
|
|
43
|
+
if (text === null)
|
|
44
|
+
continue;
|
|
45
|
+
const moduleDir = dirname(scriptPath);
|
|
46
|
+
return { scriptPath, moduleDir, gradleRoot: dirname(moduleDir), facts: parseAndroidSigning(text) };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a release build is actually signed with the release key.
|
|
3
|
+
*
|
|
4
|
+
* The pattern this exists for is in the Flutter documentation, and therefore in
|
|
5
|
+
* thousands of projects:
|
|
6
|
+
*
|
|
7
|
+
* signingConfig = if (keystorePropertiesFile.exists()) {
|
|
8
|
+
* signingConfigs.getByName("release")
|
|
9
|
+
* } else {
|
|
10
|
+
* signingConfigs.getByName("debug")
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* It is kind locally — `flutter run --release` works on a machine with no
|
|
14
|
+
* keystore — and it is a trap everywhere else. `key.properties` is gitignored
|
|
15
|
+
* by the same documentation, so it is by definition absent from a clone. The
|
|
16
|
+
* release build then *succeeds*, produces an `.aab` signed with the debug key,
|
|
17
|
+
* and the failure arrives minutes later from Google, saying nothing about
|
|
18
|
+
* signing.
|
|
19
|
+
*
|
|
20
|
+
* Silent, late, and misleading — which is exactly the shape of failure a
|
|
21
|
+
* checklist exists to move forward in time.
|
|
22
|
+
*
|
|
23
|
+
* Text, not a Gradle evaluation: running someone's build script to ask it a
|
|
24
|
+
* question is not something a checklist may do. So this errs towards saying
|
|
25
|
+
* nothing, and the check it feeds says "could not tell" rather than inventing a
|
|
26
|
+
* verdict from a file it half understood.
|
|
27
|
+
*/
|
|
28
|
+
export const NO_SIGNING_FACTS = {
|
|
29
|
+
releaseCanUseDebugKey: false,
|
|
30
|
+
conditionalOn: null,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* `rootProject.file("key.properties")`, `file('signing.properties')` — the name,
|
|
34
|
+
* and whatever the call was made on.
|
|
35
|
+
*
|
|
36
|
+
* The receiver is optional and captured separately because it is the whole
|
|
37
|
+
* difference between two directories. It is matched as a dotted chain so that
|
|
38
|
+
* the match starts at the receiver rather than at `file(` in the middle of it:
|
|
39
|
+
* a pattern that ignored the receiver would read `keystoreDir.file(…)` as a bare
|
|
40
|
+
* call and confidently name the wrong place.
|
|
41
|
+
*/
|
|
42
|
+
const PROPERTIES_FILE = /(?:([A-Za-z_][\w.]*)\s*\.\s*)?\bfile\s*\(\s*["']([^"']+\.properties)["']\s*\)/;
|
|
43
|
+
/**
|
|
44
|
+
* `project.file` is the bare call spelled out — Gradle defines one as the other
|
|
45
|
+
* — so both mean the module. Only `rootProject` climbs, and anything else is a
|
|
46
|
+
* receiver whose directory this has no way to know.
|
|
47
|
+
*/
|
|
48
|
+
function scopeOf(receiver) {
|
|
49
|
+
if (receiver === undefined || receiver === "project")
|
|
50
|
+
return "module";
|
|
51
|
+
if (receiver === "rootProject")
|
|
52
|
+
return "root";
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The `release { … }` block of `buildTypes`, as text.
|
|
57
|
+
*
|
|
58
|
+
* Braces are counted rather than matched with a regex, because the block nests
|
|
59
|
+
* and a lazy match would stop at the first `}` — which is usually inside it.
|
|
60
|
+
*/
|
|
61
|
+
function releaseBlock(source) {
|
|
62
|
+
const start = /buildTypes\s*\{/.exec(source);
|
|
63
|
+
if (!start)
|
|
64
|
+
return null;
|
|
65
|
+
const from = source.indexOf("release", start.index);
|
|
66
|
+
if (from === -1)
|
|
67
|
+
return null;
|
|
68
|
+
const open = source.indexOf("{", from);
|
|
69
|
+
if (open === -1)
|
|
70
|
+
return null;
|
|
71
|
+
let depth = 0;
|
|
72
|
+
for (let i = open; i < source.length; i += 1) {
|
|
73
|
+
if (source[i] === "{")
|
|
74
|
+
depth += 1;
|
|
75
|
+
else if (source[i] === "}") {
|
|
76
|
+
depth -= 1;
|
|
77
|
+
if (depth === 0)
|
|
78
|
+
return source.slice(open, i + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** Reads an `android/app/build.gradle` or its Kotlin equivalent. Never throws. */
|
|
84
|
+
export function parseAndroidSigning(source) {
|
|
85
|
+
const release = releaseBlock(source);
|
|
86
|
+
if (release === null)
|
|
87
|
+
return NO_SIGNING_FACTS;
|
|
88
|
+
// `signingConfigs.debug` and `signingConfigs.getByName("debug")` are the two
|
|
89
|
+
// spellings; both mean the release build may go out with the debug key.
|
|
90
|
+
const usesDebug = /signingConfigs\s*(?:\.getByName\s*\(\s*["']debug["']\s*\)|\.debug\b)/.test(release);
|
|
91
|
+
const conditional = PROPERTIES_FILE.exec(source);
|
|
92
|
+
return {
|
|
93
|
+
releaseCanUseDebugKey: usesDebug,
|
|
94
|
+
conditionalOn: usesDebug && conditional
|
|
95
|
+
? { name: conditional[2], scope: scopeOf(conditional[1]) }
|
|
96
|
+
: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What fastlane's `Appfile` says about credentials.
|
|
3
|
+
*
|
|
4
|
+
* The Appfile is the other half of a fastlane setup, and the half Laneyard used
|
|
5
|
+
* to be blind to: a project whose Play Store service account has been configured
|
|
6
|
+
* for years — `json_key_file` on line two — was told it had no service account,
|
|
7
|
+
* because the only place the checklist looked was Laneyard's own vault. A
|
|
8
|
+
* warning that is false for a working project is worse than no warning: it is
|
|
9
|
+
* the one that teaches someone the screen is wrong and can be skipped.
|
|
10
|
+
*
|
|
11
|
+
* This is a reader, not an evaluator. The Appfile is Ruby and may compute its
|
|
12
|
+
* values — `json_key_file ENV["KEY"]` is legal and common — so anything that is
|
|
13
|
+
* not a plain string literal is reported as "set, but not to something readable
|
|
14
|
+
* from here" rather than guessed at. The checks turn that into `unknown`, which
|
|
15
|
+
* is the honest answer: the value exists, and whether it resolves to a file on
|
|
16
|
+
* this machine is not a question a text file can settle.
|
|
17
|
+
*/
|
|
18
|
+
const ABSENT = { kind: "absent" };
|
|
19
|
+
export const NO_APPFILE = {
|
|
20
|
+
jsonKeyFile: ABSENT,
|
|
21
|
+
jsonKeyData: ABSENT,
|
|
22
|
+
appleId: ABSENT,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Strips comments, so `# json_key_file "old.json"` is not read as a setting.
|
|
26
|
+
*
|
|
27
|
+
* Naive about `#` inside a string, deliberately: the alternative is a Ruby
|
|
28
|
+
* lexer, and the cost of being wrong here is one line read as a comment — the
|
|
29
|
+
* check answers "not found" instead of "found", which is the direction this
|
|
30
|
+
* whole module errs in anyway.
|
|
31
|
+
*/
|
|
32
|
+
const withoutComments = (line) => line.replace(/#.*$/, "");
|
|
33
|
+
/**
|
|
34
|
+
* `json_key_file "x"`, `json_key_file("x")`, `json_key_file 'x'` — and the
|
|
35
|
+
* `for_platform`/`for_lane` blocks they may sit inside, which change nothing
|
|
36
|
+
* about whether the key is set, only about when.
|
|
37
|
+
*/
|
|
38
|
+
function read(lines, key) {
|
|
39
|
+
const declaration = new RegExp(`^\\s*${key}\\s*(\\(|\\s)`);
|
|
40
|
+
const literal = new RegExp(`^\\s*${key}\\s*\\(?\\s*(["'])(.*?)\\1\\s*\\)?\\s*$`);
|
|
41
|
+
let found = ABSENT;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (!declaration.test(line))
|
|
44
|
+
continue;
|
|
45
|
+
const match = literal.exec(line);
|
|
46
|
+
// A later assignment wins, the way it would when Ruby runs the file — and a
|
|
47
|
+
// literal that follows a computed one is still the value that lands.
|
|
48
|
+
found = match ? { kind: "literal", value: match[2] } : { kind: "computed" };
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
/** Reads an Appfile's text. Never throws: an unreadable Appfile is `NO_APPFILE`. */
|
|
53
|
+
export function parseAppfile(content) {
|
|
54
|
+
const lines = content.split("\n").map(withoutComments);
|
|
55
|
+
return {
|
|
56
|
+
jsonKeyFile: read(lines, "json_key_file"),
|
|
57
|
+
jsonKeyData: read(lines, "json_key_data"),
|
|
58
|
+
appleId: read(lines, "apple_id"),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -42,6 +42,8 @@ export const BLOCKING_RULES = [
|
|
|
42
42
|
fix: "Pass `force: true` so it uploads without asking.",
|
|
43
43
|
},
|
|
44
44
|
];
|
|
45
|
+
/** `given`, with the fallback that keeps an older cached payload readable. */
|
|
46
|
+
export const argsGiven = (action) => action.given ?? Object.keys(action.args);
|
|
45
47
|
/**
|
|
46
48
|
* Applies the table to the actions a lane calls.
|
|
47
49
|
*
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variable names a `.env.example` advertises.
|
|
3
|
+
*
|
|
4
|
+
* A project that keeps its variables in `fastlane/.env` almost always commits a
|
|
5
|
+
* `.env.example` beside it — that is what the file is *for*: telling whoever
|
|
6
|
+
* clones the repository which variables they need. It is the one manifest of
|
|
7
|
+
* required variables that is both conventional and, unlike `.env` itself,
|
|
8
|
+
* actually present in the clone a build runs from.
|
|
9
|
+
*
|
|
10
|
+
* It catches what parsing the Fastfile cannot. `SENTRY_AUTH_TOKEN` is read by
|
|
11
|
+
* `sentry-cli`, not by any `ENV.fetch` in a lane, so no amount of reading the
|
|
12
|
+
* Fastfile finds it — and it sits in `.env.example`, named, all along.
|
|
13
|
+
*
|
|
14
|
+
* Only names are taken. The file is an example by definition, so its values are
|
|
15
|
+
* placeholders; reading them would be reading fiction, and a checklist that
|
|
16
|
+
* compared them to anything would be worse than one that did not look.
|
|
17
|
+
*/
|
|
18
|
+
export function parseEnvExample(content) {
|
|
19
|
+
const names = [];
|
|
20
|
+
for (const raw of content.split("\n")) {
|
|
21
|
+
const line = raw.trim();
|
|
22
|
+
if (line === "" || line.startsWith("#"))
|
|
23
|
+
continue;
|
|
24
|
+
// `export FOO=bar` is legal in these files and means the same thing.
|
|
25
|
+
const assignment = line.replace(/^export\s+/, "");
|
|
26
|
+
const eq = assignment.indexOf("=");
|
|
27
|
+
if (eq <= 0)
|
|
28
|
+
continue;
|
|
29
|
+
const name = assignment.slice(0, eq).trim();
|
|
30
|
+
// Shell-ish variable names only: anything else is a line this has
|
|
31
|
+
// misunderstood, and inventing a requirement is worse than missing one.
|
|
32
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
33
|
+
names.push(name);
|
|
34
|
+
}
|
|
35
|
+
return [...new Set(names)];
|
|
36
|
+
}
|
|
@@ -1,17 +1,76 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* Where to look for the markers, given where the Fastfile turned out to be.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
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.
|
|
5
|
+
* Two places, and the order does not matter because the answer is a union.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* The repository root is the obvious one, and used to be the only one. It is
|
|
8
|
+
* wrong on its own: the markers sit *beside* an app's fastlane folder, not
|
|
9
|
+
* beside the repository root — `ios/Runner.xcodeproj` and `fastlane/` are
|
|
10
|
+
* siblings, and both move together when the app is one directory of a
|
|
11
|
+
* monorepo. A repository holding `app/fastlane/Fastfile` and
|
|
12
|
+
* `app/ios/Runner.xcodeproj` reported "no Xcode project and no Gradle build",
|
|
13
|
+
* because that is three levels down and the table reaches two.
|
|
14
|
+
*
|
|
15
|
+
* The app's own directory is the second, and it is the one laneyard already
|
|
16
|
+
* knows: it asked where the Fastfile was during setup. Between them they cover
|
|
17
|
+
* the arrangements that actually occur —
|
|
18
|
+
*
|
|
19
|
+
* - `fastlane/` and `*.xcodeproj` both at the root: a plain native app;
|
|
20
|
+
* - `fastlane/`, `ios/`, `android/` at the root: React Native, Flutter;
|
|
21
|
+
* - `app/fastlane/`, `app/ios/`, `app/android/`: the same app inside a monorepo;
|
|
22
|
+
* - `ios/fastlane/`: a fastlane set up for one platform only, which then
|
|
23
|
+
* reports that one platform and not its sibling — right, since those lanes
|
|
24
|
+
* build one platform, and an irrelevant section is what teaches someone to
|
|
25
|
+
* ignore the screen.
|
|
26
|
+
*
|
|
27
|
+
* Keeping both roots rather than replacing one with the other is what makes
|
|
28
|
+
* this strictly an addition: nothing the old behaviour found is lost.
|
|
29
|
+
*
|
|
30
|
+
* What is deliberately *not* done is deepening the table to `*/*/*` or `**`.
|
|
31
|
+
* A third level would match `node_modules/some-package/ios/X.xcodeproj` and
|
|
32
|
+
* `app/ios/Pods/Pods.xcodeproj`, and report iOS for an Android-only project on
|
|
33
|
+
* the strength of a dependency's own Xcode project. `**` would do that and walk
|
|
34
|
+
* every build directory to do it. Two levels from a root that is actually the
|
|
35
|
+
* app is a better question than four levels from a root that is not.
|
|
36
|
+
*/
|
|
37
|
+
export function appRootOf(fastlaneDir) {
|
|
38
|
+
if (!fastlaneDir)
|
|
39
|
+
return ".";
|
|
40
|
+
// Repository-relative and always forward-slashed, whatever the platform.
|
|
41
|
+
const parent = fastlaneDir.replace(/\/+$/, "").split("/").slice(0, -1).join("/");
|
|
42
|
+
return parent === "" ? "." : parent;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The one directory to list, given where the Fastfile turned out to be.
|
|
46
|
+
*
|
|
47
|
+
* Not the repository root, which is what it used to be and what made a
|
|
48
|
+
* repository holding `app/fastlane/Fastfile` and `app/ios/Runner.xcodeproj`
|
|
49
|
+
* report "no Xcode project and no Gradle build": that is three levels down and
|
|
50
|
+
* the table reaches two.
|
|
51
|
+
*
|
|
52
|
+
* Not both, either, though that was tempting — keeping the root as well would
|
|
53
|
+
* have made this purely additive. It also means a project configured with
|
|
54
|
+
* `ios/fastlane` is shown the Android section, on the strength of a sibling
|
|
55
|
+
* `android/` folder those lanes never touch. The fastlane directory is not a
|
|
56
|
+
* guess: setup asked for it and `laneyard.yml` records it, and it says which
|
|
57
|
+
* app this project *is*. Trusting it is the difference between a checklist
|
|
58
|
+
* about this project and a checklist about the repository it lives in.
|
|
59
|
+
*
|
|
60
|
+
* Anyone it gets wrong has the escape hatch the check itself names: `platforms`
|
|
61
|
+
* in `laneyard.yml` is read first and skips all of this.
|
|
62
|
+
*
|
|
63
|
+
* What is deliberately not done is deepening the table to `*/*/*` or `**`. A
|
|
64
|
+
* third level matches `node_modules/some-package/ios/X.xcodeproj` and
|
|
65
|
+
* `app/ios/Pods/Pods.xcodeproj`, and would report iOS for an Android-only
|
|
66
|
+
* project on the strength of a dependency. Two levels from a directory that is
|
|
67
|
+
* actually the app beats four from one that is not.
|
|
14
68
|
*/
|
|
69
|
+
export function searchDir(from, appRoot) {
|
|
70
|
+
if (!appRoot || appRoot === "." || appRoot.startsWith(".."))
|
|
71
|
+
return from;
|
|
72
|
+
return join(from, appRoot);
|
|
73
|
+
}
|
|
15
74
|
/**
|
|
16
75
|
* What a repository looks like from the outside, as a table.
|
|
17
76
|
*
|