laneyard 0.2.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 +295 -20
- package/dist/src/cli/detect.js +69 -16
- package/dist/src/cli/prompt.js +61 -0
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +319 -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 +26 -1
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- 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 +22 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +136 -0
- package/dist/src/heuristics/readiness.js +697 -25
- package/dist/src/main.js +58 -8
- 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 +94 -15
- package/dist/src/server/auth.js +127 -19
- package/dist/src/server/permissions.js +75 -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 +42 -0
- package/dist/src/server/routes/readiness.js +175 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +37 -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-D5Q4uj4n.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-D9_EL8LA.js +0 -62
- package/dist/web/assets/index-ZUqTX6d1.css +0 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CREDENTIAL_KINDS, defaultVarNames, fieldsOf } from "../../credentials/kinds.js";
|
|
2
|
+
/**
|
|
3
|
+
* Signing blocks over HTTP: the file and the fields that make it usable.
|
|
4
|
+
*
|
|
5
|
+
* The file arrives base64 inside a JSON body rather than as a multipart upload.
|
|
6
|
+
* No multipart plugin is registered — see `app.ts` — and a `.p8` is two
|
|
7
|
+
* kilobytes, so adding a dependency to carry it would be paying in supply chain
|
|
8
|
+
* for a convenience the browser can provide in one `FileReader` call.
|
|
9
|
+
*
|
|
10
|
+
* A block is taken or refused whole. That is what makes it a block rather than
|
|
11
|
+
* three loose rows: a keystore stored without its alias is not a partial
|
|
12
|
+
* success, it is a build that fails in a month with an unusable artifact.
|
|
13
|
+
*/
|
|
14
|
+
/** POSIX environment variable names. Anything else would never reach fastlane. */
|
|
15
|
+
const VALID_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
export async function registerCredentialRoutes(app, ctx) {
|
|
17
|
+
app.get("/api/credentials", async () => ctx.vault.listGlobalCredentials());
|
|
18
|
+
app.get("/api/projects/:slug/credentials", async (req, reply) => {
|
|
19
|
+
const { slug } = req.params;
|
|
20
|
+
if (!ctx.config.project(slug))
|
|
21
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
22
|
+
return ctx.vault.listCredentials(slug);
|
|
23
|
+
});
|
|
24
|
+
const put = async (slug, kind, body, reply) => {
|
|
25
|
+
const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
|
|
26
|
+
if (!spec) {
|
|
27
|
+
return reply.code(400).send({
|
|
28
|
+
error: `"${kind}" is not a kind of credential Laneyard knows: ${CREDENTIAL_KINDS.map((k) => k.kind).join(", ")}.`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const { fileName, fileBase64, fields, varNames } = (body ?? {});
|
|
32
|
+
if (typeof fileName !== "string" || fileName === "") {
|
|
33
|
+
return reply.code(400).send({ error: "A file name is required" });
|
|
34
|
+
}
|
|
35
|
+
if (typeof fileBase64 !== "string" || fileBase64 === "") {
|
|
36
|
+
return reply.code(400).send({ error: `The ${spec.what} file is required, base64-encoded.` });
|
|
37
|
+
}
|
|
38
|
+
const given = (fields ?? {});
|
|
39
|
+
if (typeof given !== "object" || Array.isArray(given)) {
|
|
40
|
+
return reply.code(400).send({ error: "`fields` is an object of name to value." });
|
|
41
|
+
}
|
|
42
|
+
// Optional fields are the settings a block may leave unanswered — where a
|
|
43
|
+
// gradle properties file goes, what it is read under. Laneyard is allowed to
|
|
44
|
+
// ask; refusing the whole block over an unanswered one would be requiring.
|
|
45
|
+
const missing = fieldsOf(spec.kind)
|
|
46
|
+
.filter((f) => !f.optional && (typeof given[f.name] !== "string" || given[f.name] === ""))
|
|
47
|
+
.map((f) => f.name);
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
return reply.code(400).send({
|
|
50
|
+
error: `A ${spec.what} needs ${missing.join(", ")}. Without it the file alone signs nothing.`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Only the fields the kind declares are kept. An extra one would be stored,
|
|
54
|
+
// never read, and quietly disagree with what the block claims to be.
|
|
55
|
+
const kept = {};
|
|
56
|
+
for (const field of fieldsOf(spec.kind)) {
|
|
57
|
+
const value = given[field.name];
|
|
58
|
+
// An unanswered optional field is absent from the block rather than
|
|
59
|
+
// stored empty: the reader then falls back to what it would have used
|
|
60
|
+
// anyway, instead of taking "" for an answer someone gave.
|
|
61
|
+
if (typeof value === "string" && value !== "")
|
|
62
|
+
kept[field.name] = value;
|
|
63
|
+
}
|
|
64
|
+
const names = { ...defaultVarNames(spec.kind), ...(varNames ?? {}) };
|
|
65
|
+
for (const [slot, name] of Object.entries(names)) {
|
|
66
|
+
if (!(slot in defaultVarNames(spec.kind))) {
|
|
67
|
+
return reply.code(400).send({ error: `A ${spec.what} exports nothing called "${slot}".` });
|
|
68
|
+
}
|
|
69
|
+
if (typeof name !== "string" || !VALID_VAR.test(name)) {
|
|
70
|
+
return reply.code(400).send({
|
|
71
|
+
error: `"${name}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Node's base64 decoder ignores what it cannot read rather than refusing, so
|
|
76
|
+
// a truncated or mistyped upload would be stored as a shorter file and only
|
|
77
|
+
// surface as an unreadable key at signing time.
|
|
78
|
+
const fileBytes = Buffer.from(fileBase64, "base64");
|
|
79
|
+
if (fileBytes.length === 0 || fileBytes.toString("base64").replace(/=+$/, "") !== fileBase64.replace(/=+$/, "")) {
|
|
80
|
+
return reply.code(400).send({ error: "The file is not valid base64." });
|
|
81
|
+
}
|
|
82
|
+
await ctx.vault.setCredential(slug, spec.kind, { fileName, fileBytes, fields: kept, varNames: names });
|
|
83
|
+
return reply.code(204).send();
|
|
84
|
+
};
|
|
85
|
+
app.put("/api/credentials/:kind", async (req, reply) => put(null, req.params.kind, req.body, reply));
|
|
86
|
+
app.put("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
87
|
+
const { slug, kind } = req.params;
|
|
88
|
+
if (!ctx.config.project(slug))
|
|
89
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
90
|
+
return put(slug, kind, req.body, reply);
|
|
91
|
+
});
|
|
92
|
+
app.delete("/api/credentials/:kind", async (req, reply) => {
|
|
93
|
+
const { kind } = req.params;
|
|
94
|
+
const removed = ctx.vault.removeCredential(null, kind);
|
|
95
|
+
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Removes this project's own block, and only that one. A global block that
|
|
99
|
+
* was shadowed comes back into view, which is the deletion someone asked for:
|
|
100
|
+
* they are undoing an override, not deleting everyone's key from inside one
|
|
101
|
+
* project.
|
|
102
|
+
*/
|
|
103
|
+
app.delete("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
104
|
+
const { slug, kind } = req.params;
|
|
105
|
+
const removed = ctx.vault.removeCredential(slug, kind);
|
|
106
|
+
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { removeProjectFromConfig } from "../../cli/setup.js";
|
|
1
3
|
export async function registerProjectRoutes(app, ctx) {
|
|
2
4
|
app.get("/api/projects", async () => ctx.config.projects().map((p) => {
|
|
3
5
|
const last = ctx.runs.listByProject(p.slug, 1)[0] ?? null;
|
|
@@ -8,6 +10,46 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
8
10
|
lastRun: last && { id: last.id, status: last.status, lane: last.lane, finishedAt: last.finishedAt },
|
|
9
11
|
};
|
|
10
12
|
}));
|
|
13
|
+
/**
|
|
14
|
+
* Stops showing a project. It is the one destructive route in the product,
|
|
15
|
+
* and what it does not destroy is most of the point:
|
|
16
|
+
*
|
|
17
|
+
* - the project's block leaves config.yml, through the YAML document so the
|
|
18
|
+
* rest of a hand-written file is untouched;
|
|
19
|
+
* - its runs stay in the database, still reachable at their own URL — the
|
|
20
|
+
* history of what this machine built is not the project's to take away;
|
|
21
|
+
* - the clone and the artifacts stay on disk, named in the answer so they
|
|
22
|
+
* can be removed by hand. Deleting files someone may still want, from a
|
|
23
|
+
* web page, on one click, is not a thing to do.
|
|
24
|
+
*/
|
|
25
|
+
app.delete("/api/projects/:slug", async (req, reply) => {
|
|
26
|
+
const { slug } = req.params;
|
|
27
|
+
const entry = ctx.config.project(slug);
|
|
28
|
+
if (!entry)
|
|
29
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
30
|
+
// A run that has begun is reading the workspace this project points at.
|
|
31
|
+
// Queued runs are not: the queue already fails a run whose project went
|
|
32
|
+
// away, so waiting on them would only mean refusing for longer.
|
|
33
|
+
if (ctx.runs.hasActiveRun(slug)) {
|
|
34
|
+
return reply.code(409).send({
|
|
35
|
+
error: `"${slug}" has a run in flight. Wait for it to finish, or cancel it, then remove the project.`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const removed = await removeProjectFromConfig(ctx.config.configPath(), slug);
|
|
39
|
+
if (!removed)
|
|
40
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
41
|
+
// The file is watched, but on a debounce: reloading here is what makes the
|
|
42
|
+
// very next request — the listing this page is about to ask for — truthful.
|
|
43
|
+
await ctx.config.load();
|
|
44
|
+
// -1 is SQLite's "no limit": the answer names every artifact folder left
|
|
45
|
+
// behind, and a project with sixty runs must not be told about fifty of them.
|
|
46
|
+
const runs = ctx.runs.listByProject(slug, -1);
|
|
47
|
+
const leftOnDisk = [
|
|
48
|
+
ctx.workspacePath(slug),
|
|
49
|
+
...runs.map((run) => ctx.artifactsDir(run.id)),
|
|
50
|
+
].filter((path) => existsSync(path));
|
|
51
|
+
return reply.send({ slug, name: entry.name, runsKept: runs.length, leftOnDisk });
|
|
52
|
+
});
|
|
11
53
|
app.get("/api/projects/:slug/lanes", async (req, reply) => {
|
|
12
54
|
const { slug } = req.params;
|
|
13
55
|
const entry = ctx.config.project(slug);
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { access } from "node:fs/promises";
|
|
2
|
+
import { access, readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { glob } from "tinyglobby";
|
|
5
6
|
import { Workspace } from "../../git/workspace.js";
|
|
6
|
-
import {
|
|
7
|
+
import { NO_APPFILE, parseAppfile } from "../../heuristics/appfile.js";
|
|
8
|
+
import { findAndroidBuild } from "../../heuristics/android-root.js";
|
|
9
|
+
import { exportedVarNames } from "../../credentials/kinds.js";
|
|
10
|
+
import { envExampleNames } from "../required-secrets.js";
|
|
11
|
+
import { appRootOf, resolvePlatforms, searchDir } from "../../heuristics/platforms.js";
|
|
12
|
+
import { runChecklist } from "../../heuristics/readiness.js";
|
|
13
|
+
import { LANEYARD_MARKER, propertyNames } from "../../runner/gradle-properties.js";
|
|
7
14
|
const exec = promisify(execFile);
|
|
8
15
|
/**
|
|
9
16
|
* `bundle check` in the workspace, rejecting with what bundler said.
|
|
@@ -33,7 +40,100 @@ async function findFastlane() {
|
|
|
33
40
|
return null;
|
|
34
41
|
}
|
|
35
42
|
}
|
|
43
|
+
/** The globbing `heuristics/platforms.ts` asks for, bound to a directory. */
|
|
44
|
+
const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
|
|
36
45
|
const exists = async (path) => access(path).then(() => true, () => false);
|
|
46
|
+
/**
|
|
47
|
+
* Is this the user's own properties file — as opposed to one Laneyard wrote
|
|
48
|
+
* and failed to clean up?
|
|
49
|
+
*
|
|
50
|
+
* `gradle-properties.ts` marks every file it writes with `LANEYARD_MARKER` as
|
|
51
|
+
* its first line, precisely so this check can tell the two apart. A run killed
|
|
52
|
+
* between writing the file and reaching its `finally` leaves a marked one
|
|
53
|
+
* behind in the persistent clone; counting it as the user's own signing
|
|
54
|
+
* configuration would flip the checklist from "the release build will use the
|
|
55
|
+
* debug key" to "the release key is used" — a green verdict Laneyard
|
|
56
|
+
* manufactured for itself out of a cleanup it failed to run.
|
|
57
|
+
*
|
|
58
|
+
* Reading fails the same way `exists` does: a missing file, a permission
|
|
59
|
+
* error, or one this process cannot open are all "not the user's file",
|
|
60
|
+
* because none of them is evidence of a real signing configuration.
|
|
61
|
+
*/
|
|
62
|
+
async function isUsersOwn(path) {
|
|
63
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
64
|
+
if (text === null)
|
|
65
|
+
return false;
|
|
66
|
+
return (text.split("\n")[0] ?? "").trimEnd() !== LANEYARD_MARKER;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Is the properties file where the build script would look for it — and is it
|
|
70
|
+
* the user's, not Laneyard's own leftover?
|
|
71
|
+
*
|
|
72
|
+
* The module directory is the one holding the build script, and the Gradle root
|
|
73
|
+
* is its parent — `android/` for an `android/app/build.gradle`. Which of the two
|
|
74
|
+
* the name is relative to is the script's decision, and the parser reports which
|
|
75
|
+
* one it made. When it could not tell, both are looked in: answering "not in the
|
|
76
|
+
* clone" because the wrong directory was searched would have the checklist
|
|
77
|
+
* inventing the very failure it exists to catch. Both places apply the same
|
|
78
|
+
* marker rule — a leftover in either one is still Laneyard's, not the user's.
|
|
79
|
+
*/
|
|
80
|
+
async function isPresent(build, file) {
|
|
81
|
+
const { moduleDir, gradleRoot } = build;
|
|
82
|
+
const places = file.scope === "root"
|
|
83
|
+
? [gradleRoot]
|
|
84
|
+
: file.scope === "module"
|
|
85
|
+
? [moduleDir]
|
|
86
|
+
: [gradleRoot, moduleDir];
|
|
87
|
+
const found = await Promise.all(places.map((dir) => isUsersOwn(join(dir, file.name))));
|
|
88
|
+
return found.some(Boolean);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What the android build script says about release signing, and whether the
|
|
92
|
+
* file it depends on is in the clone.
|
|
93
|
+
*
|
|
94
|
+
* The listing is the caller's half of the answer, as everywhere else here: the
|
|
95
|
+
* check reads text and reaches for nothing. Which script speaks for the android
|
|
96
|
+
* side is `heuristics/android-root.ts`'s decision rather than this file's,
|
|
97
|
+
* because the runner writes the properties file against that same decision — see
|
|
98
|
+
* that module for why the two must not be able to disagree.
|
|
99
|
+
*/
|
|
100
|
+
async function androidSigning(workspacePath, appRoot, unreachable) {
|
|
101
|
+
if (unreachable !== null) {
|
|
102
|
+
return { androidSigning: { ok: false, reason: unreachable }, signingFilePresent: false };
|
|
103
|
+
}
|
|
104
|
+
const build = await findAndroidBuild(join(workspacePath, appRoot));
|
|
105
|
+
if (build === null) {
|
|
106
|
+
return {
|
|
107
|
+
androidSigning: { ok: false, reason: "no android build.gradle found in the clone" },
|
|
108
|
+
signingFilePresent: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const present = build.facts.conditionalOn === null ? false : await isPresent(build, build.facts.conditionalOn);
|
|
112
|
+
return { androidSigning: { ok: true, value: build.facts }, signingFilePresent: present };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* What the keystore block says about the properties file, and nothing else.
|
|
116
|
+
*
|
|
117
|
+
* The block has to be decrypted to be asked — `property_names` and
|
|
118
|
+
* `properties_path` are stored with the passphrases — so the narrowing happens
|
|
119
|
+
* here, at the last point that touches plaintext. What crosses into the
|
|
120
|
+
* checklist is two settings a browser is already shown on the block's own form.
|
|
121
|
+
*
|
|
122
|
+
* A block that will not decrypt is not an error page: it is a keystore the
|
|
123
|
+
* checklist cannot speak for, and `credentials` already reports that separately.
|
|
124
|
+
*/
|
|
125
|
+
function keystoreSetting(vault, slug) {
|
|
126
|
+
try {
|
|
127
|
+
const block = vault.resolveCredential(slug, "android_keystore");
|
|
128
|
+
if (!block)
|
|
129
|
+
return null;
|
|
130
|
+
const path = (block.fields["properties_path"] ?? "").trim();
|
|
131
|
+
return { propertyNames: propertyNames(block.fields), propertiesPath: path === "" ? null : path };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
37
137
|
export async function registerReadinessRoutes(app, ctx) {
|
|
38
138
|
/**
|
|
39
139
|
* Computed only when asked for.
|
|
@@ -63,15 +163,61 @@ export async function registerReadinessRoutes(app, ctx) {
|
|
|
63
163
|
}
|
|
64
164
|
const resolved = await ctx.config.resolve(slug, workspacePath);
|
|
65
165
|
const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
|
|
66
|
-
const
|
|
166
|
+
const read = unreachable !== null
|
|
67
167
|
? { ok: false, reason: unreachable }
|
|
68
168
|
: await ctx
|
|
69
169
|
.uses(slug, workspacePath, fastlaneDir)
|
|
70
|
-
.then((
|
|
170
|
+
.then((value) => ({ ok: true, value }))
|
|
71
171
|
// Broken Fastfile, no Ruby, no fastlane: all of them are "could not
|
|
72
172
|
// tell", none of them is a 500.
|
|
73
173
|
.catch((cause) => ({ ok: false, reason: cause.message }));
|
|
74
|
-
const
|
|
174
|
+
const uses = read.ok
|
|
175
|
+
? { ok: true, value: read.value.lanes }
|
|
176
|
+
: { ok: false, reason: read.reason };
|
|
177
|
+
// What reading the Fastfile could not account for. `fastlane/actions` is the
|
|
178
|
+
// caller's half of the answer — a directory listing, not something the Ruby
|
|
179
|
+
// parser could know — and the sidecar supplies the other, `import`. A check
|
|
180
|
+
// that would conclude something from finding nothing consults this first.
|
|
181
|
+
const unread = read.ok
|
|
182
|
+
? {
|
|
183
|
+
ok: true,
|
|
184
|
+
value: {
|
|
185
|
+
imports: read.value.imports,
|
|
186
|
+
customActions: await exists(join(workspacePath, fastlaneDir, "actions")),
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: { ok: false, reason: read.reason };
|
|
190
|
+
// What the project builds for decides which half of the checklist applies.
|
|
191
|
+
// `laneyard.yml` answers on its own; without it the workspace is looked at,
|
|
192
|
+
// and an unreachable workspace is a "could not tell" rather than a claim
|
|
193
|
+
// that the repository holds neither an Xcode project nor a Gradle build.
|
|
194
|
+
const configured = resolved?.settings.platforms;
|
|
195
|
+
const platforms = unreachable !== null && (configured === undefined || configured.length === 0)
|
|
196
|
+
? { ok: false, reason: unreachable }
|
|
197
|
+
: {
|
|
198
|
+
ok: true,
|
|
199
|
+
// Beside the Fastfile rather than at the repository root: in a
|
|
200
|
+
// monorepo the app is one directory down, and so are its platform
|
|
201
|
+
// folders.
|
|
202
|
+
value: await resolvePlatforms(configured, findIn(searchDir(workspacePath, appRootOf(fastlaneDir)))),
|
|
203
|
+
};
|
|
204
|
+
// The Appfile is fastlane's own file, beside the Fastfile, and it is where a
|
|
205
|
+
// project configured long before it met Laneyard keeps its Play Store
|
|
206
|
+
// service account. An absent one is a fact — `NO_APPFILE` — not a failure;
|
|
207
|
+
// an unreachable workspace is the only reason this cannot be answered.
|
|
208
|
+
const appfile = unreachable !== null
|
|
209
|
+
? { ok: false, reason: unreachable }
|
|
210
|
+
: await readFile(join(workspacePath, fastlaneDir, "Appfile"), "utf8").then((text) => ({ ok: true, value: parseAppfile(text) }),
|
|
211
|
+
// Missing, unreadable, a directory: all of them mean the same to a
|
|
212
|
+
// check, which is that the Appfile says nothing.
|
|
213
|
+
() => ({ ok: true, value: NO_APPFILE }));
|
|
214
|
+
// Listed the same way platforms are, from the clone rather than from any
|
|
215
|
+
// path a Fastfile mentions: what is asked is "does the repository carry a
|
|
216
|
+
// key", which is a question about the repository.
|
|
217
|
+
const keyFilesInRepo = unreachable !== null
|
|
218
|
+
? { ok: false, reason: unreachable }
|
|
219
|
+
: await glob(["**/*.p8"], { cwd: workspacePath, onlyFiles: true, dot: true }).then((found) => ({ ok: true, value: found.sort() }), (cause) => ({ ok: false, reason: cause.message }));
|
|
220
|
+
const sections = await runChecklist({
|
|
75
221
|
probeRepository: () => workspace.probeRemote(),
|
|
76
222
|
dependencies: {
|
|
77
223
|
workspace: unreachable !== null
|
|
@@ -82,8 +228,31 @@ export async function registerReadinessRoutes(app, ctx) {
|
|
|
82
228
|
},
|
|
83
229
|
// Names only: the vault never hands a value to anything but a run.
|
|
84
230
|
secretKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
231
|
+
// Which blocks apply, resolved the way a run resolves them — a project's
|
|
232
|
+
// own shadowing a global one — so the checklist and the run cannot
|
|
233
|
+
// disagree about whether a credential exists.
|
|
234
|
+
blocks: ctx.vault.listCredentials(slug).map((c) => c.kind),
|
|
235
|
+
// And the names those blocks will export, which the environment check
|
|
236
|
+
// counts as supplied — Laneyard writes the file and sets the variable
|
|
237
|
+
// itself, so a lane reading it is not a lane short of anything. The same
|
|
238
|
+
// list the secrets screen is given, from the same call.
|
|
239
|
+
blockNames: exportedVarNames(ctx.vault.listCredentials(slug)),
|
|
240
|
+
keystore: keystoreSetting(ctx.vault, slug),
|
|
85
241
|
uses,
|
|
242
|
+
platforms,
|
|
243
|
+
appfile,
|
|
244
|
+
keyFilesInRepo,
|
|
245
|
+
unread,
|
|
246
|
+
// A run inherits the server's environment, so a variable exported where
|
|
247
|
+
// Laneyard was started really is available to a lane. Names only: a
|
|
248
|
+
// checklist has no business reading a value, here least of all.
|
|
249
|
+
serverEnv: Object.keys(process.env),
|
|
250
|
+
...(await androidSigning(workspacePath, appRootOf(fastlaneDir), unreachable)),
|
|
251
|
+
declaredSecrets: [
|
|
252
|
+
...(resolved?.settings.required_secrets ?? []),
|
|
253
|
+
...(await envExampleNames(workspacePath, fastlaneDir)),
|
|
254
|
+
],
|
|
86
255
|
});
|
|
87
|
-
return { checkedAt: new Date().toISOString(),
|
|
256
|
+
return { checkedAt: new Date().toISOString(), sections };
|
|
88
257
|
});
|
|
89
258
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { MIN_LENGTH as MIN_REDACTABLE } from "../../logs/redact.js";
|
|
2
|
+
import { exportedVarNames } from "../../credentials/kinds.js";
|
|
3
|
+
import { requiredSecrets } from "../required-secrets.js";
|
|
2
4
|
/** POSIX environment variable names. Anything else would never reach fastlane. */
|
|
3
5
|
const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4
6
|
export async function registerSecretRoutes(app, ctx) {
|
|
@@ -39,6 +41,105 @@ export async function registerSecretRoutes(app, ctx) {
|
|
|
39
41
|
return reply.code(404).send({ error: "Unknown project" });
|
|
40
42
|
return put(slug, key, req.body, reply);
|
|
41
43
|
});
|
|
44
|
+
/**
|
|
45
|
+
* One value, in the clear — and only one that was never declared secret.
|
|
46
|
+
*
|
|
47
|
+
* A separate route rather than a field on the listing, so that reading a value
|
|
48
|
+
* is always a deliberate request for one named key. A listing that carried
|
|
49
|
+
* values would put every one of them in a browser at once, for a page most
|
|
50
|
+
* people open to check a name.
|
|
51
|
+
*/
|
|
52
|
+
app.get("/api/projects/:slug/secrets/:key/value", async (req, reply) => {
|
|
53
|
+
const { slug, key } = req.params;
|
|
54
|
+
if (!ctx.config.project(slug))
|
|
55
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
56
|
+
try {
|
|
57
|
+
const value = ctx.vault.reveal(slug, key);
|
|
58
|
+
if (value === null)
|
|
59
|
+
return reply.code(404).send({ error: "Unknown secret" });
|
|
60
|
+
return { key, value };
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
// The vault refuses a masked value whoever asks. 409 rather than 403: the
|
|
64
|
+
// request is not forbidden to this account, it is refused for this secret.
|
|
65
|
+
return reply.code(409).send({ error: cause.message });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* Turns the redaction on or off, leaving the value alone.
|
|
70
|
+
*
|
|
71
|
+
* Needed because of a circle: to read a value you must first declare it not
|
|
72
|
+
* secret, and declaring that by storing it again would mean typing the value
|
|
73
|
+
* you were trying to read.
|
|
74
|
+
*/
|
|
75
|
+
app.patch("/api/projects/:slug/secrets/:key", async (req, reply) => {
|
|
76
|
+
const { slug, key } = req.params;
|
|
77
|
+
if (!ctx.config.project(slug))
|
|
78
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
79
|
+
const { masked } = (req.body ?? {});
|
|
80
|
+
if (typeof masked !== "boolean") {
|
|
81
|
+
return reply.code(400).send({ error: "`masked` is true or false." });
|
|
82
|
+
}
|
|
83
|
+
const existing = ctx.vault.list(slug).find((s) => s.key === key);
|
|
84
|
+
if (!existing)
|
|
85
|
+
return reply.code(404).send({ error: "Unknown secret" });
|
|
86
|
+
if (existing.scope === "global") {
|
|
87
|
+
// The same rule the interface draws: a global secret belongs to every
|
|
88
|
+
// project, so changing it from inside one would hide that from the rest.
|
|
89
|
+
return reply.code(409).send({ error: "That is a global secret. Change it with `laneyard secret set`." });
|
|
90
|
+
}
|
|
91
|
+
// A value too short to redact cannot be masked, the same refusal as on the
|
|
92
|
+
// way in — accepting it would leave someone believing they are protected.
|
|
93
|
+
if (masked) {
|
|
94
|
+
const value = ctx.vault.reveal(slug, key);
|
|
95
|
+
if (value !== null && value.length < MIN_REDACTABLE) {
|
|
96
|
+
return reply.code(400).send({
|
|
97
|
+
error: `A value kept out of the logs must be at least ${MIN_REDACTABLE} characters.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
ctx.vault.setMasked(slug, key, masked);
|
|
102
|
+
return reply.code(204).send();
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* The names this project needs, and which of them are still missing.
|
|
106
|
+
*
|
|
107
|
+
* Exists so the secrets screen can put the form up with the names already in
|
|
108
|
+
* it. Someone arriving here has just been told by the checklist that eight
|
|
109
|
+
* variables are missing; retyping those eight names by hand, correctly, is a
|
|
110
|
+
* chore where one typo stores a secret nothing will ever read.
|
|
111
|
+
*
|
|
112
|
+
* Names only, and never a value from anywhere: the file that holds the real
|
|
113
|
+
* ones is the file that does not reach a clone, which is the problem rather
|
|
114
|
+
* than a source. What goes in the boxes is typed by a person.
|
|
115
|
+
*/
|
|
116
|
+
app.get("/api/projects/:slug/required-secrets", async (req, reply) => {
|
|
117
|
+
const { slug } = req.params;
|
|
118
|
+
if (!ctx.config.project(slug))
|
|
119
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
120
|
+
const workspacePath = ctx.workspacePath(slug);
|
|
121
|
+
const resolved = await ctx.config.resolve(slug, workspacePath);
|
|
122
|
+
const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
|
|
123
|
+
// A workspace that was never cloned, or a Fastfile that will not parse, is
|
|
124
|
+
// a reason to offer nothing — not a reason to fail the page someone opened
|
|
125
|
+
// to store a secret by hand.
|
|
126
|
+
const lanes = await ctx
|
|
127
|
+
.uses(slug, workspacePath, fastlaneDir)
|
|
128
|
+
.then((u) => u.lanes)
|
|
129
|
+
.catch(() => []);
|
|
130
|
+
return requiredSecrets({
|
|
131
|
+
lanes,
|
|
132
|
+
declared: resolved?.settings.required_secrets ?? [],
|
|
133
|
+
workspacePath,
|
|
134
|
+
fastlaneDir,
|
|
135
|
+
vaultKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
136
|
+
// Resolved the way a run resolves them, a project's own block shadowing a
|
|
137
|
+
// global one, so the form and the checklist cannot disagree about what is
|
|
138
|
+
// already supplied.
|
|
139
|
+
blockNames: exportedVarNames(ctx.vault.listCredentials(slug)),
|
|
140
|
+
serverEnv: Object.keys(process.env),
|
|
141
|
+
});
|
|
142
|
+
});
|
|
42
143
|
app.delete("/api/secrets/:key", async (req, reply) => {
|
|
43
144
|
const removed = ctx.vault.remove(null, req.params.key);
|
|
44
145
|
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { MIN_PASSWORD_LENGTH, VALID_NAME, refusalFor, removeUserFromConfig, upsertUserInConfig, } from "../../config/accounts.js";
|
|
2
|
+
const ROLES = ["admin", "builder"];
|
|
3
|
+
/**
|
|
4
|
+
* The accounts, as the interface sees them.
|
|
5
|
+
*
|
|
6
|
+
* Every route here is on the admin list in `permissions.ts`, so nothing below
|
|
7
|
+
* checks a role: the hook already refused anyone who has no business here, and
|
|
8
|
+
* a second check inside a handler is a permission nobody finds during an audit.
|
|
9
|
+
*/
|
|
10
|
+
export async function registerUserRoutes(app, ctx) {
|
|
11
|
+
const accounts = () => ctx.config.server()?.users ?? [];
|
|
12
|
+
// Name and role, and that is the whole shape. The hash is not truncated or
|
|
13
|
+
// masked on the way out — it is simply never put on the wire.
|
|
14
|
+
app.get("/api/users", async () => accounts().map((u) => ({ name: u.name, role: u.role })));
|
|
15
|
+
app.post("/api/users", async (req, reply) => {
|
|
16
|
+
const { name, role, password } = (req.body ?? {});
|
|
17
|
+
if (typeof name !== "string" || !VALID_NAME.test(name)) {
|
|
18
|
+
return reply.code(400).send({
|
|
19
|
+
error: "A name is letters, digits, dot, dash and underscore, starting with a letter or a digit.",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (typeof role !== "string" || !ROLES.includes(role)) {
|
|
23
|
+
return reply.code(400).send({ error: `A role is ${ROLES.join(" or ")}.` });
|
|
24
|
+
}
|
|
25
|
+
if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
|
|
26
|
+
return reply
|
|
27
|
+
.code(400)
|
|
28
|
+
.send({ error: `A password is at least ${MIN_PASSWORD_LENGTH} characters.` });
|
|
29
|
+
}
|
|
30
|
+
const refusal = refusalFor(accounts(), name, role);
|
|
31
|
+
if (refusal)
|
|
32
|
+
return reply.code(409).send({ error: refusal });
|
|
33
|
+
const { created } = await upsertUserInConfig(ctx.config.configPath(), {
|
|
34
|
+
name,
|
|
35
|
+
role: role,
|
|
36
|
+
password,
|
|
37
|
+
});
|
|
38
|
+
// Replacing an account changes the password and possibly the role, and a
|
|
39
|
+
// session holds neither — it holds a snapshot taken at login. The sessions
|
|
40
|
+
// go, so that what was just written is what is true everywhere.
|
|
41
|
+
if (!created)
|
|
42
|
+
ctx.sessions.revokeAllFor(name);
|
|
43
|
+
// The file is watched, but on a debounce: reloading here is what makes the
|
|
44
|
+
// very next request — the listing this page is about to ask for — truthful,
|
|
45
|
+
// and what lets the new account log in immediately.
|
|
46
|
+
await ctx.config.load();
|
|
47
|
+
return reply.code(created ? 201 : 200).send({ name, role });
|
|
48
|
+
});
|
|
49
|
+
app.delete("/api/users/:name", async (req, reply) => {
|
|
50
|
+
const { name } = req.params;
|
|
51
|
+
if (!accounts().some((u) => u.name === name)) {
|
|
52
|
+
return reply.code(404).send({ error: "Unknown account" });
|
|
53
|
+
}
|
|
54
|
+
const refusal = refusalFor(accounts(), name, null);
|
|
55
|
+
if (refusal)
|
|
56
|
+
return reply.code(409).send({ error: refusal });
|
|
57
|
+
const removed = await removeUserFromConfig(ctx.config.configPath(), name);
|
|
58
|
+
if (!removed)
|
|
59
|
+
return reply.code(404).send({ error: "Unknown account" });
|
|
60
|
+
ctx.sessions.revokeAllFor(name);
|
|
61
|
+
await ctx.config.load();
|
|
62
|
+
return reply.code(204).send();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -1,10 +1,46 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
4
|
import { dirname, join } from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { promisify } from "node:util";
|
|
5
7
|
import { FASTLANE_UNAVAILABLE, resolveRubyEnv } from "./ruby-env.js";
|
|
6
8
|
const exec = promisify(execFile);
|
|
7
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Locates `ruby/introspect.rb` from wherever this module happens to live.
|
|
11
|
+
*
|
|
12
|
+
* Two layouts, and only trying one of them is how listing lanes came to be
|
|
13
|
+
* broken in every installed copy while working perfectly from the sources:
|
|
14
|
+
* `src/sidecar/` sits two levels under the package root, `dist/src/sidecar/`
|
|
15
|
+
* sits three. The package ships `ruby/` at its root in both cases.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveSidecarScript(moduleDir) {
|
|
18
|
+
const candidates = [
|
|
19
|
+
join(moduleDir, "..", "..", "ruby", "introspect.rb"),
|
|
20
|
+
join(moduleDir, "..", "..", "..", "ruby", "introspect.rb"),
|
|
21
|
+
];
|
|
22
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
23
|
+
}
|
|
24
|
+
const SCRIPT = resolveSidecarScript(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
/**
|
|
26
|
+
* A digest of the sidecar script, for whoever caches what it produced.
|
|
27
|
+
*
|
|
28
|
+
* Caching introspection on the fastlane folder's contents alone is wrong in one
|
|
29
|
+
* direction that is easy to miss: the answer depends on the *reader* as much as
|
|
30
|
+
* on what is read. Teaching the parser to follow a lane into the methods it
|
|
31
|
+
* calls changed what a Fastfile means without changing the Fastfile, so every
|
|
32
|
+
* existing install went on being served the answer from before — a Play Store
|
|
33
|
+
* check reporting "no lane uploads", permanently, on a project that uploads.
|
|
34
|
+
*
|
|
35
|
+
* Hashing the script rather than bumping a constant is what keeps that from
|
|
36
|
+
* happening again: there is nothing to remember. Read once, because the file
|
|
37
|
+
* cannot change under a running process that already loaded it.
|
|
38
|
+
*/
|
|
39
|
+
let scriptDigest = null;
|
|
40
|
+
export function sidecarVersion() {
|
|
41
|
+
scriptDigest ??= createHash("sha256").update(readFileSync(SCRIPT)).digest("hex").slice(0, 16);
|
|
42
|
+
return scriptDigest;
|
|
43
|
+
}
|
|
8
44
|
/**
|
|
9
45
|
* Runs the sidecar in the project's context.
|
|
10
46
|
* In `bundle` mode, the invocation goes through `bundle exec` to see the
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Refuses a missing fastlane directory with a sentence instead of an ENOENT.
|
|
4
|
+
*
|
|
5
|
+
* The raw error read `ENOENT: no such file or directory, scandir
|
|
6
|
+
* '…/workspaces/popotheque-app/fastlane'` — technically accurate and no help at
|
|
7
|
+
* all, because the interesting part is *why* it was looking there.
|
|
8
|
+
*
|
|
9
|
+
* It is nearly always the same story. `laneyard setup` writes `laneyard.yml`
|
|
10
|
+
* into the working copy, and Laneyard builds from a clone of the remote, so the
|
|
11
|
+
* file that says where fastlane lives does not reach the clone until it is
|
|
12
|
+
* committed and pushed. Until then `fastlane_dir` falls back to `fastlane`,
|
|
13
|
+
* which in a monorepo is not where anything is.
|
|
14
|
+
*/
|
|
15
|
+
export async function assertFastlaneDir(dir, configured) {
|
|
16
|
+
const found = await stat(dir).then((s) => s.isDirectory(), () => false);
|
|
17
|
+
if (found)
|
|
18
|
+
return;
|
|
19
|
+
throw new Error(`No ${configured}/ in the clone. Laneyard builds from a clone of the remote, ` +
|
|
20
|
+
"so `laneyard.yml` only takes effect once it is committed and pushed — " +
|
|
21
|
+
"until then this falls back to `fastlane`. Push it, or set `fastlane_dir` " +
|
|
22
|
+
"on the project's block in config.yml.");
|
|
23
|
+
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { sidecarVersion } from "./bridge.js";
|
|
5
|
+
import { assertFastlaneDir } from "./fastlane-dir.js";
|
|
4
6
|
/**
|
|
5
7
|
* Hash of the whole fastlane folder, not just the Fastfile:
|
|
6
8
|
* an Appfile, a Pluginfile, or an imported file change the lanes just as much.
|
|
7
9
|
*/
|
|
8
10
|
async function hashFastlaneDir(root, fastlaneDir) {
|
|
9
11
|
const dir = join(root, fastlaneDir);
|
|
12
|
+
await assertFastlaneDir(dir, fastlaneDir);
|
|
10
13
|
const hash = createHash("sha256");
|
|
14
|
+
// Same reason as in `uses.ts`: the reader is part of the key, so improving the
|
|
15
|
+
// sidecar cannot leave an install served by what the old one concluded.
|
|
16
|
+
hash.update(sidecarVersion());
|
|
11
17
|
const entries = (await readdir(dir, { withFileTypes: true, recursive: true }))
|
|
12
18
|
.filter((e) => e.isFile())
|
|
13
19
|
.map((e) => join(e.parentPath, e.name))
|