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.
- package/README.md +124 -24
- 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 +368 -0
- 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/credentials.js +25 -0
- package/dist/src/db/runs.js +11 -0
- package/dist/src/db/secrets.js +25 -0
- package/dist/src/logs/store.js +9 -1
- package/dist/src/main.js +35 -1
- package/dist/src/secrets/vault.js +32 -0
- 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 +100 -33
- package/dist/src/server/routes/users.js +47 -4
- package/dist/src/sidecar/fastlane-dir.js +8 -8
- package/dist/web/assets/{Editor-DynuBC2l.js → Editor-Bqz_dClT.js} +1 -1
- package/dist/web/assets/index-CMEreqtd.js +62 -0
- package/dist/web/assets/index-DsjxZtsO.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +0 -1
- package/dist/web/assets/index-lyZs-Y7J.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");
|
|
@@ -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
|
+
}
|