laneyard 0.6.0 → 0.7.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.
@@ -29,15 +29,16 @@ export async function runAdoption(options) {
29
29
  process.stdout.write("\n" + dim("Fastfile not analysed — no Ruby with Prism available. Nothing else changes.\n"));
30
30
  return { applied: 0 };
31
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.
32
+ // A literal path is looked up on disk so its bytes can go in the vault; one
33
+ // pointing at nothing is dropped, since patching to a variable nothing supplies
34
+ // would trade one broken build for another. An `env` value names no file — it
35
+ // is already a variable, only its *name* changes so it is kept without a
36
+ // lookup, a rewrite with nothing to store. Resolved once, here, so the prompt,
37
+ // the vault write and the git check all speak about the same file.
37
38
  const found = new Map();
38
39
  const proposals = [];
39
40
  for (const proposal of proposalsFor(literals)) {
40
- if (proposal.tier === "file") {
41
+ if (proposal.tier === "file" && proposal.literal.kind === "literal") {
41
42
  const hit = await readCredential(cwd, fastlaneDir, proposal);
42
43
  if (hit === null)
43
44
  continue;
@@ -51,7 +52,20 @@ export async function runAdoption(options) {
51
52
  const accepted = [];
52
53
  for (const proposal of proposals) {
53
54
  process.stdout.write(describe(fastlaneDir, proposal) + "\n");
54
- if (!(await asker.confirm(` Store it here and use ${bold(proposal.varName)}?`, proposal.checked))) {
55
+ // A rewrite-only proposal stores nothing — it renames a variable your lane
56
+ // already reads — so the question is not "store it here".
57
+ const rewriteOnly = proposal.tier === "file" && !found.has(proposal);
58
+ // Plural when more than one argument changes — the mapping is on screen
59
+ // above, so the question counts them rather than repeating three long names.
60
+ const all = proposal.rewrites.length;
61
+ const question = rewriteOnly
62
+ ? all > 1
63
+ ? ` Rewrite all ${all} to the names the block exports?`
64
+ : ` Rewrite it to ${bold(proposal.varName)}?`
65
+ : all > 1
66
+ ? ` Store it here and rewrite all ${all}?`
67
+ : ` Store it here and use ${bold(proposal.varName)}?`;
68
+ if (!(await asker.confirm(question, proposal.checked))) {
55
69
  continue;
56
70
  }
57
71
  accepted.push(proposal.tier === "secret" ? await named(asker, proposal) : proposal);
@@ -117,19 +131,31 @@ function describe(fastlaneDir, proposal) {
117
131
  // or a password, and setup's output is pasted into bug reports and kept in CI
118
132
  // transcripts — printing it there would be this feature leaking the very
119
133
  // 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."
134
+ const shown = (r) => r.literal.kind === "env"
135
+ ? `ENV["${r.literal.value}"]`
127
136
  : 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.";
137
+ ? `"${r.literal.value}"`
138
+ : proposal.tier === "inline"
139
+ ? dim("(a key, inline in the file)")
140
+ : dim("(a literal value, masked)");
141
+ const many = proposal.rewrites.length > 1;
142
+ const why = literal.kind === "env"
143
+ ? `Renamed to the variable${many ? "s" : ""} Laneyard's signing block exports.`
144
+ : proposal.tier === "inline"
145
+ ? "This key is in your repository in cleartext."
146
+ : proposal.tier === "file"
147
+ ? "That path does not survive the clone: Laneyard builds from your remote."
148
+ : "A literal secret in a build file is a secret in your history.";
149
+ // Every argument that changes, not just the one the proposal is anchored on:
150
+ // an `apple_asc` block rewrites its key file *and* both identifiers, and
151
+ // naming one of the three made the other two look like they were not touched.
152
+ const width = Math.max(...proposal.rewrites.map((r) => r.arg.length));
153
+ const lines = proposal.rewrites
154
+ .map((r) => ` ${`${r.arg}:`.padEnd(width + 2)} ${shown(r)} → ${bold(r.varName)}\n`)
155
+ .join("");
130
156
  return ("\n" +
131
- ` ${bold(`${fastlaneDir}/Fastfile:${literal.line}`)} ${literal.action}(${literal.arg}:)\n` +
132
- ` → ${shown}\n` +
157
+ ` ${bold(`${fastlaneDir}/Fastfile:${literal.line}`)} ${literal.action}\n` +
158
+ lines +
133
159
  dim(` ${why}\n`));
134
160
  }
135
161
  /**
@@ -152,6 +178,7 @@ async function named(asker, proposal) {
152
178
  ...proposal,
153
179
  varName,
154
180
  edits: [{ ...proposal.edits[0], replacement: `ENV.fetch("${varName}")` }],
181
+ rewrites: [{ ...proposal.rewrites[0], varName }],
155
182
  };
156
183
  }
157
184
  /** Lifts one accepted proposal into the vault. */
@@ -160,6 +187,11 @@ async function store(vault, slug, asker, proposal, found) {
160
187
  await vault.set(slug, proposal.varName, proposal.literal.value, true);
161
188
  return;
162
189
  }
190
+ // A rewrite-only proposal — a `file` arg that was already a variable — lifts
191
+ // nothing: there is no file on disk, and the block it points at is the user's
192
+ // to upload. The report tells them so; here there is simply nothing to store.
193
+ if (proposal.tier === "file" && found === undefined)
194
+ return;
163
195
  const bytes = proposal.tier === "inline" ? Buffer.from(proposal.literal.value, "utf8") : found.bytes;
164
196
  // The original name is kept: some tools read meaning from it, and
165
197
  // `materialise.ts` already relies on `AuthKey_<KEY ID>.p8` surviving intact.
@@ -177,8 +209,15 @@ async function store(vault, slug, asker, proposal, found) {
177
209
  }
178
210
  /** What is left for the user to do, including the part Laneyard will not do. */
179
211
  async function report(cwd, fastlaneDir, accepted, found) {
212
+ // A rewrite-only proposal — an arg that was already a variable — stored
213
+ // nothing; counting it as stored would claim a credential is in the vault when
214
+ // it is the user's still to upload.
215
+ const stored = accepted.filter((p) => p.tier !== "file" || found.has(p));
216
+ const normalised = accepted.filter((p) => p.tier === "file" && !found.has(p));
180
217
  process.stdout.write("\n" +
181
- ok(`Stored ${accepted.length} credential${accepted.length > 1 ? "s" : ""} in this machine's vault.\n`) +
218
+ (stored.length > 0
219
+ ? ok(`Stored ${stored.length} credential${stored.length > 1 ? "s" : ""} in this machine's vault.\n`)
220
+ : "") +
182
221
  ok(`Patched ${fastlaneDir}/Fastfile.\n`) +
183
222
  "\n" +
184
223
  // Said plainly because it is the trap `addProjectToConfig` already
@@ -186,6 +225,16 @@ async function report(cwd, fastlaneDir, accepted, found) {
186
225
  // the working copy reaches a run until it is pushed.
187
226
  warn("Commit and push it, or your runs still read the old file.\n") +
188
227
  dim(" git diff -- " + join(fastlaneDir, "Fastfile") + "\n"));
228
+ // The rewrites now read names a signing block exports. Nothing was stored for
229
+ // them, so the block is what makes them real — and the old variables the lane
230
+ // used to read are free to go.
231
+ if (normalised.length > 0) {
232
+ const names = [...new Set(normalised.map((p) => p.varName))].sort();
233
+ process.stdout.write("\n" +
234
+ warn(`Supply ${names.join(", ")} from a signing block.\n`) +
235
+ dim(" Upload the .p8 or service account there; the block exports these names,\n") +
236
+ dim(" and the variables you set as secrets for them can go.\n"));
237
+ }
189
238
  // Said, never done. Removing a file from someone's repository is not
190
239
  // setup's to decide, and `git rm --cached` does not take it out of the
191
240
  // history anyway — so the honest thing is to name it.
@@ -13,14 +13,18 @@ import { LogStore } from "../logs/store.js";
13
13
  import { Vault } from "../secrets/vault.js";
14
14
  import { readLine } from "./home.js";
15
15
  import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
16
- export const REMOVE_USAGE = `laneyard remove [--dry-run]
16
+ export const REMOVE_USAGE = `laneyard remove [<slug>] [--dry-run]
17
17
 
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.
18
+ Run from the directory that holds laneyard.yml — the repository root for most
19
+ projects, the app's own folder in a monorepo and the project is the one that
20
+ file names. Give a slug instead when there is no such file.
21
+
22
+ Removes everything Laneyard holds for that project: its block in config.yml, its
23
+ clone, its artifacts, its run history and logs, and its own secrets and signing
24
+ blocks in the vault. It also removes that laneyard.yml, which you then commit —
25
+ one naming another project is left alone. It does not touch the git remote, the
26
+ credential originals, or the global secrets and signing blocks other projects
27
+ share.
24
28
 
25
29
  laneyard remove --dry-run show what would go and stop
26
30
 
@@ -65,7 +69,7 @@ function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
65
69
  field("secrets", plural(counts.secrets, "project secret")) + "\n" +
66
70
  field("signing", plural(counts.signingBlocks, "project signing block")) + "\n" +
67
71
  // The one thing removed outside `home`: the repository's own file.
68
- field("laneyard.yml", ymlPath) + "\n" +
72
+ (ymlPath === null ? "" : field("laneyard.yml", ymlPath) + "\n") +
69
73
  heading("what will not be touched") +
70
74
  dim(" the git remote — the repository is yours, on your host and your disk.\n") +
71
75
  dim(" the credential originals — Laneyard removes only its own encrypted copy;\n") +
@@ -73,43 +77,56 @@ function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
73
77
  dim(" global secrets and global signing blocks — shared by every project.\n"));
74
78
  }
75
79
  /**
76
- * Entry point for `laneyard remove`, run from the app's own directory.
80
+ * Entry point for `laneyard remove`, run from wherever the project's
81
+ * `laneyard.yml` sits — the repository root unless the app is one folder of a
82
+ * monorepo.
83
+ *
84
+ * The project is the one whose `laneyard.yml` sits in `cwd`, so the command names
85
+ * what the directory already is. A slug given outright overrides that, and is the
86
+ * only way to reach a project whose file was never written, lost its slug, or
87
+ * whose repository is no longer on this machine.
77
88
  *
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.
89
+ * It reads the inventory first, prints what will and will not go, and only then
90
+ * asks for the slug typed back the same gate the web route uses, for the same
91
+ * reason: the run history it deletes is the one thing here nothing can rebuild.
84
92
  */
85
93
  export async function runRemoveCommand(home, cwd, args, io) {
86
94
  let dryRun = false;
95
+ let given = null;
87
96
  for (const arg of args) {
88
97
  if (arg === "--dry-run" || arg === "-n")
89
98
  dryRun = true;
90
- else {
99
+ else if (arg.startsWith("-")) {
91
100
  io.err(`Unknown option: ${arg}\n\n${REMOVE_USAGE}`);
92
101
  return 1;
93
102
  }
103
+ else if (given === null)
104
+ given = arg;
105
+ else {
106
+ io.err(`One project at a time. Give a single slug.\n\n${REMOVE_USAGE}`);
107
+ return 1;
108
+ }
94
109
  }
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.
110
+ // The project is normally the one whose `laneyard.yml` is here — the file setup
111
+ // wrote, and the slug it recorded in it.
98
112
  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;
113
+ const inFile = existsSync(ymlPath)
114
+ ? await loadRepoConfig(ymlPath).then((r) => (r.ok ? r.config.slug : undefined))
115
+ : undefined;
116
+ // A slug given outright is the way out when there is no such file: one that
117
+ // was never written, lost its slug, or whose repository is not on this machine
118
+ // any more. Without it those projects could only be removed from the web.
119
+ const slug = given ?? inFile;
107
120
  if (slug === undefined || slug === "") {
108
121
  io.err("\n" +
109
- bad("This laneyard.yml has no slug.") +
110
- " Run `laneyard setup` again to record which project it is.\n");
122
+ bad(existsSync(ymlPath) ? "This laneyard.yml has no slug." : "No laneyard.yml here.") +
123
+ " Run `laneyard remove` from the directory that holds it — usually the repository" +
124
+ " root — or name the project: `laneyard remove <slug>`.\n");
111
125
  return 1;
112
126
  }
127
+ // Removed only when it is this project's file. Given a slug explicitly, a
128
+ // `laneyard.yml` naming a different project is somebody else's and stays.
129
+ const ownYml = inFile !== undefined && inFile === slug ? ymlPath : null;
113
130
  const configPath = join(home, "config.yml");
114
131
  const config = new ConfigStore(configPath);
115
132
  const loaded = await config.load();
@@ -136,7 +153,7 @@ export async function runRemoveCommand(home, cwd, args, io) {
136
153
  " Wait for it to finish, or cancel it, then remove the project. Nothing was removed.\n");
137
154
  return 1;
138
155
  }
139
- io.out(renderInventory(slug, entry.name, home, counts, clonePath, ymlPath));
156
+ io.out(renderInventory(slug, entry.name, home, counts, clonePath, ownYml));
140
157
  if (dryRun) {
141
158
  io.out("\n" +
142
159
  dim("Nothing was removed. Run `laneyard remove` without --dry-run to remove it.") +
@@ -172,7 +189,8 @@ export async function runRemoveCommand(home, cwd, args, io) {
172
189
  // The machine data went first, so a file removed here is one whose project
173
190
  // is already gone — never the other way round. `force` because the removal
174
191
  // must still report success if the file was deleted by hand meanwhile.
175
- await rm(ymlPath, { force: true }).catch(() => { });
192
+ if (ownYml !== null)
193
+ await rm(ownYml, { force: true }).catch(() => { });
176
194
  io.out(heading("removed") +
177
195
  ok(`"${slug}": ${plural(result.runs, "run")}, ${plural(result.secrets, "secret")}, ` +
178
196
  `${plural(result.signingBlocks, "signing block")}.\n`) +
@@ -180,7 +198,7 @@ export async function runRemoveCommand(home, cwd, args, io) {
180
198
  ? dim(` ${result.clonePath} is gone.\n`)
181
199
  : dim(" There was no clone to remove.\n")) +
182
200
  dim(` The block is out of ${configPath}.\n`) +
183
- dim(` ${ymlPath} is gone.\n`) +
201
+ (ownYml === null ? "" : dim(` ${ownYml} is gone.\n`)) +
184
202
  "\n" +
185
203
  // The file is committed, so deleting the working copy is not the end of
186
204
  // it — the same trap adoption's report names about a patched Fastfile.
@@ -151,16 +151,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
151
151
  dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
152
152
  dim(" Give it another name to keep both.\n"));
153
153
  }
154
- // The repository file lives in the app's own directory, so a monorepo of N
155
- // apps carries N of them — one beside each app's fastlane folder. `appRoot`
156
- // is the fastlane dir's parent; for a plain app whose fastlane sits at the
157
- // root it is `.`, and the file lands at the repository root as it always did.
158
- const appRoot = appRootOf(d.fastlaneDir);
159
- const repoConfigPath = join(repoRoot(cwd, d.subPath), appRoot, LANEYARD_YML);
160
- if (await fileExists(repoConfigPath)) {
161
- process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
162
- dim(" Its values win over anything written here; it will be left alone.\n"));
163
- }
164
154
  // A machine with no account gets its first admin here. Asked rather than
165
155
  // assumed: the name is typed into a login form every day afterwards, and
166
156
  // `admin` is a poor thing to call a person once there are two of them.
@@ -187,7 +177,24 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
187
177
  }
188
178
  const gitUrl = await asker.ask("repository", d.gitUrl);
189
179
  const branch = await asker.ask("default branch", d.defaultBranch, dim("A run uses this branch unless you pick another one when you start it."));
190
- const fastlaneDir = await asker.ask("fastlane directory", d.fastlaneDir, dim("Relative to the repository root, because that is what Laneyard clones."));
180
+ // Asked as it is written from where the command ran: from `app/`, the folder
181
+ // is `fastlane`, not `app/fastlane`. The repository-root-relative form is
182
+ // what the clone has and what `config.yml` stores, but prefixing it is
183
+ // Laneyard's job — asking someone to type a path they are standing inside
184
+ // reads like a mistake, and is the one people correct wrongly.
185
+ const fastlaneDir = fromRepoRoot(d.subPath, await asker.ask("fastlane directory", fromHere(d.subPath, d.fastlaneDir), dim("Relative to this directory.")));
186
+ // Computed from the answer, never from the detection: correcting the folder
187
+ // moves the app root with it, and with it where `laneyard.yml` is written and
188
+ // which prefix its paths drop. Read before the prompt, a correction was
189
+ // silently ignored and the file landed beside the wrong app.
190
+ const appRoot = appRootOf(fastlaneDir);
191
+ const repoConfigPath = join(repoRoot(cwd, d.subPath), appRoot, LANEYARD_YML);
192
+ const repoConfigExists = await fileExists(repoConfigPath);
193
+ if (repoConfigExists) {
194
+ process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
195
+ dim(" Its values win over anything written here and are left alone.\n") +
196
+ dim(" Only a missing `slug:` is added, so this project can be removed later.\n"));
197
+ }
191
198
  // A fastlane folder that is on disk but not in git will not survive the
192
199
  // clone Laneyard builds from — the case that started this: a stray
193
200
  // `app copie/` macOS made, detected here and absent from the remote.
@@ -209,10 +216,14 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
209
216
  // prompt is a miserable way to start, the detected ones are nearly always
210
217
  // right, and the file is there to be edited when they are not.
211
218
  const globs = d.artifactGlobs;
212
- if (!(await asker.confirm(`\n${bold(`Set up "${slug}"`)}?`, true))) {
213
- process.stdout.write(dim("Nothing written.") + "\n");
214
- return 0;
215
- }
219
+ // No final "and write those?" here, on purpose. It was a rubber stamp: by
220
+ // this line every value has been proposed and accepted one at a time, so the
221
+ // answer was always yes — and a question nobody ever says no to teaches
222
+ // people to press Return without reading the ones that matter. Nothing here
223
+ // destroys anything either: the project block is additive and `laneyard
224
+ // remove` undoes it, an existing `laneyard.yml` keeps its values, and the
225
+ // report below says exactly what was written and where. Ctrl-C still works
226
+ // at every prompt above.
216
227
  // The account first, so the server block is written before the project list
217
228
  // it sits above. `null` when this machine already has one, and then nothing
218
229
  // is written and nothing is printed.
@@ -271,7 +282,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
271
282
  // checklist cannot end up disagreeing about the same repository.
272
283
  ...(d.platforms.length > 0 ? { platforms: d.platforms } : {}),
273
284
  });
274
- const port = await configuredPort(configPath);
275
285
  process.stdout.write(heading(`Project "${slug}" is set up`) +
276
286
  field("repository", `${gitUrl} (${branch})`) + "\n" +
277
287
  field("fastlane", fastlaneDir) + "\n" +
@@ -282,9 +292,12 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
282
292
  // one line of the file just written.
283
293
  field("platforms", d.platforms.join(", ") || dim("none detected")) + "\n" +
284
294
  "\n" +
285
- (wroteRepoConfig
295
+ (wroteRepoConfig === "written"
286
296
  ? ok(`Wrote ${bold(LANEYARD_YML)} — ${bold("commit it")} so your team builds the same way.\n`)
287
- : warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
297
+ : wroteRepoConfig === "slug-added"
298
+ ? ok(`Added ${bold(`slug: ${slug}`)} to the existing ${LANEYARD_YML}, and left the rest ` +
299
+ `alone — ${bold("commit it")}.\n`)
300
+ : warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
288
301
  ok(`Registered in ${configPath}\n`) +
289
302
  // Last thing before the invitation to start the server, because it is
290
303
  // the one line here that cannot be read again anywhere: the file holds
@@ -298,11 +311,14 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
298
311
  "\n" +
299
312
  warn("Write the password down — it is not shown again, and it is not stored.\n") +
300
313
  dim(" Add a colleague later with `laneyard user add <name> --role builder`.\n")) +
301
- heading("Start Laneyard") +
314
+ // An instruction, not a status. `Start Laneyard` over a command and a URL
315
+ // read as "started, and here is the address" — the URL especially, which
316
+ // nothing was serving yet. The verb says who does it, and the address is
317
+ // the server's to print once it is listening.
318
+ heading("The server is not running yet — start it with") +
302
319
  ` ${bold("laneyard")}\n` +
303
- ` ${dim(`http://localhost:${port}`)}\n` +
304
320
  "\n" +
305
- dim("Already running? The configuration is watched — it appears on its own.\n"));
321
+ dim("Already running? The configuration is watched — this project appears on its own.\n"));
306
322
  // The second act. After the success message, never before it: declining
307
323
  // everything here must leave exactly the project the lines above just
308
324
  // announced. See `cli/adopt.ts`.
@@ -332,17 +348,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
332
348
  asker.close();
333
349
  }
334
350
  }
335
- /**
336
- * The port the server will actually listen on.
337
- *
338
- * Read back from the file rather than assumed: telling someone to open a port
339
- * their configuration does not use is the kind of small wrongness that makes a
340
- * tool feel unreliable on first contact.
341
- */
342
- async function configuredPort(configPath) {
343
- const res = await loadServerConfig(configPath);
344
- return res.ok ? res.config.server.port : 7890;
345
- }
346
351
  /** The repository file, named once so the message and the write cannot disagree. */
347
352
  const LANEYARD_YML = "laneyard.yml";
348
353
  /** What the first account is called when nobody says otherwise. */
@@ -401,14 +406,30 @@ async function existingProject(configPath, slug) {
401
406
  * values win anyway.
402
407
  */
403
408
  async function writeRepoConfigIfAbsent(path, settings) {
404
- if (await fileExists(path))
405
- return false;
406
- const doc = new Document(settings);
407
- doc.commentBefore =
408
- " How this project builds. Committed, so everyone builds it the same way.\n" +
409
- " Values here win over the project's block in the server's config.yml.";
410
- await writeFile(path, doc.toString(), "utf8");
411
- return true;
409
+ const existing = await readFile(path, "utf8").catch(() => null);
410
+ if (existing === null) {
411
+ const doc = new Document(settings);
412
+ doc.commentBefore =
413
+ " How this project builds. Committed, so everyone builds it the same way.\n" +
414
+ " Values here win over the project's block in the server's config.yml.";
415
+ await writeFile(path, doc.toString(), "utf8");
416
+ return "written";
417
+ }
418
+ // The one edit an existing file gets: the slug, and only when it has none.
419
+ //
420
+ // A file written before slugs existed has no way to gain one otherwise, and
421
+ // `laneyard remove` refuses a slug-less file while telling you to run setup
422
+ // again — which left it untouched. That was a project nothing could remove.
423
+ //
424
+ // Through the YAML document, like `addProjectToConfig`: the comments, the key
425
+ // order and every value the file carried come back out unchanged. An existing
426
+ // slug is never touched, whatever it says.
427
+ const doc = parseDocument(existing);
428
+ if (doc.getIn(["slug"]) !== undefined)
429
+ return "left";
430
+ doc.setIn(["slug"], settings.slug);
431
+ await writeFile(path, serialize(doc), "utf8");
432
+ return "slug-added";
412
433
  }
413
434
  /**
414
435
  * A repo-root-relative path read as relative to the app directory.
@@ -425,6 +446,30 @@ function appRelative(appRoot, p) {
425
446
  const prefix = `${appRoot}/`;
426
447
  return p.startsWith(prefix) ? p.slice(prefix.length) : p;
427
448
  }
449
+ /**
450
+ * A repository-root-relative path as it is written from where setup ran.
451
+ *
452
+ * `app/fastlane` is `fastlane` when standing in `app/`. A path outside that
453
+ * directory has no shorter form and is shown as it is.
454
+ */
455
+ function fromHere(subPath, repoRelative) {
456
+ if (subPath === "")
457
+ return repoRelative;
458
+ const prefix = `${subPath}/`;
459
+ return repoRelative.startsWith(prefix) ? repoRelative.slice(prefix.length) : repoRelative;
460
+ }
461
+ /**
462
+ * The inverse: back to repository-root-relative, which is the shape of the clone.
463
+ *
464
+ * Tolerant of an answer that already carries the prefix — someone who types
465
+ * `app/fastlane` from `app/` means the same folder, and double-prefixing it into
466
+ * `app/app/fastlane` would be a worse answer than the one they gave.
467
+ */
468
+ function fromRepoRoot(subPath, here) {
469
+ if (subPath === "" || here === "")
470
+ return here;
471
+ return here === subPath || here.startsWith(`${subPath}/`) ? here : `${subPath}/${here}`;
472
+ }
428
473
  /** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
429
474
  function repositoryLabel(url) {
430
475
  return url.replace(/^.*[:/]([^/:]+\/[^/]+?)(\.git)?$/, "$1");
@@ -50,23 +50,39 @@ export function proposalsFor(literals) {
50
50
  const file = FILE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
51
51
  if (file) {
52
52
  const name = defaultVarNames(file.kind)["path"];
53
+ const main = rewriteTo(literal, name);
53
54
  const identifiers = file.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
55
+ const edits = [...(main ? [main] : []), ...identifiers.edits];
56
+ const rewrites = [
57
+ ...(main ? [{ arg: literal.arg, literal, varName: name }] : []),
58
+ ...identifiers.rewrites,
59
+ ];
60
+ // Every edit was a no-op — the value already reads its Laneyard name, and
61
+ // so did each identifier. Nothing to propose.
62
+ if (edits.length === 0)
63
+ continue;
54
64
  out.push({
55
65
  tier: "file",
56
66
  kind: file.kind,
57
67
  varName: name,
58
- // The explicit literal wins over the Key ID read from the filename.
59
- suggestedFields: { ...suggestedFields(file.kind, literal.value), ...identifiers.fields },
68
+ // The filename gives a Key ID only for a literal path; an env name is not
69
+ // one. The explicit literal identifier wins over the filename either way.
70
+ suggestedFields: {
71
+ ...(literal.kind === "literal" ? suggestedFields(file.kind, literal.value) : {}),
72
+ ...identifiers.fields,
73
+ },
60
74
  literal,
61
- edits: [
62
- { start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` },
63
- ...identifiers.edits,
64
- ],
75
+ edits,
76
+ rewrites,
65
77
  checked: true,
66
78
  });
67
79
  continue;
68
80
  }
69
- const inline = INLINE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
81
+ // Inline contents and a literal secret are, by definition, literals: an
82
+ // `ENV[...]` here is a variable, handled by the file pass above or left alone.
83
+ const inline = literal.kind === "literal"
84
+ ? INLINE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg)
85
+ : undefined;
70
86
  if (inline) {
71
87
  const name = defaultVarNames(inline.kind)["path"];
72
88
  const identifiers = inline.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
@@ -85,11 +101,12 @@ export function proposalsFor(literals) {
85
101
  },
86
102
  ...identifiers.edits,
87
103
  ],
104
+ rewrites: [{ arg: inline.becomes, literal, varName: name }, ...identifiers.rewrites],
88
105
  checked: true,
89
106
  });
90
107
  continue;
91
108
  }
92
- if (SECRET_ARG.test(literal.arg)) {
109
+ if (literal.kind === "literal" && SECRET_ARG.test(literal.arg)) {
93
110
  const name = `${literal.action}_${literal.arg}`.toUpperCase();
94
111
  out.push({
95
112
  tier: "secret",
@@ -97,6 +114,7 @@ export function proposalsFor(literals) {
97
114
  literal,
98
115
  suggestedFields: {},
99
116
  edits: [{ start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` }],
117
+ rewrites: [{ arg: literal.arg, literal, varName: name }],
100
118
  // Unticked on purpose. This is the one tier where a false positive is
101
119
  // likely — a non-secret URL, a placeholder — and a patch applied by
102
120
  // default to a value that was not a secret is a silent regression in
@@ -108,7 +126,11 @@ export function proposalsFor(literals) {
108
126
  return out;
109
127
  }
110
128
  /** Neither an edit nor a field: an `apple_asc` block that names no identifier. */
111
- const EMPTY = { edits: [], fields: {} };
129
+ const EMPTY = {
130
+ edits: [],
131
+ fields: {},
132
+ rewrites: [],
133
+ };
112
134
  /**
113
135
  * The rewrites and pre-filled fields for the identifiers written beside an
114
136
  * App Store Connect key file.
@@ -122,16 +144,37 @@ const EMPTY = { edits: [], fields: {} };
122
144
  function appleIdentifierRewrites(identifiers) {
123
145
  const defaults = defaultVarNames("apple_asc");
124
146
  const edits = [];
147
+ const rewrites = [];
125
148
  const fields = {};
126
149
  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;
150
+ const name = defaults[id.arg];
151
+ const edit = rewriteTo(id, name);
152
+ if (edit) {
153
+ edits.push(edit);
154
+ rewrites.push({ arg: id.arg, literal: id, varName: name });
155
+ }
156
+ // Only a literal is the identifier's value; an env name is the *variable* it
157
+ // was read from, not the id itself, so it pre-fills nothing.
158
+ if (id.kind === "literal")
159
+ fields[id.arg] = id.value;
133
160
  }
134
- return { edits, fields };
161
+ return { edits, fields, rewrites };
162
+ }
163
+ /**
164
+ * The edit that makes one argument read `varName`, or null when it already does.
165
+ *
166
+ * A literal is always rewritten — a path or a value is never the variable name.
167
+ * An `ENV[...]` already reading that exact name is left alone: the point is to
168
+ * reach Laneyard's name, and it is already there.
169
+ */
170
+ function rewriteTo(literal, varName) {
171
+ if (literal.kind === "env" && literal.value === varName)
172
+ return null;
173
+ return {
174
+ start: literal.valueStart,
175
+ length: literal.valueLength,
176
+ replacement: `ENV.fetch("${varName}")`,
177
+ };
135
178
  }
136
179
  /** What the credential's filename gives away, so the prompt starts filled in. */
137
180
  function suggestedFields(kind, path) {
@@ -199,6 +199,34 @@ export class Workspace {
199
199
  onProgress?.(`Cloning ${this.redact(this.gitUrl)}…`);
200
200
  await this.git(["clone", this.gitUrl, this.path], process.cwd());
201
201
  }
202
+ /**
203
+ * Commits this workspace holds that `origin/<branch>` does not.
204
+ *
205
+ * The one thing `prepare` would destroy without saying so: it moves the local
206
+ * branch onto origin's, and a commit only this clone had becomes reachable
207
+ * from the reflog and nowhere a person looks. That is not hypothetical — the
208
+ * Fastfile tab commits and pushes as two separate acts on purpose, so a
209
+ * workspace sitting one commit ahead is a state the product produces itself.
210
+ *
211
+ * Counted rather than listed: the caller refuses on any number above zero, and
212
+ * a list would invite showing it, which is a screen nobody asked for.
213
+ *
214
+ * Zero when the branch does not exist on the remote yet, and zero when
215
+ * anything about the question cannot be answered — a workspace that has never
216
+ * been fetched has no `origin/<branch>` to compare against, and refusing a
217
+ * refresh because the comparison failed would break the one case the refresh
218
+ * exists for.
219
+ */
220
+ async unpushedCount(branch) {
221
+ try {
222
+ const out = await this.git(["rev-list", "--count", `origin/${branch}..HEAD`]);
223
+ const n = Number(out.trim());
224
+ return Number.isInteger(n) ? n : 0;
225
+ }
226
+ catch {
227
+ return 0;
228
+ }
229
+ }
202
230
  /**
203
231
  * Brings the workspace to the requested branch, up to date.
204
232
  * Clones on the first call, just fetches afterwards.