laneyard 0.2.0 → 0.4.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.
Files changed (53) hide show
  1. package/README.md +295 -20
  2. package/dist/src/cli/detect.js +69 -16
  3. package/dist/src/cli/prompt.js +61 -0
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +319 -0
  7. package/dist/src/cli/style.js +31 -0
  8. package/dist/src/cli/user.js +127 -0
  9. package/dist/src/config/accounts.js +163 -0
  10. package/dist/src/config/load.js +61 -1
  11. package/dist/src/config/schema.js +26 -1
  12. package/dist/src/config/store.js +10 -0
  13. package/dist/src/config/yaml.js +14 -0
  14. package/dist/src/credentials/kinds.js +120 -0
  15. package/dist/src/db/credentials.js +75 -0
  16. package/dist/src/db/schema.sql +39 -0
  17. package/dist/src/db/secrets.js +28 -0
  18. package/dist/src/db/sessions.js +61 -0
  19. package/dist/src/git/workspace.js +32 -6
  20. package/dist/src/heuristics/android-root.js +49 -0
  21. package/dist/src/heuristics/android-signing.js +98 -0
  22. package/dist/src/heuristics/appfile.js +60 -0
  23. package/dist/src/heuristics/blocking-actions.js +22 -0
  24. package/dist/src/heuristics/env-example.js +36 -0
  25. package/dist/src/heuristics/platforms.js +136 -0
  26. package/dist/src/heuristics/readiness.js +697 -25
  27. package/dist/src/main.js +58 -8
  28. package/dist/src/runner/gradle-properties.js +187 -0
  29. package/dist/src/runner/materialise.js +92 -0
  30. package/dist/src/runner/orchestrate.js +91 -2
  31. package/dist/src/secrets/vault.js +106 -5
  32. package/dist/src/server/app.js +94 -15
  33. package/dist/src/server/auth.js +127 -19
  34. package/dist/src/server/permissions.js +75 -0
  35. package/dist/src/server/required-secrets.js +30 -0
  36. package/dist/src/server/routes/account.js +57 -0
  37. package/dist/src/server/routes/credentials.js +108 -0
  38. package/dist/src/server/routes/projects.js +42 -0
  39. package/dist/src/server/routes/readiness.js +175 -6
  40. package/dist/src/server/routes/secrets.js +101 -0
  41. package/dist/src/server/routes/users.js +64 -0
  42. package/dist/src/sidecar/bridge.js +37 -1
  43. package/dist/src/sidecar/fastlane-dir.js +23 -0
  44. package/dist/src/sidecar/lanes.js +6 -0
  45. package/dist/src/sidecar/uses.js +21 -5
  46. package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DynuBC2l.js} +1 -1
  47. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  48. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  49. package/dist/web/index.html +2 -2
  50. package/package.json +1 -1
  51. package/ruby/introspect.rb +122 -9
  52. package/dist/web/assets/index-D9_EL8LA.js +0 -62
  53. 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}` : 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
+ }
@@ -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
- return { ok: true, config: { ...res.config, projects } };
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
- password_hash: z.string().min(1),
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
@@ -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 });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * What each kind of credential block is made of — the one table the server,
3
+ * runner, readiness checks, and web UI all read, so an agreement kept in one
4
+ * place instead of copied four times.
5
+ *
6
+ * Apple and Play defaults are the names fastlane itself reads — verified
7
+ * against fastlane 2.237: `app_store_connect_api_key` declares
8
+ * `APP_STORE_CONNECT_API_KEY_KEY_FILEPATH` / `_KEY_ID` / `..._ISSUER_ID`,
9
+ * and `supply` declares `SUPPLY_JSON_KEY`.
10
+ *
11
+ * Android defaults are Laneyard's own — nothing in fastlane reads a keystore
12
+ * by convention. `ANDROID_KEYSTORE_PASSWORD` was not chosen freely: it
13
+ * matches the `/(^|_)(KEYSTORE|STORE)_PASSWORD$/` pattern that
14
+ * `src/heuristics/readiness.ts` already recognises, so the check and the
15
+ * block agree by construction rather than by coincidence.
16
+ */
17
+ export const CREDENTIAL_KINDS = [
18
+ {
19
+ kind: "apple_asc",
20
+ platform: "ios",
21
+ what: "app store connect key",
22
+ accept: ".p8",
23
+ fields: [
24
+ { name: "key_id", secret: false, label: "Key ID" },
25
+ { name: "issuer_id", secret: false, label: "Issuer ID" },
26
+ ],
27
+ defaults: {
28
+ path: "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH",
29
+ key_id: "APP_STORE_CONNECT_API_KEY_KEY_ID",
30
+ issuer_id: "APP_STORE_CONNECT_API_KEY_ISSUER_ID",
31
+ },
32
+ },
33
+ {
34
+ kind: "android_keystore",
35
+ platform: "android",
36
+ what: "android upload keystore",
37
+ accept: ".jks,.keystore",
38
+ fields: [
39
+ { name: "key_alias", secret: false, label: "Key alias" },
40
+ { name: "store_password", secret: true, label: "Store password" },
41
+ { name: "key_password", secret: true, label: "Key password" },
42
+ // The two things about a gradle properties file that cannot be deduced.
43
+ // `runner/gradle-properties.ts` writes that file where a build script
44
+ // falls back to the debug key; the script names the file, and says
45
+ // nothing about where the name is resolved when the receiver is a
46
+ // variable, nor about the keys read out of it afterwards. Both are asked
47
+ // here, pre-filled from what detection could tell, and left empty rather
48
+ // than guessed when it could tell nothing.
49
+ {
50
+ name: "properties_path",
51
+ secret: false,
52
+ optional: true,
53
+ label: "Properties file, relative to the app",
54
+ },
55
+ {
56
+ name: "property_names",
57
+ secret: false,
58
+ optional: true,
59
+ suggested: "storeFile,storePassword,keyPassword,keyAlias",
60
+ label: "Names your build reads inside it",
61
+ },
62
+ ],
63
+ defaults: {
64
+ path: "ANDROID_KEYSTORE_PATH",
65
+ store_password: "ANDROID_KEYSTORE_PASSWORD",
66
+ key_alias: "ANDROID_KEY_ALIAS",
67
+ key_password: "ANDROID_KEY_PASSWORD",
68
+ },
69
+ },
70
+ {
71
+ kind: "play_service_account",
72
+ platform: "android",
73
+ what: "play store service account",
74
+ accept: ".json,application/json",
75
+ fields: [],
76
+ defaults: {
77
+ path: "SUPPLY_JSON_KEY",
78
+ },
79
+ },
80
+ ];
81
+ function specOf(kind) {
82
+ const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
83
+ if (!spec)
84
+ throw new Error(`unknown credential kind: ${kind}`);
85
+ return spec;
86
+ }
87
+ export function defaultVarNames(kind) {
88
+ return specOf(kind).defaults;
89
+ }
90
+ export function fieldsOf(kind) {
91
+ return specOf(kind).fields;
92
+ }
93
+ /**
94
+ * The variable names a set of stored blocks will export into a run.
95
+ *
96
+ * Read off the summaries — the kind and the names stored beside it — so nothing
97
+ * here decrypts anything. That is what lets the readiness check and the secrets
98
+ * screen both ask the question: a name a block supplies is a name the project
99
+ * already has, and asking someone to type it by hand is asking them for what
100
+ * Laneyard is about to hand the run itself.
101
+ *
102
+ * Every name in the block is counted, and no value is looked at. `path` is
103
+ * always exported, and every other slot belongs to a field the block could not
104
+ * be stored without — `credentials.ts` refuses a block with a required field
105
+ * empty, and the optional two have no slot at all. So a name here is a name
106
+ * `runner/materialise.ts` will set.
107
+ *
108
+ * The blocks passed in are the ones that apply, project shadowing global, which
109
+ * is `Vault.listCredentials` and the precedence a run uses.
110
+ */
111
+ export function exportedVarNames(blocks) {
112
+ const names = new Set();
113
+ for (const block of blocks) {
114
+ for (const name of Object.values({ ...defaultVarNames(block.kind), ...block.varNames })) {
115
+ if (name)
116
+ names.add(name);
117
+ }
118
+ }
119
+ return [...names].sort();
120
+ }
@@ -0,0 +1,75 @@
1
+ const GLOBAL = "";
2
+ /**
3
+ * Stores signing credential blocks: a file plus the fields that make it
4
+ * usable. Knows nothing about encryption itself: it takes and returns
5
+ * ciphertext, so a bug here cannot leak a plaintext value.
6
+ *
7
+ * A project credential shadows a global one of the same kind — the same
8
+ * precedence as `SecretStore`, and the least surprising rule.
9
+ */
10
+ export class CredentialStore {
11
+ db;
12
+ constructor(db) {
13
+ this.db = db;
14
+ }
15
+ set(projectSlug, kind, input) {
16
+ this.db
17
+ .prepare(`INSERT INTO credential (project_slug, kind, file_name, file_enc, fields_enc, var_names, updated_at)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?)
19
+ ON CONFLICT (project_slug, kind) DO UPDATE
20
+ SET file_name = excluded.file_name,
21
+ file_enc = excluded.file_enc,
22
+ fields_enc = excluded.fields_enc,
23
+ var_names = excluded.var_names,
24
+ updated_at = excluded.updated_at`)
25
+ .run(projectSlug ?? GLOBAL, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
26
+ }
27
+ /** Rows that apply to a project, project scope winning over global. */
28
+ applicable(projectSlug) {
29
+ const rows = this.db
30
+ .prepare("SELECT * FROM credential WHERE project_slug IN (?, ?) ORDER BY kind")
31
+ .all(projectSlug, GLOBAL);
32
+ const byKind = new Map();
33
+ for (const row of rows) {
34
+ const existing = byKind.get(row.kind);
35
+ if (!existing || row.project_slug !== GLOBAL)
36
+ byKind.set(row.kind, row);
37
+ }
38
+ return [...byKind.values()]
39
+ .sort((a, b) => a.kind.localeCompare(b.kind))
40
+ .map((row) => this.toSummary(row));
41
+ }
42
+ /** One applicable block, ciphertext included, or undefined. */
43
+ find(projectSlug, kind) {
44
+ return this.applicable(projectSlug).find((r) => r.kind === kind);
45
+ }
46
+ list(projectSlug) {
47
+ return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
48
+ }
49
+ listGlobal() {
50
+ const rows = this.db
51
+ .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
52
+ .all(GLOBAL);
53
+ return rows.map((row) => {
54
+ const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
55
+ return summary;
56
+ });
57
+ }
58
+ remove(projectSlug, kind) {
59
+ const res = this.db
60
+ .prepare("DELETE FROM credential WHERE project_slug = ? AND kind = ?")
61
+ .run(projectSlug ?? GLOBAL, kind);
62
+ return res.changes > 0;
63
+ }
64
+ toSummary(row) {
65
+ return {
66
+ kind: row.kind,
67
+ fileName: row.file_name,
68
+ fileEnc: row.file_enc,
69
+ fieldsEnc: row.fields_enc,
70
+ scope: row.project_slug === GLOBAL ? "global" : "project",
71
+ varNames: JSON.parse(row.var_names),
72
+ updatedAt: row.updated_at,
73
+ };
74
+ }
75
+ }