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.
Files changed (53) hide show
  1. package/README.md +295 -20
  2. package/dist/src/cli/detect.js +69 -16
  3. package/dist/src/cli/prompt.js +61 -0
  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 +319 -0
  7. package/dist/src/cli/style.js +31 -0
  8. package/dist/src/cli/user.js +127 -0
  9. package/dist/src/config/accounts.js +163 -0
  10. package/dist/src/config/load.js +61 -1
  11. package/dist/src/config/schema.js +26 -1
  12. package/dist/src/config/store.js +10 -0
  13. package/dist/src/config/yaml.js +14 -0
  14. package/dist/src/credentials/kinds.js +120 -0
  15. package/dist/src/db/credentials.js +75 -0
  16. package/dist/src/db/schema.sql +39 -0
  17. package/dist/src/db/secrets.js +28 -0
  18. package/dist/src/db/sessions.js +61 -0
  19. package/dist/src/git/workspace.js +32 -6
  20. package/dist/src/heuristics/android-root.js +49 -0
  21. package/dist/src/heuristics/android-signing.js +98 -0
  22. package/dist/src/heuristics/appfile.js +60 -0
  23. package/dist/src/heuristics/blocking-actions.js +22 -0
  24. package/dist/src/heuristics/env-example.js +36 -0
  25. package/dist/src/heuristics/platforms.js +136 -0
  26. package/dist/src/heuristics/readiness.js +697 -25
  27. package/dist/src/main.js +58 -8
  28. package/dist/src/runner/gradle-properties.js +187 -0
  29. package/dist/src/runner/materialise.js +92 -0
  30. package/dist/src/runner/orchestrate.js +91 -2
  31. package/dist/src/secrets/vault.js +106 -5
  32. package/dist/src/server/app.js +94 -15
  33. package/dist/src/server/auth.js +127 -19
  34. package/dist/src/server/permissions.js +75 -0
  35. package/dist/src/server/required-secrets.js +30 -0
  36. package/dist/src/server/routes/account.js +57 -0
  37. package/dist/src/server/routes/credentials.js +108 -0
  38. package/dist/src/server/routes/projects.js +42 -0
  39. package/dist/src/server/routes/readiness.js +175 -6
  40. package/dist/src/server/routes/secrets.js +101 -0
  41. package/dist/src/server/routes/users.js +64 -0
  42. package/dist/src/sidecar/bridge.js +37 -1
  43. package/dist/src/sidecar/fastlane-dir.js +23 -0
  44. package/dist/src/sidecar/lanes.js +6 -0
  45. package/dist/src/sidecar/uses.js +21 -5
  46. package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DynuBC2l.js} +1 -1
  47. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  48. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  49. package/dist/web/index.html +2 -2
  50. package/package.json +1 -1
  51. package/ruby/introspect.rb +122 -9
  52. package/dist/web/assets/index-D9_EL8LA.js +0 -62
  53. 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 { runChecks } from "../../heuristics/readiness.js";
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 uses = unreachable !== null
166
+ const read = unreachable !== null
67
167
  ? { ok: false, reason: unreachable }
68
168
  : await ctx
69
169
  .uses(slug, workspacePath, fastlaneDir)
70
- .then((lanes) => ({ ok: true, value: lanes }))
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 checks = await runChecks({
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(), checks };
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
- const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ruby", "introspect.rb");
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))