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 CHANGED
@@ -58,14 +58,15 @@ Then, from a project you already build with fastlane:
58
58
 
59
59
  ```bash
60
60
  cd ~/code/your-app
61
- laneyard add # adopt this project
61
+ laneyard setup # answer a few questions about this project
62
62
  laneyard # start the server
63
63
  ```
64
64
 
65
- `laneyard add` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
66
- `Gemfile`, an Xcode project or a Gradle build — writes the matching block into
67
- `~/.laneyard/config.yml`, and prints a generated server password once. Write it down; it is not
68
- shown again.
65
+ `laneyard setup` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
66
+ `Gemfile`, an Xcode project or a Gradle build — and writes the matching block into
67
+ `~/.laneyard/config.yml`. On a machine that has no account yet, it also creates the first admin:
68
+ it asks what to call it and prints its generated password once. Write it down; it is not shown
69
+ again, and nothing stores it.
69
70
 
70
71
  <details>
71
72
  <summary>Running from source instead</summary>
@@ -95,7 +96,9 @@ Laneyard means copying one file, and restoring it means copying it back.
95
96
  server:
96
97
  port: 7890
97
98
  bind: 0.0.0.0
98
- password_hash: "scrypt$…" # written by `laneyard add`
99
+ users: # written by `laneyard setup`, see Accounts
100
+ - { name: martin, role: admin, password_hash: "scrypt$…" }
101
+ - { name: lea, role: builder, password_hash: "scrypt$…" }
99
102
  max_concurrent_runs: 1 # only 1 is accepted, see below
100
103
  retention: { runs: 50, artifact_days: 30 }
101
104
 
@@ -112,9 +115,56 @@ a time across every project — parallel runs would need a working directory per
112
115
  not exist yet. A larger number is refused when the file loads rather than silently ignored, so a
113
116
  server is never configured for builds that never happen.
114
117
 
115
- ### `laneyard.yml` — optional, committed in your repository
118
+ ### Accounts
116
119
 
117
- Build behaviour belongs next to the code, so it can be versioned with it:
120
+ Everyone who signs in has a name, a password and one of two roles. Two, because a third role is
121
+ easy to add and impossible to remove.
122
+
123
+ | | **admin** | **builder** |
124
+ |---|---|---|
125
+ | start a build, watch it, cancel it | ✓ | ✓ |
126
+ | download artifacts, read logs and the Fastfile | ✓ | ✓ |
127
+ | see the readiness checklist | ✓ | ✓ |
128
+ | read and write secrets | ✓ | |
129
+ | save, commit and push the Fastfile | ✓ | |
130
+ | remove a project | ✓ | |
131
+ | manage accounts | ✓ | |
132
+
133
+ A builder is what you give someone who ships without being trusted with the signing chain: they
134
+ can press the button and watch what happens, and they never see a credential.
135
+
136
+ The interface shows a builder only what a builder can use — the secrets, fastfile and settings
137
+ tabs are not drawn, and neither is the accounts screen. That is courtesy, not security: the
138
+ server refuses those routes on its own, whatever the browser was shown, and the test suite
139
+ proves it for every verb and every spelling of the address.
140
+
141
+ Add and remove accounts from the accounts screen, or from the command line:
142
+
143
+ ```bash
144
+ echo "$PASSWORD" | laneyard user add lea --role builder
145
+ ```
146
+
147
+ The password is read from standard input, never taken as an argument — an argument lands in your
148
+ shell history. Without `--role`, the account is a builder.
149
+
150
+ Two things are refused, in the API and on the command line alike: removing the last admin, and
151
+ demoting the last admin. A server nobody can administer cannot be repaired from the interface.
152
+
153
+ Removing an account ends its sessions immediately — "remove the account" and "revoke access" are
154
+ the same act. So does editing `config.yml` by hand: every request looks the account up again, so
155
+ a demotion takes effect at once rather than at the next restart.
156
+
157
+ **Upgrading from 0.2.** An existing `server.password_hash` keeps working, unedited. It is read as
158
+ a single admin account called `admin` — sign in with that name and the password you already have.
159
+ The first time you add someone, the file is rewritten into the `users` form above, comments and
160
+ all. Do not write both forms: a file holding a `password_hash` *and* a `users` list is refused at
161
+ load, because there is no obvious winner.
162
+
163
+ ### `laneyard.yml` — in your repository, and committed
164
+
165
+ Build behaviour belongs next to the code, so it can be versioned with it — `laneyard setup`
166
+ writes this file for you, and you should commit it. A colleague who clones the repository then
167
+ builds it the same way, without configuring anything.
118
168
 
119
169
  ```yaml
120
170
  fastlane_dir: fastlane
@@ -123,8 +173,13 @@ timeout_minutes: 60
123
173
  artifact_globs:
124
174
  - "build/**/*.ipa"
125
175
  - "build/**/*.app.dSYM.zip"
176
+ platforms: [ios] # or `[android]`, or both
126
177
  ```
127
178
 
179
+ `platforms` decides which half of the readiness checklist applies: an Android project is never
180
+ asked for an App Store Connect key. Left out, Laneyard looks at the repository — an Xcode project
181
+ means iOS, a Gradle build means Android — and reports what it found rather than assuming.
182
+
128
183
  Field by field, the repository file wins over the server block, which wins over the defaults. Any
129
184
  field of `laneyard.yml` may also be written in the server block, so a repository you would rather
130
185
  not touch can be configured entirely from `config.yml`.
@@ -146,6 +201,14 @@ The value is never an argument: a command line ends up in `~/.zsh_history` and i
146
201
  `ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
147
202
  `Ctrl-D`.
148
203
 
204
+ **Two of them are files.** An App Store Connect key arrives as a `.p8` and a Play Store service
205
+ account as a JSON file, and pasting either into a text field is the moment you are most likely to
206
+ paste it somewhere else by accident. The Secrets tab takes the file directly — *app store connect
207
+ key* and *play store service account*, beside the value field. Your browser reads it and sends its
208
+ text to the same route a typed value goes through, under the name fastlane and the readiness
209
+ checklist both look for: `APP_STORE_CONNECT_API_KEY_P8` and `SUPPLY_JSON_KEY_DATA`. Nothing is
210
+ uploaded, nothing is written to disk on the way, and the page only ever shows the file's name.
211
+
149
212
  A secret becomes an environment variable for every run of the project it belongs to. Without
150
213
  `--project` it applies to every project; a project secret of the same name wins over a global
151
214
  one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
@@ -153,18 +216,34 @@ least four characters long — see below.
153
216
 
154
217
  ### Readiness
155
218
 
156
- Every project has a Readiness tab: five checks on what stands between it and a build that runs
157
- while nobody watches.
219
+ Every project has a Readiness tab: what stands between it and a build that runs while nobody
220
+ watches. Only the checks that apply to the project are shown — an Android project is never asked
221
+ for an App Store Connect key, because one irrelevant warning teaches you to ignore the screen.
222
+
223
+ Always:
158
224
 
159
225
  - **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
160
226
  password prompt does not fail, it waits;
161
227
  - **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
162
228
  would otherwise find on the PATH;
229
+ - **no lane calls an action known to stop and ask** — `prompt`, `sigh`, `cert`, a writable
230
+ `match`, an upload waiting for its summary to be confirmed.
231
+
232
+ On iOS:
233
+
163
234
  - **App Store Connect** has an API key in the vault rather than a `FASTLANE_SESSION`, which
164
235
  expires and takes the next night's build with it;
165
236
  - **match** has its `MATCH_PASSWORD` stored and is called `readonly`, so it fetches certificates
166
- instead of trying to create them;
167
- - **no lane calls an action known to stop and ask** — `prompt`, `sigh`, a writable `match`.
237
+ instead of trying to create them.
238
+
239
+ On Android:
240
+
241
+ - **the keystore** is reachable without a prompt: a lane handing `gradle` a `storeFile` needs a
242
+ passphrase, and one that is neither in the call nor in the vault makes gradle stop and ask;
243
+ - **the Play Store service account** is in the vault when a lane calls `upload_to_play_store`.
244
+
245
+ Like the iOS ones, the Android checks read **literal arguments only**. `gradle(storePassword:
246
+ ENV["PW"])` is reported as undetermined, never guessed at: a checklist that guesses gets believed.
168
247
 
169
248
  They run when you open the tab or press refresh, never on their own: they shell out to git and to
170
249
  bundler. The time of the last run is on screen, because a stale green tick is worse than a red
@@ -204,15 +283,40 @@ Below the editor is what git makes of the workspace: the diff, a message field,
204
283
  `push`. A commit stages exactly the files that changed and never `git add -A` — a build leaves
205
284
  artifacts and reports scattered around, and none of them belong in your history.
206
285
 
286
+ ### Removing a project
287
+
288
+ Every project has a Settings tab, and the one thing on it is removal. It takes the project's block
289
+ out of `config.yml` — through the YAML document, so your comments and your key order survive —
290
+ and stops showing it. It is confirmed by typing the project's name: it is the one destructive
291
+ action in Laneyard, and a dialogue you can click through is not a confirmation.
292
+
293
+ What it does *not* do is most of the point, because "delete" elsewhere usually means the opposite:
294
+
295
+ - **the runs stay.** Every build that project ever ran keeps its page, its log and its artifacts,
296
+ each still at its own address. Removing a project means stop showing it, not destroy its past;
297
+ - **the clone and the artifacts stay on disk.** Their paths are printed when it is done, so you
298
+ can remove them yourself. Nothing is deleted from a web page on one click;
299
+ - **the secrets stay in the vault**, encrypted and unreachable, and come back if you add the
300
+ project again under the same name;
301
+ - **the repository is untouched.** Its `laneyard.yml`, its Fastfile and its history are the
302
+ repository's, not Laneyard's.
303
+
304
+ Removal is refused while a run of that project is in flight — that run is using the workspace. A
305
+ run still waiting in the queue will not start: it ends as failed, saying its project is gone.
306
+
207
307
  ## Security
208
308
 
209
309
  Read this before putting Laneyard on a network.
210
310
 
211
311
  - **It is built for a local network, not the internet.** It listens on `0.0.0.0` so you can reach
212
- it from your laptop, behind one password. Do not expose it publicly. If you need remote access,
312
+ it from your laptop, behind a password. Do not expose it publicly. If you need remote access,
213
313
  put it behind a VPN or an SSH tunnel.
214
- - **The password** is stored as an scrypt hash and repeated failures are throttled. Sessions live
215
- in memory and do not survive a restart.
314
+ - **Passwords** are stored as scrypt hashes and repeated failures are throttled, per account, so
315
+ hammering one name cannot lock out the others. Sessions live in memory and do not survive a
316
+ restart.
317
+ - **A role is enforced by the server, not by the interface.** One table names the routes that
318
+ require an admin, and one hook is the only thing that reads it — there is no permission check
319
+ hidden inside a handler. What a builder is not shown is also what a builder is refused.
216
320
  - **Secrets are encrypted at rest.** Values are stored with AES-256-GCM under a key kept in
217
321
  `~/.laneyard/key` — outside the database, mode `600`, and Laneyard refuses to start if anyone
218
322
  else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
@@ -249,6 +353,9 @@ What this does *not* cover, stated plainly:
249
353
  - `✓` build queue, cancellation, timeouts surfaced in the UI
250
354
  - `✓` a checklist that gets a project running unattended
251
355
  - `✓` edit the Fastfile in the browser, verified on every save
356
+ - `✓` store a signing credential straight from its `.p8` or JSON file
357
+ - `✓` remove a project from the interface, without touching its history
358
+ - `✓` named accounts, with a builder role that never sees a credential
252
359
  - `○` git-triggered and scheduled builds
253
360
 
254
361
  Two things worth knowing today: listing lanes does not fetch the repository, so a lane you have
@@ -1,8 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
- import { access } from "node:fs/promises";
2
+ import { access, realpath } from "node:fs/promises";
3
3
  import { basename, join, relative, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { glob } from "tinyglobby";
6
+ import { detectPlatforms } from "../heuristics/platforms.js";
6
7
  const exec = promisify(execFile);
7
8
  const exists = async (p) => {
8
9
  try {
@@ -30,14 +31,44 @@ function slugify(name) {
30
31
  .replace(/^-+|-+$/g, "");
31
32
  return s === "" ? "project" : s;
32
33
  }
34
+ /** `git@host:owner/name.git` or `https://host/owner/name.git` → `name`. */
35
+ function repositoryName(url) {
36
+ if (!url)
37
+ return null;
38
+ const last = url.replace(/\/+$/, "").split(/[/:]/).pop();
39
+ if (!last)
40
+ return null;
41
+ const name = last.replace(/\.git$/, "");
42
+ return name === "" ? null : name;
43
+ }
44
+ /** The globbing `heuristics/platforms.ts` asks for, bound to one directory. */
45
+ const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
46
+ /** Turns an absolute path into a repository-relative one, with forward slashes. */
47
+ const toRepoPath = (root, absolute) => relative(root, absolute).split(sep).join("/");
33
48
  /**
34
49
  * Inspects an existing project and proposes a configuration.
35
50
  *
36
- * Decides nothing irreversible: everything it returns is a proposal the
37
- * user sees and can correct before it's written.
51
+ * **Every path it reports is relative to the repository root, not to the
52
+ * directory the command was run in.** That distinction is the whole difficulty:
53
+ * Laneyard clones the repository, so a Fastfile at `app/fastlane` is at
54
+ * `app/fastlane` in the workspace no matter which folder someone happened to
55
+ * be standing in when they ran `laneyard setup`. Measuring from the current
56
+ * directory produced a configuration that looked right and pointed nowhere.
57
+ *
58
+ * Decides nothing irreversible: everything it returns is a proposal the user
59
+ * sees and can correct before it's written.
38
60
  */
39
61
  export async function detectProject(dir) {
40
- // The Fastfile can be at the root or under a subfolder, for monorepos.
62
+ // Without a repository there is nothing to clone; `runAddCommand` refuses
63
+ // shortly after, so falling back to `dir` here only keeps this function total.
64
+ // Both sides are resolved before being compared: on macOS the temporary
65
+ // directory is a symlink, and git always answers with the real path — so a
66
+ // raw comparison yields a nonsense `../../private/...` relative path.
67
+ const here = await realpath(dir).catch(() => dir);
68
+ const root = await realpath((await gitOr(["rev-parse", "--show-toplevel"], dir, null)) ?? dir).catch(() => dir);
69
+ const subPath = toRepoPath(root, here);
70
+ // Look from where the user is standing — that is what they meant by "this
71
+ // project" — but report what is found relative to the repository root.
41
72
  const fastfiles = await glob(["fastlane/Fastfile", "*/fastlane/Fastfile", "*/*/fastlane/Fastfile"], {
42
73
  cwd: dir,
43
74
  absolute: true,
@@ -45,26 +76,39 @@ export async function detectProject(dir) {
45
76
  });
46
77
  const fastfile = fastfiles.sort((a, b) => a.length - b.length)[0] ?? null;
47
78
  const fastlaneDir = fastfile
48
- ? relative(dir, join(fastfile, "..")).split(sep).join("/")
79
+ ? toRepoPath(root, await realpath(join(fastfile, "..")).catch(() => join(fastfile, "..")))
49
80
  : null;
50
- const isIos = (await glob(["*.xcodeproj", "*.xcworkspace", "*/*.xcodeproj"], { cwd: dir, onlyDirectories: true }))
51
- .length > 0;
52
- const isAndroid = (await glob(["build.gradle", "build.gradle.kts", "*/build.gradle", "*/build.gradle.kts"], {
53
- cwd: dir,
54
- onlyFiles: true,
55
- })).length > 0;
81
+ const platforms = await detectPlatforms(findIn(dir));
82
+ const isIos = platforms.includes("ios");
83
+ const isAndroid = platforms.includes("android");
84
+ // Artifact patterns are anchored to the sub-project too. In a monorepo an
85
+ // unanchored `**/*.ipa` would collect a sibling app's build as if it were
86
+ // this one's — and nothing downstream would notice.
87
+ const prefix = subPath === "" ? "" : `${subPath}/`;
56
88
  const artifactGlobs = [];
57
89
  if (isIos)
58
- artifactGlobs.push("**/*.ipa", "**/*.app.dSYM.zip");
90
+ artifactGlobs.push(`${prefix}**/*.ipa`, `${prefix}**/*.app.dSYM.zip`);
59
91
  if (isAndroid)
60
- artifactGlobs.push("**/*.apk", "**/*.aab");
92
+ artifactGlobs.push(`${prefix}**/*.apk`, `${prefix}**/*.aab`);
93
+ const gitUrl = await gitOr(["remote", "get-url", "origin"], dir, null);
94
+ // The slug names the repository, and the sub-project when there is one: two
95
+ // apps in the same monorepo must not both want to be called `app`.
96
+ //
97
+ // The name comes from the remote rather than the local folder, because the
98
+ // folder is an accident of where someone cloned. `…/popotheque.git` checked
99
+ // out into `~/work/current` should still be called `popotheque`.
100
+ const repoName = repositoryName(gitUrl) ?? basename(root);
101
+ const slug = slugify(subPath === "" ? repoName : `${repoName}-${subPath}`);
61
102
  return {
62
- slug: slugify(basename(dir)),
63
- gitUrl: await gitOr(["remote", "get-url", "origin"], dir, null),
103
+ slug,
104
+ gitUrl,
64
105
  defaultBranch: (await gitOr(["rev-parse", "--abbrev-ref", "HEAD"], dir, "main")) ?? "main",
65
106
  fastlaneDir,
107
+ // A Gemfile beside the Fastfile is the one fastlane will use, not one at the
108
+ // repository root — `bundle exec` runs from the sub-project.
66
109
  runtime: (await exists(join(dir, "Gemfile"))) ? "bundle" : "system",
67
110
  artifactGlobs,
68
- platform: isIos ? "ios" : isAndroid ? "android" : "unknown",
111
+ platforms,
112
+ subPath,
69
113
  };
70
114
  }
@@ -0,0 +1,36 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ /** Reads from the real terminal. */
3
+ export function terminalAsker() {
4
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5
+ return {
6
+ async ask(label, proposed, hint) {
7
+ if (hint)
8
+ process.stdout.write(`\n ${hint}\n`);
9
+ // The proposal is shown in the prompt rather than typed for the user:
10
+ // pressing Return accepts it, which is what someone does nine times out
11
+ // of ten, and correcting it costs one line.
12
+ const answer = (await rl.question(` ${label} [${proposed}]: `)).trim();
13
+ return answer === "" ? proposed : answer;
14
+ },
15
+ async confirm(question, defaultYes) {
16
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
17
+ const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
18
+ if (answer === "")
19
+ return defaultYes;
20
+ return answer.startsWith("y");
21
+ },
22
+ close() {
23
+ rl.close();
24
+ },
25
+ };
26
+ }
27
+ /** Accepts every proposal without asking. Used by `--yes` and by the tests. */
28
+ export const acceptingAsker = {
29
+ async ask(_label, proposed) {
30
+ return proposed;
31
+ },
32
+ async confirm(_question, defaultYes) {
33
+ return defaultYes;
34
+ },
35
+ close() { },
36
+ };
@@ -0,0 +1,282 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Document, parseDocument, YAMLSeq } from "yaml";
4
+ import { VALID_NAME, ensureFirstAdmin, hasAccount } from "../config/accounts.js";
5
+ import { loadServerConfig } from "../config/load.js";
6
+ import { serializeYaml as serialize } from "../config/yaml.js";
7
+ import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
8
+ import { acceptingAsker, terminalAsker } from "./prompt.js";
9
+ import { detectProject } from "./detect.js";
10
+ /**
11
+ * Adds a project block to config.yml while preserving the rest of the file.
12
+ *
13
+ * It writes no account: accounts are `ensureFirstAdmin`'s business, and a
14
+ * function that registered a project *and* invented a password would have two
15
+ * reasons to be called and one of them a surprise.
16
+ *
17
+ * The edit goes through the YAML document rather than a parse/serialize
18
+ * round trip: the user's comments — and the order of their keys — survive.
19
+ * It's the same requirement as for the Fastfile: a hand-written file must
20
+ * never come back out damaged.
21
+ */
22
+ export async function addProjectToConfig(path, entry) {
23
+ let doc;
24
+ try {
25
+ doc = parseDocument(await readFile(path, "utf8"));
26
+ }
27
+ catch {
28
+ doc = new Document({});
29
+ }
30
+ if (doc.contents === null)
31
+ doc = new Document({});
32
+ const projects = doc.getIn(["projects"]);
33
+ const seq = projects instanceof YAMLSeq ? projects : new YAMLSeq();
34
+ if (!(projects instanceof YAMLSeq))
35
+ doc.setIn(["projects"], seq);
36
+ for (const item of seq.items) {
37
+ const slug = item.get?.("slug");
38
+ if (slug === entry.slug) {
39
+ throw new Error(`A project already uses the slug "${entry.slug}" in ${path}`);
40
+ }
41
+ }
42
+ seq.add(doc.createNode(entry));
43
+ await writeFile(path, serialize(doc), "utf8");
44
+ }
45
+ /**
46
+ * Takes a project's block out of config.yml, leaving the rest of the file alone.
47
+ *
48
+ * Same requirement as `addProjectToConfig`, and for the same reason: the file is
49
+ * hand-written, so the edit goes through the YAML document and every comment,
50
+ * key order and blank line that isn't this project's survives it.
51
+ *
52
+ * Returns false when no block carried that slug, so the caller can answer 404
53
+ * rather than rewrite the file to say the same thing it already said.
54
+ */
55
+ export async function removeProjectFromConfig(path, slug) {
56
+ const doc = parseDocument(await readFile(path, "utf8"));
57
+ const projects = doc.getIn(["projects"]);
58
+ if (!(projects instanceof YAMLSeq))
59
+ return false;
60
+ const at = projects.items.findIndex((item) => item.get?.("slug") === slug);
61
+ if (at === -1)
62
+ return false;
63
+ projects.items.splice(at, 1);
64
+ await writeFile(path, serialize(doc), "utf8");
65
+ return true;
66
+ }
67
+ /**
68
+ * Entry point for `laneyard setup`.
69
+ *
70
+ * It proposes rather than decides. An earlier version detected everything
71
+ * silently and wrote a configuration that looked plausible and pointed nowhere —
72
+ * the failure only surfaced later, as an unreadable lane list, far from its
73
+ * cause. Showing the values and letting them be corrected costs one screen and
74
+ * removes a whole class of that.
75
+ */
76
+ export async function runSetupCommand(cwd, configPath, options = {}) {
77
+ const d = await detectProject(cwd);
78
+ if (d.fastlaneDir === null) {
79
+ process.stderr.write("No Fastfile found here. Laneyard drives fastlane: run the command from a project " +
80
+ "that already uses it, or run `fastlane init` first.\n");
81
+ return 1;
82
+ }
83
+ if (d.gitUrl === null) {
84
+ process.stderr.write("No git remote named \"origin\". Laneyard clones projects from their repository: " +
85
+ "add a remote, or set git_url by hand in config.yml.\n");
86
+ return 1;
87
+ }
88
+ const interactive = options.asker === undefined && options.yes !== true;
89
+ const asker = options.asker ?? (options.yes ? acceptingAsker : terminalAsker());
90
+ try {
91
+ process.stdout.write(heading("Found a fastlane project"));
92
+ process.stdout.write(field("repository", repositoryLabel(d.gitUrl)) + "\n");
93
+ if (d.subPath !== "") {
94
+ process.stdout.write(field("in", `${d.subPath}/`) + "\n");
95
+ }
96
+ // Two files, two purposes, and the difference is the one thing people get
97
+ // wrong here — so it is stated before anything is written rather than
98
+ // discovered afterwards when nothing turns out to be versioned.
99
+ process.stdout.write("\n" +
100
+ dim(" Two files will describe it:\n") +
101
+ dim(` ${LANEYARD_YML.padEnd(13)}in the repository — how it builds. Commit it.\n`) +
102
+ dim(` ${"config.yml".padEnd(13)}on this machine — where to clone it from, and your\n`) +
103
+ dim(` password. Never committed.\n`));
104
+ const existing = await existingProject(configPath, d.slug);
105
+ if (existing) {
106
+ process.stdout.write("\n" +
107
+ warn(`This machine already knows a project called ${bold(existing)}.\n`) +
108
+ dim(" Continuing replaces its entry. Give it another name to keep both.\n"));
109
+ }
110
+ const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
111
+ if (await fileExists(repoConfigPath)) {
112
+ process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
113
+ dim(" Its values win over anything written here; it will be left alone.\n"));
114
+ }
115
+ // A machine with no account gets its first admin here. Asked rather than
116
+ // assumed: the name is typed into a login form every day afterwards, and
117
+ // `admin` is a poor thing to call a person once there are two of them.
118
+ const firstAccount = !(await hasAccount(configPath));
119
+ if (firstAccount) {
120
+ process.stdout.write("\n" +
121
+ warn("This machine has no account yet.\n") +
122
+ dim(" You will sign in with a name and a password. The password is generated below.\n"));
123
+ }
124
+ if (interactive) {
125
+ process.stdout.write("\n" + dim("Press Return to accept a value, or type a new one.") + "\n\n");
126
+ }
127
+ const adminName = firstAccount
128
+ ? await asker.ask("your name for signing in", DEFAULT_ADMIN_NAME)
129
+ : null;
130
+ if (adminName !== null && !VALID_NAME.test(adminName)) {
131
+ process.stderr.write("\n" + bad(`Invalid name: "${adminName}". Letters, digits, dot, dash and underscore.`) + "\n");
132
+ return 1;
133
+ }
134
+ const slug = options.slug ?? (await asker.ask("name for this project", d.slug));
135
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
136
+ process.stderr.write("\n" + bad(`Invalid name: "${slug}". Lowercase letters, digits and hyphens only.`) + "\n");
137
+ return 1;
138
+ }
139
+ const gitUrl = await asker.ask("repository", d.gitUrl);
140
+ const branch = await asker.ask("default branch", d.defaultBranch, dim("A run uses this branch unless you pick another one when you start it."));
141
+ const fastlaneDir = await asker.ask("fastlane directory", d.fastlaneDir, dim("Relative to the repository root, because that is what Laneyard clones."));
142
+ const useBundler = await asker.confirm("\n" +
143
+ dim(" With bundler, runs use the fastlane version pinned in the Gemfile.\n") +
144
+ dim(" Without it, whichever fastlane is installed on the build machine.\n") +
145
+ " Run fastlane through bundler?", d.runtime === "bundle");
146
+ const runtime = useBundler ? "bundle" : "system";
147
+ // Artifact patterns are shown, not asked: typing four globs correctly at a
148
+ // prompt is a miserable way to start, the detected ones are nearly always
149
+ // right, and the file is there to be edited when they are not.
150
+ const globs = d.artifactGlobs;
151
+ if (!(await asker.confirm(`\n${bold(`Set up "${slug}"`)}?`, true))) {
152
+ process.stdout.write(dim("Nothing written.") + "\n");
153
+ return 0;
154
+ }
155
+ // The account first, so the server block is written before the project list
156
+ // it sits above. `null` when this machine already has one, and then nothing
157
+ // is written and nothing is printed.
158
+ const generatedPassword = adminName === null ? null : await ensureFirstAdmin(configPath, adminName);
159
+ // The machine half: how to reach the project. Nothing here belongs in a
160
+ // repository — it is this server's own registry, plus its credentials.
161
+ await addProjectToConfig(configPath, {
162
+ slug,
163
+ name: slug,
164
+ git_url: gitUrl,
165
+ default_branch: branch,
166
+ });
167
+ // The repository half: how it builds. This is the part that was going into
168
+ // the machine's file and therefore never being committed — which is exactly
169
+ // backwards, since it is the part a colleague needs.
170
+ const wroteRepoConfig = await writeRepoConfigIfAbsent(repoConfigPath, {
171
+ fastlane_dir: fastlaneDir,
172
+ runtime,
173
+ artifact_globs: globs,
174
+ // Written down rather than re-inferred on every readiness check: a value
175
+ // in a file can be corrected when the guess was wrong, and setup and the
176
+ // checklist cannot end up disagreeing about the same repository.
177
+ ...(d.platforms.length > 0 ? { platforms: d.platforms } : {}),
178
+ });
179
+ const port = await configuredPort(configPath);
180
+ process.stdout.write(heading(`Project "${slug}" is set up`) +
181
+ field("repository", `${gitUrl} (${branch})`) + "\n" +
182
+ field("fastlane", fastlaneDir) + "\n" +
183
+ field("runtime", runtime) + "\n" +
184
+ field("artifacts", globs.join(", ") || dim("none detected")) + "\n" +
185
+ // Shown because it decides which half of the readiness checklist a
186
+ // project is held to, and because a wrong guess is corrected by editing
187
+ // one line of the file just written.
188
+ field("platforms", d.platforms.join(", ") || dim("none detected")) + "\n" +
189
+ "\n" +
190
+ (wroteRepoConfig
191
+ ? ok(`Wrote ${bold(LANEYARD_YML)} — ${bold("commit it")} so your team builds the same way.\n`)
192
+ : warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
193
+ ok(`Registered in ${configPath}\n`) +
194
+ // Last thing before the invitation to start the server, because it is
195
+ // the one line here that cannot be read again anywhere: the file holds
196
+ // a hash, and nothing holds the password.
197
+ (generatedPassword === null
198
+ ? ""
199
+ : heading("Your account") +
200
+ field("name", bold(adminName)) + "\n" +
201
+ field("password", bold(generatedPassword)) + "\n" +
202
+ field("role", "admin") + "\n" +
203
+ "\n" +
204
+ warn("Write the password down — it is not shown again, and it is not stored.\n") +
205
+ dim(" Add a colleague later with `laneyard user add <name> --role builder`.\n")) +
206
+ heading("Start Laneyard") +
207
+ ` ${bold("laneyard")}\n` +
208
+ ` ${dim(`http://localhost:${port}`)}\n` +
209
+ "\n" +
210
+ dim("Already running? The configuration is watched — it appears on its own.\n"));
211
+ return 0;
212
+ }
213
+ finally {
214
+ asker.close();
215
+ }
216
+ }
217
+ /**
218
+ * The port the server will actually listen on.
219
+ *
220
+ * Read back from the file rather than assumed: telling someone to open a port
221
+ * their configuration does not use is the kind of small wrongness that makes a
222
+ * tool feel unreliable on first contact.
223
+ */
224
+ async function configuredPort(configPath) {
225
+ const res = await loadServerConfig(configPath);
226
+ return res.ok ? res.config.server.port : 7890;
227
+ }
228
+ /** The repository file, named once so the message and the write cannot disagree. */
229
+ const LANEYARD_YML = "laneyard.yml";
230
+ /**
231
+ * What the first account is called when nobody says otherwise.
232
+ *
233
+ * The same name a lone `password_hash` is read under, so a machine set up today
234
+ * and a machine upgraded from 0.2 sign in the same way.
235
+ */
236
+ const DEFAULT_ADMIN_NAME = "admin";
237
+ /**
238
+ * The repository root, from where the command ran and how deep it sits.
239
+ *
240
+ * `laneyard.yml` belongs at the root because that is where the server reads it:
241
+ * the clone is the repository, not the sub-directory someone was standing in.
242
+ */
243
+ function repoRoot(cwd, subPath) {
244
+ return subPath === "" ? cwd : join(cwd, ...subPath.split("/").map(() => ".."));
245
+ }
246
+ const fileExists = async (path) => {
247
+ try {
248
+ await readFile(path);
249
+ return true;
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ };
255
+ /** The name already registered under this slug, or null. */
256
+ async function existingProject(configPath, slug) {
257
+ const res = await loadServerConfig(configPath);
258
+ if (!res.ok)
259
+ return null;
260
+ return res.config.projects.find((p) => p.slug === slug)?.slug ?? null;
261
+ }
262
+ /**
263
+ * Writes the repository's build configuration, unless it is already there.
264
+ *
265
+ * Never overwrites: a `laneyard.yml` in a repository was put there by someone,
266
+ * possibly with comments and choices this command knows nothing about, and its
267
+ * values win anyway.
268
+ */
269
+ async function writeRepoConfigIfAbsent(path, settings) {
270
+ if (await fileExists(path))
271
+ return false;
272
+ const doc = new Document(settings);
273
+ doc.commentBefore =
274
+ " How this project builds. Committed, so everyone builds it the same way.\n" +
275
+ " Values here win over the project's block in the server's config.yml.";
276
+ await writeFile(path, doc.toString(), "utf8");
277
+ return true;
278
+ }
279
+ /** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
280
+ function repositoryLabel(url) {
281
+ return url.replace(/^.*[:/]([^/:]+\/[^/]+?)(\.git)?$/, "$1");
282
+ }