laneyard 0.4.1 → 0.6.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.
@@ -1,5 +1,7 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { readFile, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
4
+ import { promisify } from "node:util";
3
5
  import { Document, parseDocument, YAMLSeq } from "yaml";
4
6
  import { VALID_NAME, ensureFirstAdmin, hasAccount } from "../config/accounts.js";
5
7
  import { loadServerConfig } from "../config/load.js";
@@ -7,6 +9,13 @@ import { serializeYaml as serialize } from "../config/yaml.js";
7
9
  import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
8
10
  import { acceptingAsker, terminalAsker } from "./prompt.js";
9
11
  import { detectProject } from "./detect.js";
12
+ import { appRootOf } from "../heuristics/platforms.js";
13
+ import { CredentialStore } from "../db/credentials.js";
14
+ import { openDatabase } from "../db/open.js";
15
+ import { SecretStore } from "../db/secrets.js";
16
+ import { Vault } from "../secrets/vault.js";
17
+ import { runAdoption } from "./adopt.js";
18
+ const exec = promisify(execFile);
10
19
  /**
11
20
  * Adds a project block to config.yml while preserving the rest of the file.
12
21
  *
@@ -76,6 +85,28 @@ export async function removeProjectFromConfig(path, slug) {
76
85
  await writeFile(path, serialize(doc), "utf8");
77
86
  return true;
78
87
  }
88
+ /**
89
+ * Empties the project list, leaving the `server:` block and the file's shape.
90
+ *
91
+ * What `laneyard reset` does to config.yml: every project goes, the accounts and
92
+ * the port stay. The same YAML-document edit as removing one project, and for
93
+ * the same reason — the file is hand-written, so its comments and its key order
94
+ * must survive being touched. The items are spliced out of the existing sequence
95
+ * rather than the key replaced, so `projects:` keeps its place and any comment
96
+ * sitting on it.
97
+ *
98
+ * Returns how many blocks were removed, so the caller can report the count.
99
+ */
100
+ export async function clearProjectsInConfig(path) {
101
+ const doc = parseDocument(await readFile(path, "utf8"));
102
+ const projects = doc.getIn(["projects"]);
103
+ if (!(projects instanceof YAMLSeq) || projects.items.length === 0)
104
+ return 0;
105
+ const removed = projects.items.length;
106
+ projects.items.splice(0, removed);
107
+ await writeFile(path, serialize(doc), "utf8");
108
+ return removed;
109
+ }
79
110
  /**
80
111
  * Entry point for `laneyard setup`.
81
112
  *
@@ -120,7 +151,12 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
120
151
  dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
121
152
  dim(" Give it another name to keep both.\n"));
122
153
  }
123
- const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
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);
124
160
  if (await fileExists(repoConfigPath)) {
125
161
  process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
126
162
  dim(" Its values win over anything written here; it will be left alone.\n"));
@@ -152,6 +188,18 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
152
188
  const gitUrl = await asker.ask("repository", d.gitUrl);
153
189
  const branch = await asker.ask("default branch", d.defaultBranch, dim("A run uses this branch unless you pick another one when you start it."));
154
190
  const fastlaneDir = await asker.ask("fastlane directory", d.fastlaneDir, dim("Relative to the repository root, because that is what Laneyard clones."));
191
+ // A fastlane folder that is on disk but not in git will not survive the
192
+ // clone Laneyard builds from — the case that started this: a stray
193
+ // `app copie/` macOS made, detected here and absent from the remote.
194
+ // Warned, not refused: setup proposes and the user decides, exactly as with
195
+ // every value above. Silent when git cannot answer — a courtesy, not a gate.
196
+ if (!(await fastlaneDirIsTracked(repoRoot(cwd, d.subPath), fastlaneDir))) {
197
+ process.stdout.write("\n" +
198
+ warn(`${bold(fastlaneDir)} exists here but is not tracked by git.\n`) +
199
+ dim(" Laneyard builds from a clone of the remote, so a path that is not in\n") +
200
+ dim(" git will not be in the clone — the build will fail looking for it.\n") +
201
+ dim(" Commit and push it, or give a path that is in git.\n"));
202
+ }
155
203
  const useBundler = await asker.confirm("\n" +
156
204
  dim(" With bundler, runs use the fastlane version pinned in the Gemfile.\n") +
157
205
  dim(" Without it, whichever fastlane is installed on the build machine.\n") +
@@ -204,10 +252,20 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
204
252
  // The repository half: how it builds. This is the part that was going into
205
253
  // the machine's file and therefore never being committed — which is exactly
206
254
  // backwards, since it is the part a colleague needs.
255
+ //
256
+ // Its paths are written relative to the app's own directory, because the file
257
+ // lives there: `fastlane_dir` is omitted when it is the plain `fastlane` the
258
+ // default already assumes, and each glob is stripped of the app prefix. An
259
+ // app moved or duplicated keeps this file unchanged; `store.ts` puts the
260
+ // prefix back when it reads it.
207
261
  const wroteRepoConfig = await writeRepoConfigIfAbsent(repoConfigPath, {
208
- fastlane_dir: fastlaneDir,
262
+ // First, as the file's identity: `remove` reads it from here.
263
+ slug,
264
+ ...(appRelative(appRoot, fastlaneDir) === "fastlane"
265
+ ? {}
266
+ : { fastlane_dir: appRelative(appRoot, fastlaneDir) }),
209
267
  runtime,
210
- artifact_globs: globs,
268
+ artifact_globs: globs.map((g) => appRelative(appRoot, g)),
211
269
  // Written down rather than re-inferred on every readiness check: a value
212
270
  // in a file can be corrected when the guess was wrong, and setup and the
213
271
  // checklist cannot end up disagreeing about the same repository.
@@ -245,6 +303,29 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
245
303
  ` ${dim(`http://localhost:${port}`)}\n` +
246
304
  "\n" +
247
305
  dim("Already running? The configuration is watched — it appears on its own.\n"));
306
+ // The second act. After the success message, never before it: declining
307
+ // everything here must leave exactly the project the lines above just
308
+ // announced. See `cli/adopt.ts`.
309
+ //
310
+ // The repository root, not `cwd`: `fastlaneDir` is measured from there, and
311
+ // so are the credential paths a Fastfile one directory down writes and the
312
+ // `git ls-files` that asks whether they are committed.
313
+ if (options.home !== undefined) {
314
+ const db = openDatabase(join(options.home, "laneyard.db"));
315
+ try {
316
+ const vault = await Vault.open(options.home, new SecretStore(db), new CredentialStore(db));
317
+ await runAdoption({ cwd: repoRoot(cwd, d.subPath), fastlaneDir, slug, vault, asker });
318
+ }
319
+ catch (cause) {
320
+ // Adoption is a courtesy on top of a command that has already
321
+ // succeeded. Its failure is reported and swallowed: exiting non-zero
322
+ // here would say the project was not set up, and the project is set up.
323
+ process.stdout.write("\n" + warn(`Could not finish reading your Fastfile: ${cause.message}\n`));
324
+ }
325
+ finally {
326
+ db.close();
327
+ }
328
+ }
248
329
  return 0;
249
330
  }
250
331
  finally {
@@ -264,22 +345,38 @@ async function configuredPort(configPath) {
264
345
  }
265
346
  /** The repository file, named once so the message and the write cannot disagree. */
266
347
  const LANEYARD_YML = "laneyard.yml";
267
- /**
268
- * What the first account is called when nobody says otherwise.
269
- *
270
- * The same name a lone `password_hash` is read under, so a machine set up today
271
- * and a machine upgraded from 0.2 sign in the same way.
272
- */
348
+ /** What the first account is called when nobody says otherwise. */
273
349
  const DEFAULT_ADMIN_NAME = "admin";
274
350
  /**
275
351
  * The repository root, from where the command ran and how deep it sits.
276
352
  *
277
- * `laneyard.yml` belongs at the root because that is where the server reads it:
278
- * the clone is the repository, not the sub-directory someone was standing in.
353
+ * The anchor everything else is measured against: the clone is the whole
354
+ * repository, not the sub-directory someone was standing in, so `config.yml`'s
355
+ * `fastlane_dir` and the app directory `laneyard.yml` lands in are both relative
356
+ * to here, whichever folder setup was run from.
279
357
  */
280
358
  function repoRoot(cwd, subPath) {
281
359
  return subPath === "" ? cwd : join(cwd, ...subPath.split("/").map(() => ".."));
282
360
  }
361
+ /**
362
+ * Whether git tracks anything under a path in the working copy setup ran in.
363
+ *
364
+ * A folder can be on disk and absent from git — untracked, gitignored, or a
365
+ * stray local copy — and such a folder does not survive the clone Laneyard
366
+ * builds from. `git ls-files` lists nothing under an untracked path, so an
367
+ * empty listing is the signal. Returns true — warning nothing — when git
368
+ * cannot answer at all (not a repository, git missing): the check is a
369
+ * courtesy, and setup must not crash on its account.
370
+ */
371
+ export async function fastlaneDirIsTracked(root, dir) {
372
+ try {
373
+ const { stdout } = await exec("git", ["ls-files", "--", dir], { cwd: root });
374
+ return stdout.trim() !== "";
375
+ }
376
+ catch {
377
+ return true;
378
+ }
379
+ }
283
380
  const fileExists = async (path) => {
284
381
  try {
285
382
  await readFile(path);
@@ -313,6 +410,21 @@ async function writeRepoConfigIfAbsent(path, settings) {
313
410
  await writeFile(path, doc.toString(), "utf8");
314
411
  return true;
315
412
  }
413
+ /**
414
+ * A repo-root-relative path read as relative to the app directory.
415
+ *
416
+ * The inverse of what `store.ts` does when it reads an app-level file: the file
417
+ * lives in `<appRoot>/`, so its paths drop that prefix. `.` is the repository
418
+ * root — a path there is already app-relative and unchanged. A path that does
419
+ * not sit under the app (an unanchored glob like `**​/*.ipa`) is left as it is;
420
+ * it still means the same thing once the read-time prefix is applied.
421
+ */
422
+ function appRelative(appRoot, p) {
423
+ if (appRoot === "" || appRoot === ".")
424
+ return p;
425
+ const prefix = `${appRoot}/`;
426
+ return p.startsWith(prefix) ? p.slice(prefix.length) : p;
427
+ }
316
428
  /** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
317
429
  function repositoryLabel(url) {
318
430
  return url.replace(/^.*[:/]([^/:]+\/[^/]+?)(\.git)?$/, "$1");
@@ -1,8 +1,8 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { readdir, readFile, rm, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { createInterface } from "node:readline";
5
4
  import { parse } from "yaml";
5
+ import { OWN_FILES, OWN_FOLDERS, readLine, removePaths } from "./home.js";
6
6
  import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
7
7
  export const UNINSTALL_USAGE = `laneyard uninstall [--dry-run]
8
8
 
@@ -17,9 +17,6 @@ There is no npm lifecycle hook doing any of this on \`npm uninstall\`, on
17
17
  purpose: a package manager must not delete someone's signing keys on its own,
18
18
  and a lifecycle script cannot ask.
19
19
  `;
20
- /** What Laneyard writes into its home, and nothing else. */
21
- const OWN_FILES = ["config.yml", "key", "laneyard.db", "laneyard.db-wal", "laneyard.db-shm"];
22
- const OWN_FOLDERS = ["workspaces", "artifacts", "logs", "runs"];
23
20
  export async function readInventory(home) {
24
21
  const empty = {
25
22
  home,
@@ -287,18 +284,6 @@ export function renderIrreversible(inv) {
287
284
  "\n" +
288
285
  dim(" Your repositories are untouched. Nothing outside " + inv.home + " is read or written.\n"));
289
286
  }
290
- /** Reads one line, which works the same whether it is typed or piped in. */
291
- async function readLine(stdin) {
292
- const rl = createInterface({ input: stdin });
293
- try {
294
- for await (const line of rl)
295
- return line.trim();
296
- return "";
297
- }
298
- finally {
299
- rl.close();
300
- }
301
- }
302
287
  /**
303
288
  * Entry point for `laneyard uninstall`.
304
289
  *
@@ -352,14 +337,7 @@ export async function runUninstallCommand(home, args, io) {
352
337
  "\n");
353
338
  return 1;
354
339
  }
355
- const removed = [];
356
- for (const name of [...OWN_FILES, ...OWN_FOLDERS]) {
357
- const path = join(home, name);
358
- if ((await stat(path).catch(() => null)) === null)
359
- continue;
360
- await rm(path, { recursive: true, force: true });
361
- removed.push(name);
362
- }
340
+ const removed = await removePaths(home, [...OWN_FILES, ...OWN_FOLDERS]);
363
341
  // The folder itself only when there is nothing left in it. Anything Laneyard
364
342
  // did not write is somebody's, and this command has no business deciding
365
343
  // otherwise — `$LANEYARD_HOME` can point anywhere.
@@ -1,8 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
- import { Document, parseDocument, YAMLSeq } from "yaml";
3
+ import { Document, parseDocument, YAMLMap, YAMLSeq } from "yaml";
4
4
  import { hashPassword } from "../server/auth.js";
5
- import { LEGACY_ADMIN_NAME } from "./load.js";
6
5
  import { serializeYaml } from "./yaml.js";
7
6
  /**
8
7
  * Accounts, as they are written to and taken out of config.yml.
@@ -53,25 +52,6 @@ async function open(path) {
53
52
  doc = new Document({});
54
53
  return doc;
55
54
  }
56
- /**
57
- * Turns a lone `server.password_hash` into the `users` form, in place.
58
- *
59
- * A file holding both is the one combination the loader refuses, so adding a
60
- * colleague to a 0.2 installation has to migrate it — otherwise the act of
61
- * inviting someone is the act of breaking the server's configuration.
62
- *
63
- * The name is the one the loader already reads that hash under, so nobody's
64
- * password changes meaning on the way through.
65
- */
66
- function migrateLegacyHash(doc) {
67
- const hash = doc.getIn(["server", "password_hash"]);
68
- if (typeof hash !== "string")
69
- return;
70
- doc.deleteIn(["server", "password_hash"]);
71
- if (doc.hasIn(["server", "users"]))
72
- return;
73
- doc.setIn(["server", "users"], doc.createNode([{ name: LEGACY_ADMIN_NAME, role: "admin", password_hash: hash }]));
74
- }
75
55
  /** The accounts sequence, created if the file has none yet. */
76
56
  function usersSeq(doc) {
77
57
  const existing = doc.getIn(["server", "users"]);
@@ -94,21 +74,109 @@ const nameOf = (item) => item.get?.("name");
94
74
  */
95
75
  export async function upsertUserInConfig(path, entry) {
96
76
  const doc = await open(path);
97
- migrateLegacyHash(doc);
98
77
  const seq = usersSeq(doc);
99
- const stored = {
100
- name: entry.name,
101
- role: entry.role,
102
- password_hash: hashPassword(entry.password),
103
- };
104
78
  const at = seq.items.findIndex((item) => nameOf(item) === entry.name);
105
- const node = doc.createNode(stored);
79
+ const password_hash = hashPassword(entry.password);
80
+ if (at === -1) {
81
+ // A new account starts with no access: `projects: []` is the empty grant,
82
+ // told apart from an absent field, which would mean "every project". A
83
+ // builder is thus created seeing nothing until an admin grants a project;
84
+ // an admin ignores the field, and carries it only so the entries are
85
+ // uniform.
86
+ seq.add(doc.createNode({ name: entry.name, role: entry.role, password_hash, projects: [] }));
87
+ await writeFile(path, serializeYaml(doc), "utf8");
88
+ return { created: true };
89
+ }
90
+ // Replacing changes the role and the password and nothing else: the fields are
91
+ // set on the existing node rather than a fresh one put in its place, so a
92
+ // hand-added `projects` grant — and any comment or key order — survives a
93
+ // password change it has nothing to do with.
94
+ const item = seq.items[at];
95
+ item.set("role", entry.role);
96
+ item.set("password_hash", password_hash);
97
+ await writeFile(path, serializeYaml(doc), "utf8");
98
+ return { created: false };
99
+ }
100
+ /**
101
+ * Renames an account in place, keeping everything else it carried.
102
+ *
103
+ * A rename is not an upsert. `upsertUserInConfig` keys by name, so handing it the
104
+ * new name would add a second account and orphan the first — dropping the role,
105
+ * the hash and, most quietly of all, the `projects` grants the old entry held.
106
+ * So the edit is to the `name` field of the matching node, through the YAML
107
+ * document, which leaves role, password_hash, projects and the file's comments
108
+ * and key order exactly where they were.
109
+ *
110
+ * Refuses when `newName` already belongs to another account rather than folding
111
+ * two entries into one, and refuses when no account carried `oldName` — there is
112
+ * nothing there to rename. Returns whether the rename happened, which is the
113
+ * difference between a fresh name and a collision in a sentence.
114
+ */
115
+ export async function renameUserInConfig(path, oldName, newName) {
116
+ const doc = await open(path);
117
+ const users = doc.getIn(["server", "users"]);
118
+ if (!(users instanceof YAMLSeq))
119
+ return false;
120
+ // Checked before the target is looked up: a name already in the file must not
121
+ // be written onto a second entry, whoever else already holds it.
122
+ if (users.items.some((item) => nameOf(item) === newName))
123
+ return false;
124
+ const at = users.items.findIndex((item) => nameOf(item) === oldName);
106
125
  if (at === -1)
107
- seq.add(node);
108
- else
109
- seq.items[at] = node;
126
+ return false;
127
+ users.items[at].set("name", newName);
110
128
  await writeFile(path, serializeYaml(doc), "utf8");
111
- return { created: at === -1 };
129
+ return true;
130
+ }
131
+ /**
132
+ * Writes an account's project grants, replacing whatever list it had.
133
+ *
134
+ * The one edit behind `PUT /api/users/:name/projects`: it sets `projects` on the
135
+ * named account through the YAML document, so a hand-written file keeps its
136
+ * comments and its key order. Returns false when no account carried that name,
137
+ * so the caller can answer 404 rather than write a grant onto nobody.
138
+ */
139
+ export async function setUserProjectsInConfig(path, name, projects) {
140
+ const doc = await open(path);
141
+ const users = doc.getIn(["server", "users"]);
142
+ if (!(users instanceof YAMLSeq))
143
+ return false;
144
+ const at = users.items.findIndex((item) => nameOf(item) === name);
145
+ if (at === -1)
146
+ return false;
147
+ users.items[at].set("projects", doc.createNode(projects));
148
+ await writeFile(path, serializeYaml(doc), "utf8");
149
+ return true;
150
+ }
151
+ /**
152
+ * Strips a project's slug from every account's grants.
153
+ *
154
+ * Called when a project is removed: a grant pointing at a project that no longer
155
+ * exists is dead data, and — the same hazard as a re-used vault slug — a project
156
+ * re-created later under that slug must not silently inherit an old grant. Only
157
+ * accounts that carried the slug are touched, so a file with no grants at all
158
+ * comes back byte-for-byte unchanged.
159
+ */
160
+ export async function removeProjectFromAccounts(path, slug) {
161
+ const doc = await open(path);
162
+ const users = doc.getIn(["server", "users"]);
163
+ if (!(users instanceof YAMLSeq))
164
+ return;
165
+ let changed = false;
166
+ for (const item of users.items) {
167
+ if (!(item instanceof YAMLMap))
168
+ continue;
169
+ const projects = item.get("projects");
170
+ if (!(projects instanceof YAMLSeq))
171
+ continue;
172
+ const at = projects.items.findIndex((n) => (n && typeof n === "object" && "value" in n ? n.value : n) === slug);
173
+ if (at !== -1) {
174
+ projects.items.splice(at, 1);
175
+ changed = true;
176
+ }
177
+ }
178
+ if (changed)
179
+ await writeFile(path, serializeYaml(doc), "utf8");
112
180
  }
113
181
  /**
114
182
  * Takes an account out of config.yml, leaving the rest of the file alone.
@@ -118,7 +186,6 @@ export async function upsertUserInConfig(path, entry) {
118
186
  */
119
187
  export async function removeUserFromConfig(path, name) {
120
188
  const doc = await open(path);
121
- migrateLegacyHash(doc);
122
189
  const users = doc.getIn(["server", "users"]);
123
190
  if (!(users instanceof YAMLSeq))
124
191
  return false;
@@ -130,7 +197,7 @@ export async function removeUserFromConfig(path, name) {
130
197
  return true;
131
198
  }
132
199
  /**
133
- * Does this file already declare somebody, in either of the two forms?
200
+ * Does this file already declare somebody?
134
201
  *
135
202
  * Asked before a question is put to the user rather than after: `laneyard setup`
136
203
  * only asks for an admin's name on a machine that has none, and asking anyway
@@ -138,7 +205,7 @@ export async function removeUserFromConfig(path, name) {
138
205
  */
139
206
  export async function hasAccount(path) {
140
207
  const doc = await open(path);
141
- return doc.hasIn(["server", "password_hash"]) || doc.hasIn(["server", "users"]);
208
+ return doc.hasIn(["server", "users"]);
142
209
  }
143
210
  /**
144
211
  * Creates the first admin if the file declares no account at all.
@@ -151,12 +218,9 @@ export async function hasAccount(path) {
151
218
  */
152
219
  export async function ensureFirstAdmin(path, name) {
153
220
  const doc = await open(path);
154
- if (doc.hasIn(["server", "password_hash"]) || doc.hasIn(["server", "users"]))
221
+ if (doc.hasIn(["server", "users"]))
155
222
  return null;
156
223
  const password = randomBytes(9).toString("base64url");
157
- // The `users` form, never a bare `password_hash`: writing the legacy shape on
158
- // a fresh machine would mean every new installation immediately needs the
159
- // migration above the first time someone adds a colleague.
160
224
  doc.setIn(["server", "users"], doc.createNode([{ name, role: "admin", password_hash: hashPassword(password) }]));
161
225
  await writeFile(path, serializeYaml(doc), "utf8");
162
226
  return password;
@@ -1,13 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { parse as parseYaml } from "yaml";
3
3
  import { repoConfigSchema, serverConfigSchema } from "./schema.js";
4
- /**
5
- * The name a lone `password_hash` is read under.
6
- *
7
- * Also the name the legacy `{ password }` login form authenticates as, which is
8
- * what lets a 0.2 installation upgrade without anyone editing a file.
9
- */
10
- export const LEGACY_ADMIN_NAME = "admin";
11
4
  /** Reads and validates a YAML file. Never fails by throwing: the caller decides. */
12
5
  async function loadYamlFile(path, schema) {
13
6
  let raw;
@@ -34,26 +27,13 @@ async function loadYamlFile(path, schema) {
34
27
  return { ok: true, config: parsed.data };
35
28
  }
36
29
  /**
37
- * Folds both ways of declaring accounts into the one the rest of the code sees.
30
+ * Validates the declared accounts.
38
31
  *
39
32
  * Every refusal here is a locked room avoided: a server with no account, or
40
33
  * with none that can administer it, is one nobody can fix from the interface.
41
34
  */
42
35
  function normaliseUsers(server) {
43
- const { password_hash, users } = server;
44
- if (password_hash !== undefined && users !== undefined) {
45
- return {
46
- ok: false,
47
- error: "server.password_hash and server.users both set — they are two ways to say the same " +
48
- "thing and there is no obvious winner. Keep server.users and drop server.password_hash.",
49
- };
50
- }
51
- if (password_hash !== undefined) {
52
- return {
53
- ok: true,
54
- users: [{ name: LEGACY_ADMIN_NAME, role: "admin", password_hash }],
55
- };
56
- }
36
+ const { users } = server;
57
37
  if (users === undefined) {
58
38
  return {
59
39
  ok: false,
@@ -95,11 +75,7 @@ export async function loadServerConfig(path) {
95
75
  }
96
76
  // The display name falls back to the slug rather than being optional everywhere downstream.
97
77
  const projects = res.config.projects.map((p) => ({ ...p, name: p.name ?? p.slug }));
98
- // `password_hash` is dropped rather than carried along: it has been folded
99
- // into `users`, and leaving it in place would give downstream code a second
100
- // source of truth to disagree with.
101
- const { password_hash: _dropped, ...server } = res.config.server;
102
- return { ok: true, config: { server: { ...server, users: accounts.users }, projects } };
78
+ return { ok: true, config: { server: { ...res.config.server, users: accounts.users }, projects } };
103
79
  }
104
80
  export async function loadRepoConfig(path) {
105
81
  return loadYamlFile(path, repoConfigSchema);
@@ -1,5 +1,41 @@
1
1
  import { projectSettingsSchema } from "./schema.js";
2
2
  const SETTING_KEYS = Object.keys(projectSettingsSchema.shape);
3
+ /**
4
+ * Prefixes an app-relative path with the app directory.
5
+ *
6
+ * `.` (or an empty prefix) is the repository root, and a path there is already
7
+ * repo-root-relative — nothing to add.
8
+ */
9
+ function underApp(appRoot, p) {
10
+ return appRoot === "" || appRoot === "." ? p : `${appRoot}/${p}`;
11
+ }
12
+ /**
13
+ * Reads an app-level `laneyard.yml` as if it had been written at the repository
14
+ * root.
15
+ *
16
+ * The file declares its paths relative to its own directory —
17
+ * `fastlane_dir: fastlane`, `artifact_globs: ['**​/*.aab']` — so an app moved or
18
+ * duplicated keeps its file unchanged. But everything downstream resolves paths
19
+ * as repo-root-relative (`join(workspacePath, …)`, globs with `cwd:
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
22
+ * merge ever sees them, and nothing past this point learns a new rule.
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.
28
+ */
29
+ export function normaliseAppConfig(repo, appRoot) {
30
+ const out = { ...repo };
31
+ if (repo.fastlane_dir !== undefined) {
32
+ out.fastlane_dir = underApp(appRoot, repo.fastlane_dir);
33
+ }
34
+ if (repo.artifact_globs !== undefined) {
35
+ out.artifact_globs = repo.artifact_globs.map((g) => underApp(appRoot, g));
36
+ }
37
+ return out;
38
+ }
3
39
  /**
4
40
  * Merges the three sources field by field.
5
41
  * `undefined` means "not set"; any other value, including an empty array
@@ -1,6 +1,16 @@
1
1
  import { z } from "zod";
2
+ /** A slug is used as a folder name and a URL segment. */
3
+ const slugSchema = z
4
+ .string()
5
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "slug: lowercase letters, digits and hyphens only");
2
6
  /** Build behaviour settings. They can come from the repository or the server. */
3
7
  export const projectSettingsSchema = z.object({
8
+ // The project this file belongs to. Written by `setup` and read by `remove`,
9
+ // which runs from the app's directory and takes the slug from here rather than
10
+ // from an argument. An identity, not a path — `normaliseAppConfig` leaves it
11
+ // untouched — and the running server ignores it, identifying the project
12
+ // through `config.yml`. Optional so an older file without it still parses.
13
+ slug: slugSchema.optional(),
4
14
  fastlane_dir: z.string().default("fastlane"),
5
15
  runtime: z.enum(["bundle", "system"]).default("bundle"),
6
16
  timeout_minutes: z.number().int().positive().default(60),
@@ -21,10 +31,6 @@ export const projectSettingsSchema = z.object({
21
31
  });
22
32
  /** Same vocabulary, but everything is optional in the files. */
23
33
  export const projectSettingsInputSchema = projectSettingsSchema.partial();
24
- /** A slug is used as a folder name and a URL segment. */
25
- const slugSchema = z
26
- .string()
27
- .regex(/^[a-z0-9][a-z0-9-]*$/, "slug: lowercase letters, digits and hyphens only");
28
34
  export const projectEntrySchema = projectSettingsInputSchema.extend({
29
35
  slug: slugSchema,
30
36
  name: z.string().optional(),
@@ -58,16 +64,26 @@ export const userEntrySchema = z.object({
58
64
  name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "name: letters, digits, dot, dash, underscore"),
59
65
  role: userRoleSchema,
60
66
  password_hash: z.string().min(1),
67
+ /**
68
+ * Which projects a builder may reach, by slug. Three-way, and the three ways
69
+ * carry back-compat for free:
70
+ *
71
+ * absent → every project. A file written before this feature has no field
72
+ * on anyone, so nobody loses access on upgrade.
73
+ * [] → no project. What account creation now writes, so a new builder
74
+ * starts with nothing until granted.
75
+ * a list → exactly those slugs.
76
+ *
77
+ * An `admin` ignores it entirely: managing the server is their whole role.
78
+ */
79
+ projects: z.array(slugSchema).optional(),
61
80
  });
62
81
  export const serverConfigSchema = z.object({
63
82
  server: z.object({
64
83
  port: z.number().int().positive().default(7890),
65
84
  bind: z.string().default("0.0.0.0"),
66
- // Both optional in the file, exactly one required by the loader. The old
67
- // single-password form is what a 0.2 installation has on disk, and it must
68
- // keep working unedited; `load.ts` normalises it into `users` so that
69
- // nothing downstream ever learns which of the two forms was written.
70
- password_hash: z.string().min(1).optional(),
85
+ // Optional in the file, required by the loader: a configuration that
86
+ // declares no account is refused rather than started.
71
87
  users: z.array(userEntrySchema).optional(),
72
88
  // Only 1 is accepted. Runs share one working directory per project, so a
73
89
  // higher number would promise parallel builds that never happen — the
@@ -1,6 +1,7 @@
1
1
  import { watch } from "node:fs";
2
2
  import { loadRepoConfig, loadServerConfig } from "./load.js";
3
- import { resolveProjectSettings } from "./resolve.js";
3
+ import { normaliseAppConfig, resolveProjectSettings } from "./resolve.js";
4
+ import { appRootOf } from "../heuristics/platforms.js";
4
5
  import { join } from "node:path";
5
6
  /**
6
7
  * The server's live configuration.
@@ -68,13 +69,36 @@ export class ConfigStore {
68
69
  * Resolves a project's effective settings by reading its workspace's
69
70
  * laneyard.yml if it exists. The workspace may not be cloned yet: we
70
71
  * then fall back to the project's block and the defaults.
72
+ *
73
+ * The file is looked for in two places, in order:
74
+ *
75
+ * 1. `<workspace>/<appRoot>/laneyard.yml` — the app-level file, its paths
76
+ * relative to the app's own directory, so a monorepo of N apps carries N
77
+ * of them. Normalised back to repo-root-relative before the merge.
78
+ * 2. `<workspace>/laneyard.yml` — the repository-root file, repo-root-relative,
79
+ * which is what existing installs have and keeps working unchanged.
80
+ *
81
+ * `appRoot` is derived from the project's `fastlane_dir` as declared in
82
+ * `config.yml` — the server-side anchor, present for every monorepo project by
83
+ * necessity and what points at the app before any repo file is read. When it is
84
+ * the repository root (the default `fastlane`), the two locations coincide and
85
+ * nothing changes.
71
86
  */
72
87
  async resolve(slug, workspacePath) {
73
88
  const entry = this.project(slug);
74
89
  if (!entry)
75
90
  return null;
76
- const repoRes = await loadRepoConfig(join(workspacePath, "laneyard.yml"));
77
- const repo = repoRes.ok ? repoRes.config : null;
91
+ const appRoot = appRootOf(entry.fastlane_dir);
92
+ let repo = null;
93
+ if (appRoot !== ".") {
94
+ const appRes = await loadRepoConfig(join(workspacePath, appRoot, "laneyard.yml"));
95
+ if (appRes.ok)
96
+ repo = normaliseAppConfig(appRes.config, appRoot);
97
+ }
98
+ if (repo === null) {
99
+ const rootRes = await loadRepoConfig(join(workspacePath, "laneyard.yml"));
100
+ repo = rootRes.ok ? rootRes.config : null;
101
+ }
78
102
  const { settings, provenance } = resolveProjectSettings(entry, repo);
79
103
  return { entry, settings, provenance };
80
104
  }