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 +44 -5
- package/dist/src/cli/uninstall.js +390 -0
- package/dist/src/db/credentials.js +25 -0
- package/dist/src/db/secrets.js +25 -0
- package/dist/src/main.js +13 -1
- package/dist/src/secrets/vault.js +31 -0
- package/dist/src/server/routes/projects.js +60 -2
- package/dist/web/assets/{Editor-DynuBC2l.js → Editor-9nLYwtg7.js} +1 -1
- package/dist/web/assets/index-DsjxZtsO.css +1 -0
- package/dist/web/assets/index-pzPa9EZr.js +62 -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/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**,
|
|
159
|
-
|
|
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
|
|
455
|
-
|
|
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")
|
package/dist/src/db/secrets.js
CHANGED
|
@@ -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.
|
|
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
|
*
|