laneyard 0.2.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 +122 -15
- package/dist/src/cli/detect.js +60 -16
- package/dist/src/cli/prompt.js +36 -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 +26 -1
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- package/dist/src/heuristics/blocking-actions.js +20 -0
- package/dist/src/heuristics/platforms.js +77 -0
- package/dist/src/heuristics/readiness.js +159 -16
- package/dist/src/main.js +48 -7
- package/dist/src/server/app.js +65 -13
- package/dist/src/server/auth.js +85 -17
- package/dist/src/server/permissions.js +73 -0
- package/dist/src/server/routes/projects.js +42 -0
- package/dist/src/server/routes/readiness.js +16 -3
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +17 -1
- package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DNFBA4gv.js} +1 -1
- 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 +1 -1
- package/dist/web/assets/index-D9_EL8LA.js +0 -62
- package/dist/web/assets/index-ZUqTX6d1.css +0 -1
|
@@ -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
|
+
}
|
package/dist/src/config/load.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
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";
|
|
4
11
|
/** Reads and validates a YAML file. Never fails by throwing: the caller decides. */
|
|
5
12
|
async function loadYamlFile(path, schema) {
|
|
6
13
|
let raw;
|
|
@@ -26,10 +33,59 @@ async function loadYamlFile(path, schema) {
|
|
|
26
33
|
}
|
|
27
34
|
return { ok: true, config: parsed.data };
|
|
28
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Folds both ways of declaring accounts into the one the rest of the code sees.
|
|
38
|
+
*
|
|
39
|
+
* Every refusal here is a locked room avoided: a server with no account, or
|
|
40
|
+
* with none that can administer it, is one nobody can fix from the interface.
|
|
41
|
+
*/
|
|
42
|
+
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
|
+
}
|
|
57
|
+
if (users === undefined) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: "server.users is missing — without an account nobody can log in.",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (users.length === 0) {
|
|
64
|
+
return { ok: false, error: "server.users is empty — declare at least one admin account." };
|
|
65
|
+
}
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const u of users) {
|
|
68
|
+
if (seen.has(u.name)) {
|
|
69
|
+
return { ok: false, error: `duplicate user: ${u.name}` };
|
|
70
|
+
}
|
|
71
|
+
seen.add(u.name);
|
|
72
|
+
}
|
|
73
|
+
if (!users.some((u) => u.role === "admin")) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: "server.users has no admin — a server nobody can administer is a locked room.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, users };
|
|
80
|
+
}
|
|
29
81
|
export async function loadServerConfig(path) {
|
|
30
82
|
const res = await loadYamlFile(path, serverConfigSchema);
|
|
31
83
|
if (!res.ok)
|
|
32
84
|
return res;
|
|
85
|
+
const accounts = normaliseUsers(res.config.server);
|
|
86
|
+
if (!accounts.ok) {
|
|
87
|
+
return { ok: false, error: `Invalid configuration in ${path} — ${accounts.error}` };
|
|
88
|
+
}
|
|
33
89
|
const seen = new Set();
|
|
34
90
|
for (const p of res.config.projects) {
|
|
35
91
|
if (seen.has(p.slug)) {
|
|
@@ -39,7 +95,11 @@ export async function loadServerConfig(path) {
|
|
|
39
95
|
}
|
|
40
96
|
// The display name falls back to the slug rather than being optional everywhere downstream.
|
|
41
97
|
const projects = res.config.projects.map((p) => ({ ...p, name: p.name ?? p.slug }));
|
|
42
|
-
|
|
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 } };
|
|
43
103
|
}
|
|
44
104
|
export async function loadRepoConfig(path) {
|
|
45
105
|
return loadYamlFile(path, repoConfigSchema);
|
|
@@ -7,6 +7,11 @@ export const projectSettingsSchema = z.object({
|
|
|
7
7
|
interactive_default: z.boolean().default(false),
|
|
8
8
|
artifact_globs: z.array(z.string()).default([]),
|
|
9
9
|
required_secrets: z.array(z.string()).default([]),
|
|
10
|
+
// What this project builds for. No default: absent means "nobody said", and
|
|
11
|
+
// the readiness checklist falls back to looking at the repository. Setting it
|
|
12
|
+
// is how a repository that happens to carry an Xcode project it never builds
|
|
13
|
+
// stops being asked for App Store Connect credentials.
|
|
14
|
+
platforms: z.array(z.enum(["ios", "android"])).optional(),
|
|
10
15
|
retention: z
|
|
11
16
|
.object({
|
|
12
17
|
runs: z.number().int().positive(),
|
|
@@ -39,11 +44,31 @@ export const projectEntrySchema = projectSettingsInputSchema.extend({
|
|
|
39
44
|
notify_browser: z.boolean().default(true),
|
|
40
45
|
webhook_url: z.string().optional(),
|
|
41
46
|
});
|
|
47
|
+
/**
|
|
48
|
+
* Two roles, and only two.
|
|
49
|
+
*
|
|
50
|
+
* A third is easy to add and impossible to remove. These two cover the case
|
|
51
|
+
* that prompted them — someone who ships without being trusted with the
|
|
52
|
+
* signing chain — and two can be held in a reader's head.
|
|
53
|
+
*/
|
|
54
|
+
export const userRoleSchema = z.enum(["admin", "builder"]);
|
|
55
|
+
export const userEntrySchema = z.object({
|
|
56
|
+
// The name is typed into a login form and printed in a status bar; keeping it
|
|
57
|
+
// to a plain identifier avoids a name that reads differently than it is stored.
|
|
58
|
+
name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "name: letters, digits, dot, dash, underscore"),
|
|
59
|
+
role: userRoleSchema,
|
|
60
|
+
password_hash: z.string().min(1),
|
|
61
|
+
});
|
|
42
62
|
export const serverConfigSchema = z.object({
|
|
43
63
|
server: z.object({
|
|
44
64
|
port: z.number().int().positive().default(7890),
|
|
45
65
|
bind: z.string().default("0.0.0.0"),
|
|
46
|
-
|
|
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(),
|
|
71
|
+
users: z.array(userEntrySchema).optional(),
|
|
47
72
|
// Only 1 is accepted. Runs share one working directory per project, so a
|
|
48
73
|
// higher number would promise parallel builds that never happen — the
|
|
49
74
|
// queue drains one run at a time, whatever this says. Refusing at load
|
package/dist/src/config/store.js
CHANGED
|
@@ -42,6 +42,16 @@ export class ConfigStore {
|
|
|
42
42
|
watcher.close();
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The file this store reads.
|
|
47
|
+
*
|
|
48
|
+
* Exposed because removing a project edits that very file: the route would
|
|
49
|
+
* otherwise have to rebuild the path from the data root and hope the two
|
|
50
|
+
* agree, which is exactly the kind of duplicated assumption that drifts.
|
|
51
|
+
*/
|
|
52
|
+
configPath() {
|
|
53
|
+
return this.path;
|
|
54
|
+
}
|
|
45
55
|
server() {
|
|
46
56
|
return this.config?.server ?? null;
|
|
47
57
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A YAML document back as text, with the line width left alone.
|
|
3
|
+
*
|
|
4
|
+
* The default folds anything past eighty columns, which means writing one
|
|
5
|
+
* project rewraps the password hash someone else's line already held — a
|
|
6
|
+
* hand-written file coming back out changed where nobody touched it. A git url
|
|
7
|
+
* or a scrypt hash is a single token; breaking it makes the file harder to read
|
|
8
|
+
* and harder to grep, and gains nothing.
|
|
9
|
+
*
|
|
10
|
+
* Shared, because config.yml is now edited from three places — setting up a
|
|
11
|
+
* project, adding an account, removing one — and a file that comes back
|
|
12
|
+
* differently depending on which of them touched it is a file nobody trusts.
|
|
13
|
+
*/
|
|
14
|
+
export const serializeYaml = (doc) => doc.toString({ lineWidth: 0 });
|
|
@@ -21,6 +21,26 @@ export const BLOCKING_RULES = [
|
|
|
21
21
|
because: "renews provisioning profiles, which needs an Apple account interactively",
|
|
22
22
|
fix: "Use `match` in readonly mode instead, with profiles stored in a repository.",
|
|
23
23
|
},
|
|
24
|
+
{
|
|
25
|
+
action: "cert",
|
|
26
|
+
because: "creates or downloads a signing certificate, which needs an Apple account interactively",
|
|
27
|
+
fix: "Use `match` in readonly mode instead, with certificates stored in a repository.",
|
|
28
|
+
},
|
|
29
|
+
// `deliver` renders an HTML summary and waits for a yes before uploading.
|
|
30
|
+
// Only reported when the lane says `force: false` outright: the default is
|
|
31
|
+
// not something the sidecar reports, and inventing one would be a guess.
|
|
32
|
+
{
|
|
33
|
+
action: "deliver",
|
|
34
|
+
when: { arg: "force", equals: false },
|
|
35
|
+
because: "shows a summary and waits for it to be confirmed before uploading",
|
|
36
|
+
fix: "Pass `force: true` so it uploads without asking.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
action: "upload_to_app_store",
|
|
40
|
+
when: { arg: "force", equals: false },
|
|
41
|
+
because: "shows a summary and waits for it to be confirmed before uploading",
|
|
42
|
+
fix: "Pass `force: true` so it uploads without asking.",
|
|
43
|
+
},
|
|
24
44
|
];
|
|
25
45
|
/**
|
|
26
46
|
* Applies the table to the actions a lane calls.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which platforms a project builds for.
|
|
3
|
+
*
|
|
4
|
+
* Named knowledge of mobile toolchains — that `.xcodeproj` means Xcode, that
|
|
5
|
+
* `build.gradle` means Gradle — hence its place in this module, and the
|
|
6
|
+
* boundary that comes with it: nothing here refuses anything. It answers a
|
|
7
|
+
* question; `setup` writes the answer down and the checklist reads it.
|
|
8
|
+
*
|
|
9
|
+
* It lives here rather than in `cli/detect.ts` because two callers need the
|
|
10
|
+
* same answer. Setup proposes a configuration from it, and the checklist
|
|
11
|
+
* decides which sections apply from it. Two copies of this reasoning would be
|
|
12
|
+
* free to disagree about the same repository, and the checklist would be the
|
|
13
|
+
* one that looked wrong.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* What a repository looks like from the outside, as a table.
|
|
17
|
+
*
|
|
18
|
+
* The depth is deliberate: an app in a monorepo sits one level down
|
|
19
|
+
* (`android/build.gradle`, `ios/App.xcodeproj`), which is where Flutter and
|
|
20
|
+
* React Native put them.
|
|
21
|
+
*/
|
|
22
|
+
export const PLATFORM_MARKERS = [
|
|
23
|
+
{
|
|
24
|
+
platform: "ios",
|
|
25
|
+
globs: ["*.xcodeproj", "*.xcworkspace", "*/*.xcodeproj", "*/*.xcworkspace"],
|
|
26
|
+
onlyDirectories: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
platform: "android",
|
|
30
|
+
globs: ["build.gradle", "build.gradle.kts", "*/build.gradle", "*/build.gradle.kts"],
|
|
31
|
+
onlyDirectories: false,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* What the repository contains.
|
|
36
|
+
*
|
|
37
|
+
* Never throws. An absent clone is a reason to answer "nothing found", not to
|
|
38
|
+
* fail the checklist that was about to explain why the clone is absent.
|
|
39
|
+
*/
|
|
40
|
+
export async function detectPlatforms(find) {
|
|
41
|
+
const found = [];
|
|
42
|
+
// Sequential, and in the table's order, so the answer is stable: this list
|
|
43
|
+
// decides the order of the sections someone reads.
|
|
44
|
+
for (const marker of PLATFORM_MARKERS) {
|
|
45
|
+
try {
|
|
46
|
+
const paths = await find(marker.globs, { onlyDirectories: marker.onlyDirectories });
|
|
47
|
+
if (paths.length > 0)
|
|
48
|
+
found.push(marker.platform);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A marker that cannot be looked for is simply not found.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The configuration first, then the repository.
|
|
58
|
+
*
|
|
59
|
+
* An empty configured list counts as saying nothing: `platforms: []` is what a
|
|
60
|
+
* commented-out line leaves behind, and reading it as "this project builds for
|
|
61
|
+
* nothing" would silently empty the checklist.
|
|
62
|
+
*/
|
|
63
|
+
export function platformsOf(configured, detected) {
|
|
64
|
+
const chosen = configured && configured.length > 0 ? configured : detected;
|
|
65
|
+
// Normalised through the table: a hand-written `[android, ios]` must not
|
|
66
|
+
// reorder the screen, and a platform named twice must not appear twice.
|
|
67
|
+
return PLATFORM_MARKERS.map((m) => m.platform).filter((p) => chosen.includes(p));
|
|
68
|
+
}
|
|
69
|
+
/** The two steps in one call, for callers that have a directory to look at. */
|
|
70
|
+
export async function resolvePlatforms(configured, find) {
|
|
71
|
+
// The repository is only listed when the configuration did not answer:
|
|
72
|
+
// globbing a workspace costs a directory walk, and a project that says what
|
|
73
|
+
// it builds for should not pay for one.
|
|
74
|
+
if (configured && configured.length > 0)
|
|
75
|
+
return platformsOf(configured, []);
|
|
76
|
+
return platformsOf(undefined, await detectPlatforms(find));
|
|
77
|
+
}
|