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.
@@ -1,8 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
- import { Document, parseDocument, YAMLSeq } from "yaml";
3
+ import { Document, parseDocument, YAMLMap, YAMLSeq } from "yaml";
4
4
  import { hashPassword } from "../server/auth.js";
5
- import { LEGACY_ADMIN_NAME } from "./load.js";
6
5
  import { serializeYaml } from "./yaml.js";
7
6
  /**
8
7
  * Accounts, as they are written to and taken out of config.yml.
@@ -53,25 +52,6 @@ async function open(path) {
53
52
  doc = new Document({});
54
53
  return doc;
55
54
  }
56
- /**
57
- * Turns a lone `server.password_hash` into the `users` form, in place.
58
- *
59
- * A file holding both is the one combination the loader refuses, so adding a
60
- * colleague to a 0.2 installation has to migrate it — otherwise the act of
61
- * inviting someone is the act of breaking the server's configuration.
62
- *
63
- * The name is the one the loader already reads that hash under, so nobody's
64
- * password changes meaning on the way through.
65
- */
66
- function migrateLegacyHash(doc) {
67
- const hash = doc.getIn(["server", "password_hash"]);
68
- if (typeof hash !== "string")
69
- return;
70
- doc.deleteIn(["server", "password_hash"]);
71
- if (doc.hasIn(["server", "users"]))
72
- return;
73
- doc.setIn(["server", "users"], doc.createNode([{ name: LEGACY_ADMIN_NAME, role: "admin", password_hash: hash }]));
74
- }
75
55
  /** The accounts sequence, created if the file has none yet. */
76
56
  function usersSeq(doc) {
77
57
  const existing = doc.getIn(["server", "users"]);
@@ -94,21 +74,109 @@ const nameOf = (item) => item.get?.("name");
94
74
  */
95
75
  export async function upsertUserInConfig(path, entry) {
96
76
  const doc = await open(path);
97
- migrateLegacyHash(doc);
98
77
  const seq = usersSeq(doc);
99
- const stored = {
100
- name: entry.name,
101
- role: entry.role,
102
- password_hash: hashPassword(entry.password),
103
- };
104
78
  const at = seq.items.findIndex((item) => nameOf(item) === entry.name);
105
- const node = doc.createNode(stored);
79
+ const password_hash = hashPassword(entry.password);
80
+ if (at === -1) {
81
+ // A new account starts with no access: `projects: []` is the empty grant,
82
+ // told apart from an absent field, which would mean "every project". A
83
+ // builder is thus created seeing nothing until an admin grants a project;
84
+ // an admin ignores the field, and carries it only so the entries are
85
+ // uniform.
86
+ seq.add(doc.createNode({ name: entry.name, role: entry.role, password_hash, projects: [] }));
87
+ await writeFile(path, serializeYaml(doc), "utf8");
88
+ return { created: true };
89
+ }
90
+ // Replacing changes the role and the password and nothing else: the fields are
91
+ // set on the existing node rather than a fresh one put in its place, so a
92
+ // hand-added `projects` grant — and any comment or key order — survives a
93
+ // password change it has nothing to do with.
94
+ const item = seq.items[at];
95
+ item.set("role", entry.role);
96
+ item.set("password_hash", password_hash);
97
+ await writeFile(path, serializeYaml(doc), "utf8");
98
+ return { created: false };
99
+ }
100
+ /**
101
+ * Renames an account in place, keeping everything else it carried.
102
+ *
103
+ * A rename is not an upsert. `upsertUserInConfig` keys by name, so handing it the
104
+ * new name would add a second account and orphan the first — dropping the role,
105
+ * the hash and, most quietly of all, the `projects` grants the old entry held.
106
+ * So the edit is to the `name` field of the matching node, through the YAML
107
+ * document, which leaves role, password_hash, projects and the file's comments
108
+ * and key order exactly where they were.
109
+ *
110
+ * Refuses when `newName` already belongs to another account rather than folding
111
+ * two entries into one, and refuses when no account carried `oldName` — there is
112
+ * nothing there to rename. Returns whether the rename happened, which is the
113
+ * difference between a fresh name and a collision in a sentence.
114
+ */
115
+ export async function renameUserInConfig(path, oldName, newName) {
116
+ const doc = await open(path);
117
+ const users = doc.getIn(["server", "users"]);
118
+ if (!(users instanceof YAMLSeq))
119
+ return false;
120
+ // Checked before the target is looked up: a name already in the file must not
121
+ // be written onto a second entry, whoever else already holds it.
122
+ if (users.items.some((item) => nameOf(item) === newName))
123
+ return false;
124
+ const at = users.items.findIndex((item) => nameOf(item) === oldName);
106
125
  if (at === -1)
107
- seq.add(node);
108
- else
109
- seq.items[at] = node;
126
+ return false;
127
+ users.items[at].set("name", newName);
110
128
  await writeFile(path, serializeYaml(doc), "utf8");
111
- return { created: at === -1 };
129
+ return true;
130
+ }
131
+ /**
132
+ * Writes an account's project grants, replacing whatever list it had.
133
+ *
134
+ * The one edit behind `PUT /api/users/:name/projects`: it sets `projects` on the
135
+ * named account through the YAML document, so a hand-written file keeps its
136
+ * comments and its key order. Returns false when no account carried that name,
137
+ * so the caller can answer 404 rather than write a grant onto nobody.
138
+ */
139
+ export async function setUserProjectsInConfig(path, name, projects) {
140
+ const doc = await open(path);
141
+ const users = doc.getIn(["server", "users"]);
142
+ if (!(users instanceof YAMLSeq))
143
+ return false;
144
+ const at = users.items.findIndex((item) => nameOf(item) === name);
145
+ if (at === -1)
146
+ return false;
147
+ users.items[at].set("projects", doc.createNode(projects));
148
+ await writeFile(path, serializeYaml(doc), "utf8");
149
+ return true;
150
+ }
151
+ /**
152
+ * Strips a project's slug from every account's grants.
153
+ *
154
+ * Called when a project is removed: a grant pointing at a project that no longer
155
+ * exists is dead data, and — the same hazard as a re-used vault slug — a project
156
+ * re-created later under that slug must not silently inherit an old grant. Only
157
+ * accounts that carried the slug are touched, so a file with no grants at all
158
+ * comes back byte-for-byte unchanged.
159
+ */
160
+ export async function removeProjectFromAccounts(path, slug) {
161
+ const doc = await open(path);
162
+ const users = doc.getIn(["server", "users"]);
163
+ if (!(users instanceof YAMLSeq))
164
+ return;
165
+ let changed = false;
166
+ for (const item of users.items) {
167
+ if (!(item instanceof YAMLMap))
168
+ continue;
169
+ const projects = item.get("projects");
170
+ if (!(projects instanceof YAMLSeq))
171
+ continue;
172
+ const at = projects.items.findIndex((n) => (n && typeof n === "object" && "value" in n ? n.value : n) === slug);
173
+ if (at !== -1) {
174
+ projects.items.splice(at, 1);
175
+ changed = true;
176
+ }
177
+ }
178
+ if (changed)
179
+ await writeFile(path, serializeYaml(doc), "utf8");
112
180
  }
113
181
  /**
114
182
  * Takes an account out of config.yml, leaving the rest of the file alone.
@@ -118,7 +186,6 @@ export async function upsertUserInConfig(path, entry) {
118
186
  */
119
187
  export async function removeUserFromConfig(path, name) {
120
188
  const doc = await open(path);
121
- migrateLegacyHash(doc);
122
189
  const users = doc.getIn(["server", "users"]);
123
190
  if (!(users instanceof YAMLSeq))
124
191
  return false;
@@ -130,7 +197,7 @@ export async function removeUserFromConfig(path, name) {
130
197
  return true;
131
198
  }
132
199
  /**
133
- * Does this file already declare somebody, in either of the two forms?
200
+ * Does this file already declare somebody?
134
201
  *
135
202
  * Asked before a question is put to the user rather than after: `laneyard setup`
136
203
  * only asks for an admin's name on a machine that has none, and asking anyway
@@ -138,7 +205,7 @@ export async function removeUserFromConfig(path, name) {
138
205
  */
139
206
  export async function hasAccount(path) {
140
207
  const doc = await open(path);
141
- return doc.hasIn(["server", "password_hash"]) || doc.hasIn(["server", "users"]);
208
+ return doc.hasIn(["server", "users"]);
142
209
  }
143
210
  /**
144
211
  * Creates the first admin if the file declares no account at all.
@@ -151,12 +218,9 @@ export async function hasAccount(path) {
151
218
  */
152
219
  export async function ensureFirstAdmin(path, name) {
153
220
  const doc = await open(path);
154
- if (doc.hasIn(["server", "password_hash"]) || doc.hasIn(["server", "users"]))
221
+ if (doc.hasIn(["server", "users"]))
155
222
  return null;
156
223
  const password = randomBytes(9).toString("base64url");
157
- // The `users` form, never a bare `password_hash`: writing the legacy shape on
158
- // a fresh machine would mean every new installation immediately needs the
159
- // migration above the first time someone adds a colleague.
160
224
  doc.setIn(["server", "users"], doc.createNode([{ name, role: "admin", password_hash: hashPassword(password) }]));
161
225
  await writeFile(path, serializeYaml(doc), "utf8");
162
226
  return password;
@@ -1,13 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { parse as parseYaml } from "yaml";
3
3
  import { repoConfigSchema, serverConfigSchema } from "./schema.js";
4
- /**
5
- * The name a lone `password_hash` is read under.
6
- *
7
- * Also the name the legacy `{ password }` login form authenticates as, which is
8
- * what lets a 0.2 installation upgrade without anyone editing a file.
9
- */
10
- export const LEGACY_ADMIN_NAME = "admin";
11
4
  /** Reads and validates a YAML file. Never fails by throwing: the caller decides. */
12
5
  async function loadYamlFile(path, schema) {
13
6
  let raw;
@@ -34,26 +27,13 @@ async function loadYamlFile(path, schema) {
34
27
  return { ok: true, config: parsed.data };
35
28
  }
36
29
  /**
37
- * Folds both ways of declaring accounts into the one the rest of the code sees.
30
+ * Validates the declared accounts.
38
31
  *
39
32
  * Every refusal here is a locked room avoided: a server with no account, or
40
33
  * with none that can administer it, is one nobody can fix from the interface.
41
34
  */
42
35
  function normaliseUsers(server) {
43
- const { password_hash, users } = server;
44
- if (password_hash !== undefined && users !== undefined) {
45
- return {
46
- ok: false,
47
- error: "server.password_hash and server.users both set — they are two ways to say the same " +
48
- "thing and there is no obvious winner. Keep server.users and drop server.password_hash.",
49
- };
50
- }
51
- if (password_hash !== undefined) {
52
- return {
53
- ok: true,
54
- users: [{ name: LEGACY_ADMIN_NAME, role: "admin", password_hash }],
55
- };
56
- }
36
+ const { users } = server;
57
37
  if (users === undefined) {
58
38
  return {
59
39
  ok: false,
@@ -95,11 +75,7 @@ export async function loadServerConfig(path) {
95
75
  }
96
76
  // The display name falls back to the slug rather than being optional everywhere downstream.
97
77
  const projects = res.config.projects.map((p) => ({ ...p, name: p.name ?? p.slug }));
98
- // `password_hash` is dropped rather than carried along: it has been folded
99
- // into `users`, and leaving it in place would give downstream code a second
100
- // source of truth to disagree with.
101
- const { password_hash: _dropped, ...server } = res.config.server;
102
- return { ok: true, config: { server: { ...server, users: accounts.users }, projects } };
78
+ return { ok: true, config: { server: { ...res.config.server, users: accounts.users }, projects } };
103
79
  }
104
80
  export async function loadRepoConfig(path) {
105
81
  return loadYamlFile(path, repoConfigSchema);
@@ -1,5 +1,41 @@
1
1
  import { projectSettingsSchema } from "./schema.js";
2
2
  const SETTING_KEYS = Object.keys(projectSettingsSchema.shape);
3
+ /**
4
+ * Prefixes an app-relative path with the app directory.
5
+ *
6
+ * `.` (or an empty prefix) is the repository root, and a path there is already
7
+ * repo-root-relative — nothing to add.
8
+ */
9
+ function underApp(appRoot, p) {
10
+ return appRoot === "" || appRoot === "." ? p : `${appRoot}/${p}`;
11
+ }
12
+ /**
13
+ * Reads an app-level `laneyard.yml` as if it had been written at the repository
14
+ * root.
15
+ *
16
+ * The file declares its paths relative to its own directory —
17
+ * `fastlane_dir: fastlane`, `artifact_globs: ['**​/*.aab']` — so an app moved or
18
+ * duplicated keeps its file unchanged. But everything downstream resolves paths
19
+ * as repo-root-relative (`join(workspacePath, …)`, globs with `cwd:
20
+ * workspacePath`). So the app-relativity is collapsed here, once, at the
21
+ * boundary: the two path fields are prefixed with the app directory before the
22
+ * merge ever sees them, and nothing past this point learns a new rule.
23
+ *
24
+ * Only the path fields move. `platforms`, `runtime`, `timeout_minutes`,
25
+ * `interactive_default`, `required_secrets` and `retention` are not paths and
26
+ * pass through untouched. A root-level file is never handed here: its paths are
27
+ * already repo-root-relative.
28
+ */
29
+ export function normaliseAppConfig(repo, appRoot) {
30
+ const out = { ...repo };
31
+ if (repo.fastlane_dir !== undefined) {
32
+ out.fastlane_dir = underApp(appRoot, repo.fastlane_dir);
33
+ }
34
+ if (repo.artifact_globs !== undefined) {
35
+ out.artifact_globs = repo.artifact_globs.map((g) => underApp(appRoot, g));
36
+ }
37
+ return out;
38
+ }
3
39
  /**
4
40
  * Merges the three sources field by field.
5
41
  * `undefined` means "not set"; any other value, including an empty array
@@ -58,16 +58,26 @@ export const userEntrySchema = z.object({
58
58
  name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "name: letters, digits, dot, dash, underscore"),
59
59
  role: userRoleSchema,
60
60
  password_hash: z.string().min(1),
61
+ /**
62
+ * Which projects a builder may reach, by slug. Three-way, and the three ways
63
+ * carry back-compat for free:
64
+ *
65
+ * absent → every project. A file written before this feature has no field
66
+ * on anyone, so nobody loses access on upgrade.
67
+ * [] → no project. What account creation now writes, so a new builder
68
+ * starts with nothing until granted.
69
+ * a list → exactly those slugs.
70
+ *
71
+ * An `admin` ignores it entirely: managing the server is their whole role.
72
+ */
73
+ projects: z.array(slugSchema).optional(),
61
74
  });
62
75
  export const serverConfigSchema = z.object({
63
76
  server: z.object({
64
77
  port: z.number().int().positive().default(7890),
65
78
  bind: z.string().default("0.0.0.0"),
66
- // Both optional in the file, exactly one required by the loader. The old
67
- // single-password form is what a 0.2 installation has on disk, and it must
68
- // keep working unedited; `load.ts` normalises it into `users` so that
69
- // nothing downstream ever learns which of the two forms was written.
70
- password_hash: z.string().min(1).optional(),
79
+ // Optional in the file, required by the loader: a configuration that
80
+ // declares no account is refused rather than started.
71
81
  users: z.array(userEntrySchema).optional(),
72
82
  // Only 1 is accepted. Runs share one working directory per project, so a
73
83
  // higher number would promise parallel builds that never happen — the
@@ -1,6 +1,7 @@
1
1
  import { watch } from "node:fs";
2
2
  import { loadRepoConfig, loadServerConfig } from "./load.js";
3
- import { resolveProjectSettings } from "./resolve.js";
3
+ import { normaliseAppConfig, resolveProjectSettings } from "./resolve.js";
4
+ import { appRootOf } from "../heuristics/platforms.js";
4
5
  import { join } from "node:path";
5
6
  /**
6
7
  * The server's live configuration.
@@ -68,13 +69,36 @@ export class ConfigStore {
68
69
  * Resolves a project's effective settings by reading its workspace's
69
70
  * laneyard.yml if it exists. The workspace may not be cloned yet: we
70
71
  * then fall back to the project's block and the defaults.
72
+ *
73
+ * The file is looked for in two places, in order:
74
+ *
75
+ * 1. `<workspace>/<appRoot>/laneyard.yml` — the app-level file, its paths
76
+ * relative to the app's own directory, so a monorepo of N apps carries N
77
+ * of them. Normalised back to repo-root-relative before the merge.
78
+ * 2. `<workspace>/laneyard.yml` — the repository-root file, repo-root-relative,
79
+ * which is what existing installs have and keeps working unchanged.
80
+ *
81
+ * `appRoot` is derived from the project's `fastlane_dir` as declared in
82
+ * `config.yml` — the server-side anchor, present for every monorepo project by
83
+ * necessity and what points at the app before any repo file is read. When it is
84
+ * the repository root (the default `fastlane`), the two locations coincide and
85
+ * nothing changes.
71
86
  */
72
87
  async resolve(slug, workspacePath) {
73
88
  const entry = this.project(slug);
74
89
  if (!entry)
75
90
  return null;
76
- const repoRes = await loadRepoConfig(join(workspacePath, "laneyard.yml"));
77
- const repo = repoRes.ok ? repoRes.config : null;
91
+ const appRoot = appRootOf(entry.fastlane_dir);
92
+ let repo = null;
93
+ if (appRoot !== ".") {
94
+ const appRes = await loadRepoConfig(join(workspacePath, appRoot, "laneyard.yml"));
95
+ if (appRes.ok)
96
+ repo = normaliseAppConfig(appRes.config, appRoot);
97
+ }
98
+ if (repo === null) {
99
+ const rootRes = await loadRepoConfig(join(workspacePath, "laneyard.yml"));
100
+ repo = rootRes.ok ? rootRes.config : null;
101
+ }
78
102
  const { settings, provenance } = resolveProjectSettings(entry, repo);
79
103
  return { entry, settings, provenance };
80
104
  }
@@ -0,0 +1,70 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { removeProjectFromAccounts } from "../config/accounts.js";
4
+ import { removeProjectFromConfig } from "../cli/setup.js";
5
+ /**
6
+ * Removes everything Laneyard holds for one project, and reports what went.
7
+ *
8
+ * The block leaves config.yml, through the YAML document so the rest of a
9
+ * hand-written file is untouched; the clone is deleted; every artifact folder
10
+ * goes; the run history — the rows and their logs — is deleted; and the
11
+ * project's own secrets and signing blocks are forgotten from the vault. The
12
+ * history is the one thing here that cannot be made again.
13
+ *
14
+ * What it does not reach, and why each is out of scope:
15
+ *
16
+ * - the git remote. The repository is on the host and the user's disk. It is
17
+ * theirs, not Laneyard's, and nothing here reads or writes it.
18
+ * - the credential originals. Laneyard removes its own encrypted copy of a
19
+ * `.p8` or a keystore; the file that went in is still in the password manager
20
+ * or the safe it came from.
21
+ * - global secrets and global signing blocks. They are read by every project on
22
+ * the machine — `vault.forget` touches only slug-scoped rows.
23
+ *
24
+ * It removes; it does not confirm and it does not shape a reply. The callers do
25
+ * that: the route behind a slug typed back, the CLI behind the same. The one
26
+ * irreversible thing must not be reachable without one of them.
27
+ */
28
+ export async function removeProjectData(deps, slug) {
29
+ const clonePath = deps.workspacePath(slug);
30
+ // Read before anything is touched. -1 is SQLite's "no limit": every run of the
31
+ // project, because each one names an artifact folder and a log file to remove.
32
+ const runs = deps.runs.listByProject(slug, -1);
33
+ // The config block first: once it is gone the project cannot be started, so
34
+ // nothing new begins reading the files the rest of this is about to remove.
35
+ const removed = await removeProjectFromConfig(deps.configPath, slug);
36
+ if (!removed) {
37
+ return { found: false, runs: 0, artifacts: 0, workspace: false, clonePath, secrets: 0, signingBlocks: 0 };
38
+ }
39
+ // The slug also leaves every account's grants, in the same file: a grant onto
40
+ // a project that no longer exists is dead data, and a slug re-used later must
41
+ // not silently inherit it.
42
+ await removeProjectFromAccounts(deps.configPath, slug);
43
+ await deps.reloadConfig();
44
+ // The clone.
45
+ const workspace = existsSync(clonePath);
46
+ await rm(clonePath, { recursive: true, force: true });
47
+ // The artifacts and the logs, one of each per run that produced them.
48
+ let artifacts = 0;
49
+ for (const run of runs) {
50
+ const dir = deps.artifactsDir(run.id);
51
+ if (existsSync(dir))
52
+ artifacts += 1;
53
+ await rm(dir, { recursive: true, force: true });
54
+ await deps.logs.remove(run.id);
55
+ }
56
+ // The run history: the rows, and their steps and artifact records by cascade.
57
+ deps.runs.removeByProject(slug);
58
+ // The project's own secrets and signing blocks. Slug-scoped only: a global
59
+ // secret three other projects read is not this one's to take.
60
+ const forgotten = deps.vault.forget(slug);
61
+ return {
62
+ found: true,
63
+ runs: runs.length,
64
+ artifacts,
65
+ workspace,
66
+ clonePath,
67
+ secrets: forgotten.secrets,
68
+ signingBlocks: forgotten.credentials,
69
+ };
70
+ }
@@ -46,6 +46,31 @@ export class CredentialStore {
46
46
  list(projectSlug) {
47
47
  return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
48
48
  }
49
+ /**
50
+ * The blocks stored under this slug, and no global one.
51
+ *
52
+ * Same distinction as `SecretStore.listOwn`, and it matters more here: a
53
+ * global keystore shadowed by nothing is shared by every project on the
54
+ * machine, and counting it as one project's would offer to delete the one
55
+ * credential every other project signs with.
56
+ */
57
+ listOwn(projectSlug) {
58
+ if (projectSlug === GLOBAL)
59
+ return [];
60
+ const rows = this.db
61
+ .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
62
+ .all(projectSlug);
63
+ return rows.map((row) => {
64
+ const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
65
+ return summary;
66
+ });
67
+ }
68
+ /** Removes every block stored under this slug, and returns how many. */
69
+ removeAllOwn(projectSlug) {
70
+ if (projectSlug === GLOBAL)
71
+ return 0;
72
+ return this.db.prepare("DELETE FROM credential WHERE project_slug = ?").run(projectSlug).changes;
73
+ }
49
74
  listGlobal() {
50
75
  const rows = this.db
51
76
  .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
@@ -39,6 +39,17 @@ export class RunStore {
39
39
  .all(slug, limit);
40
40
  return rows.map(toRun);
41
41
  }
42
+ /**
43
+ * Deletes every run of a project, and returns how many rows went.
44
+ *
45
+ * The steps and the artifact records go with them, by the `ON DELETE CASCADE`
46
+ * on their foreign keys — with `foreign_keys = ON`, which `openDatabase` sets.
47
+ * The log files and the artifact folders live on disk, not here: the caller
48
+ * removes those, which is why it reads the run ids before calling this.
49
+ */
50
+ removeByProject(slug) {
51
+ return this.db.prepare("DELETE FROM run WHERE project_slug = ?").run(slug).changes;
52
+ }
42
53
  setStatus(id, status) {
43
54
  this.db.prepare("UPDATE run SET status = ? WHERE id = ?").run(status, id);
44
55
  }
@@ -62,6 +62,31 @@ export class SecretStore {
62
62
  WHERE project_slug = ? AND key = ?`)
63
63
  .run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
64
64
  }
65
+ /**
66
+ * The rows stored under this slug, and no global one.
67
+ *
68
+ * `list` answers "what does this project see", which is the right question
69
+ * everywhere else and the wrong one when a project is going away: a global
70
+ * secret three other projects also read is not this one's to count, and
71
+ * certainly not its to remove.
72
+ *
73
+ * The empty slug is the global scope's own key, so it is refused here rather
74
+ * than quietly returning every global row as if one project owned them.
75
+ */
76
+ listOwn(projectSlug) {
77
+ if (projectSlug === GLOBAL)
78
+ return [];
79
+ const rows = this.db
80
+ .prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
81
+ .all(projectSlug);
82
+ return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "project" }));
83
+ }
84
+ /** Removes every row stored under this slug, and returns how many. */
85
+ removeAllOwn(projectSlug) {
86
+ if (projectSlug === GLOBAL)
87
+ return 0;
88
+ return this.db.prepare("DELETE FROM secret WHERE project_slug = ?").run(projectSlug).changes;
89
+ }
65
90
  list(projectSlug) {
66
91
  return this.applicable(projectSlug).map((row) => ({
67
92
  key: row.key,
@@ -1,5 +1,5 @@
1
1
  import { createReadStream } from "node:fs";
2
- import { mkdir, open, readFile } from "node:fs/promises";
2
+ import { mkdir, open, readFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  /**
5
5
  * Append-only writer for a run.
@@ -63,4 +63,12 @@ export class LogStore {
63
63
  stream(runId, fromOffset = 0) {
64
64
  return createReadStream(this.pathFor(runId), { start: fromOffset });
65
65
  }
66
+ /**
67
+ * Removes a run's log file. A run that never opened one is not an error:
68
+ * `force` makes a missing file a no-op, which is exactly the case for a run
69
+ * that failed before it wrote a line.
70
+ */
71
+ async remove(runId) {
72
+ await rm(this.pathFor(runId), { force: true });
73
+ }
66
74
  }
package/dist/src/main.js CHANGED
@@ -7,7 +7,10 @@ import { bold, dim, field, heading } from "./cli/style.js";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { PromptAborted } from "./cli/prompt.js";
9
9
  import { runSetupCommand } from "./cli/setup.js";
10
+ import { runRemoveCommand } from "./cli/remove.js";
11
+ import { runResetCommand } from "./cli/reset.js";
10
12
  import { runSecretCommand } from "./cli/secret.js";
13
+ import { runUninstallCommand } from "./cli/uninstall.js";
11
14
  import { runUserCommand } from "./cli/user.js";
12
15
  import { ConfigStore } from "./config/store.js";
13
16
  import { CacheStore } from "./db/cache.js";
@@ -20,7 +23,7 @@ import { Vault } from "./secrets/vault.js";
20
23
  import { makeInvoke } from "./sidecar/bridge.js";
21
24
  import { LaneReader } from "./sidecar/lanes.js";
22
25
  import { UsesReader } from "./sidecar/uses.js";
23
- export const version = "0.3.0";
26
+ export const version = "0.5.0";
24
27
  /** Assembles the server from a data folder. */
25
28
  export async function createServerFromConfig(root) {
26
29
  const config = new ConfigStore(join(root, "config.yml"));
@@ -111,6 +114,13 @@ const USAGE = `laneyard — a self-hosted web UI for fastlane
111
114
  store a secret, its value read from standard input
112
115
  laneyard user add NAME [--role admin|builder]
113
116
  create an account, its password read from standard input
117
+ laneyard remove <slug>
118
+ remove one project and everything Laneyard holds for it
119
+ --dry-run shows what would go and stops
120
+ laneyard reset wipe the data, keeping the accounts and the vault key
121
+ --dry-run shows what would go and stops
122
+ laneyard uninstall remove Laneyard's data folder, after showing what is in it
123
+ --dry-run shows the inventory and stops
114
124
  laneyard start the server
115
125
  laneyard --version print the version
116
126
 
@@ -186,6 +196,30 @@ if (invokedDirectly()) {
186
196
  err: (text) => process.stderr.write(text),
187
197
  }));
188
198
  }
199
+ // No `mkdir` here, unlike the commands above: this one asks what is there
200
+ // and would look ridiculous creating the folder it is about to report on.
201
+ if (command === "uninstall") {
202
+ process.exit(await runUninstallCommand(homeDir(), rest, {
203
+ stdin: process.stdin,
204
+ out: (text) => process.stdout.write(text),
205
+ err: (text) => process.stderr.write(text),
206
+ }));
207
+ }
208
+ // No `mkdir` either: like `uninstall`, these read what is already there.
209
+ if (command === "remove") {
210
+ process.exit(await runRemoveCommand(homeDir(), rest, {
211
+ stdin: process.stdin,
212
+ out: (text) => process.stdout.write(text),
213
+ err: (text) => process.stderr.write(text),
214
+ }));
215
+ }
216
+ if (command === "reset") {
217
+ process.exit(await runResetCommand(homeDir(), rest, {
218
+ stdin: process.stdin,
219
+ out: (text) => process.stdout.write(text),
220
+ err: (text) => process.stderr.write(text),
221
+ }));
222
+ }
189
223
  // 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
190
224
  // sentence rather than "Unknown command".
191
225
  if (command === "add") {
@@ -33,6 +33,38 @@ export class Vault {
33
33
  listGlobal() {
34
34
  return this.store.listGlobal();
35
35
  }
36
+ /**
37
+ * What the vault holds under one project's own name — secrets and signing
38
+ * blocks — and nothing global.
39
+ *
40
+ * Its own method rather than a filter over `list`, because the question is a
41
+ * different one: `list` is "what would a run of this project see", and this is
42
+ * "what would be left behind if the project went away". A global secret is in
43
+ * the first answer and must never be in the second.
44
+ */
45
+ ownedBy(projectSlug) {
46
+ return {
47
+ secrets: this.store.listOwn(projectSlug),
48
+ credentials: this.credentials.listOwn(projectSlug),
49
+ };
50
+ }
51
+ /**
52
+ * Forgets everything stored under one project's name.
53
+ *
54
+ * Part of removing a project: that act clears the vault along with the clone,
55
+ * the artifacts and the run history, behind the one typed confirmation that
56
+ * covers all of it. What it forgets is Laneyard's own encrypted copy — the
57
+ * `.p8` and the keystore that went in are still wherever the user keeps them.
58
+ *
59
+ * Scoped by slug, so a global secret or a global signing block survives it:
60
+ * those are read by every project and are never one project's to remove.
61
+ */
62
+ forget(projectSlug) {
63
+ return {
64
+ secrets: this.store.removeAllOwn(projectSlug),
65
+ credentials: this.credentials.removeAllOwn(projectSlug),
66
+ };
67
+ }
36
68
  /**
37
69
  * Removes this project's secret values from a piece of text, in one shot.
38
70
  *