laneyard 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/dist/src/cli/add.js +81 -0
  4. package/dist/src/cli/detect.js +70 -0
  5. package/dist/src/config/load.js +46 -0
  6. package/dist/src/config/resolve.js +29 -0
  7. package/dist/src/config/schema.js +55 -0
  8. package/dist/src/config/store.js +71 -0
  9. package/dist/src/db/cache.js +22 -0
  10. package/dist/src/db/open.js +12 -0
  11. package/dist/src/db/runs.js +97 -0
  12. package/dist/src/db/schema.sql +45 -0
  13. package/dist/src/git/workspace.js +106 -0
  14. package/dist/src/heuristics/error-summary.js +43 -0
  15. package/dist/src/logs/store.js +66 -0
  16. package/dist/src/main.js +124 -0
  17. package/dist/src/runner/artifacts.js +59 -0
  18. package/dist/src/runner/live-steps.js +38 -0
  19. package/dist/src/runner/orchestrate.js +140 -0
  20. package/dist/src/runner/pty.js +83 -0
  21. package/dist/src/runner/report.js +86 -0
  22. package/dist/src/server/app.js +84 -0
  23. package/dist/src/server/auth.js +81 -0
  24. package/dist/src/server/routes/projects.js +35 -0
  25. package/dist/src/server/routes/runs.js +100 -0
  26. package/dist/src/server/ws.js +66 -0
  27. package/dist/src/sidecar/bridge.js +44 -0
  28. package/dist/src/sidecar/lanes.js +40 -0
  29. package/dist/src/sidecar/ruby-env.js +63 -0
  30. package/dist/tests/cli/add.test.js +64 -0
  31. package/dist/tests/cli/detect.test.js +63 -0
  32. package/dist/tests/config/load.test.js +68 -0
  33. package/dist/tests/config/resolve.test.js +44 -0
  34. package/dist/tests/config/store.test.js +58 -0
  35. package/dist/tests/db/runs.test.js +54 -0
  36. package/dist/tests/e2e/full-thread.test.js +65 -0
  37. package/dist/tests/fixtures/repos.js +30 -0
  38. package/dist/tests/git/workspace.test.js +66 -0
  39. package/dist/tests/heuristics/error-summary.test.js +31 -0
  40. package/dist/tests/logs/store.test.js +38 -0
  41. package/dist/tests/main.test.js +27 -0
  42. package/dist/tests/ruby/introspect.test.js +59 -0
  43. package/dist/tests/runner/artifacts.test.js +49 -0
  44. package/dist/tests/runner/live-steps.test.js +35 -0
  45. package/dist/tests/runner/orchestrate.test.js +124 -0
  46. package/dist/tests/runner/pty.test.js +53 -0
  47. package/dist/tests/runner/report.test.js +91 -0
  48. package/dist/tests/server/api.test.js +132 -0
  49. package/dist/tests/server/auth.test.js +53 -0
  50. package/dist/tests/server/ws.test.js +43 -0
  51. package/dist/tests/sidecar/lanes.test.js +52 -0
  52. package/dist/tests/sidecar/ruby-env.test.js +25 -0
  53. package/dist/tests/smoke.test.js +7 -0
  54. package/dist/web/assets/index-583x0xuo.css +1 -0
  55. package/dist/web/assets/index-BEpABKPS.js +61 -0
  56. package/dist/web/index.html +13 -0
  57. package/package.json +70 -0
  58. package/ruby/introspect.rb +87 -0
  59. package/scripts/fix-node-pty-permissions.mjs +26 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Frouin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # Laneyard
2
+
3
+ **A self-hosted web UI for fastlane.**
4
+
5
+ Laneyard is a browser interface for the fastlane lanes you already have, running on hardware you
6
+ own. Pick a lane, click run, watch the output stream, download the artifact. Nobody else holds
7
+ your signing keys, and nobody meters your minutes.
8
+
9
+ It replaces neither fastlane nor your Fastfile — it drives them. Your lanes stay exactly where
10
+ they are.
11
+
12
+ ![A build running in Laneyard: step timeline on the left, live output on the right, artifacts below](docs/screenshots/run.png)
13
+
14
+ ## Why
15
+
16
+ **You keep the keys.** Signing certificates, keystores, provisioning profiles, store
17
+ credentials — everything needed to publish under your name — stays on your machine. A hosted CI
18
+ is a third party holding all of it.
19
+
20
+ **Minutes stop existing.** Hardware you already own costs less than a year of most CI plans,
21
+ and never bills by the second. Long builds stop being a budget decision.
22
+
23
+ **It is just fastlane.** No new DSL, no YAML dialect, no vendor config to port. Laneyard reads
24
+ the Fastfile you already have and asks *your* fastlane what it can do — plugins included. It
25
+ hard-codes no knowledge of fastlane at all, so upgrading fastlane or adding a plugin needs no
26
+ change here.
27
+
28
+ ## Where it fits
29
+
30
+ | | Laneyard | Hosted CI | Self-hosted runner |
31
+ | ---------------------------- | ----------- | ------------ | ------------------ |
32
+ | who holds your signing keys | you | the vendor | you |
33
+ | cost per build | electricity | per minute | electricity |
34
+ | setup | one command | a signup form| a weekend |
35
+ | works offline | yes | no | yes |
36
+ | build queue across a team | planned | yes | yes |
37
+ | runs on pull requests | planned | yes | yes |
38
+
39
+ If your team needs parallel builds across a fleet today, use something else and come back later.
40
+ Laneyard is for one machine you control.
41
+
42
+ ## Requirements
43
+
44
+ - **Node 22 or newer** — the server.
45
+ - **Ruby with fastlane**, either through a project `Gemfile` (recommended) or installed for the
46
+ active Ruby. Laneyard never bundles its own fastlane; it uses your project's.
47
+ - **git**.
48
+ - macOS or Linux. iOS builds need a Mac, as they always have; Android builds do not.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ npm install -g laneyard
54
+ ```
55
+
56
+ Then, from a project you already build with fastlane:
57
+
58
+ ```bash
59
+ cd ~/code/your-app
60
+ laneyard add # adopt this project
61
+ laneyard # start the server
62
+ ```
63
+
64
+ `laneyard add` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
65
+ `Gemfile`, an Xcode project or a Gradle build — writes the matching block into
66
+ `~/.laneyard/config.yml`, and prints a generated server password once. Write it down; it is not
67
+ shown again.
68
+
69
+ <details>
70
+ <summary>Running from source instead</summary>
71
+
72
+ ```bash
73
+ git clone https://github.com/martinfrouin/laneyard.git
74
+ cd laneyard
75
+ npm install # builds on install
76
+ npm link # puts `laneyard` on your PATH
77
+ ```
78
+
79
+ </details>
80
+
81
+ Open it from any machine on your network, sign in, and your lanes are listed — because Laneyard
82
+ asked your project's own fastlane for them.
83
+
84
+ ![The project screen: lanes read from the Fastfile, with their descriptions, and the run history](docs/screenshots/project.png)
85
+
86
+ ## Configuration
87
+
88
+ All configuration lives in files. The database holds execution state only, so backing up
89
+ Laneyard means copying one file, and restoring it means copying it back.
90
+
91
+ ### `~/.laneyard/config.yml` — the server and its projects
92
+
93
+ ```yaml
94
+ server:
95
+ port: 7890
96
+ bind: 0.0.0.0
97
+ password_hash: "scrypt$…" # written by `laneyard add`
98
+ max_concurrent_runs: 1
99
+ retention: { runs: 50, artifact_days: 30 }
100
+
101
+ projects:
102
+ - slug: cartes-ios
103
+ name: Cartes iOS
104
+ git_url: git@github.com:you/cartes.git
105
+ default_branch: main
106
+ git_auth: { kind: ssh_key, ref: ~/.ssh/id_ed25519 }
107
+ ```
108
+
109
+ ### `laneyard.yml` — optional, committed in your repository
110
+
111
+ Build behaviour belongs next to the code, so it can be versioned with it:
112
+
113
+ ```yaml
114
+ fastlane_dir: fastlane
115
+ runtime: bundle # or `system`
116
+ timeout_minutes: 60
117
+ artifact_globs:
118
+ - "build/**/*.ipa"
119
+ - "build/**/*.app.dSYM.zip"
120
+ ```
121
+
122
+ Field by field, the repository file wins over the server block, which wins over the defaults. Any
123
+ field of `laneyard.yml` may also be written in the server block, so a repository you would rather
124
+ not touch can be configured entirely from `config.yml`.
125
+
126
+ Both files are watched: edit them by hand and Laneyard picks the change up. An invalid file is
127
+ reported and the last valid configuration stays live — a typo never takes the server down.
128
+
129
+ ## Security
130
+
131
+ Read this before putting Laneyard on a network.
132
+
133
+ - **It is built for a local network, not the internet.** It listens on `0.0.0.0` so you can reach
134
+ it from your laptop, behind one password. Do not expose it publicly. If you need remote access,
135
+ put it behind a VPN or an SSH tunnel.
136
+ - **The password** is stored as an scrypt hash and repeated failures are throttled. Sessions live
137
+ in memory and do not survive a restart.
138
+ - **There is no secret vault yet.** Today, fastlane sees the environment the Laneyard process was
139
+ started with, so credentials are managed the way you already manage them — an `.env` read by
140
+ your Fastfile, the system keychain, or exported variables. Do not put secrets in `config.yml`.
141
+ - **Logs are not redacted yet.** A run's full output is written to disk under `~/.laneyard/logs/`.
142
+ Assume anything fastlane prints is stored in the clear. Laneyard does remove its own git remote
143
+ URL from error messages, so a token embedded in a repository URL does not leak that way.
144
+
145
+ The encrypted vault and log redaction are the next milestone, and the reason this section is
146
+ this blunt.
147
+
148
+ ## Status
149
+
150
+ `✓` shipped · `▸` being built · `○` planned
151
+
152
+ - `✓` declare a project, clone it, list its lanes, run one, watch it live, download the artifact
153
+ - `✓` configuration entirely in files you can version and back up
154
+ - `✓` adopt an existing fastlane project with one command
155
+ - `▸` encrypted secret vault and log redaction
156
+ - `▸` build queue, cancellation, timeouts surfaced in the UI
157
+ - `○` a checklist that gets a project running unattended
158
+ - `○` Fastfile editor in the browser
159
+ - `○` git-triggered and scheduled builds
160
+
161
+ Two limitations worth knowing today: listing lanes does not fetch the repository, so a lane you
162
+ have just pushed appears after the next run; and a second run on a project is refused while the
163
+ first is still going, since they would share one git workspace.
164
+
165
+ ## Contributing
166
+
167
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: `npm test` runs the whole suite in
168
+ seconds against a fake fastlane, so you can work on any machine, with no Xcode, no signing
169
+ certificates and no network.
170
+
171
+ ## Licence
172
+
173
+ MIT — see [LICENSE](LICENSE).
174
+
175
+ Built on [fastlane](https://fastlane.tools), and not affiliated with it.
@@ -0,0 +1,81 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { randomBytes } from "node:crypto";
3
+ import { Document, parseDocument, YAMLSeq } from "yaml";
4
+ import { hashPassword } from "../server/auth.js";
5
+ import { detectProject } from "./detect.js";
6
+ /**
7
+ * Adds a project block to config.yml while preserving the rest of the file.
8
+ *
9
+ * The edit goes through the YAML document rather than a parse/serialize
10
+ * round trip: the user's comments — and the order of their keys — survive.
11
+ * It's the same requirement as for the Fastfile: a hand-written file must
12
+ * never come back out damaged.
13
+ */
14
+ export async function addProjectToConfig(path, entry) {
15
+ let doc;
16
+ try {
17
+ doc = parseDocument(await readFile(path, "utf8"));
18
+ }
19
+ catch {
20
+ doc = new Document({});
21
+ }
22
+ if (doc.contents === null)
23
+ doc = new Document({});
24
+ if (!doc.hasIn(["server", "password_hash"])) {
25
+ // A server with no password would refuse every connection: we generate one
26
+ // and print it once, leaving it to the caller to note it down.
27
+ const generated = randomBytes(9).toString("base64url");
28
+ doc.setIn(["server", "password_hash"], hashPassword(generated));
29
+ process.stdout.write(`\nGenerated password: ${generated}\n (write it down, it won't be shown again)\n`);
30
+ }
31
+ const projects = doc.getIn(["projects"]);
32
+ const seq = projects instanceof YAMLSeq ? projects : new YAMLSeq();
33
+ if (!(projects instanceof YAMLSeq))
34
+ doc.setIn(["projects"], seq);
35
+ for (const item of seq.items) {
36
+ const slug = item.get?.("slug");
37
+ if (slug === entry.slug) {
38
+ throw new Error(`A project already uses the slug "${entry.slug}" in ${path}`);
39
+ }
40
+ }
41
+ seq.add(doc.createNode(entry));
42
+ await writeFile(path, doc.toString(), "utf8");
43
+ }
44
+ /** Entry point for `laneyard add`. */
45
+ export async function runAddCommand(cwd, configPath, slugOverride) {
46
+ const d = await detectProject(cwd);
47
+ if (d.fastlaneDir === null) {
48
+ process.stderr.write("No Fastfile found here. Laneyard drives fastlane: run the command from a project " +
49
+ "that already uses it, or run `fastlane init` first.\n");
50
+ return 1;
51
+ }
52
+ if (d.gitUrl === null) {
53
+ process.stderr.write("No git remote named \"origin\". Laneyard clones projects from their repository: " +
54
+ "add a remote, or set git_url by hand in config.yml.\n");
55
+ return 1;
56
+ }
57
+ const slug = slugOverride ?? d.slug;
58
+ // A slug is used as a folder name and a URL segment. Left unvalidated, a
59
+ // `--slug ../evil` would be written without complaint and would then make
60
+ // config.yml invalid, taking every other project offline.
61
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
62
+ process.stderr.write(`Invalid slug: "${slug}". Lowercase letters, digits and hyphens only.\n`);
63
+ return 1;
64
+ }
65
+ await addProjectToConfig(configPath, {
66
+ slug,
67
+ name: slug,
68
+ git_url: d.gitUrl,
69
+ default_branch: d.defaultBranch,
70
+ fastlane_dir: d.fastlaneDir,
71
+ runtime: d.runtime,
72
+ artifact_globs: d.artifactGlobs,
73
+ });
74
+ process.stdout.write(`\nProject "${slug}" added to ${configPath}\n` +
75
+ ` repository ${d.gitUrl} (${d.defaultBranch})\n` +
76
+ ` fastlane ${d.fastlaneDir}\n` +
77
+ ` runtime ${d.runtime}\n` +
78
+ ` artifacts ${d.artifactGlobs.join(", ") || "no pattern detected — fill in manually"}\n` +
79
+ `\nRestart Laneyard or wait for the automatic reload, the project will appear in the interface.\n`);
80
+ return 0;
81
+ }
@@ -0,0 +1,70 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
+ import { basename, join, relative, sep } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { glob } from "tinyglobby";
6
+ const exec = promisify(execFile);
7
+ const exists = async (p) => {
8
+ try {
9
+ await access(p);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ };
16
+ const gitOr = async (args, cwd, fallback) => {
17
+ try {
18
+ const { stdout } = await exec("git", args, { cwd });
19
+ return stdout.trim() || fallback;
20
+ }
21
+ catch {
22
+ return fallback;
23
+ }
24
+ };
25
+ /** A folder name isn't a slug: normalize it, but never fail. */
26
+ function slugify(name) {
27
+ const s = name
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9-]+/g, "-")
30
+ .replace(/^-+|-+$/g, "");
31
+ return s === "" ? "project" : s;
32
+ }
33
+ /**
34
+ * Inspects an existing project and proposes a configuration.
35
+ *
36
+ * Decides nothing irreversible: everything it returns is a proposal the
37
+ * user sees and can correct before it's written.
38
+ */
39
+ export async function detectProject(dir) {
40
+ // The Fastfile can be at the root or under a subfolder, for monorepos.
41
+ const fastfiles = await glob(["fastlane/Fastfile", "*/fastlane/Fastfile", "*/*/fastlane/Fastfile"], {
42
+ cwd: dir,
43
+ absolute: true,
44
+ onlyFiles: true,
45
+ });
46
+ const fastfile = fastfiles.sort((a, b) => a.length - b.length)[0] ?? null;
47
+ const fastlaneDir = fastfile
48
+ ? relative(dir, join(fastfile, "..")).split(sep).join("/")
49
+ : null;
50
+ const isIos = (await glob(["*.xcodeproj", "*.xcworkspace", "*/*.xcodeproj"], { cwd: dir, onlyDirectories: true }))
51
+ .length > 0;
52
+ const isAndroid = (await glob(["build.gradle", "build.gradle.kts", "*/build.gradle", "*/build.gradle.kts"], {
53
+ cwd: dir,
54
+ onlyFiles: true,
55
+ })).length > 0;
56
+ const artifactGlobs = [];
57
+ if (isIos)
58
+ artifactGlobs.push("**/*.ipa", "**/*.app.dSYM.zip");
59
+ if (isAndroid)
60
+ artifactGlobs.push("**/*.apk", "**/*.aab");
61
+ return {
62
+ slug: slugify(basename(dir)),
63
+ gitUrl: await gitOr(["remote", "get-url", "origin"], dir, null),
64
+ defaultBranch: (await gitOr(["rev-parse", "--abbrev-ref", "HEAD"], dir, "main")) ?? "main",
65
+ fastlaneDir,
66
+ runtime: (await exists(join(dir, "Gemfile"))) ? "bundle" : "system",
67
+ artifactGlobs,
68
+ platform: isIos ? "ios" : isAndroid ? "android" : "unknown",
69
+ };
70
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse as parseYaml } from "yaml";
3
+ import { repoConfigSchema, serverConfigSchema } from "./schema.js";
4
+ /** Reads and validates a YAML file. Never fails by throwing: the caller decides. */
5
+ async function loadYamlFile(path, schema) {
6
+ let raw;
7
+ try {
8
+ raw = await readFile(path, "utf8");
9
+ }
10
+ catch (cause) {
11
+ return { ok: false, error: `Could not read ${path}: ${cause.message}` };
12
+ }
13
+ let data;
14
+ try {
15
+ data = parseYaml(raw);
16
+ }
17
+ catch (cause) {
18
+ return { ok: false, error: `Invalid YAML in ${path}: ${cause.message}` };
19
+ }
20
+ const parsed = schema.safeParse(data ?? {});
21
+ if (!parsed.success) {
22
+ const details = parsed.error.issues
23
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
24
+ .join("; ");
25
+ return { ok: false, error: `Invalid configuration in ${path} — ${details}` };
26
+ }
27
+ return { ok: true, config: parsed.data };
28
+ }
29
+ export async function loadServerConfig(path) {
30
+ const res = await loadYamlFile(path, serverConfigSchema);
31
+ if (!res.ok)
32
+ return res;
33
+ const seen = new Set();
34
+ for (const p of res.config.projects) {
35
+ if (seen.has(p.slug)) {
36
+ return { ok: false, error: `Invalid configuration in ${path} — duplicate slug: ${p.slug}` };
37
+ }
38
+ seen.add(p.slug);
39
+ }
40
+ // The display name falls back to the slug rather than being optional everywhere downstream.
41
+ const projects = res.config.projects.map((p) => ({ ...p, name: p.name ?? p.slug }));
42
+ return { ok: true, config: { ...res.config, projects } };
43
+ }
44
+ export async function loadRepoConfig(path) {
45
+ return loadYamlFile(path, repoConfigSchema);
46
+ }
@@ -0,0 +1,29 @@
1
+ import { projectSettingsSchema } from "./schema.js";
2
+ const SETTING_KEYS = Object.keys(projectSettingsSchema.shape);
3
+ /**
4
+ * Merges the three sources field by field.
5
+ * `undefined` means "not set"; any other value, including an empty array
6
+ * or `false`, is an explicit decision by the user.
7
+ */
8
+ export function resolveProjectSettings(entry, repo) {
9
+ const chosen = {};
10
+ const provenance = {};
11
+ for (const key of SETTING_KEYS) {
12
+ const fromRepo = repo?.[key];
13
+ const fromServer = entry[key];
14
+ if (fromRepo !== undefined) {
15
+ chosen[key] = fromRepo;
16
+ provenance[key] = "repo";
17
+ }
18
+ else if (fromServer !== undefined) {
19
+ chosen[key] = fromServer;
20
+ provenance[key] = "server";
21
+ }
22
+ else {
23
+ provenance[key] = "default";
24
+ }
25
+ }
26
+ // The schema applies the defaults for anything still absent.
27
+ const settings = projectSettingsSchema.parse(chosen);
28
+ return { settings, provenance };
29
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ /** Build behaviour settings. They can come from the repository or the server. */
3
+ export const projectSettingsSchema = z.object({
4
+ fastlane_dir: z.string().default("fastlane"),
5
+ runtime: z.enum(["bundle", "system"]).default("bundle"),
6
+ timeout_minutes: z.number().int().positive().default(60),
7
+ interactive_default: z.boolean().default(false),
8
+ artifact_globs: z.array(z.string()).default([]),
9
+ required_secrets: z.array(z.string()).default([]),
10
+ retention: z
11
+ .object({
12
+ runs: z.number().int().positive(),
13
+ artifact_days: z.number().int().positive(),
14
+ })
15
+ .optional(),
16
+ });
17
+ /** Same vocabulary, but everything is optional in the files. */
18
+ export const projectSettingsInputSchema = projectSettingsSchema.partial();
19
+ /** A slug is used as a folder name and a URL segment. */
20
+ const slugSchema = z
21
+ .string()
22
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "slug: lowercase letters, digits and hyphens only");
23
+ export const projectEntrySchema = projectSettingsInputSchema.extend({
24
+ slug: slugSchema,
25
+ name: z.string().optional(),
26
+ git_url: z.string().min(1),
27
+ default_branch: z.string().default("main"),
28
+ git_auth: z
29
+ .object({
30
+ kind: z.enum(["none", "ssh_key", "token"]),
31
+ /** File path if kind is ssh_key, secret name if kind is token. */
32
+ ref: z.string().optional(),
33
+ })
34
+ .default({ kind: "none" }),
35
+ color: z.string().default("green"),
36
+ notify_browser: z.boolean().default(true),
37
+ webhook_url: z.string().optional(),
38
+ });
39
+ export const serverConfigSchema = z.object({
40
+ server: z.object({
41
+ port: z.number().int().positive().default(7890),
42
+ bind: z.string().default("0.0.0.0"),
43
+ password_hash: z.string().min(1),
44
+ max_concurrent_runs: z.number().int().positive().default(1),
45
+ retention: z
46
+ .object({
47
+ runs: z.number().int().positive().default(50),
48
+ artifact_days: z.number().int().positive().default(30),
49
+ })
50
+ .default({ runs: 50, artifact_days: 30 }),
51
+ }),
52
+ projects: z.array(projectEntrySchema).default([]),
53
+ });
54
+ /** Content of laneyard.yml: build behaviour only. */
55
+ export const repoConfigSchema = projectSettingsInputSchema;
@@ -0,0 +1,71 @@
1
+ import { watch } from "node:fs";
2
+ import { loadRepoConfig, loadServerConfig } from "./load.js";
3
+ import { resolveProjectSettings } from "./resolve.js";
4
+ import { join } from "node:path";
5
+ /**
6
+ * The server's live configuration.
7
+ *
8
+ * Safety rule: an invalid configuration never replaces a valid one. The
9
+ * server keeps running with what it had, and the error is exposed to the
10
+ * interface — never a half-configured startup.
11
+ */
12
+ export class ConfigStore {
13
+ path;
14
+ config = null;
15
+ error = null;
16
+ constructor(path) {
17
+ this.path = path;
18
+ }
19
+ async load() {
20
+ const res = await loadServerConfig(this.path);
21
+ if (!res.ok) {
22
+ this.error = res.error;
23
+ return { ok: false, error: res.error };
24
+ }
25
+ this.config = res.config;
26
+ this.error = null;
27
+ return { ok: true };
28
+ }
29
+ /** Watches the file and reloads, absorbing bursts of events. */
30
+ watch(onReload) {
31
+ let timer;
32
+ const watcher = watch(this.path, () => {
33
+ if (timer)
34
+ clearTimeout(timer);
35
+ timer = setTimeout(() => {
36
+ void this.load().then((r) => onReload(r.ok));
37
+ }, 150);
38
+ });
39
+ return () => {
40
+ if (timer)
41
+ clearTimeout(timer);
42
+ watcher.close();
43
+ };
44
+ }
45
+ server() {
46
+ return this.config?.server ?? null;
47
+ }
48
+ projects() {
49
+ return this.config?.projects ?? [];
50
+ }
51
+ project(slug) {
52
+ return this.projects().find((p) => p.slug === slug) ?? null;
53
+ }
54
+ lastError() {
55
+ return this.error;
56
+ }
57
+ /**
58
+ * Resolves a project's effective settings by reading its workspace's
59
+ * laneyard.yml if it exists. The workspace may not be cloned yet: we
60
+ * then fall back to the project's block and the defaults.
61
+ */
62
+ async resolve(slug, workspacePath) {
63
+ const entry = this.project(slug);
64
+ if (!entry)
65
+ return null;
66
+ const repoRes = await loadRepoConfig(join(workspacePath, "laneyard.yml"));
67
+ const repo = repoRes.ok ? repoRes.config : null;
68
+ const { settings, provenance } = resolveProjectSettings(entry, repo);
69
+ return { entry, settings, provenance };
70
+ }
71
+ }
@@ -0,0 +1,22 @@
1
+ export class CacheStore {
2
+ db;
3
+ constructor(db) {
4
+ this.db = db;
5
+ }
6
+ get(slug, hash) {
7
+ const row = this.db
8
+ .prepare("SELECT payload FROM introspection_cache WHERE project_slug = ? AND config_hash = ?")
9
+ .get(slug, hash);
10
+ return row ? JSON.parse(row.payload) : null;
11
+ }
12
+ put(slug, hash, payload) {
13
+ this.db
14
+ .prepare(`INSERT INTO introspection_cache (project_slug, config_hash, payload, fetched_at)
15
+ VALUES (?, ?, ?, ?)
16
+ ON CONFLICT (project_slug) DO UPDATE
17
+ SET config_hash = excluded.config_hash,
18
+ payload = excluded.payload,
19
+ fetched_at = excluded.fetched_at`)
20
+ .run(slug, hash, JSON.stringify(payload), new Date().toISOString());
21
+ }
22
+ }
@@ -0,0 +1,12 @@
1
+ import Database from "better-sqlite3";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ export function openDatabase(path) {
6
+ const db = new Database(path);
7
+ db.pragma("journal_mode = WAL");
8
+ db.pragma("foreign_keys = ON");
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ db.exec(readFileSync(join(here, "schema.sql"), "utf8"));
11
+ return db;
12
+ }