laneyard 0.1.0 → 0.3.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 +238 -32
- package/dist/src/cli/detect.js +60 -16
- package/dist/src/cli/prompt.js +36 -0
- package/dist/src/cli/secret.js +133 -0
- package/dist/src/cli/setup.js +282 -0
- package/dist/src/cli/style.js +31 -0
- package/dist/src/cli/user.js +127 -0
- package/dist/src/config/accounts.js +163 -0
- package/dist/src/config/load.js +61 -1
- package/dist/src/config/schema.js +47 -4
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- package/dist/src/db/cache.js +8 -8
- package/dist/src/db/open.js +15 -0
- package/dist/src/db/runs.js +51 -6
- package/dist/src/db/schema.sql +20 -2
- package/dist/src/db/secrets.js +64 -0
- package/dist/src/fastfile/store.js +53 -0
- package/dist/src/git/workspace.js +95 -5
- package/dist/src/heuristics/blocking-actions.js +65 -0
- package/dist/src/heuristics/platforms.js +77 -0
- package/dist/src/heuristics/readiness.js +336 -0
- package/dist/src/logs/redact.js +86 -0
- package/dist/src/main.js +79 -9
- package/dist/src/runner/orchestrate.js +60 -10
- package/dist/src/runner/pty.js +27 -15
- package/dist/src/runner/queue.js +114 -0
- package/dist/src/secrets/cipher.js +28 -0
- package/dist/src/secrets/key.js +40 -0
- package/dist/src/secrets/vault.js +68 -0
- package/dist/src/server/app.js +135 -13
- package/dist/src/server/auth.js +85 -17
- package/dist/src/server/permissions.js +73 -0
- package/dist/src/server/routes/fastfile.js +131 -0
- package/dist/src/server/routes/projects.js +50 -1
- package/dist/src/server/routes/readiness.js +102 -0
- package/dist/src/server/routes/runs.js +27 -45
- package/dist/src/server/routes/secrets.js +51 -0
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +17 -1
- package/dist/src/sidecar/lanes.js +2 -2
- package/dist/src/sidecar/uses.js +40 -0
- package/dist/web/assets/Editor-DNFBA4gv.js +14 -0
- package/dist/web/assets/index-De6sxx6G.css +1 -0
- package/dist/web/assets/index-TWRQ1cJg.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +13 -5
- package/ruby/introspect.rb +80 -0
- package/dist/tests/cli/add.test.js +0 -64
- package/dist/tests/cli/detect.test.js +0 -63
- package/dist/tests/config/load.test.js +0 -68
- package/dist/tests/config/resolve.test.js +0 -44
- package/dist/tests/config/store.test.js +0 -58
- package/dist/tests/db/runs.test.js +0 -54
- package/dist/tests/e2e/full-thread.test.js +0 -65
- package/dist/tests/fixtures/repos.js +0 -30
- package/dist/tests/git/workspace.test.js +0 -66
- package/dist/tests/heuristics/error-summary.test.js +0 -31
- package/dist/tests/logs/store.test.js +0 -38
- package/dist/tests/main.test.js +0 -27
- package/dist/tests/ruby/introspect.test.js +0 -59
- package/dist/tests/runner/artifacts.test.js +0 -49
- package/dist/tests/runner/live-steps.test.js +0 -35
- package/dist/tests/runner/orchestrate.test.js +0 -124
- package/dist/tests/runner/pty.test.js +0 -53
- package/dist/tests/runner/report.test.js +0 -91
- package/dist/tests/server/api.test.js +0 -132
- package/dist/tests/server/auth.test.js +0 -53
- package/dist/tests/server/ws.test.js +0 -43
- package/dist/tests/sidecar/lanes.test.js +0 -52
- package/dist/tests/sidecar/ruby-env.test.js +0 -25
- package/dist/tests/smoke.test.js +0 -7
- package/dist/web/assets/index-583x0xuo.css +0 -1
- package/dist/web/assets/index-BEpABKPS.js +0 -61
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Document, parseDocument, YAMLSeq } from "yaml";
|
|
4
|
+
import { VALID_NAME, ensureFirstAdmin, hasAccount } from "../config/accounts.js";
|
|
5
|
+
import { loadServerConfig } from "../config/load.js";
|
|
6
|
+
import { serializeYaml as serialize } from "../config/yaml.js";
|
|
7
|
+
import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
|
|
8
|
+
import { acceptingAsker, terminalAsker } from "./prompt.js";
|
|
9
|
+
import { detectProject } from "./detect.js";
|
|
10
|
+
/**
|
|
11
|
+
* Adds a project block to config.yml while preserving the rest of the file.
|
|
12
|
+
*
|
|
13
|
+
* It writes no account: accounts are `ensureFirstAdmin`'s business, and a
|
|
14
|
+
* function that registered a project *and* invented a password would have two
|
|
15
|
+
* reasons to be called and one of them a surprise.
|
|
16
|
+
*
|
|
17
|
+
* The edit goes through the YAML document rather than a parse/serialize
|
|
18
|
+
* round trip: the user's comments — and the order of their keys — survive.
|
|
19
|
+
* It's the same requirement as for the Fastfile: a hand-written file must
|
|
20
|
+
* never come back out damaged.
|
|
21
|
+
*/
|
|
22
|
+
export async function addProjectToConfig(path, entry) {
|
|
23
|
+
let doc;
|
|
24
|
+
try {
|
|
25
|
+
doc = parseDocument(await readFile(path, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
doc = new Document({});
|
|
29
|
+
}
|
|
30
|
+
if (doc.contents === null)
|
|
31
|
+
doc = new Document({});
|
|
32
|
+
const projects = doc.getIn(["projects"]);
|
|
33
|
+
const seq = projects instanceof YAMLSeq ? projects : new YAMLSeq();
|
|
34
|
+
if (!(projects instanceof YAMLSeq))
|
|
35
|
+
doc.setIn(["projects"], seq);
|
|
36
|
+
for (const item of seq.items) {
|
|
37
|
+
const slug = item.get?.("slug");
|
|
38
|
+
if (slug === entry.slug) {
|
|
39
|
+
throw new Error(`A project already uses the slug "${entry.slug}" in ${path}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
seq.add(doc.createNode(entry));
|
|
43
|
+
await writeFile(path, serialize(doc), "utf8");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Takes a project's block out of config.yml, leaving the rest of the file alone.
|
|
47
|
+
*
|
|
48
|
+
* Same requirement as `addProjectToConfig`, and for the same reason: the file is
|
|
49
|
+
* hand-written, so the edit goes through the YAML document and every comment,
|
|
50
|
+
* key order and blank line that isn't this project's survives it.
|
|
51
|
+
*
|
|
52
|
+
* Returns false when no block carried that slug, so the caller can answer 404
|
|
53
|
+
* rather than rewrite the file to say the same thing it already said.
|
|
54
|
+
*/
|
|
55
|
+
export async function removeProjectFromConfig(path, slug) {
|
|
56
|
+
const doc = parseDocument(await readFile(path, "utf8"));
|
|
57
|
+
const projects = doc.getIn(["projects"]);
|
|
58
|
+
if (!(projects instanceof YAMLSeq))
|
|
59
|
+
return false;
|
|
60
|
+
const at = projects.items.findIndex((item) => item.get?.("slug") === slug);
|
|
61
|
+
if (at === -1)
|
|
62
|
+
return false;
|
|
63
|
+
projects.items.splice(at, 1);
|
|
64
|
+
await writeFile(path, serialize(doc), "utf8");
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Entry point for `laneyard setup`.
|
|
69
|
+
*
|
|
70
|
+
* It proposes rather than decides. An earlier version detected everything
|
|
71
|
+
* silently and wrote a configuration that looked plausible and pointed nowhere —
|
|
72
|
+
* the failure only surfaced later, as an unreadable lane list, far from its
|
|
73
|
+
* cause. Showing the values and letting them be corrected costs one screen and
|
|
74
|
+
* removes a whole class of that.
|
|
75
|
+
*/
|
|
76
|
+
export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
77
|
+
const d = await detectProject(cwd);
|
|
78
|
+
if (d.fastlaneDir === null) {
|
|
79
|
+
process.stderr.write("No Fastfile found here. Laneyard drives fastlane: run the command from a project " +
|
|
80
|
+
"that already uses it, or run `fastlane init` first.\n");
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
if (d.gitUrl === null) {
|
|
84
|
+
process.stderr.write("No git remote named \"origin\". Laneyard clones projects from their repository: " +
|
|
85
|
+
"add a remote, or set git_url by hand in config.yml.\n");
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
const interactive = options.asker === undefined && options.yes !== true;
|
|
89
|
+
const asker = options.asker ?? (options.yes ? acceptingAsker : terminalAsker());
|
|
90
|
+
try {
|
|
91
|
+
process.stdout.write(heading("Found a fastlane project"));
|
|
92
|
+
process.stdout.write(field("repository", repositoryLabel(d.gitUrl)) + "\n");
|
|
93
|
+
if (d.subPath !== "") {
|
|
94
|
+
process.stdout.write(field("in", `${d.subPath}/`) + "\n");
|
|
95
|
+
}
|
|
96
|
+
// Two files, two purposes, and the difference is the one thing people get
|
|
97
|
+
// wrong here — so it is stated before anything is written rather than
|
|
98
|
+
// discovered afterwards when nothing turns out to be versioned.
|
|
99
|
+
process.stdout.write("\n" +
|
|
100
|
+
dim(" Two files will describe it:\n") +
|
|
101
|
+
dim(` ${LANEYARD_YML.padEnd(13)}in the repository — how it builds. Commit it.\n`) +
|
|
102
|
+
dim(` ${"config.yml".padEnd(13)}on this machine — where to clone it from, and your\n`) +
|
|
103
|
+
dim(` password. Never committed.\n`));
|
|
104
|
+
const existing = await existingProject(configPath, d.slug);
|
|
105
|
+
if (existing) {
|
|
106
|
+
process.stdout.write("\n" +
|
|
107
|
+
warn(`This machine already knows a project called ${bold(existing)}.\n`) +
|
|
108
|
+
dim(" Continuing replaces its entry. Give it another name to keep both.\n"));
|
|
109
|
+
}
|
|
110
|
+
const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
|
|
111
|
+
if (await fileExists(repoConfigPath)) {
|
|
112
|
+
process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
|
|
113
|
+
dim(" Its values win over anything written here; it will be left alone.\n"));
|
|
114
|
+
}
|
|
115
|
+
// A machine with no account gets its first admin here. Asked rather than
|
|
116
|
+
// assumed: the name is typed into a login form every day afterwards, and
|
|
117
|
+
// `admin` is a poor thing to call a person once there are two of them.
|
|
118
|
+
const firstAccount = !(await hasAccount(configPath));
|
|
119
|
+
if (firstAccount) {
|
|
120
|
+
process.stdout.write("\n" +
|
|
121
|
+
warn("This machine has no account yet.\n") +
|
|
122
|
+
dim(" You will sign in with a name and a password. The password is generated below.\n"));
|
|
123
|
+
}
|
|
124
|
+
if (interactive) {
|
|
125
|
+
process.stdout.write("\n" + dim("Press Return to accept a value, or type a new one.") + "\n\n");
|
|
126
|
+
}
|
|
127
|
+
const adminName = firstAccount
|
|
128
|
+
? await asker.ask("your name for signing in", DEFAULT_ADMIN_NAME)
|
|
129
|
+
: null;
|
|
130
|
+
if (adminName !== null && !VALID_NAME.test(adminName)) {
|
|
131
|
+
process.stderr.write("\n" + bad(`Invalid name: "${adminName}". Letters, digits, dot, dash and underscore.`) + "\n");
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const slug = options.slug ?? (await asker.ask("name for this project", d.slug));
|
|
135
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
136
|
+
process.stderr.write("\n" + bad(`Invalid name: "${slug}". Lowercase letters, digits and hyphens only.`) + "\n");
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
const gitUrl = await asker.ask("repository", d.gitUrl);
|
|
140
|
+
const branch = await asker.ask("default branch", d.defaultBranch, dim("A run uses this branch unless you pick another one when you start it."));
|
|
141
|
+
const fastlaneDir = await asker.ask("fastlane directory", d.fastlaneDir, dim("Relative to the repository root, because that is what Laneyard clones."));
|
|
142
|
+
const useBundler = await asker.confirm("\n" +
|
|
143
|
+
dim(" With bundler, runs use the fastlane version pinned in the Gemfile.\n") +
|
|
144
|
+
dim(" Without it, whichever fastlane is installed on the build machine.\n") +
|
|
145
|
+
" Run fastlane through bundler?", d.runtime === "bundle");
|
|
146
|
+
const runtime = useBundler ? "bundle" : "system";
|
|
147
|
+
// Artifact patterns are shown, not asked: typing four globs correctly at a
|
|
148
|
+
// prompt is a miserable way to start, the detected ones are nearly always
|
|
149
|
+
// right, and the file is there to be edited when they are not.
|
|
150
|
+
const globs = d.artifactGlobs;
|
|
151
|
+
if (!(await asker.confirm(`\n${bold(`Set up "${slug}"`)}?`, true))) {
|
|
152
|
+
process.stdout.write(dim("Nothing written.") + "\n");
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
// The account first, so the server block is written before the project list
|
|
156
|
+
// it sits above. `null` when this machine already has one, and then nothing
|
|
157
|
+
// is written and nothing is printed.
|
|
158
|
+
const generatedPassword = adminName === null ? null : await ensureFirstAdmin(configPath, adminName);
|
|
159
|
+
// The machine half: how to reach the project. Nothing here belongs in a
|
|
160
|
+
// repository — it is this server's own registry, plus its credentials.
|
|
161
|
+
await addProjectToConfig(configPath, {
|
|
162
|
+
slug,
|
|
163
|
+
name: slug,
|
|
164
|
+
git_url: gitUrl,
|
|
165
|
+
default_branch: branch,
|
|
166
|
+
});
|
|
167
|
+
// The repository half: how it builds. This is the part that was going into
|
|
168
|
+
// the machine's file and therefore never being committed — which is exactly
|
|
169
|
+
// backwards, since it is the part a colleague needs.
|
|
170
|
+
const wroteRepoConfig = await writeRepoConfigIfAbsent(repoConfigPath, {
|
|
171
|
+
fastlane_dir: fastlaneDir,
|
|
172
|
+
runtime,
|
|
173
|
+
artifact_globs: globs,
|
|
174
|
+
// Written down rather than re-inferred on every readiness check: a value
|
|
175
|
+
// in a file can be corrected when the guess was wrong, and setup and the
|
|
176
|
+
// checklist cannot end up disagreeing about the same repository.
|
|
177
|
+
...(d.platforms.length > 0 ? { platforms: d.platforms } : {}),
|
|
178
|
+
});
|
|
179
|
+
const port = await configuredPort(configPath);
|
|
180
|
+
process.stdout.write(heading(`Project "${slug}" is set up`) +
|
|
181
|
+
field("repository", `${gitUrl} (${branch})`) + "\n" +
|
|
182
|
+
field("fastlane", fastlaneDir) + "\n" +
|
|
183
|
+
field("runtime", runtime) + "\n" +
|
|
184
|
+
field("artifacts", globs.join(", ") || dim("none detected")) + "\n" +
|
|
185
|
+
// Shown because it decides which half of the readiness checklist a
|
|
186
|
+
// project is held to, and because a wrong guess is corrected by editing
|
|
187
|
+
// one line of the file just written.
|
|
188
|
+
field("platforms", d.platforms.join(", ") || dim("none detected")) + "\n" +
|
|
189
|
+
"\n" +
|
|
190
|
+
(wroteRepoConfig
|
|
191
|
+
? ok(`Wrote ${bold(LANEYARD_YML)} — ${bold("commit it")} so your team builds the same way.\n`)
|
|
192
|
+
: warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
|
|
193
|
+
ok(`Registered in ${configPath}\n`) +
|
|
194
|
+
// Last thing before the invitation to start the server, because it is
|
|
195
|
+
// the one line here that cannot be read again anywhere: the file holds
|
|
196
|
+
// a hash, and nothing holds the password.
|
|
197
|
+
(generatedPassword === null
|
|
198
|
+
? ""
|
|
199
|
+
: heading("Your account") +
|
|
200
|
+
field("name", bold(adminName)) + "\n" +
|
|
201
|
+
field("password", bold(generatedPassword)) + "\n" +
|
|
202
|
+
field("role", "admin") + "\n" +
|
|
203
|
+
"\n" +
|
|
204
|
+
warn("Write the password down — it is not shown again, and it is not stored.\n") +
|
|
205
|
+
dim(" Add a colleague later with `laneyard user add <name> --role builder`.\n")) +
|
|
206
|
+
heading("Start Laneyard") +
|
|
207
|
+
` ${bold("laneyard")}\n` +
|
|
208
|
+
` ${dim(`http://localhost:${port}`)}\n` +
|
|
209
|
+
"\n" +
|
|
210
|
+
dim("Already running? The configuration is watched — it appears on its own.\n"));
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
asker.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The port the server will actually listen on.
|
|
219
|
+
*
|
|
220
|
+
* Read back from the file rather than assumed: telling someone to open a port
|
|
221
|
+
* their configuration does not use is the kind of small wrongness that makes a
|
|
222
|
+
* tool feel unreliable on first contact.
|
|
223
|
+
*/
|
|
224
|
+
async function configuredPort(configPath) {
|
|
225
|
+
const res = await loadServerConfig(configPath);
|
|
226
|
+
return res.ok ? res.config.server.port : 7890;
|
|
227
|
+
}
|
|
228
|
+
/** The repository file, named once so the message and the write cannot disagree. */
|
|
229
|
+
const LANEYARD_YML = "laneyard.yml";
|
|
230
|
+
/**
|
|
231
|
+
* What the first account is called when nobody says otherwise.
|
|
232
|
+
*
|
|
233
|
+
* The same name a lone `password_hash` is read under, so a machine set up today
|
|
234
|
+
* and a machine upgraded from 0.2 sign in the same way.
|
|
235
|
+
*/
|
|
236
|
+
const DEFAULT_ADMIN_NAME = "admin";
|
|
237
|
+
/**
|
|
238
|
+
* The repository root, from where the command ran and how deep it sits.
|
|
239
|
+
*
|
|
240
|
+
* `laneyard.yml` belongs at the root because that is where the server reads it:
|
|
241
|
+
* the clone is the repository, not the sub-directory someone was standing in.
|
|
242
|
+
*/
|
|
243
|
+
function repoRoot(cwd, subPath) {
|
|
244
|
+
return subPath === "" ? cwd : join(cwd, ...subPath.split("/").map(() => ".."));
|
|
245
|
+
}
|
|
246
|
+
const fileExists = async (path) => {
|
|
247
|
+
try {
|
|
248
|
+
await readFile(path);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
/** The name already registered under this slug, or null. */
|
|
256
|
+
async function existingProject(configPath, slug) {
|
|
257
|
+
const res = await loadServerConfig(configPath);
|
|
258
|
+
if (!res.ok)
|
|
259
|
+
return null;
|
|
260
|
+
return res.config.projects.find((p) => p.slug === slug)?.slug ?? null;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Writes the repository's build configuration, unless it is already there.
|
|
264
|
+
*
|
|
265
|
+
* Never overwrites: a `laneyard.yml` in a repository was put there by someone,
|
|
266
|
+
* possibly with comments and choices this command knows nothing about, and its
|
|
267
|
+
* values win anyway.
|
|
268
|
+
*/
|
|
269
|
+
async function writeRepoConfigIfAbsent(path, settings) {
|
|
270
|
+
if (await fileExists(path))
|
|
271
|
+
return false;
|
|
272
|
+
const doc = new Document(settings);
|
|
273
|
+
doc.commentBefore =
|
|
274
|
+
" How this project builds. Committed, so everyone builds it the same way.\n" +
|
|
275
|
+
" Values here win over the project's block in the server's config.yml.";
|
|
276
|
+
await writeFile(path, doc.toString(), "utf8");
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
/** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
|
|
280
|
+
function repositoryLabel(url) {
|
|
281
|
+
return url.replace(/^.*[:/]([^/:]+\/[^/]+?)(\.git)?$/, "$1");
|
|
282
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The little colour this command needs, and no library to provide it.
|
|
3
|
+
*
|
|
4
|
+
* The palette is the product's own: green for what is settled, amber for what
|
|
5
|
+
* wants attention, dim for what is context. Nothing here is decorative — a
|
|
6
|
+
* colour that means nothing trains people to stop reading colours.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Colour is dropped when the output is not a terminal, or when `NO_COLOR` is
|
|
10
|
+
* set. Escape codes in a piped log or a CI transcript are noise at best, and at
|
|
11
|
+
* worst they end up quoted into a bug report.
|
|
12
|
+
*/
|
|
13
|
+
const enabled = () => process.env["NO_COLOR"] === undefined && process.stdout.isTTY === true;
|
|
14
|
+
const wrap = (code) => (text) => enabled() ? `[${code}m${text}[0m` : text;
|
|
15
|
+
export const bold = wrap("1");
|
|
16
|
+
export const dim = wrap("2");
|
|
17
|
+
export const green = wrap("32");
|
|
18
|
+
export const amber = wrap("33");
|
|
19
|
+
export const red = wrap("31");
|
|
20
|
+
/** A heading: bold, with a blank line above it so sections breathe. */
|
|
21
|
+
export const heading = (text) => `\n${bold(text)}\n`;
|
|
22
|
+
/** `✓`, `▸`, `✗` in the same meanings they carry in the interface. */
|
|
23
|
+
export const ok = (text) => `${green("✓")} ${text}`;
|
|
24
|
+
export const warn = (text) => `${amber("▸")} ${text}`;
|
|
25
|
+
export const bad = (text) => `${red("✗")} ${text}`;
|
|
26
|
+
/**
|
|
27
|
+
* A `key value` line, aligned so a block of them reads as a column.
|
|
28
|
+
* The width is fixed rather than computed: these labels are known and short,
|
|
29
|
+
* and a column that shifts between runs is harder to scan than a wide one.
|
|
30
|
+
*/
|
|
31
|
+
export const field = (label, value) => ` ${dim(label.padEnd(13))}${value}`;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { MIN_PASSWORD_LENGTH, VALID_NAME, hasAccount, refusalFor, upsertUserInConfig, } from "../config/accounts.js";
|
|
3
|
+
import { loadServerConfig } from "../config/load.js";
|
|
4
|
+
export const USER_USAGE = `laneyard user add <name> [--role admin|builder]
|
|
5
|
+
|
|
6
|
+
The password is read from standard input, never from an argument:
|
|
7
|
+
|
|
8
|
+
echo "$PASSWORD" | laneyard user add renaud --role builder
|
|
9
|
+
|
|
10
|
+
Without --role, the account is a builder: it can start a build, watch it, cancel
|
|
11
|
+
it and download what it produced. An admin can do everything besides.
|
|
12
|
+
`;
|
|
13
|
+
const ROLES = ["admin", "builder"];
|
|
14
|
+
/**
|
|
15
|
+
* Reads standard input whole.
|
|
16
|
+
*
|
|
17
|
+
* A single trailing newline is dropped: `echo "$PASSWORD" |` adds one, and a
|
|
18
|
+
* password that silently gained a `\n` is a password that never works again,
|
|
19
|
+
* with nothing on screen to explain why.
|
|
20
|
+
*/
|
|
21
|
+
async function readPassword(stdin) {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of stdin)
|
|
24
|
+
chunks.push(Buffer.from(chunk));
|
|
25
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
26
|
+
}
|
|
27
|
+
function parse(args) {
|
|
28
|
+
let name = null;
|
|
29
|
+
// The role that can do the least, when nobody says. An account that turns out
|
|
30
|
+
// to need more is one command away; an account that quietly had more than it
|
|
31
|
+
// needed is found out later, differently.
|
|
32
|
+
let role = "builder";
|
|
33
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
34
|
+
const arg = args[i];
|
|
35
|
+
if (arg === "--role" || arg === "-r") {
|
|
36
|
+
const next = args[i + 1];
|
|
37
|
+
if (next === undefined || next.startsWith("-"))
|
|
38
|
+
return `--role needs ${ROLES.join(" or ")}.`;
|
|
39
|
+
if (!ROLES.includes(next))
|
|
40
|
+
return `Unknown role: ${next}. It is ${ROLES.join(" or ")}.`;
|
|
41
|
+
role = next;
|
|
42
|
+
i += 1;
|
|
43
|
+
}
|
|
44
|
+
else if (arg.startsWith("-")) {
|
|
45
|
+
return `Unknown option: ${arg}`;
|
|
46
|
+
}
|
|
47
|
+
else if (name === null) {
|
|
48
|
+
name = arg;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// A second bare argument is almost certainly the password typed on the
|
|
52
|
+
// command line — exactly what this command exists to prevent.
|
|
53
|
+
return "The password is read from standard input, not from the command line.";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (name === null)
|
|
57
|
+
return "Which account? Give it a name.";
|
|
58
|
+
return { name, role };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Entry point for `laneyard user`.
|
|
62
|
+
*
|
|
63
|
+
* Nothing it prints ever contains the password — not on success, not in an
|
|
64
|
+
* error. A terminal keeps scrollback and a shell keeps history; reading from
|
|
65
|
+
* stdin would be pointless if the value came straight back out.
|
|
66
|
+
*/
|
|
67
|
+
export async function runUserCommand(home, args, io) {
|
|
68
|
+
const [subcommand, ...rest] = args;
|
|
69
|
+
if (subcommand !== "add") {
|
|
70
|
+
io.err(subcommand === undefined ? USER_USAGE : `Unknown subcommand: ${subcommand}\n\n${USER_USAGE}`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
const parsed = parse(rest);
|
|
74
|
+
if (typeof parsed === "string") {
|
|
75
|
+
io.err(`${parsed}\n\n${USER_USAGE}`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
if (!VALID_NAME.test(parsed.name)) {
|
|
79
|
+
io.err(`"${parsed.name}" is not a name: letters, digits, dot, dash and underscore, ` +
|
|
80
|
+
"starting with a letter or a digit.\n");
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
const configPath = join(home, "config.yml");
|
|
84
|
+
// A machine with no account at all is a machine that has not been set up. It
|
|
85
|
+
// is refused here rather than given its first account, because that first
|
|
86
|
+
// account has to be an admin and this command would happily write a lone
|
|
87
|
+
// builder — a configuration the server then refuses to load.
|
|
88
|
+
if (!(await hasAccount(configPath))) {
|
|
89
|
+
io.err(`No account yet in ${configPath}.\n` +
|
|
90
|
+
"Run `laneyard setup` from a project that uses fastlane: it creates the first admin.\n");
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const loaded = await loadServerConfig(configPath);
|
|
94
|
+
if (!loaded.ok) {
|
|
95
|
+
// Refused rather than written blind: the accounts already in the file are
|
|
96
|
+
// what says whether this change leaves the server with an admin.
|
|
97
|
+
io.err(`Unreadable configuration in ${configPath}: ${loaded.error}\n`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const existing = loaded.config.server.users;
|
|
101
|
+
const refusal = refusalFor(existing, parsed.name, parsed.role);
|
|
102
|
+
if (refusal) {
|
|
103
|
+
io.err(`${refusal}\n`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
if (io.interactive) {
|
|
107
|
+
io.err("The password is read from standard input, and standard input is a terminal.\n" +
|
|
108
|
+
"Pipe it in instead, so it stays out of your shell history:\n\n" +
|
|
109
|
+
` echo "$PASSWORD" | laneyard user add ${parsed.name} --role ${parsed.role}\n`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
const password = await readPassword(io.stdin);
|
|
113
|
+
if (password === "") {
|
|
114
|
+
io.err("Nothing came in on standard input, so there is no password to set.\n");
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
118
|
+
io.err(`A password is at least ${MIN_PASSWORD_LENGTH} characters.\n`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
const { created } = await upsertUserInConfig(configPath, { ...parsed, password });
|
|
122
|
+
io.out(`✓ ${parsed.name} ${parsed.role} ${created ? "added" : "replaced"}\n` +
|
|
123
|
+
// The server watches config.yml, so this takes effect without a restart.
|
|
124
|
+
// Saying so is what stops someone from restarting a build server for it.
|
|
125
|
+
" the running server picks this up on its own.\n");
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { Document, parseDocument, YAMLSeq } from "yaml";
|
|
4
|
+
import { hashPassword } from "../server/auth.js";
|
|
5
|
+
import { LEGACY_ADMIN_NAME } from "./load.js";
|
|
6
|
+
import { serializeYaml } from "./yaml.js";
|
|
7
|
+
/**
|
|
8
|
+
* Accounts, as they are written to and taken out of config.yml.
|
|
9
|
+
*
|
|
10
|
+
* The API and the CLI both go through here, so that "add a builder" means the
|
|
11
|
+
* same thing however it was asked for — including the two refusals, which are
|
|
12
|
+
* decided here rather than twice, differently.
|
|
13
|
+
*/
|
|
14
|
+
/** Same rule as the schema. Stated once and read from both the API and the CLI. */
|
|
15
|
+
export const VALID_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
16
|
+
/**
|
|
17
|
+
* The shortest password that may be stored.
|
|
18
|
+
*
|
|
19
|
+
* Not a policy in the corporate sense — there is no expiry and no character
|
|
20
|
+
* classes. It is the length below which scrypt's cost stops mattering, because
|
|
21
|
+
* the whole space can be walked through faster than one honest login.
|
|
22
|
+
*/
|
|
23
|
+
export const MIN_PASSWORD_LENGTH = 8;
|
|
24
|
+
/**
|
|
25
|
+
* Why a change to the accounts must be refused, or null when it may proceed.
|
|
26
|
+
*
|
|
27
|
+
* `role` is the role the account will carry afterwards, or null when it is
|
|
28
|
+
* being removed. Both refusals are the same refusal: a server whose last admin
|
|
29
|
+
* is gone or demoted is a locked room — the interface offers nothing that can
|
|
30
|
+
* put an admin back, and the only way out is editing YAML by hand.
|
|
31
|
+
*/
|
|
32
|
+
export function refusalFor(users, name, role) {
|
|
33
|
+
const admins = users.filter((u) => u.role === "admin");
|
|
34
|
+
const isLastAdmin = admins.length === 1 && admins[0].name === name;
|
|
35
|
+
if (!isLastAdmin || role === "admin")
|
|
36
|
+
return null;
|
|
37
|
+
return role === null
|
|
38
|
+
? `${name} is the only admin. Removing them would leave a server nobody can administer — ` +
|
|
39
|
+
"make someone else an admin first."
|
|
40
|
+
: `${name} is the only admin. Demoting them would leave a server nobody can administer — ` +
|
|
41
|
+
"make someone else an admin first.";
|
|
42
|
+
}
|
|
43
|
+
/** Reads config.yml as a document, or starts an empty one when there is no file. */
|
|
44
|
+
async function open(path) {
|
|
45
|
+
let doc;
|
|
46
|
+
try {
|
|
47
|
+
doc = parseDocument(await readFile(path, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
doc = new Document({});
|
|
51
|
+
}
|
|
52
|
+
if (doc.contents === null)
|
|
53
|
+
doc = new Document({});
|
|
54
|
+
return doc;
|
|
55
|
+
}
|
|
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
|
+
/** The accounts sequence, created if the file has none yet. */
|
|
76
|
+
function usersSeq(doc) {
|
|
77
|
+
const existing = doc.getIn(["server", "users"]);
|
|
78
|
+
if (existing instanceof YAMLSeq)
|
|
79
|
+
return existing;
|
|
80
|
+
const seq = new YAMLSeq();
|
|
81
|
+
doc.setIn(["server", "users"], seq);
|
|
82
|
+
return seq;
|
|
83
|
+
}
|
|
84
|
+
const nameOf = (item) => item.get?.("name");
|
|
85
|
+
/**
|
|
86
|
+
* Writes an account into config.yml, replacing one of the same name.
|
|
87
|
+
*
|
|
88
|
+
* The edit goes through the YAML document rather than a parse/serialize round
|
|
89
|
+
* trip, for the same reason as every other edit to this file: it is
|
|
90
|
+
* hand-written, and comments and key order must survive being touched.
|
|
91
|
+
*
|
|
92
|
+
* Returns whether the account is new, which is the difference between 201 and
|
|
93
|
+
* 200 and between "created" and "replaced" in a sentence.
|
|
94
|
+
*/
|
|
95
|
+
export async function upsertUserInConfig(path, entry) {
|
|
96
|
+
const doc = await open(path);
|
|
97
|
+
migrateLegacyHash(doc);
|
|
98
|
+
const seq = usersSeq(doc);
|
|
99
|
+
const stored = {
|
|
100
|
+
name: entry.name,
|
|
101
|
+
role: entry.role,
|
|
102
|
+
password_hash: hashPassword(entry.password),
|
|
103
|
+
};
|
|
104
|
+
const at = seq.items.findIndex((item) => nameOf(item) === entry.name);
|
|
105
|
+
const node = doc.createNode(stored);
|
|
106
|
+
if (at === -1)
|
|
107
|
+
seq.add(node);
|
|
108
|
+
else
|
|
109
|
+
seq.items[at] = node;
|
|
110
|
+
await writeFile(path, serializeYaml(doc), "utf8");
|
|
111
|
+
return { created: at === -1 };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Takes an account out of config.yml, leaving the rest of the file alone.
|
|
115
|
+
*
|
|
116
|
+
* Returns false when no account carried that name, so the caller can answer 404
|
|
117
|
+
* rather than rewrite the file to say what it already said.
|
|
118
|
+
*/
|
|
119
|
+
export async function removeUserFromConfig(path, name) {
|
|
120
|
+
const doc = await open(path);
|
|
121
|
+
migrateLegacyHash(doc);
|
|
122
|
+
const users = doc.getIn(["server", "users"]);
|
|
123
|
+
if (!(users instanceof YAMLSeq))
|
|
124
|
+
return false;
|
|
125
|
+
const at = users.items.findIndex((item) => nameOf(item) === name);
|
|
126
|
+
if (at === -1)
|
|
127
|
+
return false;
|
|
128
|
+
users.items.splice(at, 1);
|
|
129
|
+
await writeFile(path, serializeYaml(doc), "utf8");
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Does this file already declare somebody, in either of the two forms?
|
|
134
|
+
*
|
|
135
|
+
* Asked before a question is put to the user rather than after: `laneyard setup`
|
|
136
|
+
* only asks for an admin's name on a machine that has none, and asking anyway
|
|
137
|
+
* and then ignoring the answer would be worse than not asking.
|
|
138
|
+
*/
|
|
139
|
+
export async function hasAccount(path) {
|
|
140
|
+
const doc = await open(path);
|
|
141
|
+
return doc.hasIn(["server", "password_hash"]) || doc.hasIn(["server", "users"]);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Creates the first admin if the file declares no account at all.
|
|
145
|
+
*
|
|
146
|
+
* Returns the generated password, once, for the caller to print — or null when
|
|
147
|
+
* an account already existed and nothing was written. The password is generated
|
|
148
|
+
* rather than asked for because this runs inside `laneyard setup`, where a
|
|
149
|
+
* prompt for a password would be one more thing to invent while trying to do
|
|
150
|
+
* something else entirely.
|
|
151
|
+
*/
|
|
152
|
+
export async function ensureFirstAdmin(path, name) {
|
|
153
|
+
const doc = await open(path);
|
|
154
|
+
if (doc.hasIn(["server", "password_hash"]) || doc.hasIn(["server", "users"]))
|
|
155
|
+
return null;
|
|
156
|
+
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
|
+
doc.setIn(["server", "users"], doc.createNode([{ name, role: "admin", password_hash: hashPassword(password) }]));
|
|
161
|
+
await writeFile(path, serializeYaml(doc), "utf8");
|
|
162
|
+
return password;
|
|
163
|
+
}
|