laneyard 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +13 -497
  2. package/dist/src/cli/adopt.js +24 -8
  3. package/dist/src/cli/remove.js +52 -34
  4. package/dist/src/cli/reset.js +2 -2
  5. package/dist/src/cli/setup.js +85 -40
  6. package/dist/src/cli/uninstall.js +5 -21
  7. package/dist/src/config/env-file-setting.js +57 -0
  8. package/dist/src/config/resolve.js +8 -5
  9. package/dist/src/config/schema.js +29 -0
  10. package/dist/src/db/credentials.js +18 -54
  11. package/dist/src/db/migrate-global-scope.js +75 -0
  12. package/dist/src/db/open.js +21 -0
  13. package/dist/src/db/schema.sql +10 -5
  14. package/dist/src/db/secrets.js +44 -65
  15. package/dist/src/fastfile/adoption.js +19 -4
  16. package/dist/src/git/workspace.js +28 -0
  17. package/dist/src/heuristics/readiness.js +80 -135
  18. package/dist/src/main.js +31 -2
  19. package/dist/src/runner/env-file.js +144 -0
  20. package/dist/src/runner/orchestrate.js +39 -5
  21. package/dist/src/secrets/vault.js +75 -40
  22. package/dist/src/server/app.js +4 -0
  23. package/dist/src/server/routes/credentials.js +0 -13
  24. package/dist/src/server/routes/fastfile.js +1 -1
  25. package/dist/src/server/routes/projects.js +59 -16
  26. package/dist/src/server/routes/readiness.js +2 -3
  27. package/dist/src/server/routes/secrets.js +111 -39
  28. package/dist/src/sidecar/fastlane-dir.js +18 -8
  29. package/dist/src/sidecar/lanes.js +1 -1
  30. package/dist/src/sidecar/uses.js +1 -1
  31. package/dist/web/assets/{Editor-CMa4-4N3.js → Editor-BK-ZRKlI.js} +1 -1
  32. package/dist/web/assets/index-DnUdqjT4.js +62 -0
  33. package/dist/web/assets/{index-DsjxZtsO.css → index-r3IyRuO4.css} +1 -1
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web/assets/index-B8lAQPEM.js +0 -62
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The environment file a build reads from disk.
3
+ *
4
+ * A project's own `.env` — the one its app reads, not the one fastlane reads —
5
+ * is gitignored, and Laneyard builds from a clone, so it is never there. The
6
+ * vault does not answer that on its own: a stored secret becomes an environment
7
+ * variable of the run, which is enough for fastlane and enough for a Fastfile
8
+ * that forwards values itself, and no use at all to anything that reads a
9
+ * *file*. `flutter_dotenv` bundles `.env` as an asset,
10
+ * `--dart-define-from-file=config.json` reads a path at compile time, an
11
+ * `.xcconfig` is a file by definition. None of them looks at the environment,
12
+ * and none of them fails loudly — they produce an app configured with nothing.
13
+ *
14
+ * So the variables a project ticks are rendered into a file at the path its
15
+ * `laneyard.yml` names, for the length of one run, and removed afterwards. The
16
+ * ticking decides membership of this file and nothing else: a ticked secret
17
+ * still reaches the run as an environment variable like every other one.
18
+ */
19
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
20
+ import { dirname, join } from "node:path";
21
+ import { LANEYARD_MARKER } from "./gradle-properties.js";
22
+ /**
23
+ * Whether a value can be written bare.
24
+ *
25
+ * Anything outside this set is quoted. The set is deliberately narrow — a bare
26
+ * line is a convenience, and being wrong about it costs a value silently read
27
+ * back short. `#` starts a comment, whitespace at either end is trimmed by most
28
+ * readers, and a quote or backslash is an escape somebody has to interpret.
29
+ */
30
+ const BARE = /^[A-Za-z0-9_./:@+-]*$/;
31
+ /**
32
+ * Escapes a value for a double-quoted dotenv line.
33
+ *
34
+ * Backslash first, or every escape this function then writes would be escaped
35
+ * again by its own output. Newlines become `\n` rather than being written
36
+ * literally: a line break inside a value ends the line, and the parser reads a
37
+ * truncated value and a second variable that does not exist. A private key
38
+ * pasted into a variable is where that happens for real.
39
+ */
40
+ function escape(value) {
41
+ return value
42
+ .replace(/\\/g, "\\\\")
43
+ .replace(/"/g, '\\"')
44
+ .replace(/\r/g, "\\r")
45
+ .replace(/\n/g, "\\n");
46
+ }
47
+ /**
48
+ * Renders variables as dotenv text.
49
+ *
50
+ * Sorted by name, so a diff between two runs shows what changed rather than
51
+ * what moved. Pure, and tested against a parser rather than against itself:
52
+ * the property worth having is "a reader gets back what went in", and a test
53
+ * written against this function's own idea of the format would pass however
54
+ * wrong that idea was.
55
+ */
56
+ export function renderDotenv(values) {
57
+ return Object.keys(values)
58
+ .sort()
59
+ .map((key) => dotenvLine(key, values[key]))
60
+ .join("");
61
+ }
62
+ /**
63
+ * One `KEY=value` line, quoted if the value needs it.
64
+ *
65
+ * Exported for the preview the secrets screen shows, which renders a stand-in
66
+ * for every masked value rather than the value. It has to compose its own lines
67
+ * — a row of dots is not ASCII, so it would come back quoted, and the quotes
68
+ * would be a claim about the real value that nobody can check. It still goes
69
+ * through this function for everything it does show, so the preview and the
70
+ * file cannot drift apart in how they write a line.
71
+ */
72
+ export function dotenvLine(key, value) {
73
+ return `${key}=${BARE.test(value) ? value : `"${escape(value)}"`}\n`;
74
+ }
75
+ /** The first line of a file, or null when there is no file to read. */
76
+ async function firstLine(path) {
77
+ const text = await readFile(path, "utf8").catch(() => null);
78
+ return text === null ? null : (text.split("\n")[0] ?? "");
79
+ }
80
+ /** Whether this file is one Laneyard wrote, and may therefore replace or remove. */
81
+ async function ours(path) {
82
+ const line = await firstLine(path);
83
+ return line === null || line.trimEnd() === LANEYARD_MARKER;
84
+ }
85
+ /**
86
+ * Writes the environment file, and returns its path — or null, having written
87
+ * nothing.
88
+ *
89
+ * Null has two causes and neither is an error: the project names no `env_file`,
90
+ * or a file is already there that Laneyard did not write. That second one is
91
+ * the important guard. The path points into a clone the user can work in by
92
+ * hand, and a `.env` they put there themselves is their build's real
93
+ * configuration — overwriting it would replace something that works with
94
+ * something assembled from a checklist. It is left exactly as it is.
95
+ *
96
+ * The marker is the first line, so the file says what it is to anyone who opens
97
+ * it, and so this function can tell its own output from a person's on the next
98
+ * run. `#` is a comment in dotenv, which is the reason dotenv is the only
99
+ * format for now: JSON has nowhere to put a line like this.
100
+ */
101
+ export async function writeEnvFile(appRoot, envFile, values) {
102
+ if (envFile === undefined)
103
+ return null;
104
+ const path = join(appRoot, envFile);
105
+ if (!(await ours(path)))
106
+ return null;
107
+ await mkdir(dirname(path), { recursive: true });
108
+ // The mode is set twice, as in `materialise.ts` and `gradle-properties.ts`:
109
+ // `writeFile`'s mode is masked by the process umask, and ignored outright for
110
+ // a file that already exists. This one holds whatever the vault held.
111
+ await writeFile(path, `${LANEYARD_MARKER}\n${renderDotenv(values)}`, { mode: 0o600 });
112
+ await chmod(path, 0o600);
113
+ return path;
114
+ }
115
+ /**
116
+ * Removes a file this run wrote, and only such a file.
117
+ *
118
+ * Called from the run's `finally`, so the values do not outlive the build that
119
+ * needed them. Takes null so the caller can hand it whatever `writeEnvFile`
120
+ * returned without a branch of its own.
121
+ */
122
+ export async function removeEnvFile(path) {
123
+ if (path === null)
124
+ return;
125
+ if ((await firstLine(path))?.trimEnd() !== LANEYARD_MARKER)
126
+ return;
127
+ await rm(path, { force: true }).catch(() => { });
128
+ }
129
+ /**
130
+ * Removes a marked file left where this run would write one.
131
+ *
132
+ * A run killed between writing the file and reaching its `finally` — an
133
+ * unplugged server, a `kill -9` — leaves values in a working tree that is kept
134
+ * between runs. Nothing at that moment can clean up after itself, so the next
135
+ * run does it first.
136
+ *
137
+ * Silent about everything: a workspace that was never cloned has nothing to
138
+ * sweep, and a sweep that failed must not be the reason a build did not start.
139
+ */
140
+ export async function sweepEnvFile(appRoot, envFile) {
141
+ if (envFile === undefined)
142
+ return;
143
+ await removeEnvFile(join(appRoot, envFile)).catch(() => { });
144
+ }
@@ -4,8 +4,10 @@ import { gitEnvFor, Workspace } from "../git/workspace.js";
4
4
  import { summarizeFailure } from "../heuristics/error-summary.js";
5
5
  import { appRootOf, searchDir } from "../heuristics/platforms.js";
6
6
  import { Redactor, scrub } from "../logs/redact.js";
7
+ import { assertFastlaneDir } from "../sidecar/fastlane-dir.js";
7
8
  import { collectArtifacts } from "./artifacts.js";
8
9
  import { removeGradleProperties, sweepGradleProperties, writeGradleProperties, } from "./gradle-properties.js";
10
+ import { removeEnvFile, sweepEnvFile, writeEnvFile } from "./env-file.js";
9
11
  import { LiveStepTracker } from "./live-steps.js";
10
12
  import { startPty } from "./pty.js";
11
13
  import { readReport } from "./report.js";
@@ -30,13 +32,14 @@ import { readReport } from "./report.js";
30
32
  * inside the run and has to be visible to a `finally` wrapped around all of it.
31
33
  */
32
34
  export async function executeRun(opts) {
33
- const wrote = { properties: null };
35
+ const wrote = { properties: null, envFile: null };
34
36
  try {
35
37
  return await execute(opts, wrote);
36
38
  }
37
39
  finally {
38
40
  // Guarded by the marker, so a file the user replaced mid-run survives.
39
41
  await removeGradleProperties(wrote.properties).catch(() => { });
42
+ await removeEnvFile(wrote.envFile).catch(() => { });
40
43
  // Deliberately swallowed: by now the log writer is closed and the run's
41
44
  // verdict is recorded, so there is nowhere left to report this without
42
45
  // rewriting a finished run's outcome as a failure it did not have. A
@@ -122,10 +125,25 @@ async function execute(opts, wrote) {
122
125
  // the next run is the first moment anything can. Its failure is not reported
123
126
  // — a leftover that resists removal is a broken disk, not a broken build, and
124
127
  // saying so here would fail a run for a file it has not needed yet.
125
- const androidRoot = searchDir(opts.workspacePath, appRootOf(settings.fastlane_dir));
126
- await sweepGradleProperties(androidRoot, opts.androidKeystore).catch(() => { });
128
+ //
129
+ // The app's own directory, which is the parent of the configured fastlane
130
+ // folder: the repository root for a plain app, `app/` for one in a monorepo.
131
+ // It is where the properties file goes and, below, where fastlane is started.
132
+ const appRoot = searchDir(opts.workspacePath, appRootOf(settings.fastlane_dir));
133
+ // Before any of that, and before fastlane: a clone without the folder is the
134
+ // one failure fastlane reports worst. It asks whether to set a project up,
135
+ // finds nobody to answer, and prints a Ruby backtrace and a list of GitHub
136
+ // issues over the sentence that mattered. This says it in one line instead.
137
+ try {
138
+ await assertFastlaneDir(opts.workspacePath, settings.fastlane_dir);
139
+ }
140
+ catch (cause) {
141
+ return fail(cause.message);
142
+ }
143
+ await sweepGradleProperties(appRoot, opts.androidKeystore).catch(() => { });
144
+ await sweepEnvFile(appRoot, settings.env_file).catch(() => { });
127
145
  try {
128
- wrote.properties = await writeGradleProperties(androidRoot, opts.androidKeystore);
146
+ wrote.properties = await writeGradleProperties(appRoot, opts.androidKeystore);
129
147
  }
130
148
  catch (cause) {
131
149
  // Failing the run rather than carrying on. Carrying on is what produces the
@@ -133,6 +151,16 @@ async function execute(opts, wrote) {
133
151
  // succeeds, signed with the debug key, rejected by the store days later.
134
152
  return fail(`Could not write the signing properties file: ${cause.message}`);
135
153
  }
154
+ try {
155
+ wrote.envFile = await writeEnvFile(appRoot, settings.env_file, opts.envFileValues ?? {});
156
+ }
157
+ catch (cause) {
158
+ // Failing for the same reason, and it is the same shape of failure. A build
159
+ // that carries on without the file it was configured to read does not stop:
160
+ // it produces an app pointed at nothing, which is discovered by a person
161
+ // opening it rather than by this run.
162
+ return fail(`Could not write the environment file: ${cause.message}`);
163
+ }
136
164
  // Cancelled during preparation: fastlane never gets to start.
137
165
  if (opts.signal?.aborted) {
138
166
  return finishAs("cancelled", "Cancelled");
@@ -162,7 +190,13 @@ async function execute(opts, wrote) {
162
190
  args: useBundle
163
191
  ? ["exec", "fastlane", ...laneArgs(opts)]
164
192
  : laneArgs(opts),
165
- cwd: opts.workspacePath,
193
+ // The app's directory, not the repository root. fastlane looks for
194
+ // `fastlane/` in the directory it was started from and nowhere else — no
195
+ // walking up, no looking down — so a project whose Fastfile sits in
196
+ // `app/fastlane` has to be started from `app`. Started from the root it
197
+ // finds nothing and offers to set fastlane up, which on a build server
198
+ // means a crash: there is nobody there to answer the question.
199
+ cwd: appRoot,
166
200
  env: {
167
201
  ...opts.env,
168
202
  ...(opts.secrets ?? {}),
@@ -21,8 +21,8 @@ export class Vault {
21
21
  static async open(home, store, credentials) {
22
22
  return new Vault(await loadOrCreateKey(home), store, credentials);
23
23
  }
24
- async set(projectSlug, key, value, masked) {
25
- this.store.set(projectSlug, key, encrypt(value, this.key), masked);
24
+ async set(projectSlug, key, value, masked, inEnvFile = false) {
25
+ this.store.set(projectSlug, key, encrypt(value, this.key), masked, inEnvFile);
26
26
  }
27
27
  remove(projectSlug, key) {
28
28
  return this.store.remove(projectSlug, key);
@@ -30,23 +30,41 @@ export class Vault {
30
30
  list(projectSlug) {
31
31
  return this.store.list(projectSlug);
32
32
  }
33
- listGlobal() {
34
- return this.store.listGlobal();
35
- }
36
33
  /**
37
- * What the vault holds under one project's own name secrets and signing
38
- * blocks — and nothing global.
34
+ * The same listing, with the value attached wherever there is nothing to hide.
35
+ *
36
+ * The rule is the user's own tick box and nothing else: `masked` means "keep
37
+ * this out of the build logs", and a value carrying it is never returned,
38
+ * whoever asks. A value without it is printed verbatim in every log the lane
39
+ * produces — hiding it on the one screen where you might want to check it
40
+ * protects nothing and costs the check.
41
+ *
42
+ * That is why this is not `list`. Everything else in the server asks a
43
+ * question about names — is this one stored, is that one missing — and it
44
+ * would be a poor trade to hand all of them plaintext for it. One method, one
45
+ * caller, and `list` stays the answer to "what is here".
39
46
  *
40
- * Its own method rather than a filter over `list`, because the question is a
41
- * different one: `list` is "what would a run of this project see", and this is
42
- * "what would be left behind if the project went away". A global secret is in
43
- * the first answer and must never be in the second.
47
+ * A masked value stays out even though `reveal` would now return it: a secret
48
+ * is readable on request, one key at a time, and that is not the same as
49
+ * putting every secret a project holds into a browser because someone opened
50
+ * a tab.
51
+ *
52
+ * A row that will not decrypt loses its value and keeps its name, the same
53
+ * leniency `resolve` takes: a rotated key should cost one line of one screen,
54
+ * not the screen.
44
55
  */
45
- ownedBy(projectSlug) {
46
- return {
47
- secrets: this.store.listOwn(projectSlug),
48
- credentials: this.credentials.listOwn(projectSlug),
49
- };
56
+ listWithValues(projectSlug) {
57
+ return this.store.list(projectSlug).map((summary) => {
58
+ if (summary.masked)
59
+ return summary;
60
+ try {
61
+ const value = this.reveal(projectSlug, summary.key);
62
+ return value === null ? summary : { ...summary, value };
63
+ }
64
+ catch {
65
+ return summary;
66
+ }
67
+ });
50
68
  }
51
69
  /**
52
70
  * Forgets everything stored under one project's name.
@@ -56,13 +74,15 @@ export class Vault {
56
74
  * covers all of it. What it forgets is Laneyard's own encrypted copy — the
57
75
  * `.p8` and the keystore that went in are still wherever the user keeps them.
58
76
  *
59
- * Scoped by slug, so a global secret or a global signing block survives it:
60
- * those are read by every project and are never one project's to remove.
77
+ * There is nothing it has to leave behind. Everything a project sees is a row
78
+ * under its own slug, so "what would a run see" and "what would be left if the
79
+ * project went away" are now the same question — which is most of the point of
80
+ * having one scope.
61
81
  */
62
82
  forget(projectSlug) {
63
83
  return {
64
- secrets: this.store.removeAllOwn(projectSlug),
65
- credentials: this.credentials.removeAllOwn(projectSlug),
84
+ secrets: this.store.removeAll(projectSlug),
85
+ credentials: this.credentials.removeAll(projectSlug),
66
86
  };
67
87
  }
68
88
  /**
@@ -75,7 +95,7 @@ export class Vault {
75
95
  return scrub(text, this.maskedValues(projectSlug));
76
96
  }
77
97
  /**
78
- * Every secret that applies to a project, ready to become environment variables.
98
+ * Every secret a project holds, ready to become environment variables.
79
99
  *
80
100
  * A row that will not decrypt is skipped rather than thrown: a key that was
81
101
  * rotated or a corrupted row should cost one variable, not the whole build.
@@ -94,35 +114,53 @@ export class Vault {
94
114
  return out;
95
115
  }
96
116
  /**
97
- * One value, in the clear and only when it was never declared secret.
117
+ * One value, in the clear, whether or not it is masked.
98
118
  *
99
- * The refusal is the point. This vault has been write-only since it was
100
- * written: the server never sent a value back, so the interface had nothing to
101
- * uncover and no browser ever held one. That is worth keeping for anything
102
- * anyone called a secret.
119
+ * This used to refuse a masked value outright: the vault was write-only, no
120
+ * route ever sent one back, and the secrets screen offered nothing to press.
121
+ * The property was real and it was worth less than it looked. `masked` means
122
+ * "keep this out of the build logs" — it is about what a run prints, and it
123
+ * was doing double duty as "and nobody may ever look at it again", which
124
+ * nobody asked for. A passphrase stored six months ago and now suspected of a
125
+ * typo could only be replaced, never checked, and replacing a credential you
126
+ * cannot read is how the wrong one gets stored twice.
103
127
  *
104
- * But not everything stored here is one. `APP_VERSION`, `SENTRY_ORG`, an
105
- * issuer id those are identifiers, and being unable to check what was stored
106
- * makes an import something you have to take on faith. The line between the
107
- * two already existed and is the user's own: `masked` is "keep this out of the
108
- * logs". A value that carries it is never returned, whoever asks.
128
+ * What the refusal actually bought was narrow, because everything around it
129
+ * still holds: the screen is admin-only, one key is read per request, and a
130
+ * masked value is still absent from `listWithValues`, so opening the page puts
131
+ * none of them in a browser. What is gone is only the guarantee that a value
132
+ * never leaves the server which was never what protected it. Redaction of
133
+ * the logs is untouched and is the property that matters.
109
134
  *
110
- * Returns null for an unknown key, and throws for a masked one — a caller
111
- * that forgot to check must fail loudly rather than leak.
135
+ * Returns null for an unknown key.
112
136
  */
113
137
  reveal(projectSlug, key) {
114
138
  const row = this.store.find(projectSlug, key);
115
139
  if (!row)
116
140
  return null;
117
- if (row.masked) {
118
- throw new Error(`${key} is kept out of the logs, so its value is never sent back.`);
119
- }
120
141
  return decrypt(row.valueEnc, this.key);
121
142
  }
122
143
  /** Flips whether a value is kept out of the logs, leaving the value alone. */
123
144
  setMasked(projectSlug, key, masked) {
124
145
  return this.store.setMasked(projectSlug, key, masked);
125
146
  }
147
+ /** Flips whether a value is written into the environment file. */
148
+ setInEnvFile(projectSlug, key, inEnvFile) {
149
+ return this.store.setInEnvFile(projectSlug, key, inEnvFile);
150
+ }
151
+ /**
152
+ * The variables that go in the environment file, in the clear, ready to be
153
+ * rendered.
154
+ *
155
+ * Decrypted here rather than in the writer, so plaintext stays inside this one
156
+ * file — the property the whole module exists to make checkable by reading.
157
+ * A row that will not decrypt is skipped, the same leniency `resolve` takes:
158
+ * the build then fails on its own terms rather than on a rotated key.
159
+ */
160
+ envFileValues(projectSlug) {
161
+ const wanted = new Set(this.store.envFileKeys(projectSlug));
162
+ return Object.fromEntries(Object.entries(this.resolve(projectSlug)).filter(([key]) => wanted.has(key)));
163
+ }
126
164
  /**
127
165
  * Stores a signing block: the file, and the fields that make it usable.
128
166
  *
@@ -140,7 +178,7 @@ export class Vault {
140
178
  });
141
179
  }
142
180
  /**
143
- * One block that applies to a project, in the clear, or undefined if there is none.
181
+ * One of a project's blocks, in the clear, or undefined if there is none.
144
182
  *
145
183
  * Unlike `resolve`, an unreadable row throws. The leniency there is earned: a
146
184
  * missing variable makes fastlane stop and say which one. A block that quietly
@@ -168,9 +206,6 @@ export class Vault {
168
206
  listCredentials(projectSlug) {
169
207
  return this.credentials.list(projectSlug);
170
208
  }
171
- listGlobalCredentials() {
172
- return this.credentials.listGlobal();
173
- }
174
209
  removeCredential(projectSlug, kind) {
175
210
  return this.credentials.remove(projectSlug, kind);
176
211
  }
@@ -98,6 +98,10 @@ export async function buildApp(deps) {
98
98
  },
99
99
  env: process.env,
100
100
  secrets: ctx.vault.resolve(slug),
101
+ // A subset of the line above, not a second source. The tick decides which
102
+ // variables are also written into the file the build reads from disk;
103
+ // every one of them still reaches the run through the environment.
104
+ envFileValues: ctx.vault.envFileValues(slug),
101
105
  credentialEnv: credentials.env,
102
106
  androidKeystore: credentials.keystore,
103
107
  cleanup: credentials.cleanup,
@@ -14,7 +14,6 @@ import { CREDENTIAL_KINDS, defaultVarNames, fieldsOf } from "../../credentials/k
14
14
  /** POSIX environment variable names. Anything else would never reach fastlane. */
15
15
  const VALID_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
16
  export async function registerCredentialRoutes(app, ctx) {
17
- app.get("/api/credentials", async () => ctx.vault.listGlobalCredentials());
18
17
  app.get("/api/projects/:slug/credentials", async (req, reply) => {
19
18
  const { slug } = req.params;
20
19
  if (!ctx.config.project(slug))
@@ -82,24 +81,12 @@ export async function registerCredentialRoutes(app, ctx) {
82
81
  await ctx.vault.setCredential(slug, spec.kind, { fileName, fileBytes, fields: kept, varNames: names });
83
82
  return reply.code(204).send();
84
83
  };
85
- app.put("/api/credentials/:kind", async (req, reply) => put(null, req.params.kind, req.body, reply));
86
84
  app.put("/api/projects/:slug/credentials/:kind", async (req, reply) => {
87
85
  const { slug, kind } = req.params;
88
86
  if (!ctx.config.project(slug))
89
87
  return reply.code(404).send({ error: "Unknown project" });
90
88
  return put(slug, kind, req.body, reply);
91
89
  });
92
- app.delete("/api/credentials/:kind", async (req, reply) => {
93
- const { kind } = req.params;
94
- const removed = ctx.vault.removeCredential(null, kind);
95
- return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
96
- });
97
- /**
98
- * Removes this project's own block, and only that one. A global block that
99
- * was shadowed comes back into view, which is the deletion someone asked for:
100
- * they are undoing an override, not deleting everyone's key from inside one
101
- * project.
102
- */
103
90
  app.delete("/api/projects/:slug/credentials/:kind", async (req, reply) => {
104
91
  const { slug, kind } = req.params;
105
92
  const removed = ctx.vault.removeCredential(slug, kind);
@@ -46,7 +46,7 @@ export async function registerFastfileRoutes(app, ctx) {
46
46
  // uncommitted or gitignored, or a stray local copy. `store.read` would
47
47
  // surface that as a bare ENOENT; this turns it into the same sentence the
48
48
  // sidecar gives, naming the directory and how to fix it.
49
- await assertFastlaneDir(join(r.workspacePath, r.fastlaneDir), r.fastlaneDir);
49
+ await assertFastlaneDir(r.workspacePath, r.fastlaneDir);
50
50
  const [content, dirty, diff] = await Promise.all([
51
51
  store.read(r.workspacePath, r.fastlaneDir),
52
52
  r.workspace.isDirty(),
@@ -1,4 +1,5 @@
1
1
  import { accountMayReach } from "../permissions.js";
2
+ import { Workspace } from "../../git/workspace.js";
2
3
  import { removeProjectData } from "../../data/remove-project.js";
3
4
  export async function registerProjectRoutes(app, ctx) {
4
5
  // The listing every account is shown, filtered to what it may reach. This is
@@ -41,9 +42,6 @@ export async function registerProjectRoutes(app, ctx) {
41
42
  * `.p8` or a keystore; the file that went in is still in the password
42
43
  * manager or the safe it came from. The answer says so, the way
43
44
  * `uninstall` does, so nobody is left thinking their keystore is gone.
44
- * - global secrets and global signing blocks. They are read by every project
45
- * on the machine, not this one's to take — `vault.forget` touches only
46
- * slug-scoped rows, and the answer counts the global ones it left alone.
47
45
  *
48
46
  * The confirmation is the project's own slug, sent back as `?confirm=`.
49
47
  * Without a match nothing is removed: a bare DELETE is a refusal, not a
@@ -72,12 +70,6 @@ export async function registerProjectRoutes(app, ctx) {
72
70
  error: `"${slug}" has a run in flight. Wait for it to finish, or cancel it, then remove the project.`,
73
71
  });
74
72
  }
75
- // The global counts are read now — the last moment anyone is looking — so
76
- // the answer can say what it left alone. They are shared by every project
77
- // and survive one of them going away, which is why the removal never takes
78
- // them and the reply names them apart.
79
- const globalSecrets = ctx.vault.listGlobal().length;
80
- const globalSigningBlocks = ctx.vault.listGlobalCredentials().length;
81
73
  // The removal itself lives in one place, shared with `laneyard remove`: the
82
74
  // route confirms and shapes the reply, the core does the deleting.
83
75
  const result = await removeProjectData({
@@ -108,13 +100,6 @@ export async function registerProjectRoutes(app, ctx) {
108
100
  secrets: result.secrets,
109
101
  signingBlocks: result.signingBlocks,
110
102
  },
111
- // Named, not removed. The git remote and the credential originals are the
112
- // user's and are never touched here; the global rows are shared by every
113
- // project and survive one of them going away.
114
- untouched: {
115
- globalSecrets,
116
- globalSigningBlocks,
117
- },
118
103
  });
119
104
  });
120
105
  app.get("/api/projects/:slug/lanes", async (req, reply) => {
@@ -135,6 +120,64 @@ export async function registerProjectRoutes(app, ctx) {
135
120
  return reply.code(503).send({ error: cause.message });
136
121
  }
137
122
  });
123
+ /**
124
+ * Brings the clone up to the remote, without building anything.
125
+ *
126
+ * Everything that reads the repository outside a run — the lanes, the
127
+ * checklist, the names a lane is missing — goes through `ensureCloned`, which
128
+ * does nothing at all once the directory exists. Only a run fetched. So a
129
+ * project whose first run failed early answered from that first commit
130
+ * indefinitely, and the screens said so with the confidence of something just
131
+ * looked up: a variable a Fastfile had stopped reading was still asked for,
132
+ * days after the commit that stopped reading it.
133
+ *
134
+ * Deliberately not a fetch hidden inside those reads. Going out to a git
135
+ * remote is seconds and a network, and putting it behind opening a tab would
136
+ * make every one of them slow and occasionally fail for a reason that has
137
+ * nothing to do with what was asked. It is a button, and this is what it
138
+ * presses.
139
+ *
140
+ * `prepare` and not a bare fetch: the point is to show what the next run would
141
+ * see, and `prepare` is exactly what the next run calls. It clones when there
142
+ * is nothing yet, which makes the button a way in for a project between
143
+ * `setup` and its first build rather than a refusal.
144
+ *
145
+ * Not admin-only. A builder starts runs, and a run fetches — refusing them the
146
+ * same fetch on its own would protect nothing.
147
+ */
148
+ app.post("/api/projects/:slug/fetch", async (req, reply) => {
149
+ const { slug } = req.params;
150
+ const entry = ctx.config.project(slug);
151
+ if (!entry)
152
+ return reply.code(404).send({ error: "Unknown project" });
153
+ // A run that has begun is reading the very files this would move under it —
154
+ // the same reason a Fastfile write is refused while one is in flight.
155
+ if (ctx.runs.hasActiveRun(slug)) {
156
+ return reply.code(409).send({
157
+ error: "A run of this project is in flight. Its workspace is in use until it finishes.",
158
+ });
159
+ }
160
+ const branch = entry.default_branch;
161
+ const workspace = new Workspace(ctx.workspacePath(slug), entry.git_url, entry.git_auth);
162
+ // Refused rather than lost. `prepare` moves the branch onto origin's, and
163
+ // the Fastfile tab commits without pushing by design, so this is a state the
164
+ // product hands people itself.
165
+ const unpushed = await workspace.unpushedCount(branch);
166
+ if (unpushed > 0) {
167
+ return reply.code(409).send({
168
+ error: `The workspace holds ${unpushed} commit${unpushed === 1 ? "" : "s"} that ${unpushed === 1 ? "has" : "have"} not been pushed. ` + "Push from the fastfile tab, or they would be left behind.",
169
+ });
170
+ }
171
+ try {
172
+ const commitSha = await workspace.prepare(branch);
173
+ return { branch, commitSha };
174
+ }
175
+ catch (cause) {
176
+ // A dirty workspace, an unreachable remote, a branch that is not there:
177
+ // git's own sentence is the only thing that explains any of them.
178
+ return reply.code(409).send({ error: cause.message });
179
+ }
180
+ });
138
181
  app.get("/api/projects/:slug/runs", async (req, reply) => {
139
182
  const { slug } = req.params;
140
183
  if (!ctx.config.project(slug))
@@ -228,9 +228,8 @@ export async function registerReadinessRoutes(app, ctx) {
228
228
  },
229
229
  // Names only: the vault never hands a value to anything but a run.
230
230
  secretKeys: ctx.vault.list(slug).map((s) => s.key),
231
- // Which blocks apply, resolved the way a run resolves them a project's
232
- // own shadowing a global one so the checklist and the run cannot
233
- // disagree about whether a credential exists.
231
+ // Which blocks this project holds, read the way a run reads them, so the
232
+ // checklist and the run cannot disagree about whether a credential exists.
234
233
  blocks: ctx.vault.listCredentials(slug).map((c) => c.kind),
235
234
  // And the names those blocks will export, which the environment check
236
235
  // counts as supplied — Laneyard writes the file and sets the variable