laneyard 0.4.1 → 0.5.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,8 @@ 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
+ const exec = promisify(execFile);
10
14
  /**
11
15
  * Adds a project block to config.yml while preserving the rest of the file.
12
16
  *
@@ -76,6 +80,28 @@ export async function removeProjectFromConfig(path, slug) {
76
80
  await writeFile(path, serialize(doc), "utf8");
77
81
  return true;
78
82
  }
83
+ /**
84
+ * Empties the project list, leaving the `server:` block and the file's shape.
85
+ *
86
+ * What `laneyard reset` does to config.yml: every project goes, the accounts and
87
+ * the port stay. The same YAML-document edit as removing one project, and for
88
+ * the same reason — the file is hand-written, so its comments and its key order
89
+ * must survive being touched. The items are spliced out of the existing sequence
90
+ * rather than the key replaced, so `projects:` keeps its place and any comment
91
+ * sitting on it.
92
+ *
93
+ * Returns how many blocks were removed, so the caller can report the count.
94
+ */
95
+ export async function clearProjectsInConfig(path) {
96
+ const doc = parseDocument(await readFile(path, "utf8"));
97
+ const projects = doc.getIn(["projects"]);
98
+ if (!(projects instanceof YAMLSeq) || projects.items.length === 0)
99
+ return 0;
100
+ const removed = projects.items.length;
101
+ projects.items.splice(0, removed);
102
+ await writeFile(path, serialize(doc), "utf8");
103
+ return removed;
104
+ }
79
105
  /**
80
106
  * Entry point for `laneyard setup`.
81
107
  *
@@ -120,7 +146,12 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
120
146
  dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
121
147
  dim(" Give it another name to keep both.\n"));
122
148
  }
123
- const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
149
+ // The repository file lives in the app's own directory, so a monorepo of N
150
+ // apps carries N of them — one beside each app's fastlane folder. `appRoot`
151
+ // is the fastlane dir's parent; for a plain app whose fastlane sits at the
152
+ // root it is `.`, and the file lands at the repository root as it always did.
153
+ const appRoot = appRootOf(d.fastlaneDir);
154
+ const repoConfigPath = join(repoRoot(cwd, d.subPath), appRoot, LANEYARD_YML);
124
155
  if (await fileExists(repoConfigPath)) {
125
156
  process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
126
157
  dim(" Its values win over anything written here; it will be left alone.\n"));
@@ -152,6 +183,18 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
152
183
  const gitUrl = await asker.ask("repository", d.gitUrl);
153
184
  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
185
  const fastlaneDir = await asker.ask("fastlane directory", d.fastlaneDir, dim("Relative to the repository root, because that is what Laneyard clones."));
186
+ // A fastlane folder that is on disk but not in git will not survive the
187
+ // clone Laneyard builds from — the case that started this: a stray
188
+ // `app copie/` macOS made, detected here and absent from the remote.
189
+ // Warned, not refused: setup proposes and the user decides, exactly as with
190
+ // every value above. Silent when git cannot answer — a courtesy, not a gate.
191
+ if (!(await fastlaneDirIsTracked(repoRoot(cwd, d.subPath), fastlaneDir))) {
192
+ process.stdout.write("\n" +
193
+ warn(`${bold(fastlaneDir)} exists here but is not tracked by git.\n`) +
194
+ dim(" Laneyard builds from a clone of the remote, so a path that is not in\n") +
195
+ dim(" git will not be in the clone — the build will fail looking for it.\n") +
196
+ dim(" Commit and push it, or give a path that is in git.\n"));
197
+ }
155
198
  const useBundler = await asker.confirm("\n" +
156
199
  dim(" With bundler, runs use the fastlane version pinned in the Gemfile.\n") +
157
200
  dim(" Without it, whichever fastlane is installed on the build machine.\n") +
@@ -204,10 +247,18 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
204
247
  // The repository half: how it builds. This is the part that was going into
205
248
  // the machine's file and therefore never being committed — which is exactly
206
249
  // backwards, since it is the part a colleague needs.
250
+ //
251
+ // Its paths are written relative to the app's own directory, because the file
252
+ // lives there: `fastlane_dir` is omitted when it is the plain `fastlane` the
253
+ // default already assumes, and each glob is stripped of the app prefix. An
254
+ // app moved or duplicated keeps this file unchanged; `store.ts` puts the
255
+ // prefix back when it reads it.
207
256
  const wroteRepoConfig = await writeRepoConfigIfAbsent(repoConfigPath, {
208
- fastlane_dir: fastlaneDir,
257
+ ...(appRelative(appRoot, fastlaneDir) === "fastlane"
258
+ ? {}
259
+ : { fastlane_dir: appRelative(appRoot, fastlaneDir) }),
209
260
  runtime,
210
- artifact_globs: globs,
261
+ artifact_globs: globs.map((g) => appRelative(appRoot, g)),
211
262
  // Written down rather than re-inferred on every readiness check: a value
212
263
  // in a file can be corrected when the guess was wrong, and setup and the
213
264
  // checklist cannot end up disagreeing about the same repository.
@@ -264,22 +315,38 @@ async function configuredPort(configPath) {
264
315
  }
265
316
  /** The repository file, named once so the message and the write cannot disagree. */
266
317
  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
- */
318
+ /** What the first account is called when nobody says otherwise. */
273
319
  const DEFAULT_ADMIN_NAME = "admin";
274
320
  /**
275
321
  * The repository root, from where the command ran and how deep it sits.
276
322
  *
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.
323
+ * The anchor everything else is measured against: the clone is the whole
324
+ * repository, not the sub-directory someone was standing in, so `config.yml`'s
325
+ * `fastlane_dir` and the app directory `laneyard.yml` lands in are both relative
326
+ * to here, whichever folder setup was run from.
279
327
  */
280
328
  function repoRoot(cwd, subPath) {
281
329
  return subPath === "" ? cwd : join(cwd, ...subPath.split("/").map(() => ".."));
282
330
  }
331
+ /**
332
+ * Whether git tracks anything under a path in the working copy setup ran in.
333
+ *
334
+ * A folder can be on disk and absent from git — untracked, gitignored, or a
335
+ * stray local copy — and such a folder does not survive the clone Laneyard
336
+ * builds from. `git ls-files` lists nothing under an untracked path, so an
337
+ * empty listing is the signal. Returns true — warning nothing — when git
338
+ * cannot answer at all (not a repository, git missing): the check is a
339
+ * courtesy, and setup must not crash on its account.
340
+ */
341
+ export async function fastlaneDirIsTracked(root, dir) {
342
+ try {
343
+ const { stdout } = await exec("git", ["ls-files", "--", dir], { cwd: root });
344
+ return stdout.trim() !== "";
345
+ }
346
+ catch {
347
+ return true;
348
+ }
349
+ }
283
350
  const fileExists = async (path) => {
284
351
  try {
285
352
  await readFile(path);
@@ -313,6 +380,21 @@ async function writeRepoConfigIfAbsent(path, settings) {
313
380
  await writeFile(path, doc.toString(), "utf8");
314
381
  return true;
315
382
  }
383
+ /**
384
+ * A repo-root-relative path read as relative to the app directory.
385
+ *
386
+ * The inverse of what `store.ts` does when it reads an app-level file: the file
387
+ * lives in `<appRoot>/`, so its paths drop that prefix. `.` is the repository
388
+ * root — a path there is already app-relative and unchanged. A path that does
389
+ * not sit under the app (an unanchored glob like `**​/*.ipa`) is left as it is;
390
+ * it still means the same thing once the read-time prefix is applied.
391
+ */
392
+ function appRelative(appRoot, p) {
393
+ if (appRoot === "" || appRoot === ".")
394
+ return p;
395
+ const prefix = `${appRoot}/`;
396
+ return p.startsWith(prefix) ? p.slice(prefix.length) : p;
397
+ }
316
398
  /** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
317
399
  function repositoryLabel(url) {
318
400
  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
@@ -58,16 +58,26 @@ export const userEntrySchema = z.object({
58
58
  name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "name: letters, digits, dot, dash, underscore"),
59
59
  role: userRoleSchema,
60
60
  password_hash: z.string().min(1),
61
+ /**
62
+ * Which projects a builder may reach, by slug. Three-way, and the three ways
63
+ * carry back-compat for free:
64
+ *
65
+ * absent → every project. A file written before this feature has no field
66
+ * on anyone, so nobody loses access on upgrade.
67
+ * [] → no project. What account creation now writes, so a new builder
68
+ * starts with nothing until granted.
69
+ * a list → exactly those slugs.
70
+ *
71
+ * An `admin` ignores it entirely: managing the server is their whole role.
72
+ */
73
+ projects: z.array(slugSchema).optional(),
61
74
  });
62
75
  export const serverConfigSchema = z.object({
63
76
  server: z.object({
64
77
  port: z.number().int().positive().default(7890),
65
78
  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(),
79
+ // Optional in the file, required by the loader: a configuration that
80
+ // declares no account is refused rather than started.
71
81
  users: z.array(userEntrySchema).optional(),
72
82
  // Only 1 is accepted. Runs share one working directory per project, so a
73
83
  // 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
  }
@@ -0,0 +1,70 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { removeProjectFromAccounts } from "../config/accounts.js";
4
+ import { removeProjectFromConfig } from "../cli/setup.js";
5
+ /**
6
+ * Removes everything Laneyard holds for one project, and reports what went.
7
+ *
8
+ * The block leaves config.yml, through the YAML document so the rest of a
9
+ * hand-written file is untouched; the clone is deleted; every artifact folder
10
+ * goes; the run history — the rows and their logs — is deleted; and the
11
+ * project's own secrets and signing blocks are forgotten from the vault. The
12
+ * history is the one thing here that cannot be made again.
13
+ *
14
+ * What it does not reach, and why each is out of scope:
15
+ *
16
+ * - the git remote. The repository is on the host and the user's disk. It is
17
+ * theirs, not Laneyard's, and nothing here reads or writes it.
18
+ * - the credential originals. Laneyard removes its own encrypted copy of a
19
+ * `.p8` or a keystore; the file that went in is still in the password manager
20
+ * or the safe it came from.
21
+ * - global secrets and global signing blocks. They are read by every project on
22
+ * the machine — `vault.forget` touches only slug-scoped rows.
23
+ *
24
+ * It removes; it does not confirm and it does not shape a reply. The callers do
25
+ * that: the route behind a slug typed back, the CLI behind the same. The one
26
+ * irreversible thing must not be reachable without one of them.
27
+ */
28
+ export async function removeProjectData(deps, slug) {
29
+ const clonePath = deps.workspacePath(slug);
30
+ // Read before anything is touched. -1 is SQLite's "no limit": every run of the
31
+ // project, because each one names an artifact folder and a log file to remove.
32
+ const runs = deps.runs.listByProject(slug, -1);
33
+ // The config block first: once it is gone the project cannot be started, so
34
+ // nothing new begins reading the files the rest of this is about to remove.
35
+ const removed = await removeProjectFromConfig(deps.configPath, slug);
36
+ if (!removed) {
37
+ return { found: false, runs: 0, artifacts: 0, workspace: false, clonePath, secrets: 0, signingBlocks: 0 };
38
+ }
39
+ // The slug also leaves every account's grants, in the same file: a grant onto
40
+ // a project that no longer exists is dead data, and a slug re-used later must
41
+ // not silently inherit it.
42
+ await removeProjectFromAccounts(deps.configPath, slug);
43
+ await deps.reloadConfig();
44
+ // The clone.
45
+ const workspace = existsSync(clonePath);
46
+ await rm(clonePath, { recursive: true, force: true });
47
+ // The artifacts and the logs, one of each per run that produced them.
48
+ let artifacts = 0;
49
+ for (const run of runs) {
50
+ const dir = deps.artifactsDir(run.id);
51
+ if (existsSync(dir))
52
+ artifacts += 1;
53
+ await rm(dir, { recursive: true, force: true });
54
+ await deps.logs.remove(run.id);
55
+ }
56
+ // The run history: the rows, and their steps and artifact records by cascade.
57
+ deps.runs.removeByProject(slug);
58
+ // The project's own secrets and signing blocks. Slug-scoped only: a global
59
+ // secret three other projects read is not this one's to take.
60
+ const forgotten = deps.vault.forget(slug);
61
+ return {
62
+ found: true,
63
+ runs: runs.length,
64
+ artifacts,
65
+ workspace,
66
+ clonePath,
67
+ secrets: forgotten.secrets,
68
+ signingBlocks: forgotten.credentials,
69
+ };
70
+ }