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.
- package/README.md +227 -20
- package/dist/src/cli/detect.js +12 -3
- package/dist/src/cli/prompt.js +28 -3
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +44 -7
- package/dist/src/cli/uninstall.js +390 -0
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +100 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +53 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +2 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +69 -10
- package/dist/src/heuristics/readiness.js +554 -25
- package/dist/src/main.js +23 -2
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +137 -5
- package/dist/src/server/app.js +30 -3
- package/dist/src/server/auth.js +50 -10
- package/dist/src/server/permissions.js +2 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/projects.js +60 -2
- package/dist/src/server/routes/readiness.js +162 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/sidecar/bridge.js +21 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-DNFBA4gv.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/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-De6sxx6G.css +0 -1
- package/dist/web/assets/index-TWRQ1cJg.js +0 -62
package/dist/src/cli/secret.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/cli/setup.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
+
}
|