laneyard 0.4.0 → 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");
@@ -0,0 +1,368 @@
1
+ import Database from "better-sqlite3";
2
+ import { readdir, readFile, rm, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { parse } from "yaml";
5
+ import { OWN_FILES, OWN_FOLDERS, readLine, removePaths } from "./home.js";
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
+ export async function readInventory(home) {
21
+ const empty = {
22
+ home,
23
+ exists: false,
24
+ config: null,
25
+ key: null,
26
+ db: null,
27
+ folders: [],
28
+ strangers: [],
29
+ bytes: 0,
30
+ };
31
+ const here = await stat(home).catch(() => null);
32
+ if (here === null || !here.isDirectory())
33
+ return empty;
34
+ const present = new Set((await readdir(home).catch(() => [])));
35
+ const configPath = join(home, "config.yml");
36
+ const configBytes = await sizeOf(configPath);
37
+ const config = configBytes === null
38
+ ? null
39
+ : { path: configPath, bytes: configBytes, projects: await projectsIn(configPath) };
40
+ const keyPath = join(home, "key");
41
+ const keyBytes = await sizeOf(keyPath);
42
+ const key = keyBytes === null ? null : { path: keyPath, bytes: keyBytes };
43
+ const dbPath = join(home, "laneyard.db");
44
+ const dbBytes = await sizeOf(dbPath);
45
+ const db = dbBytes === null
46
+ ? null
47
+ : {
48
+ path: dbPath,
49
+ // The write-ahead log is part of the database, not a stray file: a row
50
+ // written a second ago may live only there. Counted with it so the
51
+ // size on screen is the size on disk.
52
+ bytes: dbBytes + ((await sizeOf(`${dbPath}-wal`)) ?? 0) + ((await sizeOf(`${dbPath}-shm`)) ?? 0),
53
+ ...(await readVault(dbPath)),
54
+ };
55
+ const folders = [];
56
+ for (const name of OWN_FOLDERS) {
57
+ const path = join(home, name);
58
+ const tally = await tallyFolder(path);
59
+ if (tally !== null)
60
+ folders.push({ name, path, ...tally });
61
+ }
62
+ const strangers = [...present]
63
+ .filter((name) => !OWN_FILES.includes(name) && !OWN_FOLDERS.includes(name))
64
+ .sort();
65
+ return {
66
+ home,
67
+ exists: true,
68
+ config,
69
+ key,
70
+ db,
71
+ folders,
72
+ strangers,
73
+ bytes: (config?.bytes ?? 0) +
74
+ (key?.bytes ?? 0) +
75
+ (db?.bytes ?? 0) +
76
+ folders.reduce((sum, f) => sum + f.bytes, 0),
77
+ };
78
+ }
79
+ /**
80
+ * Counts what the vault holds, without writing a byte.
81
+ *
82
+ * Opened read-only rather than through `openDatabase`: that one applies the
83
+ * schema and switches on the write-ahead log, both of which are writes, and
84
+ * `--dry-run` promises not to make one.
85
+ *
86
+ * Read-only is not quite enough on its own. SQLite needs a `-shm` beside a WAL
87
+ * database, and it creates one to open it even for reading — so the two are
88
+ * noted before and removed again after if they were not there. They are scratch
89
+ * files with nothing of anyone's in them, and removing what we made is what
90
+ * lets the promise be a real one. A server that is running has them open
91
+ * already, so they exist beforehand and are never touched.
92
+ *
93
+ * The counts are read rather than skipped over: the number of stored
94
+ * credentials is exactly the wrong thing to guess at. When the database cannot
95
+ * be opened at all, that is reported instead, in its own words.
96
+ */
97
+ async function readVault(dbPath) {
98
+ const sidecars = [`${dbPath}-wal`, `${dbPath}-shm`];
99
+ const ours = await Promise.all(sidecars.map(async (path) => ((await stat(path).catch(() => null)) === null ? path : null)));
100
+ let db = null;
101
+ try {
102
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
103
+ const count = (sql) => db.prepare(sql).get().n;
104
+ return {
105
+ vault: {
106
+ // The empty slug is how a global row is stored — the same convention
107
+ // `SecretStore` and `CredentialStore` use, and the reason the two are
108
+ // counted apart: a global secret belongs to every project.
109
+ projectSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug != ''"),
110
+ globalSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug = ''"),
111
+ projectBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug != ''"),
112
+ globalBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug = ''"),
113
+ runs: count("SELECT COUNT(*) AS n FROM run"),
114
+ },
115
+ unreadable: null,
116
+ };
117
+ }
118
+ catch (cause) {
119
+ return { vault: null, unreadable: cause.message };
120
+ }
121
+ finally {
122
+ db?.close();
123
+ for (const path of ours) {
124
+ if (path !== null)
125
+ await rm(path, { force: true }).catch(() => undefined);
126
+ }
127
+ }
128
+ }
129
+ /** The slugs config.yml declares, or none if it cannot be read. */
130
+ async function projectsIn(path) {
131
+ try {
132
+ const doc = parse(await readFile(path, "utf8"));
133
+ return (doc?.projects ?? []).map((p) => p?.slug ?? "?").filter((s) => s !== "");
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ }
139
+ async function sizeOf(path) {
140
+ const info = await stat(path).catch(() => null);
141
+ return info === null || !info.isFile() ? null : info.size;
142
+ }
143
+ /**
144
+ * A folder's direct children, and the total size of everything under it.
145
+ *
146
+ * Only regular files count. A symlink is followed by nothing here — a clone is
147
+ * perfectly capable of holding a link to somewhere enormous that this command
148
+ * is not going to remove, and counting the target would report a size that is
149
+ * not the size of what goes.
150
+ */
151
+ async function tallyFolder(path) {
152
+ const info = await stat(path).catch(() => null);
153
+ if (info === null || !info.isDirectory())
154
+ return null;
155
+ const children = await readdir(path).catch(() => []);
156
+ return { entries: children.length, bytes: await sizeUnder(path) };
157
+ }
158
+ /**
159
+ * Every regular file under a folder, added up.
160
+ *
161
+ * `readdir` with file types answers from the directory entry itself, so a
162
+ * symlink is neither a file nor a directory here and is skipped — which is the
163
+ * behaviour wanted: `rm` will remove the link, not what it points at, and the
164
+ * size on screen has to be the size of what actually goes.
165
+ */
166
+ async function sizeUnder(path) {
167
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
168
+ let bytes = 0;
169
+ for (const entry of entries) {
170
+ if (entry.isDirectory())
171
+ bytes += await sizeUnder(join(path, entry.name));
172
+ else if (entry.isFile()) {
173
+ const info = await stat(join(path, entry.name)).catch(() => null);
174
+ bytes += info?.size ?? 0;
175
+ }
176
+ }
177
+ return bytes;
178
+ }
179
+ /** Sizes in the units a person reads, not in bytes. */
180
+ export function humanSize(bytes) {
181
+ if (bytes < 1024)
182
+ return `${bytes} B`;
183
+ const units = ["KB", "MB", "GB", "TB"];
184
+ let value = bytes / 1024;
185
+ let unit = 0;
186
+ while (value >= 1024 && unit < units.length - 1) {
187
+ value /= 1024;
188
+ unit += 1;
189
+ }
190
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
191
+ }
192
+ const plural = (n, noun) => `${n} ${n === 1 ? noun : `${noun}s`}`;
193
+ /** English, not a rule: "entry" does not take an `s`. */
194
+ const entries = (n) => `${n} ${n === 1 ? "entry" : "entries"}`;
195
+ /** The inventory, as it appears on screen. Shared by the dry run and the real one. */
196
+ export function renderInventory(inv) {
197
+ let out = heading("laneyard uninstall");
198
+ out += field("home", inv.home) + "\n";
199
+ out += field("total", humanSize(inv.bytes)) + "\n";
200
+ out += heading("configuration");
201
+ if (inv.config === null) {
202
+ out += field("config.yml", dim("not there")) + "\n";
203
+ }
204
+ else {
205
+ out += field("config.yml", `${inv.config.path} ${dim(humanSize(inv.config.bytes))}`) + "\n";
206
+ out +=
207
+ field("projects", inv.config.projects.length === 0 ? dim("none declared") : inv.config.projects.join(", ")) + "\n";
208
+ }
209
+ out += heading("the vault");
210
+ out += field("key", inv.key === null ? dim("not there") : inv.key.path) + "\n";
211
+ if (inv.db === null) {
212
+ out += field("database", dim("not there")) + "\n";
213
+ }
214
+ else {
215
+ out += field("database", `${inv.db.path} ${dim(humanSize(inv.db.bytes))}`) + "\n";
216
+ if (inv.db.vault === null) {
217
+ out +=
218
+ field("contents", dim("could not be read")) + "\n" +
219
+ dim(` ${inv.db.unreadable ?? "unknown reason"}\n`) +
220
+ dim(" Stop the server and run this again to see what is in it.\n");
221
+ }
222
+ else {
223
+ const v = inv.db.vault;
224
+ out += field("secrets", `${plural(v.projectSecrets, "project secret")}`) + "\n";
225
+ // Said on its own line and in words rather than folded into the number
226
+ // above: a global secret is read by every project on this machine, and
227
+ // "in scope" is the fact someone needs before they answer the question.
228
+ out +=
229
+ field("", v.globalSecrets === 0
230
+ ? dim("no global secret")
231
+ : `${plural(v.globalSecrets, "global secret")} — shared by every project, ${bold("removed too")}`) + "\n";
232
+ out += field("signing", `${plural(v.projectBlocks, "project signing block")}`) + "\n";
233
+ out +=
234
+ field("", v.globalBlocks === 0
235
+ ? dim("no global signing block")
236
+ : `${plural(v.globalBlocks, "global signing block")} — shared by every project, ${bold("removed too")}`) + "\n";
237
+ out += field("history", plural(v.runs, "run")) + "\n";
238
+ }
239
+ }
240
+ out += heading("on disk");
241
+ if (inv.folders.length === 0) {
242
+ out += dim(" nothing: no workspace was ever cloned and no run ever produced anything.\n");
243
+ }
244
+ for (const folder of inv.folders) {
245
+ out +=
246
+ field(folder.name, folder.entries === 0
247
+ ? dim("empty")
248
+ : `${entries(folder.entries)} ${dim(humanSize(folder.bytes))}`) + "\n";
249
+ out += dim(` ${folder.path}\n`);
250
+ }
251
+ if (inv.strangers.length > 0) {
252
+ out += heading("not Laneyard's");
253
+ out += dim(" These are in the folder and were not put there by Laneyard. They are left\n");
254
+ out += dim(" where they are, and the folder is left with them.\n\n");
255
+ for (const name of inv.strangers)
256
+ out += ` ${join(inv.home, name)}\n`;
257
+ }
258
+ return out;
259
+ }
260
+ /**
261
+ * The one paragraph this command exists to make sure someone reads.
262
+ *
263
+ * Everything else here is recoverable: a config.yml can be written again by
264
+ * `laneyard setup`, a workspace re-cloned, an artifact rebuilt. The key cannot.
265
+ * It is a random 32 bytes that exists in one place, and every stored value is
266
+ * ciphertext without it — so a backup of `laneyard.db` alone restores nothing.
267
+ */
268
+ export function renderIrreversible(inv) {
269
+ const v = inv.db?.vault;
270
+ const stored = v === undefined || v === null ? null : v.projectSecrets + v.globalSecrets + v.projectBlocks + v.globalBlocks;
271
+ return (heading("what cannot be undone") +
272
+ warn(`The vault key is the one thing here that has no other copy.\n`) +
273
+ dim(" Every secret and every signing block is encrypted under " + (inv.key?.path ?? join(inv.home, "key")) + ".\n") +
274
+ dim(" Once it is gone, laneyard.db is ciphertext nobody can read — restoring a backup\n") +
275
+ dim(" of the database alone will not bring anything back.\n") +
276
+ (stored === null || stored === 0
277
+ ? ""
278
+ : dim(` ${plural(stored, "stored value")} ${stored === 1 ? "goes" : "go"} with it.\n`)) +
279
+ "\n" +
280
+ dim(" The originals are yours and are untouched: the .p8 in your downloads, the\n") +
281
+ dim(" keystore in your safe, the passwords in your password manager. It is Laneyard's\n") +
282
+ dim(" copy that is unrecoverable — you will upload them again from wherever you keep\n") +
283
+ dim(" them. If you do not know where that is, stop here and go and find out.\n") +
284
+ "\n" +
285
+ dim(" Your repositories are untouched. Nothing outside " + inv.home + " is read or written.\n"));
286
+ }
287
+ /**
288
+ * Entry point for `laneyard uninstall`.
289
+ *
290
+ * Inventory, then the one irreversible thing, then a typed confirmation, then
291
+ * the removal. In that order and never any other: a question asked before the
292
+ * numbers are on screen is a question nobody can answer.
293
+ */
294
+ export async function runUninstallCommand(home, args, io) {
295
+ let dryRun = false;
296
+ for (const arg of args) {
297
+ if (arg === "--dry-run" || arg === "-n")
298
+ dryRun = true;
299
+ else {
300
+ // Deliberately no `--keep-runs`, no `--yes`, no `--force`. Every one of
301
+ // them is a way to run this without reading it, which is the only thing
302
+ // standing between someone and an unrecoverable signing key. Keeping the
303
+ // run history without its database is not a thing that exists anyway.
304
+ io.err(`Unknown option: ${arg}\n\n${UNINSTALL_USAGE}`);
305
+ return 1;
306
+ }
307
+ }
308
+ const inv = await readInventory(home);
309
+ if (!inv.exists) {
310
+ io.out(`${dim(`No data folder at ${home}. There is nothing for Laneyard to remove.`)}\n\n` +
311
+ removalHint());
312
+ return 0;
313
+ }
314
+ io.out(renderInventory(inv));
315
+ io.out(renderIrreversible(inv));
316
+ if (dryRun) {
317
+ io.out("\n" +
318
+ dim("Nothing was removed. Run `laneyard uninstall` without --dry-run to remove it.") +
319
+ "\n\n" +
320
+ removalHint());
321
+ return 0;
322
+ }
323
+ // Typed in full, not `y`. A `y/n` is answered by a reflex; this is the one
324
+ // command in the product that destroys credentials, and the path is what
325
+ // proves the person read which folder is about to go — it is not always
326
+ // ~/.laneyard, and $LANEYARD_HOME is exactly the case where a reflex is wrong.
327
+ io.out("\n" +
328
+ bold("Type the path of the folder to remove, exactly, to confirm:") + "\n" +
329
+ ` ${inv.home}\n` +
330
+ "\n> ");
331
+ const answer = await readLine(io.stdin);
332
+ if (answer !== inv.home) {
333
+ io.err("\n" +
334
+ bad(answer === ""
335
+ ? "Nothing was typed, so nothing was removed."
336
+ : "That is not the path, so nothing was removed.") +
337
+ "\n");
338
+ return 1;
339
+ }
340
+ const removed = await removePaths(home, [...OWN_FILES, ...OWN_FOLDERS]);
341
+ // The folder itself only when there is nothing left in it. Anything Laneyard
342
+ // did not write is somebody's, and this command has no business deciding
343
+ // otherwise — `$LANEYARD_HOME` can point anywhere.
344
+ const left = (await readdir(home).catch(() => []));
345
+ if (left.length === 0)
346
+ await rm(home, { recursive: true, force: true }).catch(() => undefined);
347
+ io.out(heading("removed") +
348
+ ok(`${entries(removed.length)}, ${humanSize(inv.bytes)} freed.\n`) +
349
+ (left.length === 0
350
+ ? dim(` ${home} is gone.\n`)
351
+ : dim(` ${home} is kept: ${plural(left.length, "file")} in it ${left.length === 1 ? "is" : "are"} not Laneyard's.\n`)) +
352
+ "\n" +
353
+ removalHint());
354
+ return 0;
355
+ }
356
+ /**
357
+ * The package is still installed, and this cannot be the thing that removes it.
358
+ *
359
+ * A process cannot sensibly delete the binary it is running from, and a command
360
+ * that tried would leave someone with a half-removed install and no way to ask
361
+ * about it. So it says what to type instead — the whole command, so it can be
362
+ * copied rather than remembered.
363
+ */
364
+ function removalHint() {
365
+ return (dim("The npm package is still installed. Laneyard does not remove its own binary:\n") +
366
+ ` ${bold("npm uninstall -g laneyard")}\n` +
367
+ dim("(installed from source with `npm link`? `npm unlink -g laneyard`.)\n"));
368
+ }