laneyard 0.6.1 → 0.8.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 (36) hide show
  1. package/README.md +13 -497
  2. package/dist/src/cli/adopt.js +24 -8
  3. package/dist/src/cli/remove.js +52 -34
  4. package/dist/src/cli/reset.js +2 -2
  5. package/dist/src/cli/setup.js +85 -40
  6. package/dist/src/cli/uninstall.js +5 -21
  7. package/dist/src/config/env-file-setting.js +57 -0
  8. package/dist/src/config/resolve.js +8 -5
  9. package/dist/src/config/schema.js +29 -0
  10. package/dist/src/db/credentials.js +18 -54
  11. package/dist/src/db/migrate-global-scope.js +75 -0
  12. package/dist/src/db/open.js +21 -0
  13. package/dist/src/db/schema.sql +10 -5
  14. package/dist/src/db/secrets.js +44 -65
  15. package/dist/src/fastfile/adoption.js +19 -4
  16. package/dist/src/git/workspace.js +28 -0
  17. package/dist/src/heuristics/readiness.js +80 -135
  18. package/dist/src/main.js +31 -2
  19. package/dist/src/runner/env-file.js +144 -0
  20. package/dist/src/runner/orchestrate.js +39 -5
  21. package/dist/src/secrets/vault.js +75 -40
  22. package/dist/src/server/app.js +4 -0
  23. package/dist/src/server/routes/credentials.js +0 -13
  24. package/dist/src/server/routes/fastfile.js +1 -1
  25. package/dist/src/server/routes/projects.js +59 -16
  26. package/dist/src/server/routes/readiness.js +2 -3
  27. package/dist/src/server/routes/secrets.js +111 -39
  28. package/dist/src/sidecar/fastlane-dir.js +18 -8
  29. package/dist/src/sidecar/lanes.js +1 -1
  30. package/dist/src/sidecar/uses.js +1 -1
  31. package/dist/web/assets/{Editor-CMa4-4N3.js → Editor-BK-ZRKlI.js} +1 -1
  32. package/dist/web/assets/index-DnUdqjT4.js +62 -0
  33. package/dist/web/assets/{index-DsjxZtsO.css → index-r3IyRuO4.css} +1 -1
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web/assets/index-B8lAQPEM.js +0 -62
@@ -13,14 +13,18 @@ import { LogStore } from "../logs/store.js";
13
13
  import { Vault } from "../secrets/vault.js";
14
14
  import { readLine } from "./home.js";
15
15
  import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
16
- export const REMOVE_USAGE = `laneyard remove [--dry-run]
16
+ export const REMOVE_USAGE = `laneyard remove [<slug>] [--dry-run]
17
17
 
18
- Run from your app's directory — the one that holds laneyard.yml. Removes
19
- everything Laneyard holds for that project: its block in config.yml, its clone,
20
- its artifacts, its run history and logs, and its own secrets and signing blocks
21
- in the vault. It also removes the repository's laneyard.yml, which you then
22
- commit. It does not touch the git remote, the credential originals, or the
23
- global secrets and signing blocks other projects share.
18
+ Run from the directory that holds laneyard.yml — the repository root for most
19
+ projects, the app's own folder in a monorepo and the project is the one that
20
+ file names. Give a slug instead when there is no such file.
21
+
22
+ Removes everything Laneyard holds for that project: its block in config.yml, its
23
+ clone, its artifacts, its run history and logs, and its own secrets and signing
24
+ blocks in the vault. It also removes that laneyard.yml, which you then commit —
25
+ one naming another project is left alone. It does not touch the git remote, the
26
+ credential originals, or the global secrets and signing blocks other projects
27
+ share.
24
28
 
25
29
  laneyard remove --dry-run show what would go and stop
26
30
 
@@ -41,8 +45,8 @@ async function readCounts(home, slug) {
41
45
  return {
42
46
  activeRun: runs.hasActiveRun(slug),
43
47
  runs: runs.listByProject(slug, -1).length,
44
- secrets: new SecretStore(db).listOwn(slug).length,
45
- signingBlocks: new CredentialStore(db).listOwn(slug).length,
48
+ secrets: new SecretStore(db).list(slug).length,
49
+ signingBlocks: new CredentialStore(db).list(slug).length,
46
50
  };
47
51
  }
48
52
  finally {
@@ -65,7 +69,7 @@ function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
65
69
  field("secrets", plural(counts.secrets, "project secret")) + "\n" +
66
70
  field("signing", plural(counts.signingBlocks, "project signing block")) + "\n" +
67
71
  // The one thing removed outside `home`: the repository's own file.
68
- field("laneyard.yml", ymlPath) + "\n" +
72
+ (ymlPath === null ? "" : field("laneyard.yml", ymlPath) + "\n") +
69
73
  heading("what will not be touched") +
70
74
  dim(" the git remote — the repository is yours, on your host and your disk.\n") +
71
75
  dim(" the credential originals — Laneyard removes only its own encrypted copy;\n") +
@@ -73,43 +77,56 @@ function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
73
77
  dim(" global secrets and global signing blocks — shared by every project.\n"));
74
78
  }
75
79
  /**
76
- * Entry point for `laneyard remove`, run from the app's own directory.
80
+ * Entry point for `laneyard remove`, run from wherever the project's
81
+ * `laneyard.yml` sits — the repository root unless the app is one folder of a
82
+ * monorepo.
83
+ *
84
+ * The project is the one whose `laneyard.yml` sits in `cwd`, so the command names
85
+ * what the directory already is. A slug given outright overrides that, and is the
86
+ * only way to reach a project whose file was never written, lost its slug, or
87
+ * whose repository is no longer on this machine.
77
88
  *
78
- * The project is the one whose `laneyard.yml` sits in `cwd`; the slug is read
79
- * from that file rather than given as an argument, so the command names what the
80
- * directory already is. It reads the inventory first, prints what will and will
81
- * not go, and only then asks for the slug typed back — the same gate the web
82
- * route uses, for the same reason: the run history it deletes is the one thing
83
- * here nothing can rebuild.
89
+ * It reads the inventory first, prints what will and will not go, and only then
90
+ * asks for the slug typed back the same gate the web route uses, for the same
91
+ * reason: the run history it deletes is the one thing here nothing can rebuild.
84
92
  */
85
93
  export async function runRemoveCommand(home, cwd, args, io) {
86
94
  let dryRun = false;
95
+ let given = null;
87
96
  for (const arg of args) {
88
97
  if (arg === "--dry-run" || arg === "-n")
89
98
  dryRun = true;
90
- else {
99
+ else if (arg.startsWith("-")) {
91
100
  io.err(`Unknown option: ${arg}\n\n${REMOVE_USAGE}`);
92
101
  return 1;
93
102
  }
103
+ else if (given === null)
104
+ given = arg;
105
+ else {
106
+ io.err(`One project at a time. Give a single slug.\n\n${REMOVE_USAGE}`);
107
+ return 1;
108
+ }
94
109
  }
95
- // The project is the one whose `laneyard.yml` is here — the file setup wrote
96
- // and the slug it recorded in it. Run from anywhere else and there is nothing
97
- // to name, which is the whole point: a project is removed from where it lives.
110
+ // The project is normally the one whose `laneyard.yml` is here — the file setup
111
+ // wrote, and the slug it recorded in it.
98
112
  const ymlPath = join(cwd, "laneyard.yml");
99
- if (!existsSync(ymlPath)) {
100
- io.err("\n" +
101
- bad("No laneyard.yml here.") +
102
- " Run `laneyard remove` from your app's directory the one that holds it.\n");
103
- return 1;
104
- }
105
- const repo = await loadRepoConfig(ymlPath);
106
- const slug = repo.ok ? repo.config.slug : undefined;
113
+ const inFile = existsSync(ymlPath)
114
+ ? await loadRepoConfig(ymlPath).then((r) => (r.ok ? r.config.slug : undefined))
115
+ : undefined;
116
+ // A slug given outright is the way out when there is no such file: one that
117
+ // was never written, lost its slug, or whose repository is not on this machine
118
+ // any more. Without it those projects could only be removed from the web.
119
+ const slug = given ?? inFile;
107
120
  if (slug === undefined || slug === "") {
108
121
  io.err("\n" +
109
- bad("This laneyard.yml has no slug.") +
110
- " Run `laneyard setup` again to record which project it is.\n");
122
+ bad(existsSync(ymlPath) ? "This laneyard.yml has no slug." : "No laneyard.yml here.") +
123
+ " Run `laneyard remove` from the directory that holds it — usually the repository" +
124
+ " root — or name the project: `laneyard remove <slug>`.\n");
111
125
  return 1;
112
126
  }
127
+ // Removed only when it is this project's file. Given a slug explicitly, a
128
+ // `laneyard.yml` naming a different project is somebody else's and stays.
129
+ const ownYml = inFile !== undefined && inFile === slug ? ymlPath : null;
113
130
  const configPath = join(home, "config.yml");
114
131
  const config = new ConfigStore(configPath);
115
132
  const loaded = await config.load();
@@ -136,7 +153,7 @@ export async function runRemoveCommand(home, cwd, args, io) {
136
153
  " Wait for it to finish, or cancel it, then remove the project. Nothing was removed.\n");
137
154
  return 1;
138
155
  }
139
- io.out(renderInventory(slug, entry.name, home, counts, clonePath, ymlPath));
156
+ io.out(renderInventory(slug, entry.name, home, counts, clonePath, ownYml));
140
157
  if (dryRun) {
141
158
  io.out("\n" +
142
159
  dim("Nothing was removed. Run `laneyard remove` without --dry-run to remove it.") +
@@ -172,7 +189,8 @@ export async function runRemoveCommand(home, cwd, args, io) {
172
189
  // The machine data went first, so a file removed here is one whose project
173
190
  // is already gone — never the other way round. `force` because the removal
174
191
  // must still report success if the file was deleted by hand meanwhile.
175
- await rm(ymlPath, { force: true }).catch(() => { });
192
+ if (ownYml !== null)
193
+ await rm(ownYml, { force: true }).catch(() => { });
176
194
  io.out(heading("removed") +
177
195
  ok(`"${slug}": ${plural(result.runs, "run")}, ${plural(result.secrets, "secret")}, ` +
178
196
  `${plural(result.signingBlocks, "signing block")}.\n`) +
@@ -180,7 +198,7 @@ export async function runRemoveCommand(home, cwd, args, io) {
180
198
  ? dim(` ${result.clonePath} is gone.\n`)
181
199
  : dim(" There was no clone to remove.\n")) +
182
200
  dim(` The block is out of ${configPath}.\n`) +
183
- dim(` ${ymlPath} is gone.\n`) +
201
+ (ownYml === null ? "" : dim(` ${ownYml} is gone.\n`)) +
184
202
  "\n" +
185
203
  // The file is committed, so deleting the working copy is not the end of
186
204
  // it — the same trap adoption's report names about a patched Fastfile.
@@ -31,8 +31,8 @@ function renderInventory(inv) {
31
31
  out += field("database", inv.db === null ? dim("not there") : dim("could not be read")) + "\n";
32
32
  }
33
33
  else {
34
- out += field("secrets", `${plural(v.projectSecrets, "project secret")}, ${plural(v.globalSecrets, "global secret")}`) + "\n";
35
- out += field("signing", `${plural(v.projectBlocks, "project signing block")}, ${plural(v.globalBlocks, "global signing block")}`) + "\n";
34
+ out += field("secrets", plural(v.secrets, "secret")) + "\n";
35
+ out += field("signing", plural(v.blocks, "signing block")) + "\n";
36
36
  out += field("history", plural(v.runs, "run")) + "\n";
37
37
  }
38
38
  if (inv.folders.length === 0) {
@@ -151,16 +151,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
151
151
  dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
152
152
  dim(" Give it another name to keep both.\n"));
153
153
  }
154
- // The repository file lives in the app's own directory, so a monorepo of N
155
- // apps carries N of them — one beside each app's fastlane folder. `appRoot`
156
- // is the fastlane dir's parent; for a plain app whose fastlane sits at the
157
- // root it is `.`, and the file lands at the repository root as it always did.
158
- const appRoot = appRootOf(d.fastlaneDir);
159
- const repoConfigPath = join(repoRoot(cwd, d.subPath), appRoot, LANEYARD_YML);
160
- if (await fileExists(repoConfigPath)) {
161
- process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
162
- dim(" Its values win over anything written here; it will be left alone.\n"));
163
- }
164
154
  // A machine with no account gets its first admin here. Asked rather than
165
155
  // assumed: the name is typed into a login form every day afterwards, and
166
156
  // `admin` is a poor thing to call a person once there are two of them.
@@ -187,7 +177,24 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
187
177
  }
188
178
  const gitUrl = await asker.ask("repository", d.gitUrl);
189
179
  const branch = await asker.ask("default branch", d.defaultBranch, dim("A run uses this branch unless you pick another one when you start it."));
190
- const fastlaneDir = await asker.ask("fastlane directory", d.fastlaneDir, dim("Relative to the repository root, because that is what Laneyard clones."));
180
+ // Asked as it is written from where the command ran: from `app/`, the folder
181
+ // is `fastlane`, not `app/fastlane`. The repository-root-relative form is
182
+ // what the clone has and what `config.yml` stores, but prefixing it is
183
+ // Laneyard's job — asking someone to type a path they are standing inside
184
+ // reads like a mistake, and is the one people correct wrongly.
185
+ const fastlaneDir = fromRepoRoot(d.subPath, await asker.ask("fastlane directory", fromHere(d.subPath, d.fastlaneDir), dim("Relative to this directory.")));
186
+ // Computed from the answer, never from the detection: correcting the folder
187
+ // moves the app root with it, and with it where `laneyard.yml` is written and
188
+ // which prefix its paths drop. Read before the prompt, a correction was
189
+ // silently ignored and the file landed beside the wrong app.
190
+ const appRoot = appRootOf(fastlaneDir);
191
+ const repoConfigPath = join(repoRoot(cwd, d.subPath), appRoot, LANEYARD_YML);
192
+ const repoConfigExists = await fileExists(repoConfigPath);
193
+ if (repoConfigExists) {
194
+ process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
195
+ dim(" Its values win over anything written here and are left alone.\n") +
196
+ dim(" Only a missing `slug:` is added, so this project can be removed later.\n"));
197
+ }
191
198
  // A fastlane folder that is on disk but not in git will not survive the
192
199
  // clone Laneyard builds from — the case that started this: a stray
193
200
  // `app copie/` macOS made, detected here and absent from the remote.
@@ -209,10 +216,14 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
209
216
  // prompt is a miserable way to start, the detected ones are nearly always
210
217
  // right, and the file is there to be edited when they are not.
211
218
  const globs = d.artifactGlobs;
212
- if (!(await asker.confirm(`\n${bold(`Set up "${slug}"`)}?`, true))) {
213
- process.stdout.write(dim("Nothing written.") + "\n");
214
- return 0;
215
- }
219
+ // No final "and write those?" here, on purpose. It was a rubber stamp: by
220
+ // this line every value has been proposed and accepted one at a time, so the
221
+ // answer was always yes — and a question nobody ever says no to teaches
222
+ // people to press Return without reading the ones that matter. Nothing here
223
+ // destroys anything either: the project block is additive and `laneyard
224
+ // remove` undoes it, an existing `laneyard.yml` keeps its values, and the
225
+ // report below says exactly what was written and where. Ctrl-C still works
226
+ // at every prompt above.
216
227
  // The account first, so the server block is written before the project list
217
228
  // it sits above. `null` when this machine already has one, and then nothing
218
229
  // is written and nothing is printed.
@@ -271,7 +282,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
271
282
  // checklist cannot end up disagreeing about the same repository.
272
283
  ...(d.platforms.length > 0 ? { platforms: d.platforms } : {}),
273
284
  });
274
- const port = await configuredPort(configPath);
275
285
  process.stdout.write(heading(`Project "${slug}" is set up`) +
276
286
  field("repository", `${gitUrl} (${branch})`) + "\n" +
277
287
  field("fastlane", fastlaneDir) + "\n" +
@@ -282,9 +292,12 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
282
292
  // one line of the file just written.
283
293
  field("platforms", d.platforms.join(", ") || dim("none detected")) + "\n" +
284
294
  "\n" +
285
- (wroteRepoConfig
295
+ (wroteRepoConfig === "written"
286
296
  ? ok(`Wrote ${bold(LANEYARD_YML)} — ${bold("commit it")} so your team builds the same way.\n`)
287
- : warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
297
+ : wroteRepoConfig === "slug-added"
298
+ ? ok(`Added ${bold(`slug: ${slug}`)} to the existing ${LANEYARD_YML}, and left the rest ` +
299
+ `alone — ${bold("commit it")}.\n`)
300
+ : warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
288
301
  ok(`Registered in ${configPath}\n`) +
289
302
  // Last thing before the invitation to start the server, because it is
290
303
  // the one line here that cannot be read again anywhere: the file holds
@@ -298,11 +311,14 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
298
311
  "\n" +
299
312
  warn("Write the password down — it is not shown again, and it is not stored.\n") +
300
313
  dim(" Add a colleague later with `laneyard user add <name> --role builder`.\n")) +
301
- heading("Start Laneyard") +
314
+ // An instruction, not a status. `Start Laneyard` over a command and a URL
315
+ // read as "started, and here is the address" — the URL especially, which
316
+ // nothing was serving yet. The verb says who does it, and the address is
317
+ // the server's to print once it is listening.
318
+ heading("The server is not running yet — start it with") +
302
319
  ` ${bold("laneyard")}\n` +
303
- ` ${dim(`http://localhost:${port}`)}\n` +
304
320
  "\n" +
305
- dim("Already running? The configuration is watched — it appears on its own.\n"));
321
+ dim("Already running? The configuration is watched — this project appears on its own.\n"));
306
322
  // The second act. After the success message, never before it: declining
307
323
  // everything here must leave exactly the project the lines above just
308
324
  // announced. See `cli/adopt.ts`.
@@ -332,17 +348,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
332
348
  asker.close();
333
349
  }
334
350
  }
335
- /**
336
- * The port the server will actually listen on.
337
- *
338
- * Read back from the file rather than assumed: telling someone to open a port
339
- * their configuration does not use is the kind of small wrongness that makes a
340
- * tool feel unreliable on first contact.
341
- */
342
- async function configuredPort(configPath) {
343
- const res = await loadServerConfig(configPath);
344
- return res.ok ? res.config.server.port : 7890;
345
- }
346
351
  /** The repository file, named once so the message and the write cannot disagree. */
347
352
  const LANEYARD_YML = "laneyard.yml";
348
353
  /** What the first account is called when nobody says otherwise. */
@@ -401,14 +406,30 @@ async function existingProject(configPath, slug) {
401
406
  * values win anyway.
402
407
  */
403
408
  async function writeRepoConfigIfAbsent(path, settings) {
404
- if (await fileExists(path))
405
- return false;
406
- const doc = new Document(settings);
407
- doc.commentBefore =
408
- " How this project builds. Committed, so everyone builds it the same way.\n" +
409
- " Values here win over the project's block in the server's config.yml.";
410
- await writeFile(path, doc.toString(), "utf8");
411
- return true;
409
+ const existing = await readFile(path, "utf8").catch(() => null);
410
+ if (existing === null) {
411
+ const doc = new Document(settings);
412
+ doc.commentBefore =
413
+ " How this project builds. Committed, so everyone builds it the same way.\n" +
414
+ " Values here win over the project's block in the server's config.yml.";
415
+ await writeFile(path, doc.toString(), "utf8");
416
+ return "written";
417
+ }
418
+ // The one edit an existing file gets: the slug, and only when it has none.
419
+ //
420
+ // A file written before slugs existed has no way to gain one otherwise, and
421
+ // `laneyard remove` refuses a slug-less file while telling you to run setup
422
+ // again — which left it untouched. That was a project nothing could remove.
423
+ //
424
+ // Through the YAML document, like `addProjectToConfig`: the comments, the key
425
+ // order and every value the file carried come back out unchanged. An existing
426
+ // slug is never touched, whatever it says.
427
+ const doc = parseDocument(existing);
428
+ if (doc.getIn(["slug"]) !== undefined)
429
+ return "left";
430
+ doc.setIn(["slug"], settings.slug);
431
+ await writeFile(path, serialize(doc), "utf8");
432
+ return "slug-added";
412
433
  }
413
434
  /**
414
435
  * A repo-root-relative path read as relative to the app directory.
@@ -425,6 +446,30 @@ function appRelative(appRoot, p) {
425
446
  const prefix = `${appRoot}/`;
426
447
  return p.startsWith(prefix) ? p.slice(prefix.length) : p;
427
448
  }
449
+ /**
450
+ * A repository-root-relative path as it is written from where setup ran.
451
+ *
452
+ * `app/fastlane` is `fastlane` when standing in `app/`. A path outside that
453
+ * directory has no shorter form and is shown as it is.
454
+ */
455
+ function fromHere(subPath, repoRelative) {
456
+ if (subPath === "")
457
+ return repoRelative;
458
+ const prefix = `${subPath}/`;
459
+ return repoRelative.startsWith(prefix) ? repoRelative.slice(prefix.length) : repoRelative;
460
+ }
461
+ /**
462
+ * The inverse: back to repository-root-relative, which is the shape of the clone.
463
+ *
464
+ * Tolerant of an answer that already carries the prefix — someone who types
465
+ * `app/fastlane` from `app/` means the same folder, and double-prefixing it into
466
+ * `app/app/fastlane` would be a worse answer than the one they gave.
467
+ */
468
+ function fromRepoRoot(subPath, here) {
469
+ if (subPath === "" || here === "")
470
+ return here;
471
+ return here === subPath || here.startsWith(`${subPath}/`) ? here : `${subPath}/${here}`;
472
+ }
428
473
  /** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
429
474
  function repositoryLabel(url) {
430
475
  return url.replace(/^.*[:/]([^/:]+\/[^/]+?)(\.git)?$/, "$1");
@@ -103,13 +103,8 @@ async function readVault(dbPath) {
103
103
  const count = (sql) => db.prepare(sql).get().n;
104
104
  return {
105
105
  vault: {
106
- // The empty slug is how a global row is stored — the same convention
107
- // `SecretStore` and `CredentialStore` use, and the reason the two are
108
- // counted apart: a global secret belongs to every project.
109
- projectSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug != ''"),
110
- globalSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug = ''"),
111
- projectBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug != ''"),
112
- globalBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug = ''"),
106
+ secrets: count("SELECT COUNT(*) AS n FROM secret"),
107
+ blocks: count("SELECT COUNT(*) AS n FROM credential"),
113
108
  runs: count("SELECT COUNT(*) AS n FROM run"),
114
109
  },
115
110
  unreadable: null,
@@ -221,19 +216,8 @@ export function renderInventory(inv) {
221
216
  }
222
217
  else {
223
218
  const v = inv.db.vault;
224
- out += field("secrets", `${plural(v.projectSecrets, "project secret")}`) + "\n";
225
- // Said on its own line and in words rather than folded into the number
226
- // above: a global secret is read by every project on this machine, and
227
- // "in scope" is the fact someone needs before they answer the question.
228
- out +=
229
- field("", v.globalSecrets === 0
230
- ? dim("no global secret")
231
- : `${plural(v.globalSecrets, "global secret")} — shared by every project, ${bold("removed too")}`) + "\n";
232
- out += field("signing", `${plural(v.projectBlocks, "project signing block")}`) + "\n";
233
- out +=
234
- field("", v.globalBlocks === 0
235
- ? dim("no global signing block")
236
- : `${plural(v.globalBlocks, "global signing block")} — shared by every project, ${bold("removed too")}`) + "\n";
219
+ out += field("secrets", plural(v.secrets, "secret")) + "\n";
220
+ out += field("signing", plural(v.blocks, "signing block")) + "\n";
237
221
  out += field("history", plural(v.runs, "run")) + "\n";
238
222
  }
239
223
  }
@@ -267,7 +251,7 @@ export function renderInventory(inv) {
267
251
  */
268
252
  export function renderIrreversible(inv) {
269
253
  const v = inv.db?.vault;
270
- const stored = v === undefined || v === null ? null : v.projectSecrets + v.globalSecrets + v.projectBlocks + v.globalBlocks;
254
+ const stored = v === undefined || v === null ? null : v.secrets + v.blocks;
271
255
  return (heading("what cannot be undone") +
272
256
  warn(`The vault key is the one thing here that has no other copy.\n`) +
273
257
  dim(" Every secret and every signing block is encrypted under " + (inv.key?.path ?? join(inv.home, "key")) + ".\n") +
@@ -0,0 +1,57 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { isAbsolute, normalize } from "node:path";
3
+ import { parseDocument, YAMLMap, YAMLSeq } from "yaml";
4
+ import { serializeYaml } from "./yaml.js";
5
+ /**
6
+ * Sets or clears a project's `env_file`, in `config.yml`, leaving the rest of
7
+ * the file alone.
8
+ *
9
+ * The setting is configuration, so it lives in a file — that is what makes
10
+ * backing Laneyard up a matter of copying one. But it is also the only way to
11
+ * turn the environment file on, and a feature reachable exclusively by editing
12
+ * YAML by hand is one nobody finds. So the screen writes the file, the way the
13
+ * accounts screen already writes accounts and the removal screen already
14
+ * removes a project's block.
15
+ *
16
+ * A `laneyard.yml` in the repository still wins, unchanged: that precedence is
17
+ * the same for every setting, and this function is not the place to invent an
18
+ * exception to it. The screen reports it rather than offering to fight it.
19
+ *
20
+ * Returns false when no project carries that slug, so the caller can answer 404
21
+ * rather than rewrite the file to say what it already said.
22
+ */
23
+ export async function setEnvFileSetting(path, slug, envFile) {
24
+ const doc = parseDocument(await readFile(path, "utf8"));
25
+ const projects = doc.get("projects");
26
+ if (!(projects instanceof YAMLSeq))
27
+ return false;
28
+ const entry = projects.items.find((item) => item instanceof YAMLMap && item.get("slug") === slug);
29
+ if (!entry)
30
+ return false;
31
+ if (envFile === null)
32
+ entry.delete("env_file");
33
+ else
34
+ entry.set("env_file", envFile);
35
+ await writeFile(path, serializeYaml(doc), "utf8");
36
+ return true;
37
+ }
38
+ /**
39
+ * Why this path cannot be used, or null.
40
+ *
41
+ * The same rule the schema enforces at load, applied here so a bad value is
42
+ * refused by the screen that typed it rather than accepted and then reported as
43
+ * a broken configuration on the next reload. The file holds the values the vault
44
+ * exists to protect, and a path climbing out of the app would let it be dropped
45
+ * anywhere on the server.
46
+ */
47
+ export function envFileProblem(envFile) {
48
+ if (envFile === "")
49
+ return "A path is required.";
50
+ if (isAbsolute(envFile))
51
+ return "A path inside the app, not an absolute one.";
52
+ const clean = normalize(envFile);
53
+ if (clean === ".." || clean.startsWith("../") || clean.startsWith("..\\")) {
54
+ return "A path inside the app, not one that climbs out of it.";
55
+ }
56
+ return null;
57
+ }
@@ -18,13 +18,13 @@ function underApp(appRoot, p) {
18
18
  * duplicated keeps its file unchanged. But everything downstream resolves paths
19
19
  * as repo-root-relative (`join(workspacePath, …)`, globs with `cwd:
20
20
  * workspacePath`). So the app-relativity is collapsed here, once, at the
21
- * boundary: the two path fields are prefixed with the app directory before the
21
+ * boundary: the path fields are prefixed with the app directory before the
22
22
  * merge ever sees them, and nothing past this point learns a new rule.
23
23
  *
24
- * Only the path fields move. `platforms`, `runtime`, `timeout_minutes`,
25
- * `interactive_default`, `required_secrets` and `retention` are not paths and
26
- * pass through untouched. A root-level file is never handed here: its paths are
27
- * already repo-root-relative.
24
+ * Only the path fields move `fastlane_dir`, `artifact_globs` and `env_file`.
25
+ * `platforms`, `runtime`, `timeout_minutes`, `interactive_default`,
26
+ * `required_secrets` and `retention` are not paths and pass through untouched. A
27
+ * root-level file is never handed here: its paths are already repo-root-relative.
28
28
  */
29
29
  export function normaliseAppConfig(repo, appRoot) {
30
30
  const out = { ...repo };
@@ -34,6 +34,9 @@ export function normaliseAppConfig(repo, appRoot) {
34
34
  if (repo.artifact_globs !== undefined) {
35
35
  out.artifact_globs = repo.artifact_globs.map((g) => underApp(appRoot, g));
36
36
  }
37
+ if (repo.env_file !== undefined) {
38
+ out.env_file = underApp(appRoot, repo.env_file);
39
+ }
37
40
  return out;
38
41
  }
39
42
  /**
@@ -1,4 +1,18 @@
1
+ import { isAbsolute, normalize } from "node:path";
1
2
  import { z } from "zod";
3
+ /**
4
+ * Whether a relative path leaves the directory it is relative to.
5
+ *
6
+ * `normalize` collapses `a/../../b` to `../b`, so one check after it catches
7
+ * both the obvious `../.env` and the roundabout spelling. An absolute path is
8
+ * refused outright: it does not mean "inside the app" under any reading.
9
+ */
10
+ function escapesApp(p) {
11
+ if (isAbsolute(p))
12
+ return true;
13
+ const clean = normalize(p);
14
+ return clean === ".." || clean.startsWith(`..${"/"}`) || clean.startsWith("..\\");
15
+ }
2
16
  /** A slug is used as a folder name and a URL segment. */
3
17
  const slugSchema = z
4
18
  .string()
@@ -17,6 +31,21 @@ export const projectSettingsSchema = z.object({
17
31
  interactive_default: z.boolean().default(false),
18
32
  artifact_globs: z.array(z.string()).default([]),
19
33
  required_secrets: z.array(z.string()).default([]),
34
+ // Where to write the file the build reads from disk — a gitignored `.env`, a
35
+ // `config.json` for `--dart-define-from-file`. No default: absent means the
36
+ // project wants no such file, which is almost every project, and an empty
37
+ // string would be a path to interpret rather than an absence to respect.
38
+ //
39
+ // Relative to the app directory, like `fastlane_dir`. A path that climbs out
40
+ // of it is refused here rather than at write time: the file holds the values
41
+ // the vault exists to protect, and a configuration must never be able to drop
42
+ // one anywhere on the server. Refusing at load also means the last valid
43
+ // configuration stays live, which is what every other bad value gets.
44
+ env_file: z
45
+ .string()
46
+ .min(1, "env_file: a path, or leave it out")
47
+ .refine((p) => !escapesApp(p), "env_file: a path inside the app, not one that climbs out of it")
48
+ .optional(),
20
49
  // What this project builds for. No default: absent means "nobody said", and
21
50
  // the readiness checklist falls back to looking at the repository. Setting it
22
51
  // is how a repository that happens to carry an Xcode project it never builds
@@ -1,11 +1,14 @@
1
- const GLOBAL = "";
2
1
  /**
3
2
  * Stores signing credential blocks: a file plus the fields that make it
4
3
  * usable. Knows nothing about encryption itself: it takes and returns
5
4
  * ciphertext, so a bug here cannot leak a plaintext value.
6
5
  *
7
- * A project credential shadows a global one of the same kind the same
8
- * precedence as `SecretStore`, and the least surprising rule.
6
+ * One project, one block per kind. A block used to be storable under no project
7
+ * and read by all of them an App Store Connect key is usually one per
8
+ * developer account, not one per app, so the sharing matched something real.
9
+ * It went anyway: it meant no screen could show what a project actually signs
10
+ * with. Five apps under one account now hold five copies of the key, and
11
+ * rotating it means replacing five.
9
12
  */
10
13
  export class CredentialStore {
11
14
  db;
@@ -22,68 +25,30 @@ export class CredentialStore {
22
25
  fields_enc = excluded.fields_enc,
23
26
  var_names = excluded.var_names,
24
27
  updated_at = excluded.updated_at`)
25
- .run(projectSlug ?? GLOBAL, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
28
+ .run(projectSlug, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
26
29
  }
27
- /** Rows that apply to a project, project scope winning over global. */
28
- applicable(projectSlug) {
30
+ /** This project's blocks, ciphertext included. */
31
+ rows(projectSlug) {
29
32
  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));
33
+ .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
34
+ .all(projectSlug);
35
+ return rows.map((row) => this.toSummary(row));
41
36
  }
42
- /** One applicable block, ciphertext included, or undefined. */
37
+ /** One block, ciphertext included, or undefined. */
43
38
  find(projectSlug, kind) {
44
- return this.applicable(projectSlug).find((r) => r.kind === kind);
39
+ return this.rows(projectSlug).find((r) => r.kind === kind);
45
40
  }
46
41
  list(projectSlug) {
47
- return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
42
+ return this.rows(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
48
43
  }
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;
44
+ /** Removes every block this project holds, and returns how many. */
45
+ removeAll(projectSlug) {
72
46
  return this.db.prepare("DELETE FROM credential WHERE project_slug = ?").run(projectSlug).changes;
73
47
  }
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
48
  remove(projectSlug, kind) {
84
49
  const res = this.db
85
50
  .prepare("DELETE FROM credential WHERE project_slug = ? AND kind = ?")
86
- .run(projectSlug ?? GLOBAL, kind);
51
+ .run(projectSlug, kind);
87
52
  return res.changes > 0;
88
53
  }
89
54
  toSummary(row) {
@@ -92,7 +57,6 @@ export class CredentialStore {
92
57
  fileName: row.file_name,
93
58
  fileEnc: row.file_enc,
94
59
  fieldsEnc: row.fields_enc,
95
- scope: row.project_slug === GLOBAL ? "global" : "project",
96
60
  varNames: JSON.parse(row.var_names),
97
61
  updatedAt: row.updated_at,
98
62
  };