laneyard 0.5.0 → 0.6.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,221 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { fieldsOf } from "../credentials/kinds.js";
6
+ import { appRootOf } from "../heuristics/platforms.js";
7
+ import { proposalsFor } from "../fastfile/adoption.js";
8
+ import { splice } from "../fastfile/splice.js";
9
+ import { scanFastfile } from "../sidecar/scan.js";
10
+ import { bold, dim, heading, ok, warn } from "./style.js";
11
+ const exec = promisify(execFile);
12
+ /**
13
+ * Setup's second act: what to do about credentials the Fastfile names outright.
14
+ *
15
+ * **It runs after the project is already set up, and that ordering is the
16
+ * whole guarantee.** Declining everything here must leave exactly the project
17
+ * setup produced before this feature existed — so the act is separate rather
18
+ * than folded into setup's final confirmation, and "refusing works" is true by
19
+ * construction instead of by promise.
20
+ *
21
+ * Nothing it can do is required. No Ruby with Prism, no Fastfile, an
22
+ * unparseable file, a literal naming a file that is not on disk: every one of
23
+ * those means "nothing proposed", and setup carries on.
24
+ */
25
+ export async function runAdoption(options) {
26
+ const { cwd, fastlaneDir, slug, vault, asker } = options;
27
+ const literals = await scanFastfile(cwd, fastlaneDir);
28
+ if (literals === null) {
29
+ process.stdout.write("\n" + dim("Fastfile not analysed — no Ruby with Prism available. Nothing else changes.\n"));
30
+ return { applied: 0 };
31
+ }
32
+ // A literal pointing at nothing is dropped rather than reported: there is no
33
+ // file to lift into the vault, and patching to a variable nothing supplies
34
+ // would trade one broken build for another.
35
+ // Resolved once, here, so the prompt, the vault write and the git check all
36
+ // speak about the same file rather than each resolving the path again.
37
+ const found = new Map();
38
+ const proposals = [];
39
+ for (const proposal of proposalsFor(literals)) {
40
+ if (proposal.tier === "file") {
41
+ const hit = await readCredential(cwd, fastlaneDir, proposal);
42
+ if (hit === null)
43
+ continue;
44
+ found.set(proposal, hit);
45
+ }
46
+ proposals.push(proposal);
47
+ }
48
+ if (proposals.length === 0)
49
+ return { applied: 0 };
50
+ process.stdout.write(heading("I read your Fastfile"));
51
+ const accepted = [];
52
+ for (const proposal of proposals) {
53
+ process.stdout.write(describe(fastlaneDir, proposal) + "\n");
54
+ if (!(await asker.confirm(` Store it here and use ${bold(proposal.varName)}?`, proposal.checked))) {
55
+ continue;
56
+ }
57
+ accepted.push(proposal.tier === "secret" ? await named(asker, proposal) : proposal);
58
+ }
59
+ if (accepted.length === 0) {
60
+ process.stdout.write(dim("\nNothing written. Your Fastfile is as it was.\n"));
61
+ return { applied: 0 };
62
+ }
63
+ // The vault first, always. If lifting a credential fails, no Fastfile has
64
+ // been patched to read a variable that nothing supplies.
65
+ for (const proposal of accepted)
66
+ await store(vault, slug, asker, proposal, found.get(proposal));
67
+ const path = join(cwd, fastlaneDir, "Fastfile");
68
+ const previous = await readFile(path, "utf8");
69
+ const edits = accepted.flatMap((p) => (options.editFor ? [options.editFor(p)] : p.edits));
70
+ await writeFile(path, splice(previous, edits), "utf8");
71
+ // Verified with Prism rather than with fastlane: setup has no server to ask
72
+ // for a lane list, and "does it still parse" is the question that matters.
73
+ // Same contract as `FastfileStore.write` — the previous content goes back on
74
+ // disk before this function returns, and no backup file is left behind.
75
+ if ((await scanFastfile(cwd, fastlaneDir)) === null) {
76
+ await writeFile(path, previous, "utf8");
77
+ process.stdout.write("\n" + warn("The patch stopped the Fastfile parsing. It has been put back as it was.\n"));
78
+ return { applied: 0 };
79
+ }
80
+ await report(cwd, fastlaneDir, accepted, found);
81
+ return { applied: accepted.length };
82
+ }
83
+ /**
84
+ * The bytes behind a `file` proposal, or null when the path names nothing.
85
+ *
86
+ * **A relative path in a Fastfile has no single meaning.** `"./play.json"`
87
+ * resolves against whatever directory fastlane was invoked from, which is
88
+ * usually the app root — the fastlane folder's parent — but a project that runs
89
+ * fastlane from the repository root, or writes paths relative to the fastlane
90
+ * folder itself, is equally ordinary. Nothing in the file says which.
91
+ *
92
+ * So all three are tried, nearest first. This costs nothing to be wrong about:
93
+ * the patch replaces the literal with `ENV.fetch` either way, and the only
94
+ * thing the path is needed for is finding bytes to put in the vault. Failing to
95
+ * find them means no proposal, which is the safe answer.
96
+ */
97
+ async function readCredential(cwd, fastlaneDir, proposal) {
98
+ const value = proposal.literal.value;
99
+ const candidates = isAbsolute(value)
100
+ ? [value]
101
+ : [
102
+ resolve(cwd, appRootOf(fastlaneDir), value),
103
+ resolve(cwd, fastlaneDir, value),
104
+ resolve(cwd, value),
105
+ ];
106
+ for (const path of candidates) {
107
+ const bytes = await readFile(path).catch(() => null);
108
+ if (bytes !== null)
109
+ return { bytes, path };
110
+ }
111
+ return null;
112
+ }
113
+ /** One proposal, as three lines: where, what, and why it will not survive. */
114
+ function describe(fastlaneDir, proposal) {
115
+ const { literal } = proposal;
116
+ // A path is shown; a secret is not. Tier 3 is a value someone called a token
117
+ // or a password, and setup's output is pasted into bug reports and kept in CI
118
+ // transcripts — printing it there would be this feature leaking the very
119
+ // thing it exists to put away. The file and line above say where to look.
120
+ const shown = proposal.tier === "file"
121
+ ? `"${literal.value}"`
122
+ : proposal.tier === "inline"
123
+ ? dim("(a key, inline in the file)")
124
+ : dim("(a literal value, masked)");
125
+ const why = proposal.tier === "inline"
126
+ ? "This key is in your repository in cleartext."
127
+ : proposal.tier === "file"
128
+ ? "That path does not survive the clone: Laneyard builds from your remote."
129
+ : "A literal secret in a build file is a secret in your history.";
130
+ return ("\n" +
131
+ ` ${bold(`${fastlaneDir}/Fastfile:${literal.line}`)} ${literal.action}(${literal.arg}:)\n` +
132
+ ` → ${shown}\n` +
133
+ dim(` ${why}\n`));
134
+ }
135
+ /**
136
+ * A tier-3 proposal carrying the variable name the user actually wants.
137
+ *
138
+ * This is the one tier whose name is a guess. Tiers 1 and 2 take theirs from
139
+ * `credentials/kinds.ts`, which holds the names fastlane itself reads; a
140
+ * literal secret has no such table, so the name is assembled from the action
141
+ * and the argument — `PILOT_API_TOKEN` — and a project that already calls that
142
+ * variable something else would end up with the vault holding one name and the
143
+ * patched Fastfile reading another. Nothing would report it: the run would
144
+ * simply meet an absent variable.
145
+ *
146
+ * The replacement is rebuilt from the answer rather than patched afterwards,
147
+ * so the name stored and the name read cannot drift apart.
148
+ */
149
+ async function named(asker, proposal) {
150
+ const varName = await asker.ask(" variable name", proposal.varName);
151
+ return {
152
+ ...proposal,
153
+ varName,
154
+ edits: [{ ...proposal.edits[0], replacement: `ENV.fetch("${varName}")` }],
155
+ };
156
+ }
157
+ /** Lifts one accepted proposal into the vault. */
158
+ async function store(vault, slug, asker, proposal, found) {
159
+ if (proposal.kind === undefined) {
160
+ await vault.set(slug, proposal.varName, proposal.literal.value, true);
161
+ return;
162
+ }
163
+ const bytes = proposal.tier === "inline" ? Buffer.from(proposal.literal.value, "utf8") : found.bytes;
164
+ // The original name is kept: some tools read meaning from it, and
165
+ // `materialise.ts` already relies on `AuthKey_<KEY ID>.p8` surviving intact.
166
+ const fileName = proposal.tier === "inline" ? `${proposal.kind}.key` : basename(found.path);
167
+ // The fields the file cannot carry. `fieldsOf` is the same table the web
168
+ // upload form reads, so the CLI cannot end up asking for a different set.
169
+ const fields = {};
170
+ for (const field of fieldsOf(proposal.kind)) {
171
+ if (field.optional)
172
+ continue;
173
+ const suggested = proposal.suggestedFields[field.name] ?? field.suggested ?? "";
174
+ fields[field.name] = await asker.ask(` ${field.label}`, suggested);
175
+ }
176
+ await vault.setCredential(slug, proposal.kind, { fileName, fileBytes: bytes, fields, varNames: {} });
177
+ }
178
+ /** What is left for the user to do, including the part Laneyard will not do. */
179
+ async function report(cwd, fastlaneDir, accepted, found) {
180
+ process.stdout.write("\n" +
181
+ ok(`Stored ${accepted.length} credential${accepted.length > 1 ? "s" : ""} in this machine's vault.\n`) +
182
+ ok(`Patched ${fastlaneDir}/Fastfile.\n`) +
183
+ "\n" +
184
+ // Said plainly because it is the trap `addProjectToConfig` already
185
+ // documents: Laneyard builds from a clone of the remote, so nothing in
186
+ // the working copy reaches a run until it is pushed.
187
+ warn("Commit and push it, or your runs still read the old file.\n") +
188
+ dim(" git diff -- " + join(fastlaneDir, "Fastfile") + "\n"));
189
+ // Said, never done. Removing a file from someone's repository is not
190
+ // setup's to decide, and `git rm --cached` does not take it out of the
191
+ // history anyway — so the honest thing is to name it.
192
+ const tracked = await trackedCredentials(cwd, accepted, found);
193
+ if (tracked.length > 0) {
194
+ process.stdout.write("\n" +
195
+ warn(`${tracked.join(", ")} ${tracked.length > 1 ? "are" : "is"} tracked by git.\n`) +
196
+ dim(" The patch does not take it out of your history. Rotating the key does.\n"));
197
+ }
198
+ }
199
+ /**
200
+ * Which of the accepted credentials git already has. Silent when git cannot
201
+ * answer — the same courtesy `fastlaneDirIsTracked` extends.
202
+ *
203
+ * Asked about the *resolved* paths, not the literals: `"./play.json"` written
204
+ * in a Fastfile one directory down is not a path `git ls-files` can answer
205
+ * about from the repository root.
206
+ */
207
+ async function trackedCredentials(cwd, accepted, found) {
208
+ const paths = accepted.flatMap((p) => {
209
+ const hit = found.get(p);
210
+ return hit ? [hit.path] : [];
211
+ });
212
+ if (paths.length === 0)
213
+ return [];
214
+ try {
215
+ const { stdout } = await exec("git", ["ls-files", "--", ...paths], { cwd });
216
+ return stdout.split("\n").filter((line) => line.trim() !== "");
217
+ }
218
+ catch {
219
+ return [];
220
+ }
221
+ }
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
2
2
  import { existsSync } from "node:fs";
3
3
  import { rm, stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { loadRepoConfig } from "../config/load.js";
5
6
  import { ConfigStore } from "../config/store.js";
6
7
  import { CredentialStore } from "../db/credentials.js";
7
8
  import { openDatabase } from "../db/open.js";
@@ -12,14 +13,16 @@ import { LogStore } from "../logs/store.js";
12
13
  import { Vault } from "../secrets/vault.js";
13
14
  import { readLine } from "./home.js";
14
15
  import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
15
- export const REMOVE_USAGE = `laneyard remove <slug> [--dry-run]
16
+ export const REMOVE_USAGE = `laneyard remove [--dry-run]
16
17
 
17
- Removes everything Laneyard holds for one project: its block in config.yml, its
18
- clone, its artifacts, its run history and logs, and its own secrets and signing
19
- blocks in the vault. It does not touch the git remote, the credential originals,
20
- or the global secrets and signing blocks other projects share.
18
+ Run from your app's directory the one that holds laneyard.yml. Removes
19
+ everything Laneyard holds for that project: its block in config.yml, its clone,
20
+ its artifacts, its run history and logs, and its own secrets and signing blocks
21
+ in the vault. It also removes the repository's laneyard.yml, which you then
22
+ commit. It does not touch the git remote, the credential originals, or the
23
+ global secrets and signing blocks other projects share.
21
24
 
22
- laneyard remove <slug> --dry-run show what would go and stop
25
+ laneyard remove --dry-run show what would go and stop
23
26
 
24
27
  The run history is the one thing here nothing can rebuild, so it is confirmed by
25
28
  typing the project's slug back, not \`y\`.
@@ -51,7 +54,7 @@ async function readCounts(home, slug) {
51
54
  }
52
55
  }
53
56
  /** The inventory, and what the removal will and will not reach. */
54
- function renderInventory(slug, name, home, counts, clonePath) {
57
+ function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
55
58
  const cloned = existsSync(clonePath);
56
59
  return (heading(`laneyard remove "${slug}"`) +
57
60
  field("home", home) + "\n" +
@@ -61,6 +64,8 @@ function renderInventory(slug, name, home, counts, clonePath) {
61
64
  field("clone", cloned ? clonePath : dim("not cloned")) + "\n" +
62
65
  field("secrets", plural(counts.secrets, "project secret")) + "\n" +
63
66
  field("signing", plural(counts.signingBlocks, "project signing block")) + "\n" +
67
+ // The one thing removed outside `home`: the repository's own file.
68
+ field("laneyard.yml", ymlPath) + "\n" +
64
69
  heading("what will not be touched") +
65
70
  dim(" the git remote — the repository is yours, on your host and your disk.\n") +
66
71
  dim(" the credential originals — Laneyard removes only its own encrypted copy;\n") +
@@ -68,32 +73,41 @@ function renderInventory(slug, name, home, counts, clonePath) {
68
73
  dim(" global secrets and global signing blocks — shared by every project.\n"));
69
74
  }
70
75
  /**
71
- * Entry point for `laneyard remove <slug>`.
76
+ * Entry point for `laneyard remove`, run from the app's own directory.
72
77
  *
73
- * The command-line equivalent of removing a project from the interface. It reads
74
- * the inventory first, prints what will and will not go, and only then asks for
75
- * the slug typed back the same gate the web route uses, for the same reason:
76
- * the run history it deletes is the one thing here nothing can rebuild.
78
+ * The project is the one whose `laneyard.yml` sits in `cwd`; the slug is read
79
+ * from that file rather than given as an argument, so the command names what the
80
+ * directory already is. It reads the inventory first, prints what will and will
81
+ * not go, and only then asks for the slug typed back the same gate the web
82
+ * route uses, for the same reason: the run history it deletes is the one thing
83
+ * here nothing can rebuild.
77
84
  */
78
- export async function runRemoveCommand(home, args, io) {
85
+ export async function runRemoveCommand(home, cwd, args, io) {
79
86
  let dryRun = false;
80
- let slug = null;
81
87
  for (const arg of args) {
82
88
  if (arg === "--dry-run" || arg === "-n")
83
89
  dryRun = true;
84
- else if (arg.startsWith("-")) {
85
- io.err(`Unknown option: ${arg}\n\n${REMOVE_USAGE}`);
86
- return 1;
87
- }
88
- else if (slug === null)
89
- slug = arg;
90
90
  else {
91
- io.err(`One project at a time. Give a single slug.\n\n${REMOVE_USAGE}`);
91
+ io.err(`Unknown option: ${arg}\n\n${REMOVE_USAGE}`);
92
92
  return 1;
93
93
  }
94
94
  }
95
- if (slug === null) {
96
- io.err(`Which project? Give its slug.\n\n${REMOVE_USAGE}`);
95
+ // The project is the one whose `laneyard.yml` is here — the file setup wrote
96
+ // and the slug it recorded in it. Run from anywhere else and there is nothing
97
+ // to name, which is the whole point: a project is removed from where it lives.
98
+ const ymlPath = join(cwd, "laneyard.yml");
99
+ if (!existsSync(ymlPath)) {
100
+ io.err("\n" +
101
+ bad("No laneyard.yml here.") +
102
+ " Run `laneyard remove` from your app's directory — the one that holds it.\n");
103
+ return 1;
104
+ }
105
+ const repo = await loadRepoConfig(ymlPath);
106
+ const slug = repo.ok ? repo.config.slug : undefined;
107
+ if (slug === undefined || slug === "") {
108
+ io.err("\n" +
109
+ bad("This laneyard.yml has no slug.") +
110
+ " Run `laneyard setup` again to record which project it is.\n");
97
111
  return 1;
98
112
  }
99
113
  const configPath = join(home, "config.yml");
@@ -122,10 +136,10 @@ export async function runRemoveCommand(home, args, io) {
122
136
  " Wait for it to finish, or cancel it, then remove the project. Nothing was removed.\n");
123
137
  return 1;
124
138
  }
125
- io.out(renderInventory(slug, entry.name, home, counts, clonePath));
139
+ io.out(renderInventory(slug, entry.name, home, counts, clonePath, ymlPath));
126
140
  if (dryRun) {
127
141
  io.out("\n" +
128
- dim(`Nothing was removed. Run \`laneyard remove ${slug}\` without --dry-run to remove it.`) +
142
+ dim("Nothing was removed. Run `laneyard remove` without --dry-run to remove it.") +
129
143
  "\n");
130
144
  return 0;
131
145
  }
@@ -155,6 +169,10 @@ export async function runRemoveCommand(home, args, io) {
155
169
  workspacePath: (s) => join(home, "workspaces", s),
156
170
  artifactsDir: (runId) => join(home, "artifacts", String(runId)),
157
171
  }, slug);
172
+ // The machine data went first, so a file removed here is one whose project
173
+ // is already gone — never the other way round. `force` because the removal
174
+ // must still report success if the file was deleted by hand meanwhile.
175
+ await rm(ymlPath, { force: true }).catch(() => { });
158
176
  io.out(heading("removed") +
159
177
  ok(`"${slug}": ${plural(result.runs, "run")}, ${plural(result.secrets, "secret")}, ` +
160
178
  `${plural(result.signingBlocks, "signing block")}.\n`) +
@@ -162,7 +180,11 @@ export async function runRemoveCommand(home, args, io) {
162
180
  ? dim(` ${result.clonePath} is gone.\n`)
163
181
  : dim(" There was no clone to remove.\n")) +
164
182
  dim(` The block is out of ${configPath}.\n`) +
183
+ dim(` ${ymlPath} is gone.\n`) +
165
184
  "\n" +
185
+ // The file is committed, so deleting the working copy is not the end of
186
+ // it — the same trap adoption's report names about a patched Fastfile.
187
+ warn("Commit its removal, or a clone still carries the laneyard.yml.\n") +
166
188
  warn("The git remote and your credential originals were never Laneyard's to touch, and " +
167
189
  "global secrets and signing blocks were left alone.\n"));
168
190
  return 0;
@@ -10,6 +10,11 @@ import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
10
10
  import { acceptingAsker, terminalAsker } from "./prompt.js";
11
11
  import { detectProject } from "./detect.js";
12
12
  import { appRootOf } from "../heuristics/platforms.js";
13
+ import { CredentialStore } from "../db/credentials.js";
14
+ import { openDatabase } from "../db/open.js";
15
+ import { SecretStore } from "../db/secrets.js";
16
+ import { Vault } from "../secrets/vault.js";
17
+ import { runAdoption } from "./adopt.js";
13
18
  const exec = promisify(execFile);
14
19
  /**
15
20
  * Adds a project block to config.yml while preserving the rest of the file.
@@ -254,6 +259,8 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
254
259
  // app moved or duplicated keeps this file unchanged; `store.ts` puts the
255
260
  // prefix back when it reads it.
256
261
  const wroteRepoConfig = await writeRepoConfigIfAbsent(repoConfigPath, {
262
+ // First, as the file's identity: `remove` reads it from here.
263
+ slug,
257
264
  ...(appRelative(appRoot, fastlaneDir) === "fastlane"
258
265
  ? {}
259
266
  : { fastlane_dir: appRelative(appRoot, fastlaneDir) }),
@@ -296,6 +303,29 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
296
303
  ` ${dim(`http://localhost:${port}`)}\n` +
297
304
  "\n" +
298
305
  dim("Already running? The configuration is watched — it appears on its own.\n"));
306
+ // The second act. After the success message, never before it: declining
307
+ // everything here must leave exactly the project the lines above just
308
+ // announced. See `cli/adopt.ts`.
309
+ //
310
+ // The repository root, not `cwd`: `fastlaneDir` is measured from there, and
311
+ // so are the credential paths a Fastfile one directory down writes and the
312
+ // `git ls-files` that asks whether they are committed.
313
+ if (options.home !== undefined) {
314
+ const db = openDatabase(join(options.home, "laneyard.db"));
315
+ try {
316
+ const vault = await Vault.open(options.home, new SecretStore(db), new CredentialStore(db));
317
+ await runAdoption({ cwd: repoRoot(cwd, d.subPath), fastlaneDir, slug, vault, asker });
318
+ }
319
+ catch (cause) {
320
+ // Adoption is a courtesy on top of a command that has already
321
+ // succeeded. Its failure is reported and swallowed: exiting non-zero
322
+ // here would say the project was not set up, and the project is set up.
323
+ process.stdout.write("\n" + warn(`Could not finish reading your Fastfile: ${cause.message}\n`));
324
+ }
325
+ finally {
326
+ db.close();
327
+ }
328
+ }
299
329
  return 0;
300
330
  }
301
331
  finally {
@@ -1,6 +1,16 @@
1
1
  import { z } from "zod";
2
+ /** A slug is used as a folder name and a URL segment. */
3
+ const slugSchema = z
4
+ .string()
5
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "slug: lowercase letters, digits and hyphens only");
2
6
  /** Build behaviour settings. They can come from the repository or the server. */
3
7
  export const projectSettingsSchema = z.object({
8
+ // The project this file belongs to. Written by `setup` and read by `remove`,
9
+ // which runs from the app's directory and takes the slug from here rather than
10
+ // from an argument. An identity, not a path — `normaliseAppConfig` leaves it
11
+ // untouched — and the running server ignores it, identifying the project
12
+ // through `config.yml`. Optional so an older file without it still parses.
13
+ slug: slugSchema.optional(),
4
14
  fastlane_dir: z.string().default("fastlane"),
5
15
  runtime: z.enum(["bundle", "system"]).default("bundle"),
6
16
  timeout_minutes: z.number().int().positive().default(60),
@@ -21,10 +31,6 @@ export const projectSettingsSchema = z.object({
21
31
  });
22
32
  /** Same vocabulary, but everything is optional in the files. */
23
33
  export const projectSettingsInputSchema = projectSettingsSchema.partial();
24
- /** A slug is used as a folder name and a URL segment. */
25
- const slugSchema = z
26
- .string()
27
- .regex(/^[a-z0-9][a-z0-9-]*$/, "slug: lowercase letters, digits and hyphens only");
28
34
  export const projectEntrySchema = projectSettingsInputSchema.extend({
29
35
  slug: slugSchema,
30
36
  name: z.string().optional(),
@@ -0,0 +1,142 @@
1
+ import { defaultVarNames } from "../credentials/kinds.js";
2
+ /** Which action arguments name a credential file, and which block they mean. */
3
+ const FILE_ARGS = [
4
+ { action: /^app_store_connect_api_key$/, arg: "key_filepath", kind: "apple_asc" },
5
+ { action: /^(supply|upload_to_play_store|validate_play_store_json_key)$/, arg: "json_key", kind: "play_service_account" },
6
+ ];
7
+ /**
8
+ * Arguments holding a credential's *contents* inline, and the argument they
9
+ * must become.
10
+ *
11
+ * The rename is not cosmetic. `materialise.ts` stores a block as a file and
12
+ * exports its *path*; there is no slot that exports contents. Replacing the
13
+ * value alone would hand a filesystem path to an argument expecting PEM text,
14
+ * and fastlane's complaint would point nowhere near here.
15
+ */
16
+ const INLINE_ARGS = [
17
+ { action: /^app_store_connect_api_key$/, arg: "key_content", becomes: "key_filepath", kind: "apple_asc" },
18
+ { action: /^(supply|upload_to_play_store)$/, arg: "json_key_data", becomes: "json_key", kind: "play_service_account" },
19
+ ];
20
+ /** What a literal secret looks like when nothing more specific matched. */
21
+ const SECRET_ARG = /(^|_)(token|password|secret|api_key|url)$/;
22
+ /** `AuthKey_<KEY ID>.p8` is the name fastlane's own documentation uses. */
23
+ const P8_KEY_ID = /(?:^|\/)AuthKey_([A-Z0-9]+)\.p8$/;
24
+ /**
25
+ * Turns what a Fastfile literally says into what could be done about it.
26
+ *
27
+ * Pure, and deliberately so: every judgement about credentials is here, in one
28
+ * function, beside the one table that describes them. `ruby/scan.rb` reports
29
+ * what the file says and knows nothing of any of this.
30
+ *
31
+ * The order of the three passes is the order of confidence. A `json_key`
32
+ * pointing at a literal path is unambiguous; an `api_token` holding a literal
33
+ * might be a real token or might be a placeholder, so it falls through to the
34
+ * unchecked tier rather than being claimed by a rule that was almost right.
35
+ */
36
+ export function proposalsFor(literals) {
37
+ const out = [];
38
+ // The App Store Connect identifiers written inline, to be carried by whatever
39
+ // proposal creates the `apple_asc` block. Attributed by action alone: the
40
+ // literals arrive as a flat list with no call identity, and the vault holds
41
+ // one `apple_asc` block per project — so a second `app_store_connect_api_key`
42
+ // call's key is already unrepresentable, and gathering both calls' identifiers
43
+ // here does not widen a limit the block model does not already impose.
44
+ const appleIdentifiers = literals.filter((l) => l.action === "app_store_connect_api_key" &&
45
+ (l.arg === "key_id" || l.arg === "issuer_id") &&
46
+ l.value.trim() !== "");
47
+ for (const literal of literals) {
48
+ if (literal.value.trim() === "")
49
+ continue;
50
+ const file = FILE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
51
+ if (file) {
52
+ const name = defaultVarNames(file.kind)["path"];
53
+ const identifiers = file.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
54
+ out.push({
55
+ tier: "file",
56
+ kind: file.kind,
57
+ varName: name,
58
+ // The explicit literal wins over the Key ID read from the filename.
59
+ suggestedFields: { ...suggestedFields(file.kind, literal.value), ...identifiers.fields },
60
+ literal,
61
+ edits: [
62
+ { start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` },
63
+ ...identifiers.edits,
64
+ ],
65
+ checked: true,
66
+ });
67
+ continue;
68
+ }
69
+ const inline = INLINE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
70
+ if (inline) {
71
+ const name = defaultVarNames(inline.kind)["path"];
72
+ const identifiers = inline.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
73
+ out.push({
74
+ tier: "inline",
75
+ kind: inline.kind,
76
+ varName: name,
77
+ suggestedFields: identifiers.fields,
78
+ literal,
79
+ // The whole pair, because the keyword changes too.
80
+ edits: [
81
+ {
82
+ start: literal.pairStart,
83
+ length: literal.pairLength,
84
+ replacement: `${inline.becomes}: ENV.fetch("${name}")`,
85
+ },
86
+ ...identifiers.edits,
87
+ ],
88
+ checked: true,
89
+ });
90
+ continue;
91
+ }
92
+ if (SECRET_ARG.test(literal.arg)) {
93
+ const name = `${literal.action}_${literal.arg}`.toUpperCase();
94
+ out.push({
95
+ tier: "secret",
96
+ varName: name,
97
+ literal,
98
+ suggestedFields: {},
99
+ edits: [{ start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` }],
100
+ // Unticked on purpose. This is the one tier where a false positive is
101
+ // likely — a non-secret URL, a placeholder — and a patch applied by
102
+ // default to a value that was not a secret is a silent regression in
103
+ // someone's build.
104
+ checked: false,
105
+ });
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+ /** Neither an edit nor a field: an `apple_asc` block that names no identifier. */
111
+ const EMPTY = { edits: [], fields: {} };
112
+ /**
113
+ * The rewrites and pre-filled fields for the identifiers written beside an
114
+ * App Store Connect key file.
115
+ *
116
+ * The variable names come from `defaultVarNames("apple_asc")`, the same table
117
+ * `materialise.ts` exports the block's fields through — so the name the Fastfile
118
+ * is patched to read is the name the run will set. These never form a proposal
119
+ * of their own: an identifier is only ever lifted when the key file that anchors
120
+ * its block is, because nothing else exports the variable it would come to read.
121
+ */
122
+ function appleIdentifierRewrites(identifiers) {
123
+ const defaults = defaultVarNames("apple_asc");
124
+ const edits = [];
125
+ const fields = {};
126
+ for (const id of identifiers) {
127
+ edits.push({
128
+ start: id.valueStart,
129
+ length: id.valueLength,
130
+ replacement: `ENV.fetch("${defaults[id.arg]}")`,
131
+ });
132
+ fields[id.arg] = id.value;
133
+ }
134
+ return { edits, fields };
135
+ }
136
+ /** What the credential's filename gives away, so the prompt starts filled in. */
137
+ function suggestedFields(kind, path) {
138
+ if (kind !== "apple_asc")
139
+ return {};
140
+ const keyId = P8_KEY_ID.exec(path)?.[1];
141
+ return keyId ? { key_id: keyId } : {};
142
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Replaces ranges of a source, leaving every other byte exactly as it was.
3
+ *
4
+ * The same requirement `fastfile/store.ts` documents: a file written by hand
5
+ * must never come back reformatted, reordered, or with its trailing newline
6
+ * fixed. Someone may have spent a long time on that file.
7
+ *
8
+ * **Byte offsets, not string indices.** Prism reports positions in bytes, and
9
+ * one accented character above the literal would put every later offset off by
10
+ * one — a patch landing in the middle of a string, on a build file, silently.
11
+ * So the work happens on a Buffer.
12
+ *
13
+ * Edits are applied last-first so an earlier replacement cannot shift the
14
+ * offsets of the ones after it, and may be handed in in any order. Overlapping
15
+ * edits throw: two rules that both claim the same bytes is a bug in the rule
16
+ * table, and applying one of them arbitrarily would hide it.
17
+ */
18
+ export function splice(source, edits) {
19
+ if (edits.length === 0)
20
+ return source;
21
+ const ordered = [...edits].sort((a, b) => a.start - b.start);
22
+ for (let i = 1; i < ordered.length; i += 1) {
23
+ const previous = ordered[i - 1];
24
+ if (previous.start + previous.length > ordered[i].start) {
25
+ throw new Error("edits overlap");
26
+ }
27
+ }
28
+ let buffer = Buffer.from(source, "utf8");
29
+ for (let i = ordered.length - 1; i >= 0; i -= 1) {
30
+ const edit = ordered[i];
31
+ buffer = Buffer.concat([
32
+ buffer.subarray(0, edit.start),
33
+ Buffer.from(edit.replacement, "utf8"),
34
+ buffer.subarray(edit.start + edit.length),
35
+ ]);
36
+ }
37
+ return buffer.toString("utf8");
38
+ }