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 +124 -24
- 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 +368 -0
- 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/credentials.js +25 -0
- package/dist/src/db/runs.js +11 -0
- package/dist/src/db/secrets.js +25 -0
- package/dist/src/logs/store.js +9 -1
- package/dist/src/main.js +35 -1
- package/dist/src/secrets/vault.js +32 -0
- 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 +100 -33
- package/dist/src/server/routes/users.js +47 -4
- package/dist/src/sidecar/fastlane-dir.js +8 -8
- package/dist/web/assets/{Editor-DynuBC2l.js → Editor-Bqz_dClT.js} +1 -1
- package/dist/web/assets/index-CMEreqtd.js +62 -0
- package/dist/web/assets/index-DsjxZtsO.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +0 -1
- package/dist/web/assets/index-lyZs-Y7J.js +0 -62
|
@@ -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
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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", "
|
|
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", "
|
|
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;
|
package/dist/src/config/load.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
67
|
-
//
|
|
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
|
package/dist/src/config/store.js
CHANGED
|
@@ -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
|
|
77
|
-
|
|
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")
|
package/dist/src/db/runs.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/db/secrets.js
CHANGED
|
@@ -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,
|
package/dist/src/logs/store.js
CHANGED
|
@@ -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.
|
|
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
|
*
|