laneyard 0.3.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.
Files changed (46) hide show
  1. package/README.md +227 -20
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/cli/uninstall.js +390 -0
  8. package/dist/src/credentials/kinds.js +120 -0
  9. package/dist/src/db/credentials.js +100 -0
  10. package/dist/src/db/schema.sql +39 -0
  11. package/dist/src/db/secrets.js +53 -0
  12. package/dist/src/db/sessions.js +61 -0
  13. package/dist/src/git/workspace.js +32 -6
  14. package/dist/src/heuristics/android-root.js +49 -0
  15. package/dist/src/heuristics/android-signing.js +98 -0
  16. package/dist/src/heuristics/appfile.js +60 -0
  17. package/dist/src/heuristics/blocking-actions.js +2 -0
  18. package/dist/src/heuristics/env-example.js +36 -0
  19. package/dist/src/heuristics/platforms.js +69 -10
  20. package/dist/src/heuristics/readiness.js +554 -25
  21. package/dist/src/main.js +23 -2
  22. package/dist/src/runner/gradle-properties.js +187 -0
  23. package/dist/src/runner/materialise.js +92 -0
  24. package/dist/src/runner/orchestrate.js +91 -2
  25. package/dist/src/secrets/vault.js +137 -5
  26. package/dist/src/server/app.js +30 -3
  27. package/dist/src/server/auth.js +50 -10
  28. package/dist/src/server/permissions.js +2 -0
  29. package/dist/src/server/required-secrets.js +30 -0
  30. package/dist/src/server/routes/account.js +57 -0
  31. package/dist/src/server/routes/credentials.js +108 -0
  32. package/dist/src/server/routes/projects.js +60 -2
  33. package/dist/src/server/routes/readiness.js +162 -6
  34. package/dist/src/server/routes/secrets.js +101 -0
  35. package/dist/src/sidecar/bridge.js +21 -1
  36. package/dist/src/sidecar/fastlane-dir.js +23 -0
  37. package/dist/src/sidecar/lanes.js +6 -0
  38. package/dist/src/sidecar/uses.js +21 -5
  39. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-9nLYwtg7.js} +1 -1
  40. package/dist/web/assets/index-DsjxZtsO.css +1 -0
  41. package/dist/web/assets/index-pzPa9EZr.js +62 -0
  42. package/dist/web/index.html +2 -2
  43. package/package.json +1 -1
  44. package/ruby/introspect.rb +122 -9
  45. package/dist/web/assets/index-De6sxx6G.css +0 -1
  46. package/dist/web/assets/index-TWRQ1cJg.js +0 -62
@@ -1,17 +1,29 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { join } from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { readFile } from "node:fs/promises";
5
+ import { applyImport, envFilesIn, parseEnvFile, planImport } from "./secret-import.js";
2
6
  import { ConfigStore } from "../config/store.js";
3
7
  import { openDatabase } from "../db/open.js";
8
+ import { CredentialStore } from "../db/credentials.js";
4
9
  import { SecretStore } from "../db/secrets.js";
5
10
  import { MIN_LENGTH as MIN_REDACTABLE } from "../logs/redact.js";
6
11
  import { Vault } from "../secrets/vault.js";
7
12
  /** Same rule as the API: what cannot become an environment variable is refused here too. */
8
13
  const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
14
  export const SECRET_USAGE = `laneyard secret set <NAME> [--project <slug>] [--no-mask]
15
+ laneyard secret import --project <slug> [--yes]
10
16
 
11
17
  The value is read from standard input, never from an argument:
12
18
 
13
19
  laneyard secret set MATCH_PASSWORD --project app
14
20
  echo "$TOKEN" | laneyard secret set GITHUB_TOKEN
21
+
22
+ \`import\` reads this project's fastlane/.env and stores what it finds. A
23
+ variable naming a service account JSON has the *file* stored, under the name
24
+ fastlane looks for — a path does not travel to a build machine. A variable
25
+ naming a .p8 is left alone and reported instead: that credential belongs in a
26
+ signing block from the secrets tab, not in a loose variable.
15
27
  `;
16
28
  /**
17
29
  * Reads standard input whole.
@@ -69,6 +81,9 @@ function parse(args) {
69
81
  */
70
82
  export async function runSecretCommand(home, args, io) {
71
83
  const [subcommand, ...rest] = args;
84
+ if (subcommand === "import") {
85
+ return runSecretImport(home, rest, io);
86
+ }
72
87
  if (subcommand !== "set") {
73
88
  io.err(subcommand === undefined ? `${SECRET_USAGE}` : `Unknown subcommand: ${subcommand}\n\n${SECRET_USAGE}`);
74
89
  return 1;
@@ -121,7 +136,7 @@ export async function runSecretCommand(home, args, io) {
121
136
  }
122
137
  const db = openDatabase(join(home, "laneyard.db"));
123
138
  try {
124
- const vault = await Vault.open(home, new SecretStore(db));
139
+ const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
125
140
  await vault.set(parsed.project, parsed.key, value, parsed.masked);
126
141
  }
127
142
  finally {
@@ -131,3 +146,149 @@ export async function runSecretCommand(home, args, io) {
131
146
  ` ${parsed.masked ? "kept out of the logs" : "shown in the logs"}\n`);
132
147
  return 0;
133
148
  }
149
+ /**
150
+ * `laneyard secret import` — the existing `.env` into the vault.
151
+ *
152
+ * Shows what it would do before doing it, in names only. An import that reads
153
+ * eight files and writes eight secrets before saying a word is one nobody can
154
+ * check, and this is the one command that handles every credential a project
155
+ * has at once.
156
+ */
157
+ async function runSecretImport(home, args, io) {
158
+ let project = null;
159
+ let yes = false;
160
+ for (let i = 0; i < args.length; i += 1) {
161
+ const arg = args[i];
162
+ if (arg === "--project" || arg === "-p") {
163
+ const next = args[i + 1];
164
+ if (next === undefined || next.startsWith("-")) {
165
+ io.err("--project needs a slug.\n");
166
+ return 1;
167
+ }
168
+ project = next;
169
+ i += 1;
170
+ }
171
+ else if (arg === "--yes" || arg === "-y") {
172
+ yes = true;
173
+ }
174
+ else {
175
+ io.err(`Unknown option: ${arg}\n\n${SECRET_USAGE}`);
176
+ return 1;
177
+ }
178
+ }
179
+ if (project === null) {
180
+ io.err("Which project? Give it with --project <slug>.\n");
181
+ return 1;
182
+ }
183
+ const config = new ConfigStore(join(home, "config.yml"));
184
+ const loaded = await config.load();
185
+ if (!loaded.ok) {
186
+ io.err(`Unreadable configuration in ${join(home, "config.yml")}: ${loaded.error}\n`);
187
+ return 1;
188
+ }
189
+ const entry = config.project(project);
190
+ if (!entry) {
191
+ const known = config.projects().map((p) => p.slug);
192
+ io.err(`Unknown project: "${project}".` +
193
+ (known.length > 0 ? ` Known projects: ${known.join(", ")}.` : " No project is declared yet.") +
194
+ "\n");
195
+ return 1;
196
+ }
197
+ // Read from the working copy this command was run in — the only place a
198
+ // gitignored `.env` exists; the server's clone never has one.
199
+ //
200
+ // Resolved against the repository root, not the current directory:
201
+ // `fastlane_dir` is recorded relative to the root, so running this from
202
+ // `app/` would otherwise look for `app/app/fastlane/.env`. The same
203
+ // confusion, one command along, as the one that produced an ENOENT in the
204
+ // lane list.
205
+ const fastlaneDir = entry.fastlane_dir ?? "fastlane";
206
+ const root = await repositoryRoot(process.cwd());
207
+ const env = new Map();
208
+ const readFrom = [];
209
+ for (const file of envFilesIn(fastlaneDir)) {
210
+ const text = await readFile(join(root, file), "utf8").catch(() => null);
211
+ if (text === null)
212
+ continue;
213
+ readFrom.push(file);
214
+ for (const [k, v] of parseEnvFile(text))
215
+ env.set(k, v);
216
+ }
217
+ if (readFrom.length === 0) {
218
+ io.err(`No ${fastlaneDir}/.env under ${root}. Run this from the working copy that has one — ` +
219
+ "it is the file that never reaches a clone, which is the whole reason for this command.\n");
220
+ return 1;
221
+ }
222
+ const db = openDatabase(join(home, "laneyard.db"));
223
+ try {
224
+ const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
225
+ // Paths inside a `.env` are written relative to where fastlane runs, which
226
+ // is the fastlane directory's parent — the app, not the repository root.
227
+ const plan = await planImport(env, join(root, fastlaneDir, ".."), vault.list(project).map((s) => s.key));
228
+ if (plan.planned.length === 0) {
229
+ io.out(`Nothing to import from ${readFrom.join(" and ")}.\n`);
230
+ return 0;
231
+ }
232
+ io.out(`\nFrom ${readFrom.join(" and ")}, into "${project}":\n\n`);
233
+ for (const item of plan.planned) {
234
+ if (item.kind === "unresolved-path") {
235
+ io.out(` ✗ ${item.key} — names ${item.path}, which is not there. Skipped.\n`);
236
+ }
237
+ else if (item.kind === "suggest-block") {
238
+ io.out(` ▸ ${item.key} names ${item.path} — no action in fastlane reads that variable. ` +
239
+ "Upload the `.p8` as an App Store Connect key block from the secrets tab instead, with " +
240
+ "its key id and issuer id. Not imported here.\n");
241
+ }
242
+ else if (item.kind === "file-contents") {
243
+ io.out(` ● ${item.key}${item.from ? ` (the contents of ${item.from})` : ""}\n`);
244
+ }
245
+ else {
246
+ io.out(` ● ${item.key}\n`);
247
+ }
248
+ }
249
+ if (plan.replacing.length > 0) {
250
+ io.out(`\n ${plan.replacing.join(", ")} already in the vault — they will be replaced.\n`);
251
+ }
252
+ const skipped = plan.planned.filter((p) => p.kind === "unresolved-path");
253
+ if (skipped.length > 0) {
254
+ io.out("\n A skipped file is the credential this project most needs. Check the path.\n");
255
+ }
256
+ if (!yes) {
257
+ io.out("\nNothing is written yet. Run it again with --yes to store these.\n");
258
+ return 0;
259
+ }
260
+ const stored = await applyImport(vault, project, plan);
261
+ io.out(`\n✓ Stored ${stored} ${stored === 1 ? "secret" : "secrets"}, all kept out of the logs.\n`);
262
+ // Said here because it is easy to assume an import leaves lanes with
263
+ // homework. It does not: `key_filepath:` and `json_key:` are the supported
264
+ // forms, not a stopgap. A signing credential block writes its file to disk
265
+ // for the length of a run, so a lane that names a path keeps reading a
266
+ // real one, unedited.
267
+ io.out("\nNothing in your lanes needs to change. `key_filepath:` and `json_key:` keep working as " +
268
+ "written. From here, a `.p8` or a service account JSON belongs in a signing credential block " +
269
+ "from the secrets tab, not as a loose variable in this file.\n");
270
+ return 0;
271
+ }
272
+ finally {
273
+ db.close();
274
+ }
275
+ }
276
+ /**
277
+ * The repository root, or the directory given if this is not a repository.
278
+ *
279
+ * `fastlane_dir` is recorded relative to the root — that is what the clone is
280
+ * measured from — so anything resolving it has to agree about where the root is,
281
+ * whichever sub-directory the command happens to be run from.
282
+ */
283
+ async function repositoryRoot(from) {
284
+ try {
285
+ const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], {
286
+ cwd: from,
287
+ timeout: 5_000,
288
+ });
289
+ return stdout.trim() || from;
290
+ }
291
+ catch {
292
+ return from;
293
+ }
294
+ }
@@ -33,13 +33,25 @@ export async function addProjectToConfig(path, entry) {
33
33
  const seq = projects instanceof YAMLSeq ? projects : new YAMLSeq();
34
34
  if (!(projects instanceof YAMLSeq))
35
35
  doc.setIn(["projects"], seq);
36
- for (const item of seq.items) {
37
- const slug = item.get?.("slug");
38
- if (slug === entry.slug) {
39
- throw new Error(`A project already uses the slug "${entry.slug}" in ${path}`);
40
- }
36
+ // An entry of the same name is updated, not refused.
37
+ //
38
+ // Setup prints "Continuing replaces its entry" before asking anything, and
39
+ // then this threw so the one way to correct a stale entry, running setup
40
+ // again, was the one thing that could not be done. A project written by an
41
+ // older version and missing a field it now needs was unfixable except by hand.
42
+ //
43
+ // Field by field rather than wholesale, though the warning says "replaces":
44
+ // an entry may carry things setup knows nothing about — a `git_auth` pointing
45
+ // at an SSH key, a raised `timeout_minutes` — and losing those silently, on a
46
+ // command someone ran to fix something else, would be its own bug.
47
+ const existing = seq.items.find((item) => item.get?.("slug") === entry.slug);
48
+ if (existing?.set) {
49
+ for (const [key, value] of Object.entries(entry))
50
+ existing.set(key, value);
51
+ }
52
+ else {
53
+ seq.add(doc.createNode(entry));
41
54
  }
42
- seq.add(doc.createNode(entry));
43
55
  await writeFile(path, serialize(doc), "utf8");
44
56
  }
45
57
  /**
@@ -105,7 +117,8 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
105
117
  if (existing) {
106
118
  process.stdout.write("\n" +
107
119
  warn(`This machine already knows a project called ${bold(existing)}.\n`) +
108
- dim(" Continuing replaces its entry. Give it another name to keep both.\n"));
120
+ dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
121
+ dim(" Give it another name to keep both.\n"));
109
122
  }
110
123
  const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
111
124
  if (await fileExists(repoConfigPath)) {
@@ -163,6 +176,30 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
163
176
  name: slug,
164
177
  git_url: gitUrl,
165
178
  default_branch: branch,
179
+ // Written on this machine too, and only when it is not the default,
180
+ // because of the gap between the two files. Laneyard builds from a clone
181
+ // of the remote, so nothing written into the working copy reaches it
182
+ // until `laneyard.yml` is committed and pushed — and until then
183
+ // `fastlane_dir` falls back to `fastlane`, which in a monorepo is not
184
+ // where anything is. The project was unreadable from the moment setup
185
+ // finished until a git push, with an ENOENT for an explanation.
186
+ //
187
+ // This is not a second source of truth: the repository file wins the
188
+ // moment it lands, which is the precedence `config.yml` already
189
+ // documents and the schema already allows. It is the value setup just
190
+ // proposed and the user just accepted, kept where it is useful until the
191
+ // authoritative copy arrives.
192
+ //
193
+ // Omitted when it *is* the default, so an ordinary project's block stays
194
+ // about how the project is reached and nothing else.
195
+ // Both of the fields the sidecar needs before it can read anything, and
196
+ // only when they are not already the default. `runtime` belongs here for
197
+ // exactly the same reason as `fastlane_dir`: without it the sidecar is
198
+ // launched under `bundle exec` and a project that uses a system fastlane
199
+ // fails with "Could not locate Gemfile" — the same bootstrap gap, one
200
+ // field along.
201
+ ...(fastlaneDir === "fastlane" ? {} : { fastlane_dir: fastlaneDir }),
202
+ ...(runtime === "bundle" ? {} : { runtime }),
166
203
  });
167
204
  // The repository half: how it builds. This is the part that was going into
168
205
  // the machine's file and therefore never being committed — which is exactly
@@ -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
+ }