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.
Files changed (44) hide show
  1. package/README.md +186 -18
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/credentials/kinds.js +120 -0
  8. package/dist/src/db/credentials.js +75 -0
  9. package/dist/src/db/schema.sql +39 -0
  10. package/dist/src/db/secrets.js +28 -0
  11. package/dist/src/db/sessions.js +61 -0
  12. package/dist/src/git/workspace.js +32 -6
  13. package/dist/src/heuristics/android-root.js +49 -0
  14. package/dist/src/heuristics/android-signing.js +98 -0
  15. package/dist/src/heuristics/appfile.js +60 -0
  16. package/dist/src/heuristics/blocking-actions.js +2 -0
  17. package/dist/src/heuristics/env-example.js +36 -0
  18. package/dist/src/heuristics/platforms.js +69 -10
  19. package/dist/src/heuristics/readiness.js +554 -25
  20. package/dist/src/main.js +10 -1
  21. package/dist/src/runner/gradle-properties.js +187 -0
  22. package/dist/src/runner/materialise.js +92 -0
  23. package/dist/src/runner/orchestrate.js +91 -2
  24. package/dist/src/secrets/vault.js +106 -5
  25. package/dist/src/server/app.js +30 -3
  26. package/dist/src/server/auth.js +50 -10
  27. package/dist/src/server/permissions.js +2 -0
  28. package/dist/src/server/required-secrets.js +30 -0
  29. package/dist/src/server/routes/account.js +57 -0
  30. package/dist/src/server/routes/credentials.js +108 -0
  31. package/dist/src/server/routes/readiness.js +162 -6
  32. package/dist/src/server/routes/secrets.js +101 -0
  33. package/dist/src/sidecar/bridge.js +21 -1
  34. package/dist/src/sidecar/fastlane-dir.js +23 -0
  35. package/dist/src/sidecar/lanes.js +6 -0
  36. package/dist/src/sidecar/uses.js +21 -5
  37. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
  38. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  39. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  40. package/dist/web/index.html +2 -2
  41. package/package.json +1 -1
  42. package/ruby/introspect.rb +122 -9
  43. package/dist/web/assets/index-De6sxx6G.css +0 -1
  44. 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
- * Which platforms a project builds for.
3
+ * Where to look for the markers, given where the Fastfile turned out to be.
3
4
  *
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.
5
+ * Two places, and the order does not matter because the answer is a union.
8
6
  *
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.
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
  *