laneyard 0.3.0 → 0.4.1
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 +227 -20
- 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/cli/uninstall.js +390 -0
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +100 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +53 -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 +23 -2
- 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 +137 -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/projects.js +60 -2
- 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-9nLYwtg7.js} +1 -1
- package/dist/web/assets/index-DsjxZtsO.css +1 -0
- package/dist/web/assets/index-pzPa9EZr.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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each kind of credential block is made of — the one table the server,
|
|
3
|
+
* runner, readiness checks, and web UI all read, so an agreement kept in one
|
|
4
|
+
* place instead of copied four times.
|
|
5
|
+
*
|
|
6
|
+
* Apple and Play defaults are the names fastlane itself reads — verified
|
|
7
|
+
* against fastlane 2.237: `app_store_connect_api_key` declares
|
|
8
|
+
* `APP_STORE_CONNECT_API_KEY_KEY_FILEPATH` / `_KEY_ID` / `..._ISSUER_ID`,
|
|
9
|
+
* and `supply` declares `SUPPLY_JSON_KEY`.
|
|
10
|
+
*
|
|
11
|
+
* Android defaults are Laneyard's own — nothing in fastlane reads a keystore
|
|
12
|
+
* by convention. `ANDROID_KEYSTORE_PASSWORD` was not chosen freely: it
|
|
13
|
+
* matches the `/(^|_)(KEYSTORE|STORE)_PASSWORD$/` pattern that
|
|
14
|
+
* `src/heuristics/readiness.ts` already recognises, so the check and the
|
|
15
|
+
* block agree by construction rather than by coincidence.
|
|
16
|
+
*/
|
|
17
|
+
export const CREDENTIAL_KINDS = [
|
|
18
|
+
{
|
|
19
|
+
kind: "apple_asc",
|
|
20
|
+
platform: "ios",
|
|
21
|
+
what: "app store connect key",
|
|
22
|
+
accept: ".p8",
|
|
23
|
+
fields: [
|
|
24
|
+
{ name: "key_id", secret: false, label: "Key ID" },
|
|
25
|
+
{ name: "issuer_id", secret: false, label: "Issuer ID" },
|
|
26
|
+
],
|
|
27
|
+
defaults: {
|
|
28
|
+
path: "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH",
|
|
29
|
+
key_id: "APP_STORE_CONNECT_API_KEY_KEY_ID",
|
|
30
|
+
issuer_id: "APP_STORE_CONNECT_API_KEY_ISSUER_ID",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
kind: "android_keystore",
|
|
35
|
+
platform: "android",
|
|
36
|
+
what: "android upload keystore",
|
|
37
|
+
accept: ".jks,.keystore",
|
|
38
|
+
fields: [
|
|
39
|
+
{ name: "key_alias", secret: false, label: "Key alias" },
|
|
40
|
+
{ name: "store_password", secret: true, label: "Store password" },
|
|
41
|
+
{ name: "key_password", secret: true, label: "Key password" },
|
|
42
|
+
// The two things about a gradle properties file that cannot be deduced.
|
|
43
|
+
// `runner/gradle-properties.ts` writes that file where a build script
|
|
44
|
+
// falls back to the debug key; the script names the file, and says
|
|
45
|
+
// nothing about where the name is resolved when the receiver is a
|
|
46
|
+
// variable, nor about the keys read out of it afterwards. Both are asked
|
|
47
|
+
// here, pre-filled from what detection could tell, and left empty rather
|
|
48
|
+
// than guessed when it could tell nothing.
|
|
49
|
+
{
|
|
50
|
+
name: "properties_path",
|
|
51
|
+
secret: false,
|
|
52
|
+
optional: true,
|
|
53
|
+
label: "Properties file, relative to the app",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "property_names",
|
|
57
|
+
secret: false,
|
|
58
|
+
optional: true,
|
|
59
|
+
suggested: "storeFile,storePassword,keyPassword,keyAlias",
|
|
60
|
+
label: "Names your build reads inside it",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
defaults: {
|
|
64
|
+
path: "ANDROID_KEYSTORE_PATH",
|
|
65
|
+
store_password: "ANDROID_KEYSTORE_PASSWORD",
|
|
66
|
+
key_alias: "ANDROID_KEY_ALIAS",
|
|
67
|
+
key_password: "ANDROID_KEY_PASSWORD",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
kind: "play_service_account",
|
|
72
|
+
platform: "android",
|
|
73
|
+
what: "play store service account",
|
|
74
|
+
accept: ".json,application/json",
|
|
75
|
+
fields: [],
|
|
76
|
+
defaults: {
|
|
77
|
+
path: "SUPPLY_JSON_KEY",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
function specOf(kind) {
|
|
82
|
+
const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
|
|
83
|
+
if (!spec)
|
|
84
|
+
throw new Error(`unknown credential kind: ${kind}`);
|
|
85
|
+
return spec;
|
|
86
|
+
}
|
|
87
|
+
export function defaultVarNames(kind) {
|
|
88
|
+
return specOf(kind).defaults;
|
|
89
|
+
}
|
|
90
|
+
export function fieldsOf(kind) {
|
|
91
|
+
return specOf(kind).fields;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The variable names a set of stored blocks will export into a run.
|
|
95
|
+
*
|
|
96
|
+
* Read off the summaries — the kind and the names stored beside it — so nothing
|
|
97
|
+
* here decrypts anything. That is what lets the readiness check and the secrets
|
|
98
|
+
* screen both ask the question: a name a block supplies is a name the project
|
|
99
|
+
* already has, and asking someone to type it by hand is asking them for what
|
|
100
|
+
* Laneyard is about to hand the run itself.
|
|
101
|
+
*
|
|
102
|
+
* Every name in the block is counted, and no value is looked at. `path` is
|
|
103
|
+
* always exported, and every other slot belongs to a field the block could not
|
|
104
|
+
* be stored without — `credentials.ts` refuses a block with a required field
|
|
105
|
+
* empty, and the optional two have no slot at all. So a name here is a name
|
|
106
|
+
* `runner/materialise.ts` will set.
|
|
107
|
+
*
|
|
108
|
+
* The blocks passed in are the ones that apply, project shadowing global, which
|
|
109
|
+
* is `Vault.listCredentials` and the precedence a run uses.
|
|
110
|
+
*/
|
|
111
|
+
export function exportedVarNames(blocks) {
|
|
112
|
+
const names = new Set();
|
|
113
|
+
for (const block of blocks) {
|
|
114
|
+
for (const name of Object.values({ ...defaultVarNames(block.kind), ...block.varNames })) {
|
|
115
|
+
if (name)
|
|
116
|
+
names.add(name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...names].sort();
|
|
120
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const GLOBAL = "";
|
|
2
|
+
/**
|
|
3
|
+
* Stores signing credential blocks: a file plus the fields that make it
|
|
4
|
+
* usable. Knows nothing about encryption itself: it takes and returns
|
|
5
|
+
* ciphertext, so a bug here cannot leak a plaintext value.
|
|
6
|
+
*
|
|
7
|
+
* A project credential shadows a global one of the same kind — the same
|
|
8
|
+
* precedence as `SecretStore`, and the least surprising rule.
|
|
9
|
+
*/
|
|
10
|
+
export class CredentialStore {
|
|
11
|
+
db;
|
|
12
|
+
constructor(db) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
}
|
|
15
|
+
set(projectSlug, kind, input) {
|
|
16
|
+
this.db
|
|
17
|
+
.prepare(`INSERT INTO credential (project_slug, kind, file_name, file_enc, fields_enc, var_names, updated_at)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
19
|
+
ON CONFLICT (project_slug, kind) DO UPDATE
|
|
20
|
+
SET file_name = excluded.file_name,
|
|
21
|
+
file_enc = excluded.file_enc,
|
|
22
|
+
fields_enc = excluded.fields_enc,
|
|
23
|
+
var_names = excluded.var_names,
|
|
24
|
+
updated_at = excluded.updated_at`)
|
|
25
|
+
.run(projectSlug ?? GLOBAL, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
|
|
26
|
+
}
|
|
27
|
+
/** Rows that apply to a project, project scope winning over global. */
|
|
28
|
+
applicable(projectSlug) {
|
|
29
|
+
const rows = this.db
|
|
30
|
+
.prepare("SELECT * FROM credential WHERE project_slug IN (?, ?) ORDER BY kind")
|
|
31
|
+
.all(projectSlug, GLOBAL);
|
|
32
|
+
const byKind = new Map();
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const existing = byKind.get(row.kind);
|
|
35
|
+
if (!existing || row.project_slug !== GLOBAL)
|
|
36
|
+
byKind.set(row.kind, row);
|
|
37
|
+
}
|
|
38
|
+
return [...byKind.values()]
|
|
39
|
+
.sort((a, b) => a.kind.localeCompare(b.kind))
|
|
40
|
+
.map((row) => this.toSummary(row));
|
|
41
|
+
}
|
|
42
|
+
/** One applicable block, ciphertext included, or undefined. */
|
|
43
|
+
find(projectSlug, kind) {
|
|
44
|
+
return this.applicable(projectSlug).find((r) => r.kind === kind);
|
|
45
|
+
}
|
|
46
|
+
list(projectSlug) {
|
|
47
|
+
return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The blocks stored under this slug, and no global one.
|
|
51
|
+
*
|
|
52
|
+
* Same distinction as `SecretStore.listOwn`, and it matters more here: a
|
|
53
|
+
* global keystore shadowed by nothing is shared by every project on the
|
|
54
|
+
* machine, and counting it as one project's would offer to delete the one
|
|
55
|
+
* credential every other project signs with.
|
|
56
|
+
*/
|
|
57
|
+
listOwn(projectSlug) {
|
|
58
|
+
if (projectSlug === GLOBAL)
|
|
59
|
+
return [];
|
|
60
|
+
const rows = this.db
|
|
61
|
+
.prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
|
|
62
|
+
.all(projectSlug);
|
|
63
|
+
return rows.map((row) => {
|
|
64
|
+
const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
|
|
65
|
+
return summary;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Removes every block stored under this slug, and returns how many. */
|
|
69
|
+
removeAllOwn(projectSlug) {
|
|
70
|
+
if (projectSlug === GLOBAL)
|
|
71
|
+
return 0;
|
|
72
|
+
return this.db.prepare("DELETE FROM credential WHERE project_slug = ?").run(projectSlug).changes;
|
|
73
|
+
}
|
|
74
|
+
listGlobal() {
|
|
75
|
+
const rows = this.db
|
|
76
|
+
.prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
|
|
77
|
+
.all(GLOBAL);
|
|
78
|
+
return rows.map((row) => {
|
|
79
|
+
const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
|
|
80
|
+
return summary;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
remove(projectSlug, kind) {
|
|
84
|
+
const res = this.db
|
|
85
|
+
.prepare("DELETE FROM credential WHERE project_slug = ? AND kind = ?")
|
|
86
|
+
.run(projectSlug ?? GLOBAL, kind);
|
|
87
|
+
return res.changes > 0;
|
|
88
|
+
}
|
|
89
|
+
toSummary(row) {
|
|
90
|
+
return {
|
|
91
|
+
kind: row.kind,
|
|
92
|
+
fileName: row.file_name,
|
|
93
|
+
fileEnc: row.file_enc,
|
|
94
|
+
fieldsEnc: row.fields_enc,
|
|
95
|
+
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
96
|
+
varNames: JSON.parse(row.var_names),
|
|
97
|
+
updatedAt: row.updated_at,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/src/db/schema.sql
CHANGED
|
@@ -61,3 +61,42 @@ CREATE TABLE IF NOT EXISTS secret (
|
|
|
61
61
|
updated_at TEXT NOT NULL,
|
|
62
62
|
PRIMARY KEY (project_slug, key)
|
|
63
63
|
);
|
|
64
|
+
|
|
65
|
+
-- A signing credential: a file plus the fields that make it usable. Separate
|
|
66
|
+
-- from `secret` because these are not key/value pairs — stored as loose rows,
|
|
67
|
+
-- nothing knew the parts belonged together, and deleting one left a half-dead
|
|
68
|
+
-- group no check could detect.
|
|
69
|
+
--
|
|
70
|
+
-- `fields_enc` is one encrypted JSON object rather than a column per field: the
|
|
71
|
+
-- three kinds do not share a shape, and a column per field would mean a
|
|
72
|
+
-- migration every time a kind gains one.
|
|
73
|
+
--
|
|
74
|
+
-- `var_names` is NOT encrypted. It holds variable names, never values, and the
|
|
75
|
+
-- interface has to display them.
|
|
76
|
+
CREATE TABLE IF NOT EXISTS credential (
|
|
77
|
+
project_slug TEXT NOT NULL DEFAULT '',
|
|
78
|
+
kind TEXT NOT NULL,
|
|
79
|
+
file_name TEXT NOT NULL,
|
|
80
|
+
file_enc TEXT NOT NULL,
|
|
81
|
+
fields_enc TEXT NOT NULL,
|
|
82
|
+
var_names TEXT NOT NULL,
|
|
83
|
+
updated_at TEXT NOT NULL,
|
|
84
|
+
PRIMARY KEY (project_slug, kind)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
-- Sessions outlive a restart, which is the whole reason they are here rather
|
|
88
|
+
-- than in a Map. What is stored is a SHA-256 of the token, never the token: the
|
|
89
|
+
-- cookie is a bearer credential, and a stolen `laneyard.db` must not hand
|
|
90
|
+
-- anybody a live session the way a table of raw tokens would.
|
|
91
|
+
--
|
|
92
|
+
-- `expires_at` is an ISO timestamp compared as text, which sorts correctly
|
|
93
|
+
-- because ISO-8601 does. A session with no end is not a convenience, it is a
|
|
94
|
+
-- credential nobody can lose track of.
|
|
95
|
+
CREATE TABLE IF NOT EXISTS session (
|
|
96
|
+
token_hash TEXT PRIMARY KEY,
|
|
97
|
+
name TEXT NOT NULL,
|
|
98
|
+
role TEXT NOT NULL,
|
|
99
|
+
created_at TEXT NOT NULL,
|
|
100
|
+
expires_at TEXT NOT NULL
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS session_by_name ON session (name);
|
package/dist/src/db/secrets.js
CHANGED
|
@@ -34,6 +34,59 @@ export class SecretStore {
|
|
|
34
34
|
}
|
|
35
35
|
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
36
36
|
}
|
|
37
|
+
/** One applicable row, ciphertext included, or undefined. */
|
|
38
|
+
find(projectSlug, key) {
|
|
39
|
+
const row = this.applicable(projectSlug).find((r) => r.key === key);
|
|
40
|
+
return row
|
|
41
|
+
? {
|
|
42
|
+
key: row.key,
|
|
43
|
+
masked: row.masked === 1,
|
|
44
|
+
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
45
|
+
valueEnc: row.value_enc,
|
|
46
|
+
}
|
|
47
|
+
: undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Flips whether a value is kept out of the logs, leaving the value alone.
|
|
51
|
+
*
|
|
52
|
+
* Its own operation rather than a re-`set`, because of a circle: to reveal a
|
|
53
|
+
* value you must first declare it not secret, and declaring that by storing it
|
|
54
|
+
* again would mean typing the value you were trying to read.
|
|
55
|
+
*
|
|
56
|
+
* Returns false when no row matches, so a caller can answer 404 rather than
|
|
57
|
+
* report a change that did not happen.
|
|
58
|
+
*/
|
|
59
|
+
setMasked(projectSlug, key, masked) {
|
|
60
|
+
return (this.db
|
|
61
|
+
.prepare(`UPDATE secret SET masked = ?, updated_at = ?
|
|
62
|
+
WHERE project_slug = ? AND key = ?`)
|
|
63
|
+
.run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The rows stored under this slug, and no global one.
|
|
67
|
+
*
|
|
68
|
+
* `list` answers "what does this project see", which is the right question
|
|
69
|
+
* everywhere else and the wrong one when a project is going away: a global
|
|
70
|
+
* secret three other projects also read is not this one's to count, and
|
|
71
|
+
* certainly not its to remove.
|
|
72
|
+
*
|
|
73
|
+
* The empty slug is the global scope's own key, so it is refused here rather
|
|
74
|
+
* than quietly returning every global row as if one project owned them.
|
|
75
|
+
*/
|
|
76
|
+
listOwn(projectSlug) {
|
|
77
|
+
if (projectSlug === GLOBAL)
|
|
78
|
+
return [];
|
|
79
|
+
const rows = this.db
|
|
80
|
+
.prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
|
|
81
|
+
.all(projectSlug);
|
|
82
|
+
return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "project" }));
|
|
83
|
+
}
|
|
84
|
+
/** Removes every row stored under this slug, and returns how many. */
|
|
85
|
+
removeAllOwn(projectSlug) {
|
|
86
|
+
if (projectSlug === GLOBAL)
|
|
87
|
+
return 0;
|
|
88
|
+
return this.db.prepare("DELETE FROM secret WHERE project_slug = ?").run(projectSlug).changes;
|
|
89
|
+
}
|
|
37
90
|
list(projectSlug) {
|
|
38
91
|
return this.applicable(projectSlug).map((row) => ({
|
|
39
92
|
key: row.key,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* A token is never stored, only its digest.
|
|
4
|
+
*
|
|
5
|
+
* SHA-256 without a salt on purpose, unlike a password: the token is 32 random
|
|
6
|
+
* bytes from `randomBytes`, so there is no dictionary to build and no cheaper
|
|
7
|
+
* attack than guessing the token itself. What this buys is that a copy of
|
|
8
|
+
* `laneyard.db` is a list of digests rather than a ring of working keys.
|
|
9
|
+
*/
|
|
10
|
+
const digest = (token) => createHash("sha256").update(token).digest("hex");
|
|
11
|
+
/**
|
|
12
|
+
* Where sessions live between restarts.
|
|
13
|
+
*
|
|
14
|
+
* They used to live in a Map, with a comment saying they did not survive a
|
|
15
|
+
* restart "and that's just fine". It was not fine: a server restarted to pick
|
|
16
|
+
* up a configuration change signed everybody out, and on a machine you are
|
|
17
|
+
* still setting up that is several times an hour.
|
|
18
|
+
*/
|
|
19
|
+
export class SessionRecords {
|
|
20
|
+
db;
|
|
21
|
+
constructor(db) {
|
|
22
|
+
this.db = db;
|
|
23
|
+
}
|
|
24
|
+
insert(token, owner, expiresAt) {
|
|
25
|
+
this.db
|
|
26
|
+
.prepare(`INSERT INTO session (token_hash, name, role, created_at, expires_at)
|
|
27
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
28
|
+
.run(digest(token), owner.name, owner.role, new Date().toISOString(), expiresAt.toISOString());
|
|
29
|
+
}
|
|
30
|
+
/** The owner, or undefined when the token is unknown or its time is up. */
|
|
31
|
+
find(token, now = new Date()) {
|
|
32
|
+
const row = this.db
|
|
33
|
+
.prepare(`SELECT name, role FROM session WHERE token_hash = ? AND expires_at > ?`)
|
|
34
|
+
.get(digest(token), now.toISOString());
|
|
35
|
+
return row ? { name: row.name, role: row.role } : undefined;
|
|
36
|
+
}
|
|
37
|
+
remove(token) {
|
|
38
|
+
this.db.prepare(`DELETE FROM session WHERE token_hash = ?`).run(digest(token));
|
|
39
|
+
}
|
|
40
|
+
removeAllFor(name) {
|
|
41
|
+
this.db.prepare(`DELETE FROM session WHERE name = ?`).run(name);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Drops what has already expired.
|
|
45
|
+
*
|
|
46
|
+
* Called at startup rather than on a timer: the rows are harmless — `find`
|
|
47
|
+
* already refuses them — so this is housekeeping, not correctness, and a
|
|
48
|
+
* timer would be a moving part earning nothing.
|
|
49
|
+
*/
|
|
50
|
+
prune(now = new Date()) {
|
|
51
|
+
return this.db.prepare(`DELETE FROM session WHERE expires_at <= ?`).run(now.toISOString())
|
|
52
|
+
.changes;
|
|
53
|
+
}
|
|
54
|
+
/** Number of live sessions. Exposed so expiry can be tested without sleeping. */
|
|
55
|
+
count(now = new Date()) {
|
|
56
|
+
const row = this.db
|
|
57
|
+
.prepare(`SELECT COUNT(*) AS n FROM session WHERE expires_at > ?`)
|
|
58
|
+
.get(now.toISOString());
|
|
59
|
+
return row.n;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -7,6 +7,27 @@ const exec = promisify(execFile);
|
|
|
7
7
|
* A clone managed by Laneyard, kept between runs.
|
|
8
8
|
* All git commands go through here to share the authentication environment.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The environment git needs to work without a person at the keyboard.
|
|
12
|
+
*
|
|
13
|
+
* Lifted out of `Workspace` because it is not only Workspace's problem. A lane
|
|
14
|
+
* may run git itself — pushing a bumped build number is a common and reasonable
|
|
15
|
+
* thing for a Fastfile to do — and that `sh("git push")` inherited none of
|
|
16
|
+
* this. The result was the worst possible failure: a push that needed a
|
|
17
|
+
* credential did not fail, it *waited*, and the run sat there until it hit its
|
|
18
|
+
* timeout with nothing in the log to say what it was waiting for.
|
|
19
|
+
*
|
|
20
|
+
* `GIT_TERMINAL_PROMPT=0` is what turns that wait into an error. The SSH
|
|
21
|
+
* command is what makes the push succeed instead — the key configured for
|
|
22
|
+
* cloning is by definition the right one for pushing to the same remote.
|
|
23
|
+
*/
|
|
24
|
+
export function gitEnvFor(auth) {
|
|
25
|
+
const env = { GIT_TERMINAL_PROMPT: "0" };
|
|
26
|
+
if (auth.kind === "ssh_key" && auth.ref) {
|
|
27
|
+
env["GIT_SSH_COMMAND"] = `ssh -i ${auth.ref} -o IdentitiesOnly=yes -o BatchMode=yes`;
|
|
28
|
+
}
|
|
29
|
+
return env;
|
|
30
|
+
}
|
|
10
31
|
export class Workspace {
|
|
11
32
|
path;
|
|
12
33
|
gitUrl;
|
|
@@ -17,12 +38,17 @@ export class Workspace {
|
|
|
17
38
|
this.auth = auth;
|
|
18
39
|
}
|
|
19
40
|
env() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
return { ...process.env, ...gitEnvFor(this.auth) };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The git identity this workspace would commit under, or null.
|
|
45
|
+
*
|
|
46
|
+
* Exposed because a lane that runs `git commit` itself needs the same answer,
|
|
47
|
+
* and a clone Laneyard made has no identity of its own — so the question is
|
|
48
|
+
* whether the *server* has one.
|
|
49
|
+
*/
|
|
50
|
+
async identity() {
|
|
51
|
+
return this.gitIdentity();
|
|
26
52
|
}
|
|
27
53
|
/**
|
|
28
54
|
* Replaces the repository URL with a neutral token in a piece of text.
|
|
@@ -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
|
*
|