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.
@@ -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 });
@@ -21,6 +21,26 @@ export const BLOCKING_RULES = [
21
21
  because: "renews provisioning profiles, which needs an Apple account interactively",
22
22
  fix: "Use `match` in readonly mode instead, with profiles stored in a repository.",
23
23
  },
24
+ {
25
+ action: "cert",
26
+ because: "creates or downloads a signing certificate, which needs an Apple account interactively",
27
+ fix: "Use `match` in readonly mode instead, with certificates stored in a repository.",
28
+ },
29
+ // `deliver` renders an HTML summary and waits for a yes before uploading.
30
+ // Only reported when the lane says `force: false` outright: the default is
31
+ // not something the sidecar reports, and inventing one would be a guess.
32
+ {
33
+ action: "deliver",
34
+ when: { arg: "force", equals: false },
35
+ because: "shows a summary and waits for it to be confirmed before uploading",
36
+ fix: "Pass `force: true` so it uploads without asking.",
37
+ },
38
+ {
39
+ action: "upload_to_app_store",
40
+ when: { arg: "force", equals: false },
41
+ because: "shows a summary and waits for it to be confirmed before uploading",
42
+ fix: "Pass `force: true` so it uploads without asking.",
43
+ },
24
44
  ];
25
45
  /**
26
46
  * Applies the table to the actions a lane calls.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Which platforms a project builds for.
3
+ *
4
+ * Named knowledge of mobile toolchains — that `.xcodeproj` means Xcode, that
5
+ * `build.gradle` means Gradle — hence its place in this module, and the
6
+ * boundary that comes with it: nothing here refuses anything. It answers a
7
+ * question; `setup` writes the answer down and the checklist reads it.
8
+ *
9
+ * It lives here rather than in `cli/detect.ts` because two callers need the
10
+ * same answer. Setup proposes a configuration from it, and the checklist
11
+ * decides which sections apply from it. Two copies of this reasoning would be
12
+ * free to disagree about the same repository, and the checklist would be the
13
+ * one that looked wrong.
14
+ */
15
+ /**
16
+ * What a repository looks like from the outside, as a table.
17
+ *
18
+ * The depth is deliberate: an app in a monorepo sits one level down
19
+ * (`android/build.gradle`, `ios/App.xcodeproj`), which is where Flutter and
20
+ * React Native put them.
21
+ */
22
+ export const PLATFORM_MARKERS = [
23
+ {
24
+ platform: "ios",
25
+ globs: ["*.xcodeproj", "*.xcworkspace", "*/*.xcodeproj", "*/*.xcworkspace"],
26
+ onlyDirectories: true,
27
+ },
28
+ {
29
+ platform: "android",
30
+ globs: ["build.gradle", "build.gradle.kts", "*/build.gradle", "*/build.gradle.kts"],
31
+ onlyDirectories: false,
32
+ },
33
+ ];
34
+ /**
35
+ * What the repository contains.
36
+ *
37
+ * Never throws. An absent clone is a reason to answer "nothing found", not to
38
+ * fail the checklist that was about to explain why the clone is absent.
39
+ */
40
+ export async function detectPlatforms(find) {
41
+ const found = [];
42
+ // Sequential, and in the table's order, so the answer is stable: this list
43
+ // decides the order of the sections someone reads.
44
+ for (const marker of PLATFORM_MARKERS) {
45
+ try {
46
+ const paths = await find(marker.globs, { onlyDirectories: marker.onlyDirectories });
47
+ if (paths.length > 0)
48
+ found.push(marker.platform);
49
+ }
50
+ catch {
51
+ // A marker that cannot be looked for is simply not found.
52
+ }
53
+ }
54
+ return found;
55
+ }
56
+ /**
57
+ * The configuration first, then the repository.
58
+ *
59
+ * An empty configured list counts as saying nothing: `platforms: []` is what a
60
+ * commented-out line leaves behind, and reading it as "this project builds for
61
+ * nothing" would silently empty the checklist.
62
+ */
63
+ export function platformsOf(configured, detected) {
64
+ const chosen = configured && configured.length > 0 ? configured : detected;
65
+ // Normalised through the table: a hand-written `[android, ios]` must not
66
+ // reorder the screen, and a platform named twice must not appear twice.
67
+ return PLATFORM_MARKERS.map((m) => m.platform).filter((p) => chosen.includes(p));
68
+ }
69
+ /** The two steps in one call, for callers that have a directory to look at. */
70
+ export async function resolvePlatforms(configured, find) {
71
+ // The repository is only listed when the configuration did not answer:
72
+ // globbing a workspace costs a directory walk, and a project that says what
73
+ // it builds for should not pay for one.
74
+ if (configured && configured.length > 0)
75
+ return platformsOf(configured, []);
76
+ return platformsOf(undefined, await detectPlatforms(find));
77
+ }