laneyard 0.1.0 → 0.2.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 (60) hide show
  1. package/README.md +120 -21
  2. package/dist/src/cli/secret.js +133 -0
  3. package/dist/src/config/schema.js +21 -3
  4. package/dist/src/db/cache.js +8 -8
  5. package/dist/src/db/open.js +15 -0
  6. package/dist/src/db/runs.js +51 -6
  7. package/dist/src/db/schema.sql +20 -2
  8. package/dist/src/db/secrets.js +64 -0
  9. package/dist/src/fastfile/store.js +53 -0
  10. package/dist/src/git/workspace.js +95 -5
  11. package/dist/src/heuristics/blocking-actions.js +45 -0
  12. package/dist/src/heuristics/readiness.js +193 -0
  13. package/dist/src/logs/redact.js +86 -0
  14. package/dist/src/main.js +32 -3
  15. package/dist/src/runner/orchestrate.js +60 -10
  16. package/dist/src/runner/pty.js +27 -15
  17. package/dist/src/runner/queue.js +114 -0
  18. package/dist/src/secrets/cipher.js +28 -0
  19. package/dist/src/secrets/key.js +40 -0
  20. package/dist/src/secrets/vault.js +68 -0
  21. package/dist/src/server/app.js +70 -0
  22. package/dist/src/server/routes/fastfile.js +131 -0
  23. package/dist/src/server/routes/projects.js +8 -1
  24. package/dist/src/server/routes/readiness.js +89 -0
  25. package/dist/src/server/routes/runs.js +27 -45
  26. package/dist/src/server/routes/secrets.js +51 -0
  27. package/dist/src/sidecar/lanes.js +2 -2
  28. package/dist/src/sidecar/uses.js +40 -0
  29. package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
  30. package/dist/web/assets/index-D9_EL8LA.js +62 -0
  31. package/dist/web/assets/index-ZUqTX6d1.css +1 -0
  32. package/dist/web/index.html +2 -2
  33. package/package.json +13 -5
  34. package/ruby/introspect.rb +80 -0
  35. package/dist/tests/cli/add.test.js +0 -64
  36. package/dist/tests/cli/detect.test.js +0 -63
  37. package/dist/tests/config/load.test.js +0 -68
  38. package/dist/tests/config/resolve.test.js +0 -44
  39. package/dist/tests/config/store.test.js +0 -58
  40. package/dist/tests/db/runs.test.js +0 -54
  41. package/dist/tests/e2e/full-thread.test.js +0 -65
  42. package/dist/tests/fixtures/repos.js +0 -30
  43. package/dist/tests/git/workspace.test.js +0 -66
  44. package/dist/tests/heuristics/error-summary.test.js +0 -31
  45. package/dist/tests/logs/store.test.js +0 -38
  46. package/dist/tests/main.test.js +0 -27
  47. package/dist/tests/ruby/introspect.test.js +0 -59
  48. package/dist/tests/runner/artifacts.test.js +0 -49
  49. package/dist/tests/runner/live-steps.test.js +0 -35
  50. package/dist/tests/runner/orchestrate.test.js +0 -124
  51. package/dist/tests/runner/pty.test.js +0 -53
  52. package/dist/tests/runner/report.test.js +0 -91
  53. package/dist/tests/server/api.test.js +0 -132
  54. package/dist/tests/server/auth.test.js +0 -53
  55. package/dist/tests/server/ws.test.js +0 -43
  56. package/dist/tests/sidecar/lanes.test.js +0 -52
  57. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  58. package/dist/tests/smoke.test.js +0 -7
  59. package/dist/web/assets/index-583x0xuo.css +0 -1
  60. package/dist/web/assets/index-BEpABKPS.js +0 -61
@@ -29,16 +29,33 @@ export class Workspace {
29
29
  *
30
30
  * An HTTPS URL can carry a password — `https://user:token@github.com/…`
31
31
  * is perfectly legal in `config.yml`. But git errors end up in the run's
32
- * log file. General secret redaction comes at the next milestone; this
33
- * particular leak comes from our own formatting, and is fixed here.
32
+ * log file. The vault's redaction does not help here: the repository URL is
33
+ * configuration, not a stored secret, so this leak — which comes from our own
34
+ * formatting — has to be closed on the spot.
34
35
  */
35
36
  redact(text) {
36
37
  return text.split(this.gitUrl).join("<repository>");
37
38
  }
38
- async git(args, cwd = this.path) {
39
+ async git(args, cwd = this.path, timeout) {
40
+ return (await this.gitRaw(args, cwd, timeout)).trim();
41
+ }
42
+ /**
43
+ * Same as `git`, but without trimming the output.
44
+ *
45
+ * `status --porcelain` output is column-sensitive: its first two characters
46
+ * are a status code that can themselves be a literal space. Trimming the
47
+ * whole blob — fine for a commit hash or a single config value — would eat
48
+ * that leading space and misalign every line parsed after it.
49
+ */
50
+ async gitRaw(args, cwd = this.path, timeout) {
39
51
  try {
40
- const { stdout } = await exec("git", args, { cwd, env: this.env(), maxBuffer: 32 * 1024 * 1024 });
41
- return stdout.trim();
52
+ const { stdout } = await exec("git", args, {
53
+ cwd,
54
+ env: this.env(),
55
+ maxBuffer: 32 * 1024 * 1024,
56
+ ...(timeout === undefined ? {} : { timeout }),
57
+ });
58
+ return stdout;
42
59
  }
43
60
  catch (cause) {
44
61
  const err = cause;
@@ -46,6 +63,18 @@ export class Workspace {
46
63
  throw new Error(`git ${this.redact(args.join(" "))} failed: ${this.redact(detail)}`);
47
64
  }
48
65
  }
66
+ /**
67
+ * Asks the remote for its branches, and nothing else.
68
+ *
69
+ * Reads no working copy and writes nothing, so it answers the one question
70
+ * the readiness checklist asks: would a run reach this repository on its own?
71
+ * The timeout matters as much as the exit code — with `GIT_TERMINAL_PROMPT=0`
72
+ * git gives up on a credentials prompt, but a host that never answers would
73
+ * otherwise hang the checklist exactly the way it hangs a run at 2am.
74
+ */
75
+ async probeRemote(timeoutMs = 10_000) {
76
+ await this.git(["ls-remote", "--heads", this.gitUrl], process.cwd(), timeoutMs);
77
+ }
49
78
  async exists() {
50
79
  try {
51
80
  await access(join(this.path, ".git"));
@@ -71,6 +100,67 @@ export class Workspace {
71
100
  async headSha() {
72
101
  return this.git(["rev-parse", "HEAD"]);
73
102
  }
103
+ /**
104
+ * Paths with uncommitted changes, tracked only — the same rule as
105
+ * `isDirty`, for the same reason: a build scatters untracked files around,
106
+ * and listing them here would bury the change someone actually made.
107
+ */
108
+ async status() {
109
+ const raw = await this.gitRaw(["status", "--porcelain", "--untracked-files=no"]);
110
+ // Porcelain v1: two status letters, a space, then the path. The trailing
111
+ // split produces one empty element for the final newline; drop it rather
112
+ // than turn it into a bogus empty path.
113
+ return raw
114
+ .split("\n")
115
+ .filter((line) => line.length > 0)
116
+ .map((line) => line.slice(3));
117
+ }
118
+ /** The unified diff of uncommitted changes, as text. Every path if none is given. */
119
+ async diff(path) {
120
+ return this.git(path === undefined ? ["diff"] : ["diff", "--", path]);
121
+ }
122
+ /**
123
+ * Stages exactly the given paths and commits them — never `git add -A`. A
124
+ * build leaves files scattered in the workspace, and committing them
125
+ * because they happened to be there is how something ends up in someone's
126
+ * release.
127
+ *
128
+ * Commits under the repository's own git identity if it has one; otherwise
129
+ * as `Laneyard <laneyard@localhost>`, because a commit from a name nobody
130
+ * recognises is worse than one that admits what made it. The identity
131
+ * actually used is returned so the interface can say so.
132
+ */
133
+ async commit(message, paths) {
134
+ if (paths.length === 0)
135
+ throw new Error("commit: no paths given");
136
+ await this.git(["add", "--", ...paths]);
137
+ const identity = await this.gitIdentity();
138
+ const author = identity ?? "Laneyard <laneyard@localhost>";
139
+ const asLaneyard = identity
140
+ ? []
141
+ : ["-c", "user.name=Laneyard", "-c", "user.email=laneyard@localhost"];
142
+ await this.git([...asLaneyard, "commit", "-m", message]);
143
+ return { author };
144
+ }
145
+ /**
146
+ * `name <email>` from the repository's own git configuration — local or
147
+ * global, however git itself resolves it for this workspace — or null if
148
+ * none is set at all.
149
+ */
150
+ async gitIdentity() {
151
+ try {
152
+ const name = await this.git(["config", "user.name"]);
153
+ const email = await this.git(["config", "user.email"]);
154
+ return name && email ? `${name} <${email}>` : null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ /** Pushes the branch, surfacing git's own message on failure rather than a generic one. */
161
+ async push(branch) {
162
+ await this.git(["push", "origin", branch]);
163
+ }
74
164
  /**
75
165
  * Guarantees the clone is present, without touching the current branch.
76
166
  *
@@ -0,0 +1,45 @@
1
+ export const BLOCKING_RULES = [
2
+ {
3
+ action: "prompt",
4
+ because: "asks a question and waits for an answer",
5
+ fix: "Remove it from the lane, or give it a default that applies when `CI` is set.",
6
+ },
7
+ {
8
+ action: "match",
9
+ when: { arg: "readonly", equals: false },
10
+ because: "may create certificates, which needs an Apple account interactively",
11
+ fix: "Pass `readonly: true` so it only fetches what already exists.",
12
+ },
13
+ {
14
+ action: "sync_code_signing",
15
+ when: { arg: "readonly", equals: false },
16
+ because: "may create certificates, which needs an Apple account interactively",
17
+ fix: "Pass `readonly: true` so it only fetches what already exists.",
18
+ },
19
+ {
20
+ action: "sigh",
21
+ because: "renews provisioning profiles, which needs an Apple account interactively",
22
+ fix: "Use `match` in readonly mode instead, with profiles stored in a repository.",
23
+ },
24
+ ];
25
+ /**
26
+ * Applies the table to the actions a lane calls.
27
+ *
28
+ * A rule whose `when` names an argument the lane didn't pass literally is not
29
+ * reported: the sidecar already dropped that argument rather than guess at
30
+ * it (see `ruby/introspect.rb`), so `arg in action.args` is false and the
31
+ * rule stays silent instead of pretending to know the answer.
32
+ */
33
+ export function findBlockingActions(actions) {
34
+ const findings = [];
35
+ for (const action of actions) {
36
+ for (const rule of BLOCKING_RULES) {
37
+ if (rule.action !== action.name)
38
+ continue;
39
+ if (rule.when && action.args[rule.when.arg] !== rule.when.equals)
40
+ continue;
41
+ findings.push({ action: rule.action, because: rule.because, fix: rule.fix });
42
+ }
43
+ }
44
+ return findings;
45
+ }
@@ -0,0 +1,193 @@
1
+ import { findBlockingActions } from "./blocking-actions.js";
2
+ const ok = (detail) => ({ state: "ok", detail });
3
+ const warn = (detail, fix, fixIn) => ({
4
+ state: "warn",
5
+ detail,
6
+ ...(fix === undefined ? {} : { fix }),
7
+ ...(fixIn === undefined ? {} : { fixIn }),
8
+ });
9
+ const undetermined = (detail, fix) => ({
10
+ state: "unknown",
11
+ detail,
12
+ ...(fix === undefined ? {} : { fix }),
13
+ });
14
+ /** A rejection is not always an `Error`; a checklist saying "undefined" is worse than useless. */
15
+ const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
16
+ /** Identity and wording live here so a check and its fallback cannot drift apart. */
17
+ const META = {
18
+ repository: { id: "repository", title: "repository reachable without a password" },
19
+ dependencies: { id: "dependencies", title: "dependencies installable" },
20
+ appStoreConnect: { id: "app-store-connect", title: "App Store Connect authentication" },
21
+ match: { id: "match", title: "match usable without intervention" },
22
+ blockingActions: { id: "blocking-actions", title: "no action known to stop and ask" },
23
+ };
24
+ /**
25
+ * Does the remote answer without asking anyone for credentials?
26
+ *
27
+ * The probe is injected — `Workspace` already knows how to run git with
28
+ * `GIT_TERMINAL_PROMPT=0` and how to keep the repository URL, which may carry a
29
+ * token, out of its own error messages. Redacting is the probe's job, not this
30
+ * check's: whatever it rejects with is repeated verbatim.
31
+ */
32
+ export async function checkRepository(probe) {
33
+ try {
34
+ await probe();
35
+ return { ...META.repository, ...ok("the remote answers without asking for credentials.") };
36
+ }
37
+ catch (cause) {
38
+ return {
39
+ ...META.repository,
40
+ ...warn(reasonOf(cause), "Give the project a key it can use without a passphrase — " +
41
+ "`git_auth: {kind: ssh_key, ref: /path/to/key}` in config.yml — or a URL that needs no password. " +
42
+ "A run that meets a credentials prompt does not fail: it waits."),
43
+ };
44
+ }
45
+ }
46
+ export async function checkDependencies(input) {
47
+ const { workspace } = input;
48
+ if (!workspace.ok) {
49
+ return { ...META.dependencies, ...undetermined(`could not read the workspace: ${workspace.reason}`) };
50
+ }
51
+ if (workspace.value.hasGemfile) {
52
+ try {
53
+ await input.bundleCheck();
54
+ return { ...META.dependencies, ...ok("the Gemfile's bundle is installed.") };
55
+ }
56
+ catch (cause) {
57
+ return {
58
+ ...META.dependencies,
59
+ ...warn(reasonOf(cause), "Run `bundle install` in the project's workspace."),
60
+ };
61
+ }
62
+ }
63
+ let fastlane;
64
+ try {
65
+ fastlane = await input.findFastlane();
66
+ }
67
+ catch (cause) {
68
+ return { ...META.dependencies, ...undetermined(`could not look for fastlane: ${reasonOf(cause)}`) };
69
+ }
70
+ if (fastlane !== null) {
71
+ // Not a failure, but a fact worth stating: a system fastlane can be
72
+ // upgraded underneath a project that was working yesterday.
73
+ return {
74
+ ...META.dependencies,
75
+ ...ok(`no Gemfile — runs use ${fastlane}, whose version nothing pins.`),
76
+ };
77
+ }
78
+ return {
79
+ ...META.dependencies,
80
+ ...warn("no Gemfile, and no fastlane on the PATH: a run has nothing to execute.", 'Add a Gemfile with `gem "fastlane"` and run `bundle install`, or install fastlane system-wide.'),
81
+ };
82
+ }
83
+ /** Any suffix: the key is split across several variables, and their names vary by lane. */
84
+ const API_KEY = /^APP_STORE_CONNECT_API_KEY/;
85
+ const STORE_API_KEY = "Store an App Store Connect API key — `APP_STORE_CONNECT_API_KEY_ID`, " +
86
+ "`APP_STORE_CONNECT_API_KEY_ISSUER_ID` and the `.p8` contents — from the secrets tab. " +
87
+ "An API key does not expire on its own.";
88
+ export function checkAppStoreConnect(secretKeys) {
89
+ if (secretKeys.some((key) => API_KEY.test(key))) {
90
+ return { ...META.appStoreConnect, ...ok("an App Store Connect API key is in the vault.") };
91
+ }
92
+ if (secretKeys.includes("FASTLANE_SESSION")) {
93
+ return {
94
+ ...META.appStoreConnect,
95
+ ...warn("only a FASTLANE_SESSION: Apple sessions expire, and the run that finds it expired " +
96
+ "stops and asks for a verification code.", STORE_API_KEY, "secrets"),
97
+ };
98
+ }
99
+ return {
100
+ ...META.appStoreConnect,
101
+ ...warn("no App Store Connect credential in the vault: a lane that uploads will ask for an Apple ID.", STORE_API_KEY, "secrets"),
102
+ };
103
+ }
104
+ /** The two names for the same action. `match` is the older one, still the common one. */
105
+ const MATCH_ACTIONS = new Set(["match", "sync_code_signing"]);
106
+ /** "beta and release" reads better than a list of one, and than a trailing comma. */
107
+ function nameList(names) {
108
+ const unique = [...new Set(names)];
109
+ if (unique.length <= 1)
110
+ return unique[0] ?? "";
111
+ return `${unique.slice(0, -1).join(", ")} and ${unique[unique.length - 1]}`;
112
+ }
113
+ export function checkMatch(uses, secretKeys) {
114
+ if (!uses.ok) {
115
+ return { ...META.match, ...undetermined(`could not read the lanes: ${uses.reason}`) };
116
+ }
117
+ const calls = uses.value.flatMap((lane) => lane.actions.filter((a) => MATCH_ACTIONS.has(a.name)).map((action) => ({ lane: lane.lane, action })));
118
+ if (calls.length === 0) {
119
+ return { ...META.match, ...ok("no lane uses match.") };
120
+ }
121
+ if (!secretKeys.includes("MATCH_PASSWORD")) {
122
+ return {
123
+ ...META.match,
124
+ ...warn(`${nameList(calls.map((c) => c.lane))} calls match, but MATCH_PASSWORD is not in the vault: ` +
125
+ "match asks for the passphrase and waits for it.", "Store `MATCH_PASSWORD` from the secrets tab.", "secrets"),
126
+ };
127
+ }
128
+ const writable = calls.filter((c) => c.action.args["readonly"] === false);
129
+ if (writable.length > 0) {
130
+ return {
131
+ ...META.match,
132
+ ...warn(`${nameList(writable.map((c) => c.lane))} calls match with \`readonly: false\`: it may try to ` +
133
+ "create certificates, which needs an Apple account interactively.", "Pass `readonly: true` so it only fetches what already exists."),
134
+ };
135
+ }
136
+ // `match(readonly: ENV["RO"])` arrives with no `readonly` at all: the sidecar
137
+ // reports literal arguments and refuses to guess at the rest. Calling that
138
+ // green would be the checklist claiming to know something it does not.
139
+ const undecided = calls.filter((c) => !("readonly" in c.action.args));
140
+ if (undecided.length > 0) {
141
+ return {
142
+ ...META.match,
143
+ ...undetermined(`MATCH_PASSWORD is stored, but \`readonly\` is not a literal in ` +
144
+ `${nameList(undecided.map((c) => c.lane))}: Laneyard reads literal arguments only, and will not guess.`, "Pass `readonly: true` in the call itself if that is what you mean."),
145
+ };
146
+ }
147
+ return { ...META.match, ...ok("MATCH_PASSWORD is stored, and every match call is readonly.") };
148
+ }
149
+ export function checkBlockingActions(uses) {
150
+ if (!uses.ok) {
151
+ return { ...META.blockingActions, ...undetermined(`could not read the lanes: ${uses.reason}`) };
152
+ }
153
+ const findings = uses.value.flatMap((lane) => findBlockingActions(lane.actions).map((finding) => ({ lane: lane.lane, finding })));
154
+ if (findings.length === 0) {
155
+ return { ...META.blockingActions, ...ok("no lane calls an action known to stop and ask.") };
156
+ }
157
+ return {
158
+ ...META.blockingActions,
159
+ ...warn(findings.map((f) => `${f.lane} calls ${f.finding.action}: it ${f.finding.because}`).join("; ") + ".",
160
+ // The same fix stated twice reads as two different fixes.
161
+ [...new Set(findings.map((f) => f.finding.fix))].join(" ")),
162
+ };
163
+ }
164
+ /**
165
+ * The checklist itself: a table, so that adding a check is adding a row.
166
+ *
167
+ * Exported because the order it declares is the order the interface shows, and
168
+ * a test that checks that should read the same list.
169
+ */
170
+ export const CHECKS = [
171
+ { ...META.repository, run: (i) => checkRepository(i.probeRepository) },
172
+ { ...META.dependencies, run: (i) => checkDependencies(i.dependencies) },
173
+ { ...META.appStoreConnect, run: (i) => checkAppStoreConnect(i.secretKeys) },
174
+ { ...META.match, run: (i) => checkMatch(i.uses, i.secretKeys) },
175
+ { ...META.blockingActions, run: (i) => checkBlockingActions(i.uses) },
176
+ ];
177
+ /**
178
+ * Runs every check, and guarantees the list comes back whole.
179
+ *
180
+ * Each check already commits to not throwing. This wrapper exists because that
181
+ * promise is worth exactly as much as the next person to edit a check: one
182
+ * probe misbehaving must cost one line of the checklist, not the checklist.
183
+ */
184
+ export async function runChecks(input) {
185
+ return Promise.all(CHECKS.map(async ({ id, title, run }) => {
186
+ try {
187
+ return await run(input);
188
+ }
189
+ catch (cause) {
190
+ return { id, title, state: "unknown", detail: `the check itself failed: ${reasonOf(cause)}` };
191
+ }
192
+ }));
193
+ }
@@ -0,0 +1,86 @@
1
+ /** What a redacted value is replaced with. Fixed width, so it leaks no length. */
2
+ const MARKER = "••••••";
3
+ /**
4
+ * Values shorter than this are left alone.
5
+ *
6
+ * A two-character secret would match constantly and turn the log into confetti
7
+ * while hiding nothing an attacker could not guess. Refusing is more honest than
8
+ * pretending to protect it.
9
+ */
10
+ export const MIN_LENGTH = 4;
11
+ /**
12
+ * Removes secret values from a stream of text.
13
+ *
14
+ * The difficulty is not the replacement, it is the boundaries: a pseudo-terminal
15
+ * cuts its output wherever it likes, so a secret can arrive as `hun` then `ter2`.
16
+ * Replacing chunk by chunk would let it through in two pieces — and the file on
17
+ * disk would contain it in full.
18
+ *
19
+ * So the redactor holds back the last few characters, exactly as many as could
20
+ * still turn out to be the beginning of a secret, and releases them only once
21
+ * they cannot. `flush()` empties that buffer at the end of the run.
22
+ */
23
+ /**
24
+ * One-shot removal, for text that is not part of the live stream.
25
+ *
26
+ * A run's error summary is stored in the database and rendered in the interface
27
+ * without ever passing through the stream, so it needs its own pass. Using the
28
+ * live `Redactor` instance here would corrupt its buffer mid-run.
29
+ */
30
+ export function scrub(text, values) {
31
+ let out = text;
32
+ for (const value of [...new Set(values.filter((v) => v.length >= MIN_LENGTH))].sort((a, b) => b.length - a.length)) {
33
+ out = out.split(value).join(MARKER);
34
+ }
35
+ return out;
36
+ }
37
+ export class Redactor {
38
+ values;
39
+ longest;
40
+ held = "";
41
+ constructor(values) {
42
+ // Longest first: replacing "token" before "token-suffix" would leave the
43
+ // suffix behind in the log.
44
+ this.values = [...new Set(values.filter((v) => v.length >= MIN_LENGTH))].sort((a, b) => b.length - a.length);
45
+ this.longest = this.values.reduce((max, v) => Math.max(max, v.length), 0);
46
+ }
47
+ replaceAll(text) {
48
+ let out = text;
49
+ for (const value of this.values)
50
+ out = out.split(value).join(MARKER);
51
+ return out;
52
+ }
53
+ /**
54
+ * How many trailing characters could still turn into a secret.
55
+ *
56
+ * Holding a fixed window of `longest - 1` would be correct but wasteful: with
57
+ * a 200-character API key in the vault, every chunk would stall its last 199
58
+ * characters until the next one arrived, and the live terminal would lag
59
+ * visibly. So we keep only what is genuinely ambiguous — the longest suffix
60
+ * that is a proper prefix of some secret — which is usually nothing at all.
61
+ */
62
+ ambiguousTail(text) {
63
+ const max = Math.min(this.longest - 1, text.length);
64
+ for (let length = max; length > 0; length -= 1) {
65
+ const suffix = text.slice(text.length - length);
66
+ if (this.values.some((value) => value.startsWith(suffix)))
67
+ return length;
68
+ }
69
+ return 0;
70
+ }
71
+ /** Takes a chunk, returns the part that is safe to write out. */
72
+ push(chunk) {
73
+ if (this.values.length === 0)
74
+ return chunk;
75
+ const combined = this.replaceAll(this.held + chunk);
76
+ const keep = this.ambiguousTail(combined);
77
+ this.held = combined.slice(combined.length - keep);
78
+ return keep === 0 ? combined : combined.slice(0, combined.length - keep);
79
+ }
80
+ /** Releases the tail. Call once, when the stream is over. */
81
+ flush() {
82
+ const rest = this.replaceAll(this.held);
83
+ this.held = "";
84
+ return rest;
85
+ }
86
+ }
package/dist/src/main.js CHANGED
@@ -5,14 +5,18 @@ import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runAddCommand } from "./cli/add.js";
8
+ import { runSecretCommand } from "./cli/secret.js";
8
9
  import { ConfigStore } from "./config/store.js";
9
10
  import { CacheStore } from "./db/cache.js";
10
11
  import { openDatabase } from "./db/open.js";
11
12
  import { RunStore } from "./db/runs.js";
13
+ import { SecretStore } from "./db/secrets.js";
12
14
  import { buildApp } from "./server/app.js";
15
+ import { Vault } from "./secrets/vault.js";
13
16
  import { makeInvoke } from "./sidecar/bridge.js";
14
17
  import { LaneReader } from "./sidecar/lanes.js";
15
- export const version = "0.1.0";
18
+ import { UsesReader } from "./sidecar/uses.js";
19
+ export const version = "0.2.0";
16
20
  /** Assembles the server from a data folder. */
17
21
  export async function createServerFromConfig(root) {
18
22
  const config = new ConfigStore(join(root, "config.yml"));
@@ -20,19 +24,31 @@ export async function createServerFromConfig(root) {
20
24
  if (!loaded.ok)
21
25
  throw new Error(`Unreadable configuration: ${loaded.error}`);
22
26
  const db = openDatabase(join(root, "laneyard.db"));
23
- // No run can survive the shutdown of the process that carried it.
24
- new RunStore(db).interruptActive();
27
+ // No run that had begun can survive the shutdown of the process that carried
28
+ // it. Queued runs never began, so they stay queued for the next start.
29
+ new RunStore(db).interruptInFlight();
25
30
  const cache = new CacheStore(db);
31
+ const vault = await Vault.open(root, new SecretStore(db));
26
32
  const app = await buildApp({
27
33
  config,
28
34
  db,
29
35
  root,
36
+ vault,
30
37
  lanes: async (slug, workspacePath, fastlaneDir) => {
31
38
  const resolved = await config.resolve(slug, workspacePath);
32
39
  const reader = new LaneReader(cache, makeInvoke(resolved?.settings.runtime ?? "bundle"));
33
40
  return reader.read(slug, workspacePath, fastlaneDir);
34
41
  },
42
+ uses: async (slug, workspacePath, fastlaneDir) => {
43
+ const resolved = await config.resolve(slug, workspacePath);
44
+ const reader = new UsesReader(cache, makeInvoke(resolved?.settings.runtime ?? "bundle"));
45
+ return reader.read(slug, workspacePath, fastlaneDir);
46
+ },
35
47
  });
48
+ // Anything left queued from the previous life starts moving again now. Without
49
+ // this, `wake()` would only ever be called from the trigger route, and three
50
+ // runs queued before a restart would wait for someone to trigger a fourth.
51
+ app.queue.wake();
36
52
  return { app, db, config };
37
53
  }
38
54
  /** Real startup, outside tests. */
@@ -69,6 +85,8 @@ function invokedDirectly() {
69
85
  const USAGE = `laneyard — a self-hosted web UI for fastlane
70
86
 
71
87
  laneyard add adopt the project in the current directory
88
+ laneyard secret set NAME [--project <slug>]
89
+ store a secret, its value read from standard input
72
90
  laneyard start the server
73
91
  laneyard --version print the version
74
92
 
@@ -113,6 +131,17 @@ if (invokedDirectly()) {
113
131
  process.exit(1);
114
132
  }
115
133
  }
134
+ // Above the catch-all below, which would otherwise reject it as unknown.
135
+ if (command === "secret") {
136
+ const home = homeDir();
137
+ await mkdir(home, { recursive: true });
138
+ process.exit(await runSecretCommand(home, rest, {
139
+ stdin: process.stdin,
140
+ interactive: process.stdin.isTTY === true,
141
+ out: (text) => process.stdout.write(text),
142
+ err: (text) => process.stderr.write(text),
143
+ }));
144
+ }
116
145
  if (command !== undefined) {
117
146
  process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
118
147
  process.exit(1);
@@ -2,6 +2,7 @@ import { rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { Workspace } from "../git/workspace.js";
4
4
  import { summarizeFailure } from "../heuristics/error-summary.js";
5
+ import { Redactor, scrub } from "../logs/redact.js";
5
6
  import { collectArtifacts } from "./artifacts.js";
6
7
  import { LiveStepTracker } from "./live-steps.js";
7
8
  import { startPty } from "./pty.js";
@@ -17,19 +18,47 @@ export async function executeRun(opts) {
17
18
  const { runId, runs, logs } = opts;
18
19
  const writer = await logs.open(runId);
19
20
  const tracker = new LiveStepTracker();
21
+ const redactor = new Redactor(opts.maskedValues ?? []);
22
+ // Redaction happens here and nowhere else: this is the single point through
23
+ // which every byte of output passes on its way to the file, the step tracker
24
+ // and the browser. Filtering further downstream would mean filtering three
25
+ // times, and forgetting one of them eventually.
20
26
  const emit = async (text) => {
21
- const offset = await writer.append(text);
22
- tracker.consume(text, offset);
23
- opts.onChunk(text, offset);
27
+ const safe = redactor.push(text);
28
+ if (safe === "")
29
+ return;
30
+ const offset = await writer.append(safe);
31
+ tracker.consume(safe, offset);
32
+ opts.onChunk(safe, offset);
24
33
  };
25
- const fail = async (message) => {
34
+ const emitRest = async () => {
35
+ const rest = redactor.flush();
36
+ if (rest === "")
37
+ return;
38
+ const offset = await writer.append(rest);
39
+ tracker.consume(rest, offset);
40
+ opts.onChunk(rest, offset);
41
+ };
42
+ // The error summary is stored in the database and rendered in the interface
43
+ // without passing through the stream above, so it needs its own, one-shot pass.
44
+ const hide = (text) => scrub(text, opts.maskedValues ?? []);
45
+ // Shared by every early exit, so flushing and closing the log writer cannot
46
+ // drift between the failure path and the two cancellation checkpoints below.
47
+ const finishAs = async (status, message) => {
26
48
  await emit(`\n${message}\n`);
49
+ await emitRest();
27
50
  await writer.close();
28
- runs.finish(runId, { status: "failed", exitCode: null, errorSummary: message });
29
- return { status: "failed" };
51
+ runs.finish(runId, { status, exitCode: null, errorSummary: hide(message) });
52
+ return { status };
30
53
  };
54
+ const fail = (message) => finishAs("failed", message);
31
55
  // --- Preparation --------------------------------------------------------
32
56
  runs.setStatus(runId, "preparing");
57
+ // Cancelled before it even started: no point cloning or fetching a
58
+ // workspace nobody wants built anymore.
59
+ if (opts.signal?.aborted) {
60
+ return finishAs("cancelled", "Cancelled");
61
+ }
33
62
  const workspace = new Workspace(opts.workspacePath, opts.gitUrl, opts.gitAuth);
34
63
  let commitSha;
35
64
  try {
@@ -50,6 +79,10 @@ export async function executeRun(opts) {
50
79
  catch (cause) {
51
80
  return fail(`Unreadable project settings: ${cause.message}`);
52
81
  }
82
+ // Cancelled during preparation: fastlane never gets to start.
83
+ if (opts.signal?.aborted) {
84
+ return finishAs("cancelled", "Cancelled");
85
+ }
53
86
  // --- Execution -----------------------------------------------------------
54
87
  const useBundle = settings.runtime === "bundle";
55
88
  const reportPath = join(opts.workspacePath, settings.fastlane_dir, "report.xml");
@@ -65,16 +98,20 @@ export async function executeRun(opts) {
65
98
  cwd: opts.workspacePath,
66
99
  env: {
67
100
  ...opts.env,
68
- // A non-interactive run fails fast instead of freezing on an invisible prompt.
101
+ ...(opts.secrets ?? {}),
102
+ // Order matters: secrets come after opts.env so a stored secret wins over
103
+ // a variable that happens to exist in the server's own environment, and
104
+ // before these three fixed variables so no secret can override CI.
69
105
  CI: "true",
70
106
  FASTLANE_SKIP_UPDATE_CHECK: "1",
71
107
  FORCE_COLOR: "1",
72
108
  },
73
109
  onData: (chunk) => void emit(chunk),
74
110
  timeoutMs: settings.timeout_minutes * 60_000,
111
+ signal: opts.signal,
75
112
  });
76
113
  const outcome = await done;
77
- await writer.close();
114
+ await emitRest();
78
115
  // --- Timeline -------------------------------------------------------------
79
116
  // Everything that follows is after-sales service: the timeline and the
80
117
  // artifacts embellish a run that's already finished. A database that
@@ -86,14 +123,27 @@ export async function executeRun(opts) {
86
123
  }
87
124
  catch (cause) {
88
125
  await emit(`\nIncomplete timeline or artifacts: ${cause.message}\n`);
126
+ await emitRest();
89
127
  }
128
+ await writer.close();
90
129
  if (outcome.exitCode === 0 && !outcome.timedOut) {
91
130
  runs.finish(runId, { status: "success", exitCode: 0, errorSummary: null });
92
131
  return { status: "success" };
93
132
  }
133
+ if (opts.signal?.aborted) {
134
+ // Checked ahead of the failure case: the SIGINT/SIGKILL escalation leaves
135
+ // fastlane with a nonzero exit code, which would otherwise read as a crash
136
+ // rather than the cancellation it actually was.
137
+ runs.finish(runId, {
138
+ status: "cancelled",
139
+ exitCode: outcome.exitCode,
140
+ errorSummary: "Cancelled",
141
+ });
142
+ return { status: "cancelled" };
143
+ }
94
144
  const summary = outcome.timedOut
95
- ? `Run interrupted after ${settings.timeout_minutes} minutes`
96
- : summarizeFailure(await logs.read(runId), outcome.exitCode);
145
+ ? hide(`Run interrupted after ${settings.timeout_minutes} minutes`)
146
+ : hide(summarizeFailure(await logs.read(runId), outcome.exitCode));
97
147
  runs.finish(runId, { status: "failed", exitCode: outcome.exitCode, errorSummary: summary });
98
148
  return { status: "failed" };
99
149
  async function recordOutcome() {