laneyard 0.4.0 → 0.4.1

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 CHANGED
@@ -155,9 +155,9 @@ shell history. Without `--role`, the account is a builder.
155
155
  Two things are refused, in the API and on the command line alike: removing the last admin, and
156
156
  demoting the last admin. A server nobody can administer cannot be repaired from the interface.
157
157
 
158
- Anyone changes their own password from **your account**, reached by clicking your name in the
159
- header — a builder included, since that page is about one person rather than about the server's
160
- list of people. It asks for the current password even though you are already signed in: a session
158
+ Anyone changes their own password from **your account**, which is said in those words in the header
159
+ and again on your own row of the accounts screen — a builder included, since that page is about one
160
+ person rather than about the server's list of people. It asks for the current password even though you are already signed in: a session
161
161
  is a cookie in a browser that may have been left open on a desk. Doing it ends every other session
162
162
  that account has, and leaves the page you did it on signed in. That is how the random password
163
163
  `laneyard setup` printed once stops being a string on a sticky note.
@@ -451,14 +451,52 @@ What it does *not* do is most of the point, because "delete" elsewhere usually m
451
451
  each still at its own address. Removing a project means stop showing it, not destroy its past;
452
452
  - **the clone and the artifacts stay on disk.** Their paths are printed when it is done, so you
453
453
  can remove them yourself. Nothing is deleted from a web page on one click;
454
- - **the secrets stay in the vault**, encrypted and unreachable, and come back if you add the
455
- project again under the same name;
454
+ - **the secrets and the signing blocks stay in the vault**, encrypted. The screen counts them
455
+ before you confirm and again once it is done, because they are the one thing on this list you
456
+ cannot go and look at — no route ever sends a credential back. Their scope is the slug, so a
457
+ project set up later under the same name finds them and would sign with a keystore nobody
458
+ uploaded;
456
459
  - **the repository is untouched.** Its `laneyard.yml`, its Fastfile and its history are the
457
460
  repository's, not Laneyard's.
458
461
 
459
462
  Removal is refused while a run of that project is in flight — that run is using the workspace. A
460
463
  run still waiting in the queue will not start: it ends as failed, saying its project is gone.
461
464
 
465
+ Once it is done, the result screen offers the one thing removal deliberately did not do: removing
466
+ what the vault still holds under that name. It is a second action, confirmed by typing the name
467
+ again, and it is never part of removing the project — a `.p8` or a keystore cannot be read back
468
+ out of Laneyard, so the copy it holds is the only one it ever had. Global secrets and global
469
+ signing blocks are shared by every project and are left alone.
470
+
471
+ ### Uninstalling
472
+
473
+ ```bash
474
+ laneyard uninstall --dry-run # list what is there, and stop
475
+ laneyard uninstall # remove it, after a typed confirmation
476
+ npm uninstall -g laneyard # remove the package itself
477
+ ```
478
+
479
+ `laneyard uninstall` removes the data folder: `config.yml`, the vault key, the database, the
480
+ workspaces, the artifacts and the logs. It reads the whole inventory from disk first — the
481
+ projects, the number of secrets and signing blocks, the real sizes and paths — and prints it
482
+ before asking anything.
483
+
484
+ The vault key is the one loss that cannot be undone. Every secret and every signing block is
485
+ encrypted under `~/.laneyard/key`; once it is gone the database is ciphertext nobody can read, and
486
+ restoring a backup of `laneyard.db` alone brings nothing back. The originals are yours and are
487
+ untouched — the `.p8` in your downloads, the keystore in your safe — so what you are agreeing to
488
+ is uploading them again. Global secrets and global signing blocks are shared by every project and
489
+ go too; the inventory says so on its own line.
490
+
491
+ It is confirmed by typing the folder's path, not `y`: this is the one command in Laneyard that
492
+ destroys credentials, and `$LANEYARD_HOME` is exactly the case where a reflex is wrong. Anything
493
+ in the folder that Laneyard did not put there is named, left alone, and the folder is kept for it.
494
+
495
+ It does not remove the npm package — a command cannot sensibly delete the binary it is running
496
+ from — and it prints the command that does. There is no npm lifecycle hook doing any of this on
497
+ `npm uninstall`, on purpose: a package manager must not delete someone's signing keys on its own,
498
+ and a lifecycle script cannot ask.
499
+
462
500
  ## Security
463
501
 
464
502
  Read this before putting Laneyard on a network.
@@ -523,6 +561,7 @@ What this does *not* cover, stated plainly:
523
561
  - `✓` signing credentials stored whole — the file and the fields beside it — written to disk for
524
562
  the length of a run and exported under the names your project already reads
525
563
  - `✓` remove a project from the interface, without touching its history
564
+ - `✓` `laneyard uninstall`: the whole inventory first, then a typed confirmation, then the folder
526
565
  - `✓` named accounts, with a builder role that never sees a credential
527
566
  - `○` git-triggered and scheduled builds
528
567
 
@@ -0,0 +1,390 @@
1
+ import Database from "better-sqlite3";
2
+ import { readdir, readFile, rm, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { parse } from "yaml";
6
+ import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
7
+ export const UNINSTALL_USAGE = `laneyard uninstall [--dry-run]
8
+
9
+ Removes Laneyard's data folder: the configuration, the vault key, the database,
10
+ the workspaces, the artifacts and the logs. It does not remove the npm package —
11
+ a command cannot sensibly delete the binary it is running from — and it prints
12
+ the command that does.
13
+
14
+ laneyard uninstall --dry-run list what is there and stop
15
+
16
+ There is no npm lifecycle hook doing any of this on \`npm uninstall\`, on
17
+ purpose: a package manager must not delete someone's signing keys on its own,
18
+ and a lifecycle script cannot ask.
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
+ export async function readInventory(home) {
24
+ const empty = {
25
+ home,
26
+ exists: false,
27
+ config: null,
28
+ key: null,
29
+ db: null,
30
+ folders: [],
31
+ strangers: [],
32
+ bytes: 0,
33
+ };
34
+ const here = await stat(home).catch(() => null);
35
+ if (here === null || !here.isDirectory())
36
+ return empty;
37
+ const present = new Set((await readdir(home).catch(() => [])));
38
+ const configPath = join(home, "config.yml");
39
+ const configBytes = await sizeOf(configPath);
40
+ const config = configBytes === null
41
+ ? null
42
+ : { path: configPath, bytes: configBytes, projects: await projectsIn(configPath) };
43
+ const keyPath = join(home, "key");
44
+ const keyBytes = await sizeOf(keyPath);
45
+ const key = keyBytes === null ? null : { path: keyPath, bytes: keyBytes };
46
+ const dbPath = join(home, "laneyard.db");
47
+ const dbBytes = await sizeOf(dbPath);
48
+ const db = dbBytes === null
49
+ ? null
50
+ : {
51
+ path: dbPath,
52
+ // The write-ahead log is part of the database, not a stray file: a row
53
+ // written a second ago may live only there. Counted with it so the
54
+ // size on screen is the size on disk.
55
+ bytes: dbBytes + ((await sizeOf(`${dbPath}-wal`)) ?? 0) + ((await sizeOf(`${dbPath}-shm`)) ?? 0),
56
+ ...(await readVault(dbPath)),
57
+ };
58
+ const folders = [];
59
+ for (const name of OWN_FOLDERS) {
60
+ const path = join(home, name);
61
+ const tally = await tallyFolder(path);
62
+ if (tally !== null)
63
+ folders.push({ name, path, ...tally });
64
+ }
65
+ const strangers = [...present]
66
+ .filter((name) => !OWN_FILES.includes(name) && !OWN_FOLDERS.includes(name))
67
+ .sort();
68
+ return {
69
+ home,
70
+ exists: true,
71
+ config,
72
+ key,
73
+ db,
74
+ folders,
75
+ strangers,
76
+ bytes: (config?.bytes ?? 0) +
77
+ (key?.bytes ?? 0) +
78
+ (db?.bytes ?? 0) +
79
+ folders.reduce((sum, f) => sum + f.bytes, 0),
80
+ };
81
+ }
82
+ /**
83
+ * Counts what the vault holds, without writing a byte.
84
+ *
85
+ * Opened read-only rather than through `openDatabase`: that one applies the
86
+ * schema and switches on the write-ahead log, both of which are writes, and
87
+ * `--dry-run` promises not to make one.
88
+ *
89
+ * Read-only is not quite enough on its own. SQLite needs a `-shm` beside a WAL
90
+ * database, and it creates one to open it even for reading — so the two are
91
+ * noted before and removed again after if they were not there. They are scratch
92
+ * files with nothing of anyone's in them, and removing what we made is what
93
+ * lets the promise be a real one. A server that is running has them open
94
+ * already, so they exist beforehand and are never touched.
95
+ *
96
+ * The counts are read rather than skipped over: the number of stored
97
+ * credentials is exactly the wrong thing to guess at. When the database cannot
98
+ * be opened at all, that is reported instead, in its own words.
99
+ */
100
+ async function readVault(dbPath) {
101
+ const sidecars = [`${dbPath}-wal`, `${dbPath}-shm`];
102
+ const ours = await Promise.all(sidecars.map(async (path) => ((await stat(path).catch(() => null)) === null ? path : null)));
103
+ let db = null;
104
+ try {
105
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
106
+ const count = (sql) => db.prepare(sql).get().n;
107
+ return {
108
+ vault: {
109
+ // The empty slug is how a global row is stored — the same convention
110
+ // `SecretStore` and `CredentialStore` use, and the reason the two are
111
+ // counted apart: a global secret belongs to every project.
112
+ projectSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug != ''"),
113
+ globalSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug = ''"),
114
+ projectBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug != ''"),
115
+ globalBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug = ''"),
116
+ runs: count("SELECT COUNT(*) AS n FROM run"),
117
+ },
118
+ unreadable: null,
119
+ };
120
+ }
121
+ catch (cause) {
122
+ return { vault: null, unreadable: cause.message };
123
+ }
124
+ finally {
125
+ db?.close();
126
+ for (const path of ours) {
127
+ if (path !== null)
128
+ await rm(path, { force: true }).catch(() => undefined);
129
+ }
130
+ }
131
+ }
132
+ /** The slugs config.yml declares, or none if it cannot be read. */
133
+ async function projectsIn(path) {
134
+ try {
135
+ const doc = parse(await readFile(path, "utf8"));
136
+ return (doc?.projects ?? []).map((p) => p?.slug ?? "?").filter((s) => s !== "");
137
+ }
138
+ catch {
139
+ return [];
140
+ }
141
+ }
142
+ async function sizeOf(path) {
143
+ const info = await stat(path).catch(() => null);
144
+ return info === null || !info.isFile() ? null : info.size;
145
+ }
146
+ /**
147
+ * A folder's direct children, and the total size of everything under it.
148
+ *
149
+ * Only regular files count. A symlink is followed by nothing here — a clone is
150
+ * perfectly capable of holding a link to somewhere enormous that this command
151
+ * is not going to remove, and counting the target would report a size that is
152
+ * not the size of what goes.
153
+ */
154
+ async function tallyFolder(path) {
155
+ const info = await stat(path).catch(() => null);
156
+ if (info === null || !info.isDirectory())
157
+ return null;
158
+ const children = await readdir(path).catch(() => []);
159
+ return { entries: children.length, bytes: await sizeUnder(path) };
160
+ }
161
+ /**
162
+ * Every regular file under a folder, added up.
163
+ *
164
+ * `readdir` with file types answers from the directory entry itself, so a
165
+ * symlink is neither a file nor a directory here and is skipped — which is the
166
+ * behaviour wanted: `rm` will remove the link, not what it points at, and the
167
+ * size on screen has to be the size of what actually goes.
168
+ */
169
+ async function sizeUnder(path) {
170
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
171
+ let bytes = 0;
172
+ for (const entry of entries) {
173
+ if (entry.isDirectory())
174
+ bytes += await sizeUnder(join(path, entry.name));
175
+ else if (entry.isFile()) {
176
+ const info = await stat(join(path, entry.name)).catch(() => null);
177
+ bytes += info?.size ?? 0;
178
+ }
179
+ }
180
+ return bytes;
181
+ }
182
+ /** Sizes in the units a person reads, not in bytes. */
183
+ export function humanSize(bytes) {
184
+ if (bytes < 1024)
185
+ return `${bytes} B`;
186
+ const units = ["KB", "MB", "GB", "TB"];
187
+ let value = bytes / 1024;
188
+ let unit = 0;
189
+ while (value >= 1024 && unit < units.length - 1) {
190
+ value /= 1024;
191
+ unit += 1;
192
+ }
193
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
194
+ }
195
+ const plural = (n, noun) => `${n} ${n === 1 ? noun : `${noun}s`}`;
196
+ /** English, not a rule: "entry" does not take an `s`. */
197
+ const entries = (n) => `${n} ${n === 1 ? "entry" : "entries"}`;
198
+ /** The inventory, as it appears on screen. Shared by the dry run and the real one. */
199
+ export function renderInventory(inv) {
200
+ let out = heading("laneyard uninstall");
201
+ out += field("home", inv.home) + "\n";
202
+ out += field("total", humanSize(inv.bytes)) + "\n";
203
+ out += heading("configuration");
204
+ if (inv.config === null) {
205
+ out += field("config.yml", dim("not there")) + "\n";
206
+ }
207
+ else {
208
+ out += field("config.yml", `${inv.config.path} ${dim(humanSize(inv.config.bytes))}`) + "\n";
209
+ out +=
210
+ field("projects", inv.config.projects.length === 0 ? dim("none declared") : inv.config.projects.join(", ")) + "\n";
211
+ }
212
+ out += heading("the vault");
213
+ out += field("key", inv.key === null ? dim("not there") : inv.key.path) + "\n";
214
+ if (inv.db === null) {
215
+ out += field("database", dim("not there")) + "\n";
216
+ }
217
+ else {
218
+ out += field("database", `${inv.db.path} ${dim(humanSize(inv.db.bytes))}`) + "\n";
219
+ if (inv.db.vault === null) {
220
+ out +=
221
+ field("contents", dim("could not be read")) + "\n" +
222
+ dim(` ${inv.db.unreadable ?? "unknown reason"}\n`) +
223
+ dim(" Stop the server and run this again to see what is in it.\n");
224
+ }
225
+ else {
226
+ const v = inv.db.vault;
227
+ out += field("secrets", `${plural(v.projectSecrets, "project secret")}`) + "\n";
228
+ // Said on its own line and in words rather than folded into the number
229
+ // above: a global secret is read by every project on this machine, and
230
+ // "in scope" is the fact someone needs before they answer the question.
231
+ out +=
232
+ field("", v.globalSecrets === 0
233
+ ? dim("no global secret")
234
+ : `${plural(v.globalSecrets, "global secret")} — shared by every project, ${bold("removed too")}`) + "\n";
235
+ out += field("signing", `${plural(v.projectBlocks, "project signing block")}`) + "\n";
236
+ out +=
237
+ field("", v.globalBlocks === 0
238
+ ? dim("no global signing block")
239
+ : `${plural(v.globalBlocks, "global signing block")} — shared by every project, ${bold("removed too")}`) + "\n";
240
+ out += field("history", plural(v.runs, "run")) + "\n";
241
+ }
242
+ }
243
+ out += heading("on disk");
244
+ if (inv.folders.length === 0) {
245
+ out += dim(" nothing: no workspace was ever cloned and no run ever produced anything.\n");
246
+ }
247
+ for (const folder of inv.folders) {
248
+ out +=
249
+ field(folder.name, folder.entries === 0
250
+ ? dim("empty")
251
+ : `${entries(folder.entries)} ${dim(humanSize(folder.bytes))}`) + "\n";
252
+ out += dim(` ${folder.path}\n`);
253
+ }
254
+ if (inv.strangers.length > 0) {
255
+ out += heading("not Laneyard's");
256
+ out += dim(" These are in the folder and were not put there by Laneyard. They are left\n");
257
+ out += dim(" where they are, and the folder is left with them.\n\n");
258
+ for (const name of inv.strangers)
259
+ out += ` ${join(inv.home, name)}\n`;
260
+ }
261
+ return out;
262
+ }
263
+ /**
264
+ * The one paragraph this command exists to make sure someone reads.
265
+ *
266
+ * Everything else here is recoverable: a config.yml can be written again by
267
+ * `laneyard setup`, a workspace re-cloned, an artifact rebuilt. The key cannot.
268
+ * It is a random 32 bytes that exists in one place, and every stored value is
269
+ * ciphertext without it — so a backup of `laneyard.db` alone restores nothing.
270
+ */
271
+ export function renderIrreversible(inv) {
272
+ const v = inv.db?.vault;
273
+ const stored = v === undefined || v === null ? null : v.projectSecrets + v.globalSecrets + v.projectBlocks + v.globalBlocks;
274
+ return (heading("what cannot be undone") +
275
+ warn(`The vault key is the one thing here that has no other copy.\n`) +
276
+ dim(" Every secret and every signing block is encrypted under " + (inv.key?.path ?? join(inv.home, "key")) + ".\n") +
277
+ dim(" Once it is gone, laneyard.db is ciphertext nobody can read — restoring a backup\n") +
278
+ dim(" of the database alone will not bring anything back.\n") +
279
+ (stored === null || stored === 0
280
+ ? ""
281
+ : dim(` ${plural(stored, "stored value")} ${stored === 1 ? "goes" : "go"} with it.\n`)) +
282
+ "\n" +
283
+ dim(" The originals are yours and are untouched: the .p8 in your downloads, the\n") +
284
+ dim(" keystore in your safe, the passwords in your password manager. It is Laneyard's\n") +
285
+ dim(" copy that is unrecoverable — you will upload them again from wherever you keep\n") +
286
+ dim(" them. If you do not know where that is, stop here and go and find out.\n") +
287
+ "\n" +
288
+ dim(" Your repositories are untouched. Nothing outside " + inv.home + " is read or written.\n"));
289
+ }
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
+ /**
303
+ * Entry point for `laneyard uninstall`.
304
+ *
305
+ * Inventory, then the one irreversible thing, then a typed confirmation, then
306
+ * the removal. In that order and never any other: a question asked before the
307
+ * numbers are on screen is a question nobody can answer.
308
+ */
309
+ export async function runUninstallCommand(home, args, io) {
310
+ let dryRun = false;
311
+ for (const arg of args) {
312
+ if (arg === "--dry-run" || arg === "-n")
313
+ dryRun = true;
314
+ else {
315
+ // Deliberately no `--keep-runs`, no `--yes`, no `--force`. Every one of
316
+ // them is a way to run this without reading it, which is the only thing
317
+ // standing between someone and an unrecoverable signing key. Keeping the
318
+ // run history without its database is not a thing that exists anyway.
319
+ io.err(`Unknown option: ${arg}\n\n${UNINSTALL_USAGE}`);
320
+ return 1;
321
+ }
322
+ }
323
+ const inv = await readInventory(home);
324
+ if (!inv.exists) {
325
+ io.out(`${dim(`No data folder at ${home}. There is nothing for Laneyard to remove.`)}\n\n` +
326
+ removalHint());
327
+ return 0;
328
+ }
329
+ io.out(renderInventory(inv));
330
+ io.out(renderIrreversible(inv));
331
+ if (dryRun) {
332
+ io.out("\n" +
333
+ dim("Nothing was removed. Run `laneyard uninstall` without --dry-run to remove it.") +
334
+ "\n\n" +
335
+ removalHint());
336
+ return 0;
337
+ }
338
+ // Typed in full, not `y`. A `y/n` is answered by a reflex; this is the one
339
+ // command in the product that destroys credentials, and the path is what
340
+ // proves the person read which folder is about to go — it is not always
341
+ // ~/.laneyard, and $LANEYARD_HOME is exactly the case where a reflex is wrong.
342
+ io.out("\n" +
343
+ bold("Type the path of the folder to remove, exactly, to confirm:") + "\n" +
344
+ ` ${inv.home}\n` +
345
+ "\n> ");
346
+ const answer = await readLine(io.stdin);
347
+ if (answer !== inv.home) {
348
+ io.err("\n" +
349
+ bad(answer === ""
350
+ ? "Nothing was typed, so nothing was removed."
351
+ : "That is not the path, so nothing was removed.") +
352
+ "\n");
353
+ return 1;
354
+ }
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
+ }
363
+ // The folder itself only when there is nothing left in it. Anything Laneyard
364
+ // did not write is somebody's, and this command has no business deciding
365
+ // otherwise — `$LANEYARD_HOME` can point anywhere.
366
+ const left = (await readdir(home).catch(() => []));
367
+ if (left.length === 0)
368
+ await rm(home, { recursive: true, force: true }).catch(() => undefined);
369
+ io.out(heading("removed") +
370
+ ok(`${entries(removed.length)}, ${humanSize(inv.bytes)} freed.\n`) +
371
+ (left.length === 0
372
+ ? dim(` ${home} is gone.\n`)
373
+ : dim(` ${home} is kept: ${plural(left.length, "file")} in it ${left.length === 1 ? "is" : "are"} not Laneyard's.\n`)) +
374
+ "\n" +
375
+ removalHint());
376
+ return 0;
377
+ }
378
+ /**
379
+ * The package is still installed, and this cannot be the thing that removes it.
380
+ *
381
+ * A process cannot sensibly delete the binary it is running from, and a command
382
+ * that tried would leave someone with a half-removed install and no way to ask
383
+ * about it. So it says what to type instead — the whole command, so it can be
384
+ * copied rather than remembered.
385
+ */
386
+ function removalHint() {
387
+ return (dim("The npm package is still installed. Laneyard does not remove its own binary:\n") +
388
+ ` ${bold("npm uninstall -g laneyard")}\n` +
389
+ dim("(installed from source with `npm link`? `npm unlink -g laneyard`.)\n"));
390
+ }
@@ -46,6 +46,31 @@ export class CredentialStore {
46
46
  list(projectSlug) {
47
47
  return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
48
48
  }
49
+ /**
50
+ * The blocks stored under this slug, and no global one.
51
+ *
52
+ * Same distinction as `SecretStore.listOwn`, and it matters more here: a
53
+ * global keystore shadowed by nothing is shared by every project on the
54
+ * machine, and counting it as one project's would offer to delete the one
55
+ * credential every other project signs with.
56
+ */
57
+ listOwn(projectSlug) {
58
+ if (projectSlug === GLOBAL)
59
+ return [];
60
+ const rows = this.db
61
+ .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
62
+ .all(projectSlug);
63
+ return rows.map((row) => {
64
+ const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
65
+ return summary;
66
+ });
67
+ }
68
+ /** Removes every block stored under this slug, and returns how many. */
69
+ removeAllOwn(projectSlug) {
70
+ if (projectSlug === GLOBAL)
71
+ return 0;
72
+ return this.db.prepare("DELETE FROM credential WHERE project_slug = ?").run(projectSlug).changes;
73
+ }
49
74
  listGlobal() {
50
75
  const rows = this.db
51
76
  .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
@@ -62,6 +62,31 @@ export class SecretStore {
62
62
  WHERE project_slug = ? AND key = ?`)
63
63
  .run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
64
64
  }
65
+ /**
66
+ * The rows stored under this slug, and no global one.
67
+ *
68
+ * `list` answers "what does this project see", which is the right question
69
+ * everywhere else and the wrong one when a project is going away: a global
70
+ * secret three other projects also read is not this one's to count, and
71
+ * certainly not its to remove.
72
+ *
73
+ * The empty slug is the global scope's own key, so it is refused here rather
74
+ * than quietly returning every global row as if one project owned them.
75
+ */
76
+ listOwn(projectSlug) {
77
+ if (projectSlug === GLOBAL)
78
+ return [];
79
+ const rows = this.db
80
+ .prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
81
+ .all(projectSlug);
82
+ return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "project" }));
83
+ }
84
+ /** Removes every row stored under this slug, and returns how many. */
85
+ removeAllOwn(projectSlug) {
86
+ if (projectSlug === GLOBAL)
87
+ return 0;
88
+ return this.db.prepare("DELETE FROM secret WHERE project_slug = ?").run(projectSlug).changes;
89
+ }
65
90
  list(projectSlug) {
66
91
  return this.applicable(projectSlug).map((row) => ({
67
92
  key: row.key,
package/dist/src/main.js CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { PromptAborted } from "./cli/prompt.js";
9
9
  import { runSetupCommand } from "./cli/setup.js";
10
10
  import { runSecretCommand } from "./cli/secret.js";
11
+ import { runUninstallCommand } from "./cli/uninstall.js";
11
12
  import { runUserCommand } from "./cli/user.js";
12
13
  import { ConfigStore } from "./config/store.js";
13
14
  import { CacheStore } from "./db/cache.js";
@@ -20,7 +21,7 @@ import { Vault } from "./secrets/vault.js";
20
21
  import { makeInvoke } from "./sidecar/bridge.js";
21
22
  import { LaneReader } from "./sidecar/lanes.js";
22
23
  import { UsesReader } from "./sidecar/uses.js";
23
- export const version = "0.3.0";
24
+ export const version = "0.4.1";
24
25
  /** Assembles the server from a data folder. */
25
26
  export async function createServerFromConfig(root) {
26
27
  const config = new ConfigStore(join(root, "config.yml"));
@@ -111,6 +112,8 @@ const USAGE = `laneyard — a self-hosted web UI for fastlane
111
112
  store a secret, its value read from standard input
112
113
  laneyard user add NAME [--role admin|builder]
113
114
  create an account, its password read from standard input
115
+ laneyard uninstall remove Laneyard's data folder, after showing what is in it
116
+ --dry-run shows the inventory and stops
114
117
  laneyard start the server
115
118
  laneyard --version print the version
116
119
 
@@ -186,6 +189,15 @@ if (invokedDirectly()) {
186
189
  err: (text) => process.stderr.write(text),
187
190
  }));
188
191
  }
192
+ // No `mkdir` here, unlike the commands above: this one asks what is there
193
+ // and would look ridiculous creating the folder it is about to report on.
194
+ if (command === "uninstall") {
195
+ process.exit(await runUninstallCommand(homeDir(), rest, {
196
+ stdin: process.stdin,
197
+ out: (text) => process.stdout.write(text),
198
+ err: (text) => process.stderr.write(text),
199
+ }));
200
+ }
189
201
  // 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
190
202
  // sentence rather than "Unknown command".
191
203
  if (command === "add") {
@@ -33,6 +33,37 @@ export class Vault {
33
33
  listGlobal() {
34
34
  return this.store.listGlobal();
35
35
  }
36
+ /**
37
+ * What the vault holds under one project's own name — secrets and signing
38
+ * blocks — and nothing global.
39
+ *
40
+ * Its own method rather than a filter over `list`, because the question is a
41
+ * different one: `list` is "what would a run of this project see", and this is
42
+ * "what would be left behind if the project went away". A global secret is in
43
+ * the first answer and must never be in the second.
44
+ */
45
+ ownedBy(projectSlug) {
46
+ return {
47
+ secrets: this.store.listOwn(projectSlug),
48
+ credentials: this.credentials.listOwn(projectSlug),
49
+ };
50
+ }
51
+ /**
52
+ * Forgets everything stored under one project's name.
53
+ *
54
+ * Never called by removing a project: that route leaves the vault alone on
55
+ * purpose and says what stayed. This is the separate, deliberate act someone
56
+ * takes afterwards, once they have read what it is they are about to lose —
57
+ * a credential is the one thing here that cannot be looked at before it goes.
58
+ *
59
+ * Scoped by slug, so a global secret or a global signing block survives it.
60
+ */
61
+ forget(projectSlug) {
62
+ return {
63
+ secrets: this.store.removeAllOwn(projectSlug),
64
+ credentials: this.credentials.removeAllOwn(projectSlug),
65
+ };
66
+ }
36
67
  /**
37
68
  * Removes this project's secret values from a piece of text, in one shot.
38
69
  *