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,162 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { parseEnvExample } from "../heuristics/env-example.js";
4
+ /**
5
+ * Bringing an existing `fastlane/.env` into the vault.
6
+ *
7
+ * Every project that already builds has its variables somewhere, and that
8
+ * somewhere is almost always `fastlane/.env` — gitignored, on one laptop, and
9
+ * therefore absent from the clone a build server works from. Typing eight
10
+ * values into a web form to reproduce a file that is already on disk is a poor
11
+ * way to start, and the sort of chore where one typo costs an evening.
12
+ *
13
+ * This runs from the CLI and not from the server on purpose: the `.env` exists
14
+ * where the working copy is, and the server only ever sees a clone.
15
+ *
16
+ * Values are never printed, never logged, and never passed as arguments. What
17
+ * the command shows is names.
18
+ */
19
+ /**
20
+ * The variables whose value is a *path*, and the name their *contents* belong
21
+ * under.
22
+ *
23
+ * This is the translation that makes an import worth doing. `SUPPLY_JSON_KEY`
24
+ * points at a service account JSON on this laptop; copied verbatim into the
25
+ * vault it would point at nothing on the build machine, and the run would fail
26
+ * exactly as it does today. What has to travel is the file, so the file is
27
+ * read and stored under `SUPPLY_JSON_KEY_DATA` — a name supply reads on its
28
+ * own, verified against fastlane 2.237.
29
+ *
30
+ * There is no such entry for a `.p8`. `APP_STORE_CONNECT_API_KEY_P8` was one —
31
+ * an earlier version of this interface invented it, no action in fastlane has
32
+ * ever declared it, and the interface has since dropped it. See `P8_PATH_NAMES`
33
+ * for what a `.p8` path becomes instead.
34
+ */
35
+ export const PATH_TO_CONTENTS = {
36
+ SUPPLY_JSON_KEY: "SUPPLY_JSON_KEY_DATA",
37
+ GOOGLE_APPLICATION_CREDENTIALS: "SUPPLY_JSON_KEY_DATA",
38
+ };
39
+ /**
40
+ * Variables that name a `.p8` the way projects and this interface's own
41
+ * earlier version did.
42
+ *
43
+ * None of them is a name fastlane reads: the App Store Connect action takes
44
+ * its key from `key_id`, `issuer_id` and a file, not from an environment
45
+ * variable an import could invent on its own. What this command can do
46
+ * honestly is find the file and say where it belongs — an App Store Connect
47
+ * key block, uploaded from the secrets tab with the two fields the file alone
48
+ * cannot supply.
49
+ */
50
+ export const P8_PATH_NAMES = new Set([
51
+ "ASC_KEY_FILEPATH",
52
+ "APP_STORE_CONNECT_API_KEY_PATH",
53
+ "APP_STORE_CONNECT_API_KEY_FILEPATH",
54
+ ]);
55
+ /**
56
+ * Reads a `.env` into a name → value map.
57
+ *
58
+ * Shares `parseEnvExample`'s idea of what a line is, and adds the values, which
59
+ * that one deliberately drops. Quotes are stripped because a `.env` written by
60
+ * hand often has them and fastlane's own dotenv strips them too — a value that
61
+ * silently kept its quotes is a credential that never works, with nothing on
62
+ * screen to say why.
63
+ */
64
+ export function parseEnvFile(content) {
65
+ const out = new Map();
66
+ for (const name of parseEnvExample(content)) {
67
+ // Re-scan for this name's value; `parseEnvExample` already vouched for the
68
+ // name, so this only has to find the last assignment, the way dotenv does.
69
+ for (const raw of content.split("\n")) {
70
+ const line = raw.trim().replace(/^export\s+/, "");
71
+ const eq = line.indexOf("=");
72
+ if (eq <= 0 || line.slice(0, eq).trim() !== name)
73
+ continue;
74
+ let value = line.slice(eq + 1).trim();
75
+ const quoted = /^(["'])(.*)\1$/.exec(value);
76
+ if (quoted)
77
+ value = quoted[2];
78
+ out.set(name, value);
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * Decides what would be stored, without storing anything.
85
+ *
86
+ * Separate from the writing so the command can show its work first. An import
87
+ * that reads eight files and writes eight secrets before saying a word is one
88
+ * nobody can check.
89
+ */
90
+ export async function planImport(env, cwd, existingKeys) {
91
+ const planned = [];
92
+ const already = new Set(existingKeys);
93
+ for (const [name, value] of env) {
94
+ if (value === "")
95
+ continue;
96
+ if (P8_PATH_NAMES.has(name)) {
97
+ const path = isAbsolute(value) ? value : resolve(cwd, value);
98
+ const found = await access(path)
99
+ .then(() => true)
100
+ .catch(() => false);
101
+ if (!found) {
102
+ // Same handling as any other missing file: reported, not skipped in
103
+ // silence, because it is the credential the project most needs.
104
+ planned.push({ key: name, kind: "unresolved-path", path, value });
105
+ continue;
106
+ }
107
+ // The file is real, but nothing is stored under `name` — there is
108
+ // nowhere fastlane would read it from. Left to the caller to say what
109
+ // to do instead, in the same voice as the rest of the plan.
110
+ planned.push({ key: name, kind: "suggest-block", path, value });
111
+ continue;
112
+ }
113
+ const contentsKey = PATH_TO_CONTENTS[name];
114
+ if (contentsKey) {
115
+ const path = isAbsolute(value) ? value : resolve(cwd, value);
116
+ const contents = await readFile(path, "utf8").catch(() => null);
117
+ if (contents === null) {
118
+ // Named a file that is not there. Reported rather than skipped: it is
119
+ // the credential the project most needs, and silence would read as
120
+ // success.
121
+ planned.push({ key: name, kind: "unresolved-path", path, value });
122
+ continue;
123
+ }
124
+ planned.push({
125
+ key: contentsKey,
126
+ ...(contentsKey === name ? {} : { from: name }),
127
+ kind: "file-contents",
128
+ path,
129
+ value: contents,
130
+ });
131
+ continue;
132
+ }
133
+ planned.push({ key: name, kind: "value", value });
134
+ }
135
+ return {
136
+ planned,
137
+ replacing: planned
138
+ .filter((p) => p.kind !== "unresolved-path" && p.kind !== "suggest-block" && already.has(p.key))
139
+ .map((p) => p.key),
140
+ };
141
+ }
142
+ /**
143
+ * Writes the plan into the vault.
144
+ *
145
+ * Everything is masked. A value that came out of a `.env` is a credential by
146
+ * assumption — that is what the file is for — and the one that turns out not to
147
+ * be secret costs a redacted line in a log, while the reverse costs a leak.
148
+ */
149
+ export async function applyImport(vault, slug, plan) {
150
+ let stored = 0;
151
+ for (const item of plan.planned) {
152
+ if (item.kind === "unresolved-path" || item.kind === "suggest-block")
153
+ continue;
154
+ await vault.set(slug, item.key, item.value, true);
155
+ stored += 1;
156
+ }
157
+ return stored;
158
+ }
159
+ /** Where a project's `.env` files live, in the order dotenv itself reads them. */
160
+ export function envFilesIn(fastlaneDir) {
161
+ return [join(fastlaneDir, ".env.default"), join(fastlaneDir, ".env")];
162
+ }
@@ -1,17 +1,29 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { join } from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { readFile } from "node:fs/promises";
5
+ import { applyImport, envFilesIn, parseEnvFile, planImport } from "./secret-import.js";
2
6
  import { ConfigStore } from "../config/store.js";
3
7
  import { openDatabase } from "../db/open.js";
8
+ import { CredentialStore } from "../db/credentials.js";
4
9
  import { SecretStore } from "../db/secrets.js";
5
10
  import { MIN_LENGTH as MIN_REDACTABLE } from "../logs/redact.js";
6
11
  import { Vault } from "../secrets/vault.js";
7
12
  /** Same rule as the API: what cannot become an environment variable is refused here too. */
8
13
  const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
14
  export const SECRET_USAGE = `laneyard secret set <NAME> [--project <slug>] [--no-mask]
15
+ laneyard secret import --project <slug> [--yes]
10
16
 
11
17
  The value is read from standard input, never from an argument:
12
18
 
13
19
  laneyard secret set MATCH_PASSWORD --project app
14
20
  echo "$TOKEN" | laneyard secret set GITHUB_TOKEN
21
+
22
+ \`import\` reads this project's fastlane/.env and stores what it finds. A
23
+ variable naming a service account JSON has the *file* stored, under the name
24
+ fastlane looks for — a path does not travel to a build machine. A variable
25
+ naming a .p8 is left alone and reported instead: that credential belongs in a
26
+ signing block from the secrets tab, not in a loose variable.
15
27
  `;
16
28
  /**
17
29
  * Reads standard input whole.
@@ -69,6 +81,9 @@ function parse(args) {
69
81
  */
70
82
  export async function runSecretCommand(home, args, io) {
71
83
  const [subcommand, ...rest] = args;
84
+ if (subcommand === "import") {
85
+ return runSecretImport(home, rest, io);
86
+ }
72
87
  if (subcommand !== "set") {
73
88
  io.err(subcommand === undefined ? `${SECRET_USAGE}` : `Unknown subcommand: ${subcommand}\n\n${SECRET_USAGE}`);
74
89
  return 1;
@@ -121,7 +136,7 @@ export async function runSecretCommand(home, args, io) {
121
136
  }
122
137
  const db = openDatabase(join(home, "laneyard.db"));
123
138
  try {
124
- const vault = await Vault.open(home, new SecretStore(db));
139
+ const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
125
140
  await vault.set(parsed.project, parsed.key, value, parsed.masked);
126
141
  }
127
142
  finally {
@@ -131,3 +146,149 @@ export async function runSecretCommand(home, args, io) {
131
146
  ` ${parsed.masked ? "kept out of the logs" : "shown in the logs"}\n`);
132
147
  return 0;
133
148
  }
149
+ /**
150
+ * `laneyard secret import` — the existing `.env` into the vault.
151
+ *
152
+ * Shows what it would do before doing it, in names only. An import that reads
153
+ * eight files and writes eight secrets before saying a word is one nobody can
154
+ * check, and this is the one command that handles every credential a project
155
+ * has at once.
156
+ */
157
+ async function runSecretImport(home, args, io) {
158
+ let project = null;
159
+ let yes = false;
160
+ for (let i = 0; i < args.length; i += 1) {
161
+ const arg = args[i];
162
+ if (arg === "--project" || arg === "-p") {
163
+ const next = args[i + 1];
164
+ if (next === undefined || next.startsWith("-")) {
165
+ io.err("--project needs a slug.\n");
166
+ return 1;
167
+ }
168
+ project = next;
169
+ i += 1;
170
+ }
171
+ else if (arg === "--yes" || arg === "-y") {
172
+ yes = true;
173
+ }
174
+ else {
175
+ io.err(`Unknown option: ${arg}\n\n${SECRET_USAGE}`);
176
+ return 1;
177
+ }
178
+ }
179
+ if (project === null) {
180
+ io.err("Which project? Give it with --project <slug>.\n");
181
+ return 1;
182
+ }
183
+ const config = new ConfigStore(join(home, "config.yml"));
184
+ const loaded = await config.load();
185
+ if (!loaded.ok) {
186
+ io.err(`Unreadable configuration in ${join(home, "config.yml")}: ${loaded.error}\n`);
187
+ return 1;
188
+ }
189
+ const entry = config.project(project);
190
+ if (!entry) {
191
+ const known = config.projects().map((p) => p.slug);
192
+ io.err(`Unknown project: "${project}".` +
193
+ (known.length > 0 ? ` Known projects: ${known.join(", ")}.` : " No project is declared yet.") +
194
+ "\n");
195
+ return 1;
196
+ }
197
+ // Read from the working copy this command was run in — the only place a
198
+ // gitignored `.env` exists; the server's clone never has one.
199
+ //
200
+ // Resolved against the repository root, not the current directory:
201
+ // `fastlane_dir` is recorded relative to the root, so running this from
202
+ // `app/` would otherwise look for `app/app/fastlane/.env`. The same
203
+ // confusion, one command along, as the one that produced an ENOENT in the
204
+ // lane list.
205
+ const fastlaneDir = entry.fastlane_dir ?? "fastlane";
206
+ const root = await repositoryRoot(process.cwd());
207
+ const env = new Map();
208
+ const readFrom = [];
209
+ for (const file of envFilesIn(fastlaneDir)) {
210
+ const text = await readFile(join(root, file), "utf8").catch(() => null);
211
+ if (text === null)
212
+ continue;
213
+ readFrom.push(file);
214
+ for (const [k, v] of parseEnvFile(text))
215
+ env.set(k, v);
216
+ }
217
+ if (readFrom.length === 0) {
218
+ io.err(`No ${fastlaneDir}/.env under ${root}. Run this from the working copy that has one — ` +
219
+ "it is the file that never reaches a clone, which is the whole reason for this command.\n");
220
+ return 1;
221
+ }
222
+ const db = openDatabase(join(home, "laneyard.db"));
223
+ try {
224
+ const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
225
+ // Paths inside a `.env` are written relative to where fastlane runs, which
226
+ // is the fastlane directory's parent — the app, not the repository root.
227
+ const plan = await planImport(env, join(root, fastlaneDir, ".."), vault.list(project).map((s) => s.key));
228
+ if (plan.planned.length === 0) {
229
+ io.out(`Nothing to import from ${readFrom.join(" and ")}.\n`);
230
+ return 0;
231
+ }
232
+ io.out(`\nFrom ${readFrom.join(" and ")}, into "${project}":\n\n`);
233
+ for (const item of plan.planned) {
234
+ if (item.kind === "unresolved-path") {
235
+ io.out(` ✗ ${item.key} — names ${item.path}, which is not there. Skipped.\n`);
236
+ }
237
+ else if (item.kind === "suggest-block") {
238
+ io.out(` ▸ ${item.key} names ${item.path} — no action in fastlane reads that variable. ` +
239
+ "Upload the `.p8` as an App Store Connect key block from the secrets tab instead, with " +
240
+ "its key id and issuer id. Not imported here.\n");
241
+ }
242
+ else if (item.kind === "file-contents") {
243
+ io.out(` ● ${item.key}${item.from ? ` (the contents of ${item.from})` : ""}\n`);
244
+ }
245
+ else {
246
+ io.out(` ● ${item.key}\n`);
247
+ }
248
+ }
249
+ if (plan.replacing.length > 0) {
250
+ io.out(`\n ${plan.replacing.join(", ")} already in the vault — they will be replaced.\n`);
251
+ }
252
+ const skipped = plan.planned.filter((p) => p.kind === "unresolved-path");
253
+ if (skipped.length > 0) {
254
+ io.out("\n A skipped file is the credential this project most needs. Check the path.\n");
255
+ }
256
+ if (!yes) {
257
+ io.out("\nNothing is written yet. Run it again with --yes to store these.\n");
258
+ return 0;
259
+ }
260
+ const stored = await applyImport(vault, project, plan);
261
+ io.out(`\n✓ Stored ${stored} ${stored === 1 ? "secret" : "secrets"}, all kept out of the logs.\n`);
262
+ // Said here because it is easy to assume an import leaves lanes with
263
+ // homework. It does not: `key_filepath:` and `json_key:` are the supported
264
+ // forms, not a stopgap. A signing credential block writes its file to disk
265
+ // for the length of a run, so a lane that names a path keeps reading a
266
+ // real one, unedited.
267
+ io.out("\nNothing in your lanes needs to change. `key_filepath:` and `json_key:` keep working as " +
268
+ "written. From here, a `.p8` or a service account JSON belongs in a signing credential block " +
269
+ "from the secrets tab, not as a loose variable in this file.\n");
270
+ return 0;
271
+ }
272
+ finally {
273
+ db.close();
274
+ }
275
+ }
276
+ /**
277
+ * The repository root, or the directory given if this is not a repository.
278
+ *
279
+ * `fastlane_dir` is recorded relative to the root — that is what the clone is
280
+ * measured from — so anything resolving it has to agree about where the root is,
281
+ * whichever sub-directory the command happens to be run from.
282
+ */
283
+ async function repositoryRoot(from) {
284
+ try {
285
+ const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], {
286
+ cwd: from,
287
+ timeout: 5_000,
288
+ });
289
+ return stdout.trim() || from;
290
+ }
291
+ catch {
292
+ return from;
293
+ }
294
+ }
@@ -0,0 +1,319 @@
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
+ // An entry of the same name is updated, not refused.
37
+ //
38
+ // Setup prints "Continuing replaces its entry" before asking anything, and
39
+ // then this threw — so the one way to correct a stale entry, running setup
40
+ // again, was the one thing that could not be done. A project written by an
41
+ // older version and missing a field it now needs was unfixable except by hand.
42
+ //
43
+ // Field by field rather than wholesale, though the warning says "replaces":
44
+ // an entry may carry things setup knows nothing about — a `git_auth` pointing
45
+ // at an SSH key, a raised `timeout_minutes` — and losing those silently, on a
46
+ // command someone ran to fix something else, would be its own bug.
47
+ const existing = seq.items.find((item) => item.get?.("slug") === entry.slug);
48
+ if (existing?.set) {
49
+ for (const [key, value] of Object.entries(entry))
50
+ existing.set(key, value);
51
+ }
52
+ else {
53
+ seq.add(doc.createNode(entry));
54
+ }
55
+ await writeFile(path, serialize(doc), "utf8");
56
+ }
57
+ /**
58
+ * Takes a project's block out of config.yml, leaving the rest of the file alone.
59
+ *
60
+ * Same requirement as `addProjectToConfig`, and for the same reason: the file is
61
+ * hand-written, so the edit goes through the YAML document and every comment,
62
+ * key order and blank line that isn't this project's survives it.
63
+ *
64
+ * Returns false when no block carried that slug, so the caller can answer 404
65
+ * rather than rewrite the file to say the same thing it already said.
66
+ */
67
+ export async function removeProjectFromConfig(path, slug) {
68
+ const doc = parseDocument(await readFile(path, "utf8"));
69
+ const projects = doc.getIn(["projects"]);
70
+ if (!(projects instanceof YAMLSeq))
71
+ return false;
72
+ const at = projects.items.findIndex((item) => item.get?.("slug") === slug);
73
+ if (at === -1)
74
+ return false;
75
+ projects.items.splice(at, 1);
76
+ await writeFile(path, serialize(doc), "utf8");
77
+ return true;
78
+ }
79
+ /**
80
+ * Entry point for `laneyard setup`.
81
+ *
82
+ * It proposes rather than decides. An earlier version detected everything
83
+ * silently and wrote a configuration that looked plausible and pointed nowhere —
84
+ * the failure only surfaced later, as an unreadable lane list, far from its
85
+ * cause. Showing the values and letting them be corrected costs one screen and
86
+ * removes a whole class of that.
87
+ */
88
+ export async function runSetupCommand(cwd, configPath, options = {}) {
89
+ const d = await detectProject(cwd);
90
+ if (d.fastlaneDir === null) {
91
+ process.stderr.write("No Fastfile found here. Laneyard drives fastlane: run the command from a project " +
92
+ "that already uses it, or run `fastlane init` first.\n");
93
+ return 1;
94
+ }
95
+ if (d.gitUrl === null) {
96
+ process.stderr.write("No git remote named \"origin\". Laneyard clones projects from their repository: " +
97
+ "add a remote, or set git_url by hand in config.yml.\n");
98
+ return 1;
99
+ }
100
+ const interactive = options.asker === undefined && options.yes !== true;
101
+ const asker = options.asker ?? (options.yes ? acceptingAsker : terminalAsker());
102
+ try {
103
+ process.stdout.write(heading("Found a fastlane project"));
104
+ process.stdout.write(field("repository", repositoryLabel(d.gitUrl)) + "\n");
105
+ if (d.subPath !== "") {
106
+ process.stdout.write(field("in", `${d.subPath}/`) + "\n");
107
+ }
108
+ // Two files, two purposes, and the difference is the one thing people get
109
+ // wrong here — so it is stated before anything is written rather than
110
+ // discovered afterwards when nothing turns out to be versioned.
111
+ process.stdout.write("\n" +
112
+ dim(" Two files will describe it:\n") +
113
+ dim(` ${LANEYARD_YML.padEnd(13)}in the repository — how it builds. Commit it.\n`) +
114
+ dim(` ${"config.yml".padEnd(13)}on this machine — where to clone it from, and your\n`) +
115
+ dim(` password. Never committed.\n`));
116
+ const existing = await existingProject(configPath, d.slug);
117
+ if (existing) {
118
+ process.stdout.write("\n" +
119
+ warn(`This machine already knows a project called ${bold(existing)}.\n`) +
120
+ dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
121
+ dim(" Give it another name to keep both.\n"));
122
+ }
123
+ const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
124
+ if (await fileExists(repoConfigPath)) {
125
+ process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
126
+ dim(" Its values win over anything written here; it will be left alone.\n"));
127
+ }
128
+ // A machine with no account gets its first admin here. Asked rather than
129
+ // assumed: the name is typed into a login form every day afterwards, and
130
+ // `admin` is a poor thing to call a person once there are two of them.
131
+ const firstAccount = !(await hasAccount(configPath));
132
+ if (firstAccount) {
133
+ process.stdout.write("\n" +
134
+ warn("This machine has no account yet.\n") +
135
+ dim(" You will sign in with a name and a password. The password is generated below.\n"));
136
+ }
137
+ if (interactive) {
138
+ process.stdout.write("\n" + dim("Press Return to accept a value, or type a new one.") + "\n\n");
139
+ }
140
+ const adminName = firstAccount
141
+ ? await asker.ask("your name for signing in", DEFAULT_ADMIN_NAME)
142
+ : null;
143
+ if (adminName !== null && !VALID_NAME.test(adminName)) {
144
+ process.stderr.write("\n" + bad(`Invalid name: "${adminName}". Letters, digits, dot, dash and underscore.`) + "\n");
145
+ return 1;
146
+ }
147
+ const slug = options.slug ?? (await asker.ask("name for this project", d.slug));
148
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
149
+ process.stderr.write("\n" + bad(`Invalid name: "${slug}". Lowercase letters, digits and hyphens only.`) + "\n");
150
+ return 1;
151
+ }
152
+ const gitUrl = await asker.ask("repository", d.gitUrl);
153
+ const branch = await asker.ask("default branch", d.defaultBranch, dim("A run uses this branch unless you pick another one when you start it."));
154
+ const fastlaneDir = await asker.ask("fastlane directory", d.fastlaneDir, dim("Relative to the repository root, because that is what Laneyard clones."));
155
+ const useBundler = await asker.confirm("\n" +
156
+ dim(" With bundler, runs use the fastlane version pinned in the Gemfile.\n") +
157
+ dim(" Without it, whichever fastlane is installed on the build machine.\n") +
158
+ " Run fastlane through bundler?", d.runtime === "bundle");
159
+ const runtime = useBundler ? "bundle" : "system";
160
+ // Artifact patterns are shown, not asked: typing four globs correctly at a
161
+ // prompt is a miserable way to start, the detected ones are nearly always
162
+ // right, and the file is there to be edited when they are not.
163
+ const globs = d.artifactGlobs;
164
+ if (!(await asker.confirm(`\n${bold(`Set up "${slug}"`)}?`, true))) {
165
+ process.stdout.write(dim("Nothing written.") + "\n");
166
+ return 0;
167
+ }
168
+ // The account first, so the server block is written before the project list
169
+ // it sits above. `null` when this machine already has one, and then nothing
170
+ // is written and nothing is printed.
171
+ const generatedPassword = adminName === null ? null : await ensureFirstAdmin(configPath, adminName);
172
+ // The machine half: how to reach the project. Nothing here belongs in a
173
+ // repository — it is this server's own registry, plus its credentials.
174
+ await addProjectToConfig(configPath, {
175
+ slug,
176
+ name: slug,
177
+ git_url: gitUrl,
178
+ default_branch: branch,
179
+ // Written on this machine too, and only when it is not the default,
180
+ // because of the gap between the two files. Laneyard builds from a clone
181
+ // of the remote, so nothing written into the working copy reaches it
182
+ // until `laneyard.yml` is committed and pushed — and until then
183
+ // `fastlane_dir` falls back to `fastlane`, which in a monorepo is not
184
+ // where anything is. The project was unreadable from the moment setup
185
+ // finished until a git push, with an ENOENT for an explanation.
186
+ //
187
+ // This is not a second source of truth: the repository file wins the
188
+ // moment it lands, which is the precedence `config.yml` already
189
+ // documents and the schema already allows. It is the value setup just
190
+ // proposed and the user just accepted, kept where it is useful until the
191
+ // authoritative copy arrives.
192
+ //
193
+ // Omitted when it *is* the default, so an ordinary project's block stays
194
+ // about how the project is reached and nothing else.
195
+ // Both of the fields the sidecar needs before it can read anything, and
196
+ // only when they are not already the default. `runtime` belongs here for
197
+ // exactly the same reason as `fastlane_dir`: without it the sidecar is
198
+ // launched under `bundle exec` and a project that uses a system fastlane
199
+ // fails with "Could not locate Gemfile" — the same bootstrap gap, one
200
+ // field along.
201
+ ...(fastlaneDir === "fastlane" ? {} : { fastlane_dir: fastlaneDir }),
202
+ ...(runtime === "bundle" ? {} : { runtime }),
203
+ });
204
+ // The repository half: how it builds. This is the part that was going into
205
+ // the machine's file and therefore never being committed — which is exactly
206
+ // backwards, since it is the part a colleague needs.
207
+ const wroteRepoConfig = await writeRepoConfigIfAbsent(repoConfigPath, {
208
+ fastlane_dir: fastlaneDir,
209
+ runtime,
210
+ artifact_globs: globs,
211
+ // Written down rather than re-inferred on every readiness check: a value
212
+ // in a file can be corrected when the guess was wrong, and setup and the
213
+ // checklist cannot end up disagreeing about the same repository.
214
+ ...(d.platforms.length > 0 ? { platforms: d.platforms } : {}),
215
+ });
216
+ const port = await configuredPort(configPath);
217
+ process.stdout.write(heading(`Project "${slug}" is set up`) +
218
+ field("repository", `${gitUrl} (${branch})`) + "\n" +
219
+ field("fastlane", fastlaneDir) + "\n" +
220
+ field("runtime", runtime) + "\n" +
221
+ field("artifacts", globs.join(", ") || dim("none detected")) + "\n" +
222
+ // Shown because it decides which half of the readiness checklist a
223
+ // project is held to, and because a wrong guess is corrected by editing
224
+ // one line of the file just written.
225
+ field("platforms", d.platforms.join(", ") || dim("none detected")) + "\n" +
226
+ "\n" +
227
+ (wroteRepoConfig
228
+ ? ok(`Wrote ${bold(LANEYARD_YML)} — ${bold("commit it")} so your team builds the same way.\n`)
229
+ : warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
230
+ ok(`Registered in ${configPath}\n`) +
231
+ // Last thing before the invitation to start the server, because it is
232
+ // the one line here that cannot be read again anywhere: the file holds
233
+ // a hash, and nothing holds the password.
234
+ (generatedPassword === null
235
+ ? ""
236
+ : heading("Your account") +
237
+ field("name", bold(adminName)) + "\n" +
238
+ field("password", bold(generatedPassword)) + "\n" +
239
+ field("role", "admin") + "\n" +
240
+ "\n" +
241
+ warn("Write the password down — it is not shown again, and it is not stored.\n") +
242
+ dim(" Add a colleague later with `laneyard user add <name> --role builder`.\n")) +
243
+ heading("Start Laneyard") +
244
+ ` ${bold("laneyard")}\n` +
245
+ ` ${dim(`http://localhost:${port}`)}\n` +
246
+ "\n" +
247
+ dim("Already running? The configuration is watched — it appears on its own.\n"));
248
+ return 0;
249
+ }
250
+ finally {
251
+ asker.close();
252
+ }
253
+ }
254
+ /**
255
+ * The port the server will actually listen on.
256
+ *
257
+ * Read back from the file rather than assumed: telling someone to open a port
258
+ * their configuration does not use is the kind of small wrongness that makes a
259
+ * tool feel unreliable on first contact.
260
+ */
261
+ async function configuredPort(configPath) {
262
+ const res = await loadServerConfig(configPath);
263
+ return res.ok ? res.config.server.port : 7890;
264
+ }
265
+ /** The repository file, named once so the message and the write cannot disagree. */
266
+ const LANEYARD_YML = "laneyard.yml";
267
+ /**
268
+ * What the first account is called when nobody says otherwise.
269
+ *
270
+ * The same name a lone `password_hash` is read under, so a machine set up today
271
+ * and a machine upgraded from 0.2 sign in the same way.
272
+ */
273
+ const DEFAULT_ADMIN_NAME = "admin";
274
+ /**
275
+ * The repository root, from where the command ran and how deep it sits.
276
+ *
277
+ * `laneyard.yml` belongs at the root because that is where the server reads it:
278
+ * the clone is the repository, not the sub-directory someone was standing in.
279
+ */
280
+ function repoRoot(cwd, subPath) {
281
+ return subPath === "" ? cwd : join(cwd, ...subPath.split("/").map(() => ".."));
282
+ }
283
+ const fileExists = async (path) => {
284
+ try {
285
+ await readFile(path);
286
+ return true;
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ };
292
+ /** The name already registered under this slug, or null. */
293
+ async function existingProject(configPath, slug) {
294
+ const res = await loadServerConfig(configPath);
295
+ if (!res.ok)
296
+ return null;
297
+ return res.config.projects.find((p) => p.slug === slug)?.slug ?? null;
298
+ }
299
+ /**
300
+ * Writes the repository's build configuration, unless it is already there.
301
+ *
302
+ * Never overwrites: a `laneyard.yml` in a repository was put there by someone,
303
+ * possibly with comments and choices this command knows nothing about, and its
304
+ * values win anyway.
305
+ */
306
+ async function writeRepoConfigIfAbsent(path, settings) {
307
+ if (await fileExists(path))
308
+ return false;
309
+ const doc = new Document(settings);
310
+ doc.commentBefore =
311
+ " How this project builds. Committed, so everyone builds it the same way.\n" +
312
+ " Values here win over the project's block in the server's config.yml.";
313
+ await writeFile(path, doc.toString(), "utf8");
314
+ return true;
315
+ }
316
+ /** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
317
+ function repositoryLabel(url) {
318
+ return url.replace(/^.*[:/]([^/:]+\/[^/]+?)(\.git)?$/, "$1");
319
+ }