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.
- package/README.md +95 -34
- package/dist/src/cli/home.js +46 -0
- package/dist/src/cli/remove.js +173 -0
- package/dist/src/cli/reset.js +117 -0
- package/dist/src/cli/setup.js +93 -11
- package/dist/src/cli/uninstall.js +2 -24
- package/dist/src/config/accounts.js +103 -39
- package/dist/src/config/load.js +3 -27
- package/dist/src/config/resolve.js +36 -0
- package/dist/src/config/schema.js +15 -5
- package/dist/src/config/store.js +27 -3
- package/dist/src/data/remove-project.js +70 -0
- package/dist/src/db/runs.js +11 -0
- package/dist/src/logs/store.js +9 -1
- package/dist/src/main.js +23 -1
- package/dist/src/secrets/vault.js +6 -5
- package/dist/src/server/app.js +17 -12
- package/dist/src/server/permissions.js +50 -0
- package/dist/src/server/routes/account.js +56 -1
- package/dist/src/server/routes/fastfile.js +7 -0
- package/dist/src/server/routes/projects.js +95 -86
- package/dist/src/server/routes/users.js +47 -4
- package/dist/src/sidecar/fastlane-dir.js +8 -8
- package/dist/web/assets/{Editor-9nLYwtg7.js → Editor-Bqz_dClT.js} +1 -1
- package/dist/web/assets/index-CMEreqtd.js +62 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-pzPa9EZr.js +0 -62
package/dist/src/cli/setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
278
|
-
*
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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", "
|
|
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", "
|
|
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;
|
package/dist/src/config/load.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
67
|
-
//
|
|
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
|
package/dist/src/config/store.js
CHANGED
|
@@ -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
|
|
77
|
-
|
|
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
|
+
}
|