laneyard 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,7 +103,7 @@ server:
103
103
  bind: 0.0.0.0
104
104
  users: # written by `laneyard setup`, see Accounts
105
105
  - { name: martin, role: admin, password_hash: "scrypt$…" }
106
- - { name: lea, role: builder, password_hash: "scrypt$…" }
106
+ - { name: lea, role: builder, password_hash: "scrypt$…", projects: [cartes-ios] }
107
107
  max_concurrent_runs: 1 # only 1 is accepted, see below
108
108
  retention: { runs: 50, artifact_days: 30 }
109
109
 
@@ -143,6 +143,26 @@ tabs are not drawn, and neither is the accounts screen. That is courtesy, not se
143
143
  server refuses those routes on its own, whatever the browser was shown, and the test suite
144
144
  proves it for every verb and every spelling of the address.
145
145
 
146
+ #### Which projects a builder reaches
147
+
148
+ An admin reaches every project — managing the server is the whole role. A builder reaches only the
149
+ projects it is granted, one at a time, from the accounts screen: each builder carries a checklist
150
+ of the projects, and ticking one grants it. A project a builder cannot reach is **invisible**, not
151
+ shown-and-locked — absent from its project list and its navigation, and a 404 by URL, answered with
152
+ the very body a project that does not exist gives, so the two cannot be told apart. This is enforced
153
+ by the server, in one place, not hidden in the browser.
154
+
155
+ The reach is a `projects` list on the account in `config.yml`, and its three states are deliberate:
156
+
157
+ - **absent** — every project. A config written before this feature has no such field on anyone, so
158
+ nobody loses access on an upgrade.
159
+ - **`[]`** — no project. This is what a newly created account is written with, so a new builder
160
+ starts seeing nothing until granted.
161
+ - **a list of slugs** — exactly those projects.
162
+
163
+ Removing a project strips its slug from every account, so a grant never points at a project that is
164
+ gone, and a project re-created later under the same slug does not silently inherit an old grant.
165
+
146
166
  Add and remove accounts from the accounts screen, or from the command line:
147
167
 
148
168
  ```bash
@@ -155,23 +175,23 @@ shell history. Without `--role`, the account is a builder.
155
175
  Two things are refused, in the API and on the command line alike: removing the last admin, and
156
176
  demoting the last admin. A server nobody can administer cannot be repaired from the interface.
157
177
 
158
- Anyone changes their own password from **your account**, reached by clicking your name in the
159
- header a builder included, since that page is about one person rather than about the server's
160
- list of people. It asks for the current password even though you are already signed in: a session
178
+ Anyone changes their own password — and their own name — from **your account**, which is said in
179
+ those words in the header and again on your own row of the accounts screen a builder included,
180
+ since that page is about one person rather than about the server's list of people. Either one asks
181
+ for the current password even though you are already signed in: a session
161
182
  is a cookie in a browser that may have been left open on a desk. Doing it ends every other session
162
183
  that account has, and leaves the page you did it on signed in. That is how the random password
163
184
  `laneyard setup` printed once stops being a string on a sticky note.
164
185
 
186
+ Changing your name edits your entry in `config.yml` in place, keeping your role and your project
187
+ access exactly as they were, and refuses a name another account already has. It is your login name
188
+ that changes: the next time you sign in, it is the new one you type. This is self-service and needs
189
+ no admin — the identifier is yours to change, whatever your role.
190
+
165
191
  Removing an account ends its sessions immediately — "remove the account" and "revoke access" are
166
192
  the same act. So does editing `config.yml` by hand: every request looks the account up again, so
167
193
  a demotion takes effect at once rather than at the next restart.
168
194
 
169
- **Upgrading from 0.2.** An existing `server.password_hash` keeps working, unedited. It is read as
170
- a single admin account called `admin` — sign in with that name and the password you already have.
171
- The first time you add someone, the file is rewritten into the `users` form above, comments and
172
- all. Do not write both forms: a file holding a `password_hash` *and* a `users` list is refused at
173
- load, because there is no obvious winner.
174
-
175
195
  ### `laneyard.yml` — in your repository, and committed
176
196
 
177
197
  Build behaviour belongs next to the code, so it can be versioned with it — `laneyard setup`
@@ -203,6 +223,16 @@ Field by field, the repository file wins over the server block, which wins over
203
223
  field of `laneyard.yml` may also be written in the server block, so a repository you would rather
204
224
  not touch can be configured entirely from `config.yml`.
205
225
 
226
+ **A monorepo of several apps carries one `laneyard.yml` per app.** The file may live in the app's
227
+ own directory — beside its fastlane folder — rather than at the repository root, so two apps on one
228
+ git remote each describe their own build without a single root file having to speak for both.
229
+ `laneyard setup` writes it there for you. Inside an app-level file **paths are relative to that
230
+ file's own directory**, not to the repository: `artifact_globs: ["**/*.aab"]` and a plain
231
+ `fastlane_dir: fastlane` (usually left out entirely), so an app moved or duplicated keeps its file
232
+ unchanged. Laneyard finds the app from the project's `fastlane_dir` in `config.yml` and reads the
233
+ file from there; a `laneyard.yml` at the repository root still works and keeps its paths
234
+ repo-root-relative, exactly as before.
235
+
206
236
  Both files are watched: edit them by hand and Laneyard picks the change up. An invalid file is
207
237
  reported and the last valid configuration stays live — a typo never takes the server down.
208
238
 
@@ -440,25 +470,94 @@ artifacts and reports scattered around, and none of them belong in your history.
440
470
 
441
471
  ### Removing a project
442
472
 
443
- Every project has a Settings tab, and the one thing on it is removal. It takes the project's block
444
- out of `config.yml` through the YAML document, so your comments and your key order survive —
445
- and stops showing it. It is confirmed by typing the project's name: it is the one destructive
446
- action in Laneyard, and a dialogue you can click through is not a confirmation.
473
+ Every project has a Settings tab, and the one thing on it is removal. It removes everything
474
+ Laneyard holds for the project, in one confirmed act. It is confirmed by typing the project's
475
+ name: it is the one destructive action in Laneyard, and a dialogue you can click through is not a
476
+ confirmation.
447
477
 
448
- What it does *not* do is most of the point, because "delete" elsewhere usually means the opposite:
478
+ What it removes:
449
479
 
450
- - **the runs stay.** Every build that project ever ran keeps its page, its log and its artifacts,
451
- each still at its own address. Removing a project means stop showing it, not destroy its past;
452
- - **the clone and the artifacts stay on disk.** Their paths are printed when it is done, so you
453
- can remove them yourself. Nothing is deleted from a web page on one click;
454
- - **the secrets stay in the vault**, encrypted and unreachable, and come back if you add the
455
- project again under the same name;
456
- - **the repository is untouched.** Its `laneyard.yml`, its Fastfile and its history are the
457
- repository's, not Laneyard's.
480
+ - **its block in `config.yml`** taken out through the YAML document, so your comments and your
481
+ key order survive;
482
+ - **its run history and its logs.** Every build the project ran, its rows and its logs, deleted.
483
+ This is the one thing here nothing can rebuild, and the reason removal is behind a typed name;
484
+ - **the clone and the artifacts on disk**, deleted;
485
+ - **its secrets and its signing blocks in the vault** — Laneyard's own encrypted copies, forgotten.
486
+ The screen counts them before you confirm and again once it is done, because they are the one
487
+ thing on this list you cannot go and look at: no route ever sends a credential back.
488
+
489
+ What it does *not* touch, said as plainly:
490
+
491
+ - **the git remote.** The repository is on your host and your disk. Laneyard neither reads nor
492
+ writes it;
493
+ - **the credential originals.** The `.p8` and the keystore you uploaded are wherever you keep them
494
+ — a password manager, a safe. Laneyard removes only its own encrypted copy; you would upload them
495
+ again from there;
496
+ - **global secrets and global signing blocks.** They are read by every project on the machine, not
497
+ this one's to take, and they are left alone. The result names how many it left.
458
498
 
459
499
  Removal is refused while a run of that project is in flight — that run is using the workspace. A
460
500
  run still waiting in the queue will not start: it ends as failed, saying its project is gone.
461
501
 
502
+ The same thing from the command line:
503
+
504
+ ```bash
505
+ laneyard remove cartes-ios --dry-run # show what would go, and stop
506
+ laneyard remove cartes-ios # remove it, after a typed confirmation
507
+ ```
508
+
509
+ It removes exactly what the Settings tab does, leaves exactly what it leaves, and is confirmed the
510
+ same way — by typing the project's slug back, not `y`. `--dry-run` prints the inventory and stops.
511
+ It is refused for an unknown slug, and while a run of the project is in flight.
512
+
513
+ ### Resetting
514
+
515
+ ```bash
516
+ laneyard reset --dry-run # show what would go, and stop
517
+ laneyard reset # wipe it, after a typed confirmation
518
+ ```
519
+
520
+ `laneyard reset` wipes the data and keeps you able to use Laneyard: every project, the database,
521
+ the workspaces, the artifacts and the logs go; your accounts and the vault key stay. It is a data
522
+ reset that does not lock you out — you sign in with the same names afterwards — and it keeps the
523
+ key, so any older `laneyard.db` backup stays readable rather than becoming ciphertext nobody can
524
+ open. The database comes back empty from the schema on the next start, which also clears the
525
+ sessions, so everyone signs in again.
526
+
527
+ It keeps the `server:` block of `config.yml` (accounts, port, bind, retention) and
528
+ `~/.laneyard/key`. It never touches the git remotes or the credential originals — those were never
529
+ Laneyard's. It reads the inventory first and, like `uninstall`, is confirmed by typing the
530
+ folder's path, not `y`.
531
+
532
+ ### Uninstalling
533
+
534
+ ```bash
535
+ laneyard uninstall --dry-run # list what is there, and stop
536
+ laneyard uninstall # remove it, after a typed confirmation
537
+ npm uninstall -g laneyard # remove the package itself
538
+ ```
539
+
540
+ `laneyard uninstall` removes the data folder: `config.yml`, the vault key, the database, the
541
+ workspaces, the artifacts and the logs. It reads the whole inventory from disk first — the
542
+ projects, the number of secrets and signing blocks, the real sizes and paths — and prints it
543
+ before asking anything.
544
+
545
+ The vault key is the one loss that cannot be undone. Every secret and every signing block is
546
+ encrypted under `~/.laneyard/key`; once it is gone the database is ciphertext nobody can read, and
547
+ restoring a backup of `laneyard.db` alone brings nothing back. The originals are yours and are
548
+ untouched — the `.p8` in your downloads, the keystore in your safe — so what you are agreeing to
549
+ is uploading them again. Global secrets and global signing blocks are shared by every project and
550
+ go too; the inventory says so on its own line.
551
+
552
+ It is confirmed by typing the folder's path, not `y`: this is the one command in Laneyard that
553
+ destroys credentials, and `$LANEYARD_HOME` is exactly the case where a reflex is wrong. Anything
554
+ in the folder that Laneyard did not put there is named, left alone, and the folder is kept for it.
555
+
556
+ It does not remove the npm package — a command cannot sensibly delete the binary it is running
557
+ from — and it prints the command that does. There is no npm lifecycle hook doing any of this on
558
+ `npm uninstall`, on purpose: a package manager must not delete someone's signing keys on its own,
559
+ and a lifecycle script cannot ask.
560
+
462
561
  ## Security
463
562
 
464
563
  Read this before putting Laneyard on a network.
@@ -522,7 +621,8 @@ What this does *not* cover, stated plainly:
522
621
  - `✓` edit the Fastfile in the browser, verified on every save
523
622
  - `✓` signing credentials stored whole — the file and the fields beside it — written to disk for
524
623
  the length of a run and exported under the names your project already reads
525
- - `✓` remove a project from the interface, without touching its history
624
+ - `✓` remove a project from the interface — everything Laneyard holds for it, behind a typed name
625
+ - `✓` `laneyard uninstall`: the whole inventory first, then a typed confirmation, then the folder
526
626
  - `✓` named accounts, with a builder role that never sees a credential
527
627
  - `○` git-triggered and scheduled builds
528
628
 
@@ -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,173 @@
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 { ConfigStore } from "../config/store.js";
6
+ import { CredentialStore } from "../db/credentials.js";
7
+ import { openDatabase } from "../db/open.js";
8
+ import { RunStore } from "../db/runs.js";
9
+ import { SecretStore } from "../db/secrets.js";
10
+ import { removeProjectData } from "../data/remove-project.js";
11
+ import { LogStore } from "../logs/store.js";
12
+ import { Vault } from "../secrets/vault.js";
13
+ import { readLine } from "./home.js";
14
+ import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
15
+ export const REMOVE_USAGE = `laneyard remove <slug> [--dry-run]
16
+
17
+ Removes everything Laneyard holds for one project: its block in config.yml, its
18
+ clone, its artifacts, its run history and logs, and its own secrets and signing
19
+ blocks in the vault. It does not touch the git remote, the credential originals,
20
+ or the global secrets and signing blocks other projects share.
21
+
22
+ laneyard remove <slug> --dry-run show what would go and stop
23
+
24
+ The run history is the one thing here nothing can rebuild, so it is confirmed by
25
+ typing the project's slug back, not \`y\`.
26
+ `;
27
+ const plural = (n, noun) => `${n} ${n === 1 ? noun : `${noun}s`}`;
28
+ async function readCounts(home, slug) {
29
+ const dbPath = join(home, "laneyard.db");
30
+ if (!existsSync(dbPath))
31
+ return { activeRun: false, runs: 0, secrets: 0, signingBlocks: 0 };
32
+ const sidecars = [`${dbPath}-wal`, `${dbPath}-shm`];
33
+ const ours = await Promise.all(sidecars.map(async (path) => ((await stat(path).catch(() => null)) === null ? path : null)));
34
+ let db = null;
35
+ try {
36
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
37
+ const runs = new RunStore(db);
38
+ return {
39
+ activeRun: runs.hasActiveRun(slug),
40
+ runs: runs.listByProject(slug, -1).length,
41
+ secrets: new SecretStore(db).listOwn(slug).length,
42
+ signingBlocks: new CredentialStore(db).listOwn(slug).length,
43
+ };
44
+ }
45
+ finally {
46
+ db?.close();
47
+ for (const path of ours) {
48
+ if (path !== null)
49
+ await rm(path, { force: true }).catch(() => undefined);
50
+ }
51
+ }
52
+ }
53
+ /** The inventory, and what the removal will and will not reach. */
54
+ function renderInventory(slug, name, home, counts, clonePath) {
55
+ const cloned = existsSync(clonePath);
56
+ return (heading(`laneyard remove "${slug}"`) +
57
+ field("home", home) + "\n" +
58
+ field("project", name === slug ? slug : `${name} (${slug})`) + "\n" +
59
+ heading("what will be removed") +
60
+ field("runs", counts.runs === 0 ? dim("no run yet") : plural(counts.runs, "run")) + "\n" +
61
+ field("clone", cloned ? clonePath : dim("not cloned")) + "\n" +
62
+ field("secrets", plural(counts.secrets, "project secret")) + "\n" +
63
+ field("signing", plural(counts.signingBlocks, "project signing block")) + "\n" +
64
+ heading("what will not be touched") +
65
+ dim(" the git remote — the repository is yours, on your host and your disk.\n") +
66
+ dim(" the credential originals — Laneyard removes only its own encrypted copy;\n") +
67
+ dim(" the .p8 and the keystore you uploaded are wherever you keep them.\n") +
68
+ dim(" global secrets and global signing blocks — shared by every project.\n"));
69
+ }
70
+ /**
71
+ * Entry point for `laneyard remove <slug>`.
72
+ *
73
+ * The command-line equivalent of removing a project from the interface. It reads
74
+ * the inventory first, prints what will and will not go, and only then asks for
75
+ * the slug typed back — the same gate the web route uses, for the same reason:
76
+ * the run history it deletes is the one thing here nothing can rebuild.
77
+ */
78
+ export async function runRemoveCommand(home, args, io) {
79
+ let dryRun = false;
80
+ let slug = null;
81
+ for (const arg of args) {
82
+ if (arg === "--dry-run" || arg === "-n")
83
+ dryRun = true;
84
+ else if (arg.startsWith("-")) {
85
+ io.err(`Unknown option: ${arg}\n\n${REMOVE_USAGE}`);
86
+ return 1;
87
+ }
88
+ else if (slug === null)
89
+ slug = arg;
90
+ else {
91
+ io.err(`One project at a time. Give a single slug.\n\n${REMOVE_USAGE}`);
92
+ return 1;
93
+ }
94
+ }
95
+ if (slug === null) {
96
+ io.err(`Which project? Give its slug.\n\n${REMOVE_USAGE}`);
97
+ return 1;
98
+ }
99
+ const configPath = join(home, "config.yml");
100
+ const config = new ConfigStore(configPath);
101
+ const loaded = await config.load();
102
+ if (!loaded.ok) {
103
+ io.err(`Unreadable configuration in ${configPath}: ${loaded.error}\n`);
104
+ return 1;
105
+ }
106
+ const entry = config.project(slug);
107
+ if (!entry) {
108
+ const known = config.projects().map((p) => p.slug);
109
+ io.err(`${bad(`Unknown project: "${slug}".`)} ` +
110
+ (known.length > 0 ? `Known projects: ${known.join(", ")}.` : "No project is declared yet.") +
111
+ "\n");
112
+ return 1;
113
+ }
114
+ const clonePath = join(home, "workspaces", slug);
115
+ const counts = await readCounts(home, slug);
116
+ // A run that has begun is reading the workspace this project points at.
117
+ // Refused for the same reason the interface refuses it, and with the same way
118
+ // out: wait for it, or cancel it, then remove the project.
119
+ if (counts.activeRun) {
120
+ io.err("\n" +
121
+ bad(`"${slug}" has a run in flight.`) +
122
+ " Wait for it to finish, or cancel it, then remove the project. Nothing was removed.\n");
123
+ return 1;
124
+ }
125
+ io.out(renderInventory(slug, entry.name, home, counts, clonePath));
126
+ if (dryRun) {
127
+ io.out("\n" +
128
+ dim(`Nothing was removed. Run \`laneyard remove ${slug}\` without --dry-run to remove it.`) +
129
+ "\n");
130
+ return 0;
131
+ }
132
+ // Typed in full, not `y`: this deletes a run history nothing can rebuild, and
133
+ // the slug typed back is what proves the person read which project is going.
134
+ io.out("\n" +
135
+ bold("Type the project's slug, exactly, to confirm:") + "\n" +
136
+ ` ${slug}\n` +
137
+ "\n> ");
138
+ const answer = await readLine(io.stdin);
139
+ if (answer !== slug) {
140
+ io.err("\n" +
141
+ bad(answer === ""
142
+ ? "Nothing was typed, so nothing was removed."
143
+ : "That is not the slug, so nothing was removed.") +
144
+ "\n");
145
+ return 1;
146
+ }
147
+ const db = openDatabase(join(home, "laneyard.db"));
148
+ try {
149
+ const result = await removeProjectData({
150
+ configPath,
151
+ reloadConfig: () => config.load(),
152
+ runs: new RunStore(db),
153
+ logs: new LogStore(join(home, "logs")),
154
+ vault: await Vault.open(home, new SecretStore(db), new CredentialStore(db)),
155
+ workspacePath: (s) => join(home, "workspaces", s),
156
+ artifactsDir: (runId) => join(home, "artifacts", String(runId)),
157
+ }, slug);
158
+ io.out(heading("removed") +
159
+ ok(`"${slug}": ${plural(result.runs, "run")}, ${plural(result.secrets, "secret")}, ` +
160
+ `${plural(result.signingBlocks, "signing block")}.\n`) +
161
+ (result.workspace
162
+ ? dim(` ${result.clonePath} is gone.\n`)
163
+ : dim(" There was no clone to remove.\n")) +
164
+ dim(` The block is out of ${configPath}.\n`) +
165
+ "\n" +
166
+ warn("The git remote and your credential originals were never Laneyard's to touch, and " +
167
+ "global secrets and signing blocks were left alone.\n"));
168
+ return 0;
169
+ }
170
+ finally {
171
+ db.close();
172
+ }
173
+ }
@@ -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
+ }