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.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/src/cli/add.js +81 -0
- package/dist/src/cli/detect.js +70 -0
- package/dist/src/config/load.js +46 -0
- package/dist/src/config/resolve.js +29 -0
- package/dist/src/config/schema.js +55 -0
- package/dist/src/config/store.js +71 -0
- package/dist/src/db/cache.js +22 -0
- package/dist/src/db/open.js +12 -0
- package/dist/src/db/runs.js +97 -0
- package/dist/src/db/schema.sql +45 -0
- package/dist/src/git/workspace.js +106 -0
- package/dist/src/heuristics/error-summary.js +43 -0
- package/dist/src/logs/store.js +66 -0
- package/dist/src/main.js +124 -0
- package/dist/src/runner/artifacts.js +59 -0
- package/dist/src/runner/live-steps.js +38 -0
- package/dist/src/runner/orchestrate.js +140 -0
- package/dist/src/runner/pty.js +83 -0
- package/dist/src/runner/report.js +86 -0
- package/dist/src/server/app.js +84 -0
- package/dist/src/server/auth.js +81 -0
- package/dist/src/server/routes/projects.js +35 -0
- package/dist/src/server/routes/runs.js +100 -0
- package/dist/src/server/ws.js +66 -0
- package/dist/src/sidecar/bridge.js +44 -0
- package/dist/src/sidecar/lanes.js +40 -0
- package/dist/src/sidecar/ruby-env.js +63 -0
- package/dist/tests/cli/add.test.js +64 -0
- package/dist/tests/cli/detect.test.js +63 -0
- package/dist/tests/config/load.test.js +68 -0
- package/dist/tests/config/resolve.test.js +44 -0
- package/dist/tests/config/store.test.js +58 -0
- package/dist/tests/db/runs.test.js +54 -0
- package/dist/tests/e2e/full-thread.test.js +65 -0
- package/dist/tests/fixtures/repos.js +30 -0
- package/dist/tests/git/workspace.test.js +66 -0
- package/dist/tests/heuristics/error-summary.test.js +31 -0
- package/dist/tests/logs/store.test.js +38 -0
- package/dist/tests/main.test.js +27 -0
- package/dist/tests/ruby/introspect.test.js +59 -0
- package/dist/tests/runner/artifacts.test.js +49 -0
- package/dist/tests/runner/live-steps.test.js +35 -0
- package/dist/tests/runner/orchestrate.test.js +124 -0
- package/dist/tests/runner/pty.test.js +53 -0
- package/dist/tests/runner/report.test.js +91 -0
- package/dist/tests/server/api.test.js +132 -0
- package/dist/tests/server/auth.test.js +53 -0
- package/dist/tests/server/ws.test.js +43 -0
- package/dist/tests/sidecar/lanes.test.js +52 -0
- package/dist/tests/sidecar/ruby-env.test.js +25 -0
- package/dist/tests/smoke.test.js +7 -0
- package/dist/web/assets/index-583x0xuo.css +1 -0
- package/dist/web/assets/index-BEpABKPS.js +61 -0
- package/dist/web/index.html +13 -0
- package/package.json +70 -0
- package/ruby/introspect.rb +87 -0
- 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
|
+

|
|
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
|
+

|
|
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
|
+
}
|