laneyard 0.4.1 → 0.6.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 +312 -282
- package/dist/src/cli/adopt.js +221 -0
- package/dist/src/cli/home.js +46 -0
- package/dist/src/cli/remove.js +195 -0
- package/dist/src/cli/reset.js +117 -0
- package/dist/src/cli/setup.js +123 -11
- package/dist/src/cli/uninstall.js +2 -24
- 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 +25 -9
- package/dist/src/config/store.js +27 -3
- package/dist/src/data/remove-project.js +70 -0
- package/dist/src/db/runs.js +11 -0
- package/dist/src/fastfile/adoption.js +142 -0
- package/dist/src/fastfile/splice.js +38 -0
- package/dist/src/logs/store.js +9 -1
- package/dist/src/main.js +24 -16
- package/dist/src/secrets/vault.js +6 -5
- 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 +95 -86
- package/dist/src/server/routes/users.js +47 -4
- package/dist/src/sidecar/bridge.js +5 -4
- package/dist/src/sidecar/fastlane-dir.js +8 -8
- package/dist/src/sidecar/prism-ruby.js +42 -0
- package/dist/src/sidecar/scan.js +44 -0
- package/dist/web/assets/{Editor-9nLYwtg7.js → Editor-CMa4-4N3.js} +1 -1
- package/dist/web/assets/index-B8lAQPEM.js +62 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/ruby/scan.rb +176 -0
- package/dist/web/assets/index-pzPa9EZr.js +0 -62
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { fieldsOf } from "../credentials/kinds.js";
|
|
6
|
+
import { appRootOf } from "../heuristics/platforms.js";
|
|
7
|
+
import { proposalsFor } from "../fastfile/adoption.js";
|
|
8
|
+
import { splice } from "../fastfile/splice.js";
|
|
9
|
+
import { scanFastfile } from "../sidecar/scan.js";
|
|
10
|
+
import { bold, dim, heading, ok, warn } from "./style.js";
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
/**
|
|
13
|
+
* Setup's second act: what to do about credentials the Fastfile names outright.
|
|
14
|
+
*
|
|
15
|
+
* **It runs after the project is already set up, and that ordering is the
|
|
16
|
+
* whole guarantee.** Declining everything here must leave exactly the project
|
|
17
|
+
* setup produced before this feature existed — so the act is separate rather
|
|
18
|
+
* than folded into setup's final confirmation, and "refusing works" is true by
|
|
19
|
+
* construction instead of by promise.
|
|
20
|
+
*
|
|
21
|
+
* Nothing it can do is required. No Ruby with Prism, no Fastfile, an
|
|
22
|
+
* unparseable file, a literal naming a file that is not on disk: every one of
|
|
23
|
+
* those means "nothing proposed", and setup carries on.
|
|
24
|
+
*/
|
|
25
|
+
export async function runAdoption(options) {
|
|
26
|
+
const { cwd, fastlaneDir, slug, vault, asker } = options;
|
|
27
|
+
const literals = await scanFastfile(cwd, fastlaneDir);
|
|
28
|
+
if (literals === null) {
|
|
29
|
+
process.stdout.write("\n" + dim("Fastfile not analysed — no Ruby with Prism available. Nothing else changes.\n"));
|
|
30
|
+
return { applied: 0 };
|
|
31
|
+
}
|
|
32
|
+
// A literal pointing at nothing is dropped rather than reported: there is no
|
|
33
|
+
// file to lift into the vault, and patching to a variable nothing supplies
|
|
34
|
+
// would trade one broken build for another.
|
|
35
|
+
// Resolved once, here, so the prompt, the vault write and the git check all
|
|
36
|
+
// speak about the same file rather than each resolving the path again.
|
|
37
|
+
const found = new Map();
|
|
38
|
+
const proposals = [];
|
|
39
|
+
for (const proposal of proposalsFor(literals)) {
|
|
40
|
+
if (proposal.tier === "file") {
|
|
41
|
+
const hit = await readCredential(cwd, fastlaneDir, proposal);
|
|
42
|
+
if (hit === null)
|
|
43
|
+
continue;
|
|
44
|
+
found.set(proposal, hit);
|
|
45
|
+
}
|
|
46
|
+
proposals.push(proposal);
|
|
47
|
+
}
|
|
48
|
+
if (proposals.length === 0)
|
|
49
|
+
return { applied: 0 };
|
|
50
|
+
process.stdout.write(heading("I read your Fastfile"));
|
|
51
|
+
const accepted = [];
|
|
52
|
+
for (const proposal of proposals) {
|
|
53
|
+
process.stdout.write(describe(fastlaneDir, proposal) + "\n");
|
|
54
|
+
if (!(await asker.confirm(` Store it here and use ${bold(proposal.varName)}?`, proposal.checked))) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
accepted.push(proposal.tier === "secret" ? await named(asker, proposal) : proposal);
|
|
58
|
+
}
|
|
59
|
+
if (accepted.length === 0) {
|
|
60
|
+
process.stdout.write(dim("\nNothing written. Your Fastfile is as it was.\n"));
|
|
61
|
+
return { applied: 0 };
|
|
62
|
+
}
|
|
63
|
+
// The vault first, always. If lifting a credential fails, no Fastfile has
|
|
64
|
+
// been patched to read a variable that nothing supplies.
|
|
65
|
+
for (const proposal of accepted)
|
|
66
|
+
await store(vault, slug, asker, proposal, found.get(proposal));
|
|
67
|
+
const path = join(cwd, fastlaneDir, "Fastfile");
|
|
68
|
+
const previous = await readFile(path, "utf8");
|
|
69
|
+
const edits = accepted.flatMap((p) => (options.editFor ? [options.editFor(p)] : p.edits));
|
|
70
|
+
await writeFile(path, splice(previous, edits), "utf8");
|
|
71
|
+
// Verified with Prism rather than with fastlane: setup has no server to ask
|
|
72
|
+
// for a lane list, and "does it still parse" is the question that matters.
|
|
73
|
+
// Same contract as `FastfileStore.write` — the previous content goes back on
|
|
74
|
+
// disk before this function returns, and no backup file is left behind.
|
|
75
|
+
if ((await scanFastfile(cwd, fastlaneDir)) === null) {
|
|
76
|
+
await writeFile(path, previous, "utf8");
|
|
77
|
+
process.stdout.write("\n" + warn("The patch stopped the Fastfile parsing. It has been put back as it was.\n"));
|
|
78
|
+
return { applied: 0 };
|
|
79
|
+
}
|
|
80
|
+
await report(cwd, fastlaneDir, accepted, found);
|
|
81
|
+
return { applied: accepted.length };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The bytes behind a `file` proposal, or null when the path names nothing.
|
|
85
|
+
*
|
|
86
|
+
* **A relative path in a Fastfile has no single meaning.** `"./play.json"`
|
|
87
|
+
* resolves against whatever directory fastlane was invoked from, which is
|
|
88
|
+
* usually the app root — the fastlane folder's parent — but a project that runs
|
|
89
|
+
* fastlane from the repository root, or writes paths relative to the fastlane
|
|
90
|
+
* folder itself, is equally ordinary. Nothing in the file says which.
|
|
91
|
+
*
|
|
92
|
+
* So all three are tried, nearest first. This costs nothing to be wrong about:
|
|
93
|
+
* the patch replaces the literal with `ENV.fetch` either way, and the only
|
|
94
|
+
* thing the path is needed for is finding bytes to put in the vault. Failing to
|
|
95
|
+
* find them means no proposal, which is the safe answer.
|
|
96
|
+
*/
|
|
97
|
+
async function readCredential(cwd, fastlaneDir, proposal) {
|
|
98
|
+
const value = proposal.literal.value;
|
|
99
|
+
const candidates = isAbsolute(value)
|
|
100
|
+
? [value]
|
|
101
|
+
: [
|
|
102
|
+
resolve(cwd, appRootOf(fastlaneDir), value),
|
|
103
|
+
resolve(cwd, fastlaneDir, value),
|
|
104
|
+
resolve(cwd, value),
|
|
105
|
+
];
|
|
106
|
+
for (const path of candidates) {
|
|
107
|
+
const bytes = await readFile(path).catch(() => null);
|
|
108
|
+
if (bytes !== null)
|
|
109
|
+
return { bytes, path };
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/** One proposal, as three lines: where, what, and why it will not survive. */
|
|
114
|
+
function describe(fastlaneDir, proposal) {
|
|
115
|
+
const { literal } = proposal;
|
|
116
|
+
// A path is shown; a secret is not. Tier 3 is a value someone called a token
|
|
117
|
+
// or a password, and setup's output is pasted into bug reports and kept in CI
|
|
118
|
+
// transcripts — printing it there would be this feature leaking the very
|
|
119
|
+
// thing it exists to put away. The file and line above say where to look.
|
|
120
|
+
const shown = proposal.tier === "file"
|
|
121
|
+
? `"${literal.value}"`
|
|
122
|
+
: proposal.tier === "inline"
|
|
123
|
+
? dim("(a key, inline in the file)")
|
|
124
|
+
: dim("(a literal value, masked)");
|
|
125
|
+
const why = proposal.tier === "inline"
|
|
126
|
+
? "This key is in your repository in cleartext."
|
|
127
|
+
: proposal.tier === "file"
|
|
128
|
+
? "That path does not survive the clone: Laneyard builds from your remote."
|
|
129
|
+
: "A literal secret in a build file is a secret in your history.";
|
|
130
|
+
return ("\n" +
|
|
131
|
+
` ${bold(`${fastlaneDir}/Fastfile:${literal.line}`)} ${literal.action}(${literal.arg}:)\n` +
|
|
132
|
+
` → ${shown}\n` +
|
|
133
|
+
dim(` ${why}\n`));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A tier-3 proposal carrying the variable name the user actually wants.
|
|
137
|
+
*
|
|
138
|
+
* This is the one tier whose name is a guess. Tiers 1 and 2 take theirs from
|
|
139
|
+
* `credentials/kinds.ts`, which holds the names fastlane itself reads; a
|
|
140
|
+
* literal secret has no such table, so the name is assembled from the action
|
|
141
|
+
* and the argument — `PILOT_API_TOKEN` — and a project that already calls that
|
|
142
|
+
* variable something else would end up with the vault holding one name and the
|
|
143
|
+
* patched Fastfile reading another. Nothing would report it: the run would
|
|
144
|
+
* simply meet an absent variable.
|
|
145
|
+
*
|
|
146
|
+
* The replacement is rebuilt from the answer rather than patched afterwards,
|
|
147
|
+
* so the name stored and the name read cannot drift apart.
|
|
148
|
+
*/
|
|
149
|
+
async function named(asker, proposal) {
|
|
150
|
+
const varName = await asker.ask(" variable name", proposal.varName);
|
|
151
|
+
return {
|
|
152
|
+
...proposal,
|
|
153
|
+
varName,
|
|
154
|
+
edits: [{ ...proposal.edits[0], replacement: `ENV.fetch("${varName}")` }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Lifts one accepted proposal into the vault. */
|
|
158
|
+
async function store(vault, slug, asker, proposal, found) {
|
|
159
|
+
if (proposal.kind === undefined) {
|
|
160
|
+
await vault.set(slug, proposal.varName, proposal.literal.value, true);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const bytes = proposal.tier === "inline" ? Buffer.from(proposal.literal.value, "utf8") : found.bytes;
|
|
164
|
+
// The original name is kept: some tools read meaning from it, and
|
|
165
|
+
// `materialise.ts` already relies on `AuthKey_<KEY ID>.p8` surviving intact.
|
|
166
|
+
const fileName = proposal.tier === "inline" ? `${proposal.kind}.key` : basename(found.path);
|
|
167
|
+
// The fields the file cannot carry. `fieldsOf` is the same table the web
|
|
168
|
+
// upload form reads, so the CLI cannot end up asking for a different set.
|
|
169
|
+
const fields = {};
|
|
170
|
+
for (const field of fieldsOf(proposal.kind)) {
|
|
171
|
+
if (field.optional)
|
|
172
|
+
continue;
|
|
173
|
+
const suggested = proposal.suggestedFields[field.name] ?? field.suggested ?? "";
|
|
174
|
+
fields[field.name] = await asker.ask(` ${field.label}`, suggested);
|
|
175
|
+
}
|
|
176
|
+
await vault.setCredential(slug, proposal.kind, { fileName, fileBytes: bytes, fields, varNames: {} });
|
|
177
|
+
}
|
|
178
|
+
/** What is left for the user to do, including the part Laneyard will not do. */
|
|
179
|
+
async function report(cwd, fastlaneDir, accepted, found) {
|
|
180
|
+
process.stdout.write("\n" +
|
|
181
|
+
ok(`Stored ${accepted.length} credential${accepted.length > 1 ? "s" : ""} in this machine's vault.\n`) +
|
|
182
|
+
ok(`Patched ${fastlaneDir}/Fastfile.\n`) +
|
|
183
|
+
"\n" +
|
|
184
|
+
// Said plainly because it is the trap `addProjectToConfig` already
|
|
185
|
+
// documents: Laneyard builds from a clone of the remote, so nothing in
|
|
186
|
+
// the working copy reaches a run until it is pushed.
|
|
187
|
+
warn("Commit and push it, or your runs still read the old file.\n") +
|
|
188
|
+
dim(" git diff -- " + join(fastlaneDir, "Fastfile") + "\n"));
|
|
189
|
+
// Said, never done. Removing a file from someone's repository is not
|
|
190
|
+
// setup's to decide, and `git rm --cached` does not take it out of the
|
|
191
|
+
// history anyway — so the honest thing is to name it.
|
|
192
|
+
const tracked = await trackedCredentials(cwd, accepted, found);
|
|
193
|
+
if (tracked.length > 0) {
|
|
194
|
+
process.stdout.write("\n" +
|
|
195
|
+
warn(`${tracked.join(", ")} ${tracked.length > 1 ? "are" : "is"} tracked by git.\n`) +
|
|
196
|
+
dim(" The patch does not take it out of your history. Rotating the key does.\n"));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Which of the accepted credentials git already has. Silent when git cannot
|
|
201
|
+
* answer — the same courtesy `fastlaneDirIsTracked` extends.
|
|
202
|
+
*
|
|
203
|
+
* Asked about the *resolved* paths, not the literals: `"./play.json"` written
|
|
204
|
+
* in a Fastfile one directory down is not a path `git ls-files` can answer
|
|
205
|
+
* about from the repository root.
|
|
206
|
+
*/
|
|
207
|
+
async function trackedCredentials(cwd, accepted, found) {
|
|
208
|
+
const paths = accepted.flatMap((p) => {
|
|
209
|
+
const hit = found.get(p);
|
|
210
|
+
return hit ? [hit.path] : [];
|
|
211
|
+
});
|
|
212
|
+
if (paths.length === 0)
|
|
213
|
+
return [];
|
|
214
|
+
try {
|
|
215
|
+
const { stdout } = await exec("git", ["ls-files", "--", ...paths], { cwd });
|
|
216
|
+
return stdout.split("\n").filter((line) => line.trim() !== "");
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
/**
|
|
5
|
+
* What Laneyard writes into its home, and the folders it fills as it runs.
|
|
6
|
+
*
|
|
7
|
+
* Named in one place because three commands read them — `uninstall` removes all
|
|
8
|
+
* of it, `reset` removes the data but keeps the accounts and the key, and each
|
|
9
|
+
* has to agree with the others about what "Laneyard's own" means.
|
|
10
|
+
*/
|
|
11
|
+
/** The three files SQLite keeps for one WAL database. */
|
|
12
|
+
export const DB_FILES = ["laneyard.db", "laneyard.db-wal", "laneyard.db-shm"];
|
|
13
|
+
/** The files Laneyard owns in its home. */
|
|
14
|
+
export const OWN_FILES = ["config.yml", "key", ...DB_FILES];
|
|
15
|
+
/** The folders Laneyard fills: clones, artifacts, logs, per-run scratch. */
|
|
16
|
+
export const OWN_FOLDERS = ["workspaces", "artifacts", "logs", "runs"];
|
|
17
|
+
/** Reads one line, which works the same whether it is typed or piped in. */
|
|
18
|
+
export async function readLine(stdin) {
|
|
19
|
+
const rl = createInterface({ input: stdin });
|
|
20
|
+
try {
|
|
21
|
+
for await (const line of rl)
|
|
22
|
+
return line.trim();
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rl.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Removes named entries under `home`, and returns the names it actually removed.
|
|
31
|
+
*
|
|
32
|
+
* A missing entry is skipped rather than reported, so a caller can hand in the
|
|
33
|
+
* full list without first checking which parts are there. `rm` with `recursive`
|
|
34
|
+
* and `force` handles a file and a whole folder alike.
|
|
35
|
+
*/
|
|
36
|
+
export async function removePaths(home, names) {
|
|
37
|
+
const removed = [];
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const path = join(home, name);
|
|
40
|
+
if ((await stat(path).catch(() => null)) === null)
|
|
41
|
+
continue;
|
|
42
|
+
await rm(path, { recursive: true, force: true });
|
|
43
|
+
removed.push(name);
|
|
44
|
+
}
|
|
45
|
+
return removed;
|
|
46
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rm, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadRepoConfig } from "../config/load.js";
|
|
6
|
+
import { ConfigStore } from "../config/store.js";
|
|
7
|
+
import { CredentialStore } from "../db/credentials.js";
|
|
8
|
+
import { openDatabase } from "../db/open.js";
|
|
9
|
+
import { RunStore } from "../db/runs.js";
|
|
10
|
+
import { SecretStore } from "../db/secrets.js";
|
|
11
|
+
import { removeProjectData } from "../data/remove-project.js";
|
|
12
|
+
import { LogStore } from "../logs/store.js";
|
|
13
|
+
import { Vault } from "../secrets/vault.js";
|
|
14
|
+
import { readLine } from "./home.js";
|
|
15
|
+
import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
|
|
16
|
+
export const REMOVE_USAGE = `laneyard remove [--dry-run]
|
|
17
|
+
|
|
18
|
+
Run from your app's directory — the one that holds laneyard.yml. Removes
|
|
19
|
+
everything Laneyard holds for that project: its block in config.yml, its clone,
|
|
20
|
+
its artifacts, its run history and logs, and its own secrets and signing blocks
|
|
21
|
+
in the vault. It also removes the repository's laneyard.yml, which you then
|
|
22
|
+
commit. It does not touch the git remote, the credential originals, or the
|
|
23
|
+
global secrets and signing blocks other projects share.
|
|
24
|
+
|
|
25
|
+
laneyard remove --dry-run show what would go and stop
|
|
26
|
+
|
|
27
|
+
The run history is the one thing here nothing can rebuild, so it is confirmed by
|
|
28
|
+
typing the project's slug back, not \`y\`.
|
|
29
|
+
`;
|
|
30
|
+
const plural = (n, noun) => `${n} ${n === 1 ? noun : `${noun}s`}`;
|
|
31
|
+
async function readCounts(home, slug) {
|
|
32
|
+
const dbPath = join(home, "laneyard.db");
|
|
33
|
+
if (!existsSync(dbPath))
|
|
34
|
+
return { activeRun: false, runs: 0, secrets: 0, signingBlocks: 0 };
|
|
35
|
+
const sidecars = [`${dbPath}-wal`, `${dbPath}-shm`];
|
|
36
|
+
const ours = await Promise.all(sidecars.map(async (path) => ((await stat(path).catch(() => null)) === null ? path : null)));
|
|
37
|
+
let db = null;
|
|
38
|
+
try {
|
|
39
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
40
|
+
const runs = new RunStore(db);
|
|
41
|
+
return {
|
|
42
|
+
activeRun: runs.hasActiveRun(slug),
|
|
43
|
+
runs: runs.listByProject(slug, -1).length,
|
|
44
|
+
secrets: new SecretStore(db).listOwn(slug).length,
|
|
45
|
+
signingBlocks: new CredentialStore(db).listOwn(slug).length,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
db?.close();
|
|
50
|
+
for (const path of ours) {
|
|
51
|
+
if (path !== null)
|
|
52
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The inventory, and what the removal will and will not reach. */
|
|
57
|
+
function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
|
|
58
|
+
const cloned = existsSync(clonePath);
|
|
59
|
+
return (heading(`laneyard remove "${slug}"`) +
|
|
60
|
+
field("home", home) + "\n" +
|
|
61
|
+
field("project", name === slug ? slug : `${name} (${slug})`) + "\n" +
|
|
62
|
+
heading("what will be removed") +
|
|
63
|
+
field("runs", counts.runs === 0 ? dim("no run yet") : plural(counts.runs, "run")) + "\n" +
|
|
64
|
+
field("clone", cloned ? clonePath : dim("not cloned")) + "\n" +
|
|
65
|
+
field("secrets", plural(counts.secrets, "project secret")) + "\n" +
|
|
66
|
+
field("signing", plural(counts.signingBlocks, "project signing block")) + "\n" +
|
|
67
|
+
// The one thing removed outside `home`: the repository's own file.
|
|
68
|
+
field("laneyard.yml", ymlPath) + "\n" +
|
|
69
|
+
heading("what will not be touched") +
|
|
70
|
+
dim(" the git remote — the repository is yours, on your host and your disk.\n") +
|
|
71
|
+
dim(" the credential originals — Laneyard removes only its own encrypted copy;\n") +
|
|
72
|
+
dim(" the .p8 and the keystore you uploaded are wherever you keep them.\n") +
|
|
73
|
+
dim(" global secrets and global signing blocks — shared by every project.\n"));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Entry point for `laneyard remove`, run from the app's own directory.
|
|
77
|
+
*
|
|
78
|
+
* The project is the one whose `laneyard.yml` sits in `cwd`; the slug is read
|
|
79
|
+
* from that file rather than given as an argument, so the command names what the
|
|
80
|
+
* directory already is. It reads the inventory first, prints what will and will
|
|
81
|
+
* not go, and only then asks for the slug typed back — the same gate the web
|
|
82
|
+
* route uses, for the same reason: the run history it deletes is the one thing
|
|
83
|
+
* here nothing can rebuild.
|
|
84
|
+
*/
|
|
85
|
+
export async function runRemoveCommand(home, cwd, args, io) {
|
|
86
|
+
let dryRun = false;
|
|
87
|
+
for (const arg of args) {
|
|
88
|
+
if (arg === "--dry-run" || arg === "-n")
|
|
89
|
+
dryRun = true;
|
|
90
|
+
else {
|
|
91
|
+
io.err(`Unknown option: ${arg}\n\n${REMOVE_USAGE}`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// The project is the one whose `laneyard.yml` is here — the file setup wrote
|
|
96
|
+
// and the slug it recorded in it. Run from anywhere else and there is nothing
|
|
97
|
+
// to name, which is the whole point: a project is removed from where it lives.
|
|
98
|
+
const ymlPath = join(cwd, "laneyard.yml");
|
|
99
|
+
if (!existsSync(ymlPath)) {
|
|
100
|
+
io.err("\n" +
|
|
101
|
+
bad("No laneyard.yml here.") +
|
|
102
|
+
" Run `laneyard remove` from your app's directory — the one that holds it.\n");
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const repo = await loadRepoConfig(ymlPath);
|
|
106
|
+
const slug = repo.ok ? repo.config.slug : undefined;
|
|
107
|
+
if (slug === undefined || slug === "") {
|
|
108
|
+
io.err("\n" +
|
|
109
|
+
bad("This laneyard.yml has no slug.") +
|
|
110
|
+
" Run `laneyard setup` again to record which project it is.\n");
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
const configPath = join(home, "config.yml");
|
|
114
|
+
const config = new ConfigStore(configPath);
|
|
115
|
+
const loaded = await config.load();
|
|
116
|
+
if (!loaded.ok) {
|
|
117
|
+
io.err(`Unreadable configuration in ${configPath}: ${loaded.error}\n`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
const entry = config.project(slug);
|
|
121
|
+
if (!entry) {
|
|
122
|
+
const known = config.projects().map((p) => p.slug);
|
|
123
|
+
io.err(`${bad(`Unknown project: "${slug}".`)} ` +
|
|
124
|
+
(known.length > 0 ? `Known projects: ${known.join(", ")}.` : "No project is declared yet.") +
|
|
125
|
+
"\n");
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
const clonePath = join(home, "workspaces", slug);
|
|
129
|
+
const counts = await readCounts(home, slug);
|
|
130
|
+
// A run that has begun is reading the workspace this project points at.
|
|
131
|
+
// Refused for the same reason the interface refuses it, and with the same way
|
|
132
|
+
// out: wait for it, or cancel it, then remove the project.
|
|
133
|
+
if (counts.activeRun) {
|
|
134
|
+
io.err("\n" +
|
|
135
|
+
bad(`"${slug}" has a run in flight.`) +
|
|
136
|
+
" Wait for it to finish, or cancel it, then remove the project. Nothing was removed.\n");
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
io.out(renderInventory(slug, entry.name, home, counts, clonePath, ymlPath));
|
|
140
|
+
if (dryRun) {
|
|
141
|
+
io.out("\n" +
|
|
142
|
+
dim("Nothing was removed. Run `laneyard remove` without --dry-run to remove it.") +
|
|
143
|
+
"\n");
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
// Typed in full, not `y`: this deletes a run history nothing can rebuild, and
|
|
147
|
+
// the slug typed back is what proves the person read which project is going.
|
|
148
|
+
io.out("\n" +
|
|
149
|
+
bold("Type the project's slug, exactly, to confirm:") + "\n" +
|
|
150
|
+
` ${slug}\n` +
|
|
151
|
+
"\n> ");
|
|
152
|
+
const answer = await readLine(io.stdin);
|
|
153
|
+
if (answer !== slug) {
|
|
154
|
+
io.err("\n" +
|
|
155
|
+
bad(answer === ""
|
|
156
|
+
? "Nothing was typed, so nothing was removed."
|
|
157
|
+
: "That is not the slug, so nothing was removed.") +
|
|
158
|
+
"\n");
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
const db = openDatabase(join(home, "laneyard.db"));
|
|
162
|
+
try {
|
|
163
|
+
const result = await removeProjectData({
|
|
164
|
+
configPath,
|
|
165
|
+
reloadConfig: () => config.load(),
|
|
166
|
+
runs: new RunStore(db),
|
|
167
|
+
logs: new LogStore(join(home, "logs")),
|
|
168
|
+
vault: await Vault.open(home, new SecretStore(db), new CredentialStore(db)),
|
|
169
|
+
workspacePath: (s) => join(home, "workspaces", s),
|
|
170
|
+
artifactsDir: (runId) => join(home, "artifacts", String(runId)),
|
|
171
|
+
}, slug);
|
|
172
|
+
// The machine data went first, so a file removed here is one whose project
|
|
173
|
+
// is already gone — never the other way round. `force` because the removal
|
|
174
|
+
// must still report success if the file was deleted by hand meanwhile.
|
|
175
|
+
await rm(ymlPath, { force: true }).catch(() => { });
|
|
176
|
+
io.out(heading("removed") +
|
|
177
|
+
ok(`"${slug}": ${plural(result.runs, "run")}, ${plural(result.secrets, "secret")}, ` +
|
|
178
|
+
`${plural(result.signingBlocks, "signing block")}.\n`) +
|
|
179
|
+
(result.workspace
|
|
180
|
+
? dim(` ${result.clonePath} is gone.\n`)
|
|
181
|
+
: dim(" There was no clone to remove.\n")) +
|
|
182
|
+
dim(` The block is out of ${configPath}.\n`) +
|
|
183
|
+
dim(` ${ymlPath} is gone.\n`) +
|
|
184
|
+
"\n" +
|
|
185
|
+
// The file is committed, so deleting the working copy is not the end of
|
|
186
|
+
// it — the same trap adoption's report names about a patched Fastfile.
|
|
187
|
+
warn("Commit its removal, or a clone still carries the laneyard.yml.\n") +
|
|
188
|
+
warn("The git remote and your credential originals were never Laneyard's to touch, and " +
|
|
189
|
+
"global secrets and signing blocks were left alone.\n"));
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
db.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { clearProjectsInConfig } from "./setup.js";
|
|
4
|
+
import { DB_FILES, OWN_FOLDERS, readLine, removePaths } from "./home.js";
|
|
5
|
+
import { humanSize, readInventory } from "./uninstall.js";
|
|
6
|
+
import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
|
|
7
|
+
export const RESET_USAGE = `laneyard reset [--dry-run]
|
|
8
|
+
|
|
9
|
+
Wipes Laneyard's data — every project, the database, the workspaces, the
|
|
10
|
+
artifacts and the logs — and keeps the accounts and the vault key. A reset that
|
|
11
|
+
does not lock you out: you sign in with the same names, and older database
|
|
12
|
+
backups stay readable because the key they were encrypted under is still there.
|
|
13
|
+
|
|
14
|
+
laneyard reset --dry-run show what would go and stop
|
|
15
|
+
|
|
16
|
+
It keeps the \`server:\` block of config.yml (accounts, port, bind, retention)
|
|
17
|
+
and ~/.laneyard/key. It never touches the git remotes or the credential
|
|
18
|
+
originals — those were never Laneyard's.
|
|
19
|
+
`;
|
|
20
|
+
const plural = (n, noun) => `${n} ${n === 1 ? noun : `${noun}s`}`;
|
|
21
|
+
const entries = (n) => `${n} ${n === 1 ? "entry" : "entries"}`;
|
|
22
|
+
/** The inventory, in reset's own framing: what goes, and what stays. */
|
|
23
|
+
function renderInventory(inv) {
|
|
24
|
+
let out = heading("laneyard reset");
|
|
25
|
+
out += field("home", inv.home) + "\n";
|
|
26
|
+
out += heading("what will be wiped");
|
|
27
|
+
const projects = inv.config?.projects ?? [];
|
|
28
|
+
out += field("projects", projects.length === 0 ? dim("none declared") : projects.join(", ")) + "\n";
|
|
29
|
+
const v = inv.db?.vault ?? null;
|
|
30
|
+
if (v === null) {
|
|
31
|
+
out += field("database", inv.db === null ? dim("not there") : dim("could not be read")) + "\n";
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
out += field("secrets", `${plural(v.projectSecrets, "project secret")}, ${plural(v.globalSecrets, "global secret")}`) + "\n";
|
|
35
|
+
out += field("signing", `${plural(v.projectBlocks, "project signing block")}, ${plural(v.globalBlocks, "global signing block")}`) + "\n";
|
|
36
|
+
out += field("history", plural(v.runs, "run")) + "\n";
|
|
37
|
+
}
|
|
38
|
+
if (inv.folders.length === 0) {
|
|
39
|
+
out += field("on disk", dim("nothing cloned, nothing built")) + "\n";
|
|
40
|
+
}
|
|
41
|
+
for (const folder of inv.folders) {
|
|
42
|
+
out +=
|
|
43
|
+
field(folder.name, folder.entries === 0 ? dim("empty") : `${entries(folder.entries)} ${dim(humanSize(folder.bytes))}`) + "\n";
|
|
44
|
+
}
|
|
45
|
+
out += heading("what will be kept");
|
|
46
|
+
// Said plainly, because these are the two facts that make a reset a reset and
|
|
47
|
+
// not an uninstall: the door is not locked behind you, and the old backups are
|
|
48
|
+
// not turned into ciphertext nobody can read.
|
|
49
|
+
out += field("accounts", inv.config === null ? dim("none") : "left as they are — you sign in with the same names") + "\n";
|
|
50
|
+
out += field("vault key", (inv.key?.path ?? join(inv.home, "key")) + " — kept, so older database backups stay readable") + "\n";
|
|
51
|
+
out += "\n";
|
|
52
|
+
out += dim(" The git remotes and your credential originals were never Laneyard's, and are not touched.\n");
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Entry point for `laneyard reset`.
|
|
57
|
+
*
|
|
58
|
+
* Inventory first, then the typed confirmation, then the wipe — the order
|
|
59
|
+
* `uninstall` uses, and never any other: a question asked before the numbers are
|
|
60
|
+
* on screen is one nobody can answer. Unlike `uninstall`, the accounts and the
|
|
61
|
+
* key stay, so the confirmation guards a reset, not a lock-out.
|
|
62
|
+
*/
|
|
63
|
+
export async function runResetCommand(home, args, io) {
|
|
64
|
+
let dryRun = false;
|
|
65
|
+
for (const arg of args) {
|
|
66
|
+
if (arg === "--dry-run" || arg === "-n")
|
|
67
|
+
dryRun = true;
|
|
68
|
+
else {
|
|
69
|
+
io.err(`Unknown option: ${arg}\n\n${RESET_USAGE}`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const inv = await readInventory(home);
|
|
74
|
+
if (!inv.exists) {
|
|
75
|
+
io.out(dim(`No data folder at ${home}. There is nothing to reset.`) + "\n");
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
io.out(renderInventory(inv));
|
|
79
|
+
if (dryRun) {
|
|
80
|
+
io.out("\n" + dim("Nothing was removed. Run `laneyard reset` without --dry-run to wipe it.") + "\n");
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
// The folder's path, exactly, not `y` — the same gate as `uninstall`. A reset
|
|
84
|
+
// is less final than an uninstall, but it still throws away every run anyone
|
|
85
|
+
// ever kept, and `$LANEYARD_HOME` is exactly the case where a reflex is wrong.
|
|
86
|
+
io.out("\n" +
|
|
87
|
+
bold("Type the path of the folder to reset, exactly, to confirm:") + "\n" +
|
|
88
|
+
` ${inv.home}\n` +
|
|
89
|
+
"\n> ");
|
|
90
|
+
const answer = await readLine(io.stdin);
|
|
91
|
+
if (answer !== inv.home) {
|
|
92
|
+
io.err("\n" +
|
|
93
|
+
bad(answer === ""
|
|
94
|
+
? "Nothing was typed, so nothing was removed."
|
|
95
|
+
: "That is not the path, so nothing was removed.") +
|
|
96
|
+
"\n");
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const configPath = join(home, "config.yml");
|
|
100
|
+
const projectsCleared = existsSync(configPath) ? await clearProjectsInConfig(configPath) : 0;
|
|
101
|
+
// The database file, and the WAL sidecars beside it. It comes back empty from
|
|
102
|
+
// the schema on the next start — which also clears the sessions, so everyone
|
|
103
|
+
// signs in again, exactly what a reset should mean.
|
|
104
|
+
const dbRemoved = await removePaths(home, DB_FILES);
|
|
105
|
+
const foldersRemoved = await removePaths(home, OWN_FOLDERS);
|
|
106
|
+
io.out(heading("reset") +
|
|
107
|
+
ok(`${plural(projectsCleared, "project")} cleared, the database and ` +
|
|
108
|
+
`${plural(foldersRemoved.length, "data folder")} wiped.\n`) +
|
|
109
|
+
(dbRemoved.includes("laneyard.db")
|
|
110
|
+
? dim(" laneyard.db is gone; it comes back empty on the next start.\n")
|
|
111
|
+
: dim(" There was no database to remove.\n")) +
|
|
112
|
+
"\n" +
|
|
113
|
+
warn("Kept: your accounts, and the vault key at " + (inv.key?.path ?? join(home, "key")) + ".\n") +
|
|
114
|
+
dim(" You sign in with the same names, and older database backups stay readable.\n") +
|
|
115
|
+
dim(" The git remotes and your credential originals were never Laneyard's to touch.\n"));
|
|
116
|
+
return 0;
|
|
117
|
+
}
|