laneyard 0.4.1 → 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 +95 -34
- package/dist/src/cli/home.js +46 -0
- package/dist/src/cli/remove.js +173 -0
- package/dist/src/cli/reset.js +117 -0
- package/dist/src/cli/setup.js +93 -11
- package/dist/src/cli/uninstall.js +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 +15 -5
- 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/logs/store.js +9 -1
- package/dist/src/main.js +23 -1
- 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/fastlane-dir.js +8 -8
- package/dist/web/assets/{Editor-9nLYwtg7.js → Editor-Bqz_dClT.js} +1 -1
- package/dist/web/assets/index-CMEreqtd.js +62 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-pzPa9EZr.js +0 -62
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**, which is said in
|
|
159
|
-
and again on your own row of the accounts screen — a builder included,
|
|
160
|
-
person rather than about the server's list of people.
|
|
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,33 +470,64 @@ 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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
What it
|
|
449
|
-
|
|
450
|
-
- **
|
|
451
|
-
|
|
452
|
-
- **
|
|
453
|
-
|
|
454
|
-
- **the
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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.
|
|
477
|
+
|
|
478
|
+
What it removes:
|
|
479
|
+
|
|
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.
|
|
461
498
|
|
|
462
499
|
Removal is refused while a run of that project is in flight — that run is using the workspace. A
|
|
463
500
|
run still waiting in the queue will not start: it ends as failed, saying its project is gone.
|
|
464
501
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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`.
|
|
470
531
|
|
|
471
532
|
### Uninstalling
|
|
472
533
|
|
|
@@ -560,7 +621,7 @@ What this does *not* cover, stated plainly:
|
|
|
560
621
|
- `✓` edit the Fastfile in the browser, verified on every save
|
|
561
622
|
- `✓` signing credentials stored whole — the file and the fields beside it — written to disk for
|
|
562
623
|
the length of a run and exported under the names your project already reads
|
|
563
|
-
- `✓` remove a project from the interface,
|
|
624
|
+
- `✓` remove a project from the interface — everything Laneyard holds for it, behind a typed name
|
|
564
625
|
- `✓` `laneyard uninstall`: the whole inventory first, then a typed confirmation, then the folder
|
|
565
626
|
- `✓` named accounts, with a builder role that never sees a credential
|
|
566
627
|
- `○` git-triggered and scheduled builds
|
|
@@ -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
|
+
}
|